mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-12 11:49:28 +00:00
v3 library: multi-chart work grouping, complete (engine + API + card + drawer + toggle/split/filter-law) — P5a–P5e (#702)
* v3 library: multi-chart work grouping engine + work-charts API — P5a/P5b
Charts of the same song (same normalized artist+title) now GROUP under a
computed work_key, with a materialized representative filter so the grid
can collapse them without breaking keyset paging:
- work_key = normalize(artist+title) (diacritics/punct/case folded,
leading "The" folded on artist); resolves the effective artist via the
artist_alias table when present (feature-detected, no hard dep).
- Sparse, never-purged-on-rescan tables: chart_group_pref(work_key,
preferred_filename) + chart_group_split(filename, split_key); purged
only by the explicit per-song delete.
- Materialized work_display(filename, work_key, effective_work_key,
is_group_representative, group_size) read-model: lazy rebuild via a
dirty flag set on put/delete; set_chart_preferred does an incremental
re-flip (no full rebuild). Auto-pick representative = most
arrangements -> most plays -> newest -> filename; a user pref wins and
degrades to auto-pick if its file disappears.
- group=1 on query_page/query_stats = one extra representative
predicate applied identically to page + total + sort_letters, so the
keyset cursor (sort_value, filename) stays a valid total order and
counts works, not charts. Grouped rows carry chart_count + work_key.
- Charts API: GET /api/work/{work_key}/charts (members + which is the
keeper, your pick vs auto), PUT/DELETE .../preferred, and
POST /api/chart/{filename}/split + /unsplit (under /api/chart so the
DELETE /api/song catch-all can't shadow them).
Tests: 15 grouping-engine + 7 charts-API tests, including grouped
keyset pagination (no skip/dupe across pages).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nm7tHs1Yvjjtnnu4nzJgdN
* v3 library: grouped grid card + persistent "N charts" chip — P5c
Flip the v3 grid to the grouped library (group=1 on /api/library and the
rail's /api/library/stats fetch): one card per song, showing the
representative (preferred/auto-pick) chart. group rides page, total and
sort_letters identically so the A-Z rail's cumulative-seek math and the
virtualized sizer stay consistent, counting works not charts; the keyset
cursor chains with group on every page.
- New groupingActive() helper, default ON per the design; the persisted
per-view toggle (P5e) lands there. Only the local provider implements
group=; smart collections and remote providers ignore it and stay
flat, so it is safe to send unconditionally.
- chartsChipHtml(): a "flag N charts" chip rendered ONLY when
chart_count >= 2 - single-chart cards emit byte-identical markup.
First in the fixed-height chip row + shrink-0 so it never clips and
card height is unchanged.
- Chip click = feature-detected window.__fbOpenChartsDrawer (the Charts
drawer arrives in P5d); until then a no-op. Plain-click / play / the
arrangement chips are untouched and play the representative.
- The library-home repertoire meter's stats fetch deliberately stays
ungrouped: its mastered numerator counts chart filenames, so a works
denominator could exceed 100% - reconciling that is P5e's
mastery-anchor work. The tree view stays flat (query_artists has no
grouping; its opener is wired in P5d).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nm7tHs1Yvjjtnnu4nzJgdN
* v3 library: Charts drawer + openers — P5d
The single deep-management surface for a work's charts (design UX-2/3):
a body-appended slide-in drawer (filter-drawer idiom) listing every
chart of the work as a radiogroup — the checked row is the keeper the
grid card plays.
- Rows show format / tuning / arrangements / year / your accuracy (or
"not played") plus the pack filename, usually the only human-readable
distinguisher between duplicate charts. Keeper is labeled
"Preferred (auto)" vs "Preferred - your pick".
- Row click (or Enter/Space) = one-tap Set-preferred; "Reset to auto
pick" appears when the keeper is an explicit pick. Writes go through
the work-charts API and the drawer re-renders from the response; the
grid re-fetches in place since the representative may have flipped.
- Per-row Play (plays that exact chart) and Add-to-playlist (the picker
is z-[200], layering over the z-50 drawer).
- a11y: Tab focus-trap, Escape closes, ArrowUp/Down move focus between
rows (focus only - arrow-select would fire a preferred write per
keystroke), focus restored to the opener on close.
- Openers: the "N charts" chip opens the drawer directly; the card's
overflow menu gains "Charts (N)..." and "Play version >" (expands
inline; picking one plays it as a one-off - the keeper/headline does
not move). Tree rows ride the ungrouped artists endpoint, so the menu
resolves their work lazily via the new GET /api/chart/{fn}/work
({work_key, chart_count}) and slots a "Charts (N)..." entry in when
versions exist. A window.__fbOpenChartsDrawer global lets other views
open the drawer. Right-click is deferred: the open native card
context-menu PR should host that entry once both merge.
- tailwind.min.css rebuilt: carries the new utility classes from this
and the previous commit (the grouped-card chip tint was missing).
Split keys contain '#', so clients MUST URL-encode work_key in paths
(the v3 client does; a test documents the round-trip). 4 new endpoint
tests; 26/26 grouping+charts tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nm7tHs1Yvjjtnnu4nzJgdN
* v3 library: group toggle, split UI, the filter law + mastery-anchor rules — P5e
Completes the multi-chart grouping slice (design 7.1):
- Filter law under group=1: work-identity (artist/album/search) and
practice-state (favorites/mastery/tags/difficulty) predicates stay on
the representative row, while CHART-INTRINSIC predicates (format/
arrangements/stems/lyrics/tuning) now match if ANY member of the work
does - a song you own in Drop D is no longer hidden because your
preferred chart is E Standard. Intrinsic clauses moved to an
alias-aware builder and re-applied as a member EXISTS; identical in
query_page and query_stats so counts and the A-Z rail stay in
lockstep. A pure predicate - keyset paging is untouched (tested).
- Display-chart switch: when the representative itself doesn't match,
the row carries a display_chart override (the matching member). The
row stays the representative's - swapping rows wholesale would break
the (sort_value, filename) cursor - and the card renders/plays the
member while the accuracy badge and heart stay anchored on the
preferred chart.
- Mastery sort aggregates MAX across the group ("a song surfaces on any
chart you've touched"); OFFSET-paged, so cursor-safe. The
Recently-Added aggregate is deliberately deferred: mtime IS a keyset
sort, so its aggregate would need materializing into work_display.
- History-sticky auto-pick: most-played -> most-complete -> newest.
A newer/"more complete" import can't silently take the pick from the
chart your reps accrued on, and a one-off try of an alternate can't
out-rank a practiced incumbent; all-unplayed groups still pick by
completeness.
- Persisted "One card per song" toggle in the filter drawer (default
ON; OFF = one card per chart). A view mode: never counted in the
filter badge, never saved into collection rules, local provider only.
- Split escape hatch: "Split out" per drawer row gives a chart its own
card; the split card's overflow menu offers "Rejoin other versions"
(rows and the chart-work lookup now carry is_split).
- Mastery-anchor heads-up: after set-preferred the drawer shows a
one-line ambient note that practice history stays with each chart
(no toast - hearing-safe).
10 new filter-law tests; 38/38 grouping tests green. tailwind.min.css
rebuilt for the new utility classes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nm7tHs1Yvjjtnnu4nzJgdN
* fix(v3): work-grouping — escape Charts-drawer meta (XSS), keep non-Latin titles distinct, guard mid-rebuild reads (PR #702 review)
- XSS: esc() the composed `meta` string in _chartRowHtml (arrangement/tuning
names come from untrusted feedpak metadata) before innerHTML; acc stays HTML.
- Non-Latin titles: _norm_token falls back to raw lowercased whitespace-collapsed
text when the NFKD+strip fold yields "" (CJK/Cyrillic/Greek/Arabic), so distinct
non-Latin titles keep distinct _work_key values instead of collapsing into one
bogus work. Latin names still hit the folded branch — behavior unchanged.
- Mid-rebuild reads: wrap the grouped representative SELECT in query_page and
query_stats under self._lock (nullcontext when ungrouped, so lazy reads stay
lock-free) so a reader can't observe work_display between rebuild_work_display's
DELETE and INSERT/commit. _ensure_work_display stays OUTSIDE the lock — it
self-locks the rebuild and self._lock is non-reentrant — so only the SELECT is
guarded (rebuild fully completes before the guarded SELECT runs).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
byrongamatos
parent
a47accd894
commit
e9d95ad190
@@ -53,6 +53,7 @@ import loosefolder as loosefolder_mod
|
|||||||
from scan_worker import _extract_meta_for_file, _relpath, _scan_one
|
from scan_worker import _extract_meta_for_file, _relpath, _scan_one
|
||||||
|
|
||||||
import concurrent.futures
|
import concurrent.futures
|
||||||
|
import contextlib
|
||||||
import contextvars
|
import contextvars
|
||||||
import inspect
|
import inspect
|
||||||
import ipaddress
|
import ipaddress
|
||||||
@@ -288,10 +289,16 @@ def _env_flag(name: str) -> bool:
|
|||||||
# raw offsets so distinct customs stay distinct, while named tunings keep
|
# raw offsets so distinct customs stay distinct, while named tunings keep
|
||||||
# grouping by name (stable across the offsets-column migration). Used by both
|
# grouping by name (stable across the offsets-column migration). Used by both
|
||||||
# the tuning-names listing and the filter WHERE so the contract matches.
|
# the tuning-names listing and the filter WHERE so the contract matches.
|
||||||
_TUNING_GROUP_KEY_SQL = (
|
def _tuning_group_key_sql(alias: str) -> str:
|
||||||
"CASE WHEN tuning_name = 'Custom Tuning' AND COALESCE(tuning_offsets, '') != '' "
|
"""The tuning grouping key (name for named tunings, raw offsets for
|
||||||
"THEN tuning_offsets ELSE tuning_name END"
|
customs) against an explicit table alias — the grouped filter law (§7.1)
|
||||||
)
|
evaluates chart-intrinsic predicates inside a member subquery, where bare
|
||||||
|
column names would resolve against the wrong scope."""
|
||||||
|
return (f"CASE WHEN {alias}.tuning_name = 'Custom Tuning' AND COALESCE({alias}.tuning_offsets, '') != '' "
|
||||||
|
f"THEN {alias}.tuning_offsets ELSE {alias}.tuning_name END")
|
||||||
|
|
||||||
|
|
||||||
|
_TUNING_GROUP_KEY_SQL = _tuning_group_key_sql("songs")
|
||||||
|
|
||||||
|
|
||||||
# ── SQLite metadata cache ─────────────────────────────────────────────────────
|
# ── SQLite metadata cache ─────────────────────────────────────────────────────
|
||||||
@@ -614,6 +621,54 @@ class MetadataDB:
|
|||||||
updated_at TEXT
|
updated_at TEXT
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
|
# ── Multi-chart grouping (P5a) ───────────────────────────────────────
|
||||||
|
# A "work" is a song that may be charted by several feedpaks; each chart
|
||||||
|
# stays its own `songs` row (unchanged), but they GROUP under a shared
|
||||||
|
# work_key = normalize(artist+title). Two sparse, never-purged-on-rescan
|
||||||
|
# override tables + one MATERIALIZED read-model so the grid can group
|
||||||
|
# server-side without a query-time GROUP BY (which would kill the keyset
|
||||||
|
# seek / A–Z / virtualization — see query_page).
|
||||||
|
#
|
||||||
|
# chart_group_pref: your chosen "keeper" chart per work (sparse; unset ⇒
|
||||||
|
# auto-pick). Keyed by work_key, NOT filename, so it survives a chart's
|
||||||
|
# rescan; an orphaned preferred (file gone) degrades to auto-pick.
|
||||||
|
self.conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS chart_group_pref (
|
||||||
|
work_key TEXT PRIMARY KEY,
|
||||||
|
preferred_filename TEXT NOT NULL,
|
||||||
|
updated_at TEXT
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
# chart_group_split: "these aren't the same song" escape hatch — a chart
|
||||||
|
# gets its own unique split_key so it stands alone as a singleton work.
|
||||||
|
# Filename-keyed → purged with the song on delete_song.
|
||||||
|
self.conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS chart_group_split (
|
||||||
|
filename TEXT PRIMARY KEY,
|
||||||
|
split_key TEXT NOT NULL,
|
||||||
|
updated_at TEXT
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
# work_display: the MATERIALIZED representative-filter read-model, rebuilt
|
||||||
|
# from songs + the two override tables. One row per song:
|
||||||
|
# effective_work_key = split_key if split else work_key
|
||||||
|
# is_group_representative = 1 for the keeper (pref or auto-pick) of a work
|
||||||
|
# group_size = the ⚑ N charts in the work
|
||||||
|
# Grouping-ON is then just `WHERE is_group_representative = 1` (keyset-safe).
|
||||||
|
# A derived cache: filename-keyed, rebuilt on demand (dirty flag) — safe to
|
||||||
|
# drop/rebuild, so it's purged on delete and re-materialized after a scan.
|
||||||
|
self.conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS work_display (
|
||||||
|
filename TEXT PRIMARY KEY,
|
||||||
|
work_key TEXT NOT NULL,
|
||||||
|
effective_work_key TEXT NOT NULL,
|
||||||
|
is_group_representative INTEGER NOT NULL DEFAULT 1,
|
||||||
|
group_size INTEGER NOT NULL DEFAULT 1
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
self.conn.execute("CREATE INDEX IF NOT EXISTS idx_work_display_rep ON work_display(is_group_representative)")
|
||||||
|
self.conn.execute("CREATE INDEX IF NOT EXISTS idx_work_display_eff ON work_display(effective_work_key)")
|
||||||
|
self.conn.execute("CREATE INDEX IF NOT EXISTS idx_work_display_wk ON work_display(work_key)")
|
||||||
self.conn.execute("""
|
self.conn.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS loops (
|
CREATE TABLE IF NOT EXISTS loops (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -813,6 +868,9 @@ class MetadataDB:
|
|||||||
self.conn.execute("INSERT OR IGNORE INTO wallet (id) VALUES (1)")
|
self.conn.execute("INSERT OR IGNORE INTO wallet (id) VALUES (1)")
|
||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
|
# work_display (P5a) is a derived cache; True forces a (re)build on the
|
||||||
|
# first grouped query and after any songs churn (put / delete / rescan).
|
||||||
|
self._work_display_dirty = True
|
||||||
# One-time repair of pre-fix rows written under URL-encoded filenames
|
# One-time repair of pre-fix rows written under URL-encoded filenames
|
||||||
# (idempotent: a no-op once every row is canonical).
|
# (idempotent: a no-op once every row is canonical).
|
||||||
self._migrate_decode_stat_filenames()
|
self._migrate_decode_stat_filenames()
|
||||||
@@ -2315,6 +2373,8 @@ class MetadataDB:
|
|||||||
meta.get("tuning_offsets", "") or ""),
|
meta.get("tuning_offsets", "") or ""),
|
||||||
)
|
)
|
||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
|
# A song's identity may have changed → the grouping read-model is stale.
|
||||||
|
self._work_display_dirty = True
|
||||||
|
|
||||||
def count(self) -> int:
|
def count(self) -> int:
|
||||||
return self.conn.execute("SELECT COUNT(*) FROM songs WHERE title != ''").fetchone()[0]
|
return self.conn.execute("SELECT COUNT(*) FROM songs WHERE title != ''").fetchone()[0]
|
||||||
@@ -2336,6 +2396,7 @@ class MetadataDB:
|
|||||||
if stale:
|
if stale:
|
||||||
self.conn.executemany("DELETE FROM songs WHERE filename = ?", [(f,) for f in stale])
|
self.conn.executemany("DELETE FROM songs WHERE filename = ?", [(f,) for f in stale])
|
||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
|
self._work_display_dirty = True # membership changed → regroup
|
||||||
return len(stale)
|
return len(stale)
|
||||||
|
|
||||||
def _estd_set(self) -> set[str]:
|
def _estd_set(self) -> set[str]:
|
||||||
@@ -2392,18 +2453,23 @@ class MetadataDB:
|
|||||||
mastery: list[str] | None = None,
|
mastery: list[str] | None = None,
|
||||||
tags_has: list[str] | None = None,
|
tags_has: list[str] | None = None,
|
||||||
user_difficulty_in: list[str] | None = None,
|
user_difficulty_in: list[str] | None = None,
|
||||||
naming_mode: str = "legacy") -> tuple[str, list]:
|
naming_mode: str = "legacy",
|
||||||
|
include_intrinsic: bool = True) -> 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
|
||||||
included so callers paste it directly. See feedBack#129/#69.
|
included so callers paste it directly. See feedBack#129/#69.
|
||||||
|
|
||||||
|
Clauses are two classes (the §7.1 filter law): work-identity +
|
||||||
|
practice-state predicates live here; CHART-INTRINSIC predicates
|
||||||
|
(format / arrangements / stems / lyrics / tuning) are built by
|
||||||
|
`_build_intrinsic_where` and appended when `include_intrinsic`.
|
||||||
|
Grouped queries pass include_intrinsic=False and re-apply the
|
||||||
|
intrinsic set as a match-if-ANY-member subquery instead.
|
||||||
"""
|
"""
|
||||||
where = "WHERE title != ''"
|
where = "WHERE title != ''"
|
||||||
params: list = []
|
params: list = []
|
||||||
if favorites_only:
|
if favorites_only:
|
||||||
where += " AND filename IN (SELECT filename FROM favorites)"
|
where += " AND filename IN (SELECT filename FROM favorites)"
|
||||||
if format_filter:
|
|
||||||
where += " AND format = ?"
|
|
||||||
params.append(format_filter)
|
|
||||||
if artist_filter:
|
if artist_filter:
|
||||||
# The dropdown/tree list CANONICAL names (query_artists), so a filter
|
# The dropdown/tree list CANONICAL names (query_artists), so a filter
|
||||||
# value is canonical — expand it to every raw variant aliased to it so
|
# value is canonical — expand it to every raw variant aliased to it so
|
||||||
@@ -2455,6 +2521,35 @@ class MetadataDB:
|
|||||||
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
|
||||||
|
if include_intrinsic:
|
||||||
|
ifrag, iparams = self._build_intrinsic_where(
|
||||||
|
"songs", format_filter=format_filter,
|
||||||
|
arrangements_has=arrangements_has, arrangements_lacks=arrangements_lacks,
|
||||||
|
stems_has=stems_has, stems_lacks=stems_lacks,
|
||||||
|
has_lyrics=has_lyrics, tunings=tunings, naming_mode=naming_mode)
|
||||||
|
where += ifrag
|
||||||
|
params += iparams
|
||||||
|
return where, params
|
||||||
|
|
||||||
|
def _build_intrinsic_where(self, alias: str, format_filter: str = "",
|
||||||
|
arrangements_has: list[str] | None = None,
|
||||||
|
arrangements_lacks: list[str] | None = None,
|
||||||
|
stems_has: list[str] | None = None,
|
||||||
|
stems_lacks: list[str] | None = None,
|
||||||
|
has_lyrics: int | None = None,
|
||||||
|
tunings: list[str] | None = None,
|
||||||
|
naming_mode: str = "legacy") -> tuple[str, list]:
|
||||||
|
"""CHART-INTRINSIC predicates (format / arrangements / stems / lyrics /
|
||||||
|
tuning) as ' AND …' fragments against an explicit table alias. Flat
|
||||||
|
queries apply them to `songs` directly; grouped queries evaluate them
|
||||||
|
against each work member `m` inside an EXISTS (§7.1 filter law — a
|
||||||
|
work matches when ANY of its charts does, so a song you own in Drop D
|
||||||
|
isn't hidden because your preferred chart is E Standard)."""
|
||||||
|
where = ""
|
||||||
|
params: list = []
|
||||||
|
if format_filter:
|
||||||
|
where += f" AND {alias}.format = ?"
|
||||||
|
params.append(format_filter)
|
||||||
# arrangements_has / arrangements_lacks: OR within axis (any-of).
|
# arrangements_has / arrangements_lacks: OR within axis (any-of).
|
||||||
# Uses JSON1's json_each which yields one row per arrangement, then
|
# Uses JSON1's json_each which yields one row per arrangement, then
|
||||||
# matches the relevant field. The whole subquery is wrapped in EXISTS
|
# matches the relevant field. The whole subquery is wrapped in EXISTS
|
||||||
@@ -2507,13 +2602,13 @@ class MetadataDB:
|
|||||||
f"Bonus {arr_type}%",
|
f"Bonus {arr_type}%",
|
||||||
] + extra_null_params
|
] + extra_null_params
|
||||||
where += (
|
where += (
|
||||||
" AND EXISTS (SELECT 1 FROM json_each(songs.arrangements) WHERE "
|
f" AND EXISTS (SELECT 1 FROM json_each({alias}.arrangements) WHERE "
|
||||||
+ " OR ".join(f"({c})" for c in clauses)
|
+ " OR ".join(f"({c})" for c in clauses)
|
||||||
+ ")"
|
+ ")"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
placeholders = ",".join(["?"] * len(arr_has))
|
placeholders = ",".join(["?"] * len(arr_has))
|
||||||
where += (" AND EXISTS (SELECT 1 FROM json_each(songs.arrangements) "
|
where += (f" AND EXISTS (SELECT 1 FROM json_each({alias}.arrangements) "
|
||||||
f"WHERE json_extract(value, '$.name') IN ({placeholders}))")
|
f"WHERE json_extract(value, '$.name') IN ({placeholders}))")
|
||||||
params += arr_has
|
params += arr_has
|
||||||
arr_lacks = [a for a in (arrangements_lacks or []) if a in self._ALLOWED_ARRANGEMENT_NAMES]
|
arr_lacks = [a for a in (arrangements_lacks or []) if a in self._ALLOWED_ARRANGEMENT_NAMES]
|
||||||
@@ -2553,29 +2648,29 @@ class MetadataDB:
|
|||||||
f"Bonus {arr_type}%",
|
f"Bonus {arr_type}%",
|
||||||
] + extra_null_params
|
] + extra_null_params
|
||||||
where += (
|
where += (
|
||||||
" AND NOT EXISTS (SELECT 1 FROM json_each(songs.arrangements) WHERE "
|
f" AND NOT EXISTS (SELECT 1 FROM json_each({alias}.arrangements) WHERE "
|
||||||
+ " OR ".join(f"({c})" for c in clauses)
|
+ " OR ".join(f"({c})" for c in clauses)
|
||||||
+ ")"
|
+ ")"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
placeholders = ",".join(["?"] * len(arr_lacks))
|
placeholders = ",".join(["?"] * len(arr_lacks))
|
||||||
where += (" AND NOT EXISTS (SELECT 1 FROM json_each(songs.arrangements) "
|
where += (f" AND NOT EXISTS (SELECT 1 FROM json_each({alias}.arrangements) "
|
||||||
f"WHERE json_extract(value, '$.name') IN ({placeholders}))")
|
f"WHERE json_extract(value, '$.name') IN ({placeholders}))")
|
||||||
params += arr_lacks
|
params += arr_lacks
|
||||||
stems_h = [s for s in (stems_has or []) if s in self._ALLOWED_STEM_IDS]
|
stems_h = [s for s in (stems_has or []) if s in self._ALLOWED_STEM_IDS]
|
||||||
if stems_h:
|
if stems_h:
|
||||||
placeholders = ",".join(["?"] * len(stems_h))
|
placeholders = ",".join(["?"] * len(stems_h))
|
||||||
where += (" AND EXISTS (SELECT 1 FROM json_each(songs.stem_ids) "
|
where += (f" AND EXISTS (SELECT 1 FROM json_each({alias}.stem_ids) "
|
||||||
f"WHERE value IN ({placeholders}))")
|
f"WHERE value IN ({placeholders}))")
|
||||||
params += stems_h
|
params += stems_h
|
||||||
stems_l = [s for s in (stems_lacks or []) if s in self._ALLOWED_STEM_IDS]
|
stems_l = [s for s in (stems_lacks or []) if s in self._ALLOWED_STEM_IDS]
|
||||||
if stems_l:
|
if stems_l:
|
||||||
placeholders = ",".join(["?"] * len(stems_l))
|
placeholders = ",".join(["?"] * len(stems_l))
|
||||||
where += (" AND NOT EXISTS (SELECT 1 FROM json_each(songs.stem_ids) "
|
where += (f" AND NOT EXISTS (SELECT 1 FROM json_each({alias}.stem_ids) "
|
||||||
f"WHERE value IN ({placeholders}))")
|
f"WHERE value IN ({placeholders}))")
|
||||||
params += stems_l
|
params += stems_l
|
||||||
if has_lyrics in (0, 1):
|
if has_lyrics in (0, 1):
|
||||||
where += " AND has_lyrics = ?"
|
where += f" AND {alias}.has_lyrics = ?"
|
||||||
params.append(has_lyrics)
|
params.append(has_lyrics)
|
||||||
if tunings:
|
if tunings:
|
||||||
# Keep the input cap conservative (32) so a hostile caller
|
# Keep the input cap conservative (32) so a hostile caller
|
||||||
@@ -2587,11 +2682,257 @@ class MetadataDB:
|
|||||||
# Match the same grouping key tuning_names() returns so a single
|
# Match the same grouping key tuning_names() returns so a single
|
||||||
# "Custom Tuning" pill selects exactly its offset set while named
|
# "Custom Tuning" pill selects exactly its offset set while named
|
||||||
# tunings still match by name.
|
# tunings still match by name.
|
||||||
where += (f" AND {_TUNING_GROUP_KEY_SQL} "
|
where += (f" AND {_tuning_group_key_sql(alias)} "
|
||||||
f"COLLATE NOCASE IN ({placeholders})")
|
f"COLLATE NOCASE IN ({placeholders})")
|
||||||
params += tn
|
params += tn
|
||||||
return where, params
|
return where, params
|
||||||
|
|
||||||
|
# Under group=1, chart-intrinsic filters match if ANY member of the work
|
||||||
|
# matches (§7.1 filter law). A pure predicate on the representative scan —
|
||||||
|
# no GROUP BY, no row multiplication — so the keyset cursor stays valid.
|
||||||
|
def _grouped_member_match(self, intrinsic_frag: str, intrinsic_params: list) -> tuple[str, list]:
|
||||||
|
if not intrinsic_frag:
|
||||||
|
return "", []
|
||||||
|
return ((" AND EXISTS (SELECT 1 FROM songs m JOIN work_display mw ON mw.filename = m.filename "
|
||||||
|
"WHERE mw.effective_work_key = (SELECT w0.effective_work_key FROM work_display w0 "
|
||||||
|
"WHERE w0.filename = songs.filename)" + intrinsic_frag + ")"),
|
||||||
|
list(intrinsic_params))
|
||||||
|
|
||||||
|
# ── Multi-chart grouping engine (P5a) ────────────────────────────────────
|
||||||
|
@staticmethod
|
||||||
|
def _norm_token(s, fold_the=False):
|
||||||
|
"""Fold a name to a comparison token: strip diacritics + punctuation +
|
||||||
|
whitespace, lowercase, optionally drop a leading 'the ' (artist names)."""
|
||||||
|
import re
|
||||||
|
import unicodedata
|
||||||
|
raw = str(s or "")
|
||||||
|
s = unicodedata.normalize("NFKD", raw)
|
||||||
|
s = "".join(c for c in s if not unicodedata.combining(c)).lower()
|
||||||
|
if fold_the:
|
||||||
|
s = re.sub(r"^the\s+", "", s)
|
||||||
|
folded = re.sub(r"[^a-z0-9]+", "", s)
|
||||||
|
if folded:
|
||||||
|
return folded
|
||||||
|
# All-non-Latin titles (CJK/Cyrillic/Greek/Arabic) fold to "" above,
|
||||||
|
# which would collapse every such song into one bogus work. Fall back to
|
||||||
|
# the raw text lowercased with whitespace collapsed so distinct titles
|
||||||
|
# keep distinct keys. Latin names always hit the `folded` branch, so
|
||||||
|
# their behavior is unchanged.
|
||||||
|
return re.sub(r"\s+", " ", raw.strip().lower())
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _work_key(cls, artist, title) -> str:
|
||||||
|
"""Identity of a musical WORK = normalize(artist)+'|'+normalize(title).
|
||||||
|
Recording-MBID identity is a later enrichment upgrade (§3); this text key
|
||||||
|
groups the common 'same song, several charts' case now."""
|
||||||
|
return cls._norm_token(artist, fold_the=True) + "|" + cls._norm_token(title)
|
||||||
|
|
||||||
|
def _alias_map_if_exists(self) -> dict:
|
||||||
|
"""{raw_artist_lower: canonical} from P4's artist_alias when that table is
|
||||||
|
present, so work_key groups across artist aliases (ACDC/AC/DC) once P4 is
|
||||||
|
merged; {} (→ raw artist) when it isn't. Forward-compatible, no hard P4 dep."""
|
||||||
|
try:
|
||||||
|
rows = self.conn.execute("SELECT raw_name, canonical_name FROM artist_alias").fetchall()
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
return {r[0].lower(): r[1] for r in rows}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _pick_representative(members: list, prefs: dict) -> str:
|
||||||
|
"""The keeper chart of a group: the user's chart_group_pref when its file
|
||||||
|
is present, else auto-pick = MOST-PLAYED (history-sticky, §7.1: real
|
||||||
|
practice wins — a newer/'more complete' import must not silently take
|
||||||
|
the pick from the chart your reps accrued on, and a one-off try of an
|
||||||
|
alternate can't out-rank a practiced incumbent) → most-complete
|
||||||
|
(arrangements) → newest → filename. An all-unplayed group therefore
|
||||||
|
still picks by completeness. `members` = dicts {fn, wk, arr, plays, mtime}."""
|
||||||
|
if members:
|
||||||
|
pref = prefs.get(members[0]["wk"])
|
||||||
|
if pref and any(m["fn"] == pref for m in members):
|
||||||
|
return pref
|
||||||
|
best = min(members, key=lambda m: (-m["plays"], -m["arr"], -m["mtime"], m["fn"]))
|
||||||
|
return best["fn"]
|
||||||
|
|
||||||
|
def _load_work_members(self):
|
||||||
|
"""Read songs + overrides → ({effective_work_key: [member dicts]}, prefs)."""
|
||||||
|
amap = self._alias_map_if_exists()
|
||||||
|
splits = dict(self.conn.execute(
|
||||||
|
"SELECT filename, split_key FROM chart_group_split").fetchall())
|
||||||
|
prefs = dict(self.conn.execute(
|
||||||
|
"SELECT work_key, preferred_filename FROM chart_group_pref").fetchall())
|
||||||
|
plays = dict(self.conn.execute(
|
||||||
|
"SELECT filename, SUM(plays) FROM song_stats GROUP BY filename").fetchall())
|
||||||
|
groups: dict = {}
|
||||||
|
for fn, artist, title, arr_json, mtime in self.conn.execute(
|
||||||
|
"SELECT filename, artist, title, arrangements, mtime FROM songs WHERE title != ''"):
|
||||||
|
wk = self._work_key(amap.get((artist or "").lower(), artist), title)
|
||||||
|
eff = splits.get(fn) or wk
|
||||||
|
try:
|
||||||
|
arr = len(json.loads(arr_json)) if arr_json else 0
|
||||||
|
except Exception:
|
||||||
|
arr = 0
|
||||||
|
groups.setdefault(eff, []).append(
|
||||||
|
{"fn": fn, "wk": wk, "arr": arr, "plays": int(plays.get(fn) or 0), "mtime": mtime or 0})
|
||||||
|
return groups, prefs
|
||||||
|
|
||||||
|
def rebuild_work_display(self) -> None:
|
||||||
|
"""Full re-materialization of work_display from songs + the override
|
||||||
|
tables. O(n) — cheap enough to run lazily after any songs churn."""
|
||||||
|
with self._lock:
|
||||||
|
groups, prefs = self._load_work_members()
|
||||||
|
out = []
|
||||||
|
for eff, members in groups.items():
|
||||||
|
rep = self._pick_representative(members, prefs)
|
||||||
|
n = len(members)
|
||||||
|
for m in members:
|
||||||
|
out.append((m["fn"], m["wk"], eff, 1 if m["fn"] == rep else 0, n))
|
||||||
|
self.conn.execute("DELETE FROM work_display")
|
||||||
|
if out:
|
||||||
|
self.conn.executemany(
|
||||||
|
"INSERT INTO work_display (filename, work_key, effective_work_key, "
|
||||||
|
"is_group_representative, group_size) VALUES (?, ?, ?, ?, ?)", out)
|
||||||
|
self.conn.commit()
|
||||||
|
self._work_display_dirty = False
|
||||||
|
|
||||||
|
def _ensure_work_display(self) -> None:
|
||||||
|
"""(Re)build the read-model when a change marked it dirty (or it's never
|
||||||
|
been built). Called at the top of every grouped query."""
|
||||||
|
if getattr(self, "_work_display_dirty", True):
|
||||||
|
self.rebuild_work_display()
|
||||||
|
|
||||||
|
def work_key_for(self, filename: str):
|
||||||
|
"""work_key of a song (from its current artist+title), or None if absent."""
|
||||||
|
row = self.conn.execute(
|
||||||
|
"SELECT artist, title FROM songs WHERE filename = ?", (filename,)).fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
amap = self._alias_map_if_exists()
|
||||||
|
return self._work_key(amap.get((row[0] or "").lower(), row[0]), row[1])
|
||||||
|
|
||||||
|
def set_chart_preferred(self, work_key: str, filename: str) -> None:
|
||||||
|
"""Pick the keeper chart of a work. Incremental: re-flips
|
||||||
|
is_group_representative within the work's (non-split) group only —
|
||||||
|
group_size is unchanged — so no full rebuild."""
|
||||||
|
with self._lock:
|
||||||
|
self.conn.execute(
|
||||||
|
"INSERT INTO chart_group_pref (work_key, preferred_filename, updated_at) "
|
||||||
|
"VALUES (?, ?, datetime('now')) "
|
||||||
|
"ON CONFLICT(work_key) DO UPDATE SET "
|
||||||
|
"preferred_filename = excluded.preferred_filename, updated_at = excluded.updated_at",
|
||||||
|
(work_key, filename))
|
||||||
|
if not self._work_display_dirty:
|
||||||
|
members = [r[0] for r in self.conn.execute(
|
||||||
|
"SELECT filename FROM work_display WHERE effective_work_key = ?",
|
||||||
|
(work_key,)).fetchall()]
|
||||||
|
if filename in members:
|
||||||
|
self.conn.execute(
|
||||||
|
"UPDATE work_display SET is_group_representative = "
|
||||||
|
"CASE WHEN filename = ? THEN 1 ELSE 0 END "
|
||||||
|
"WHERE effective_work_key = ?", (filename, work_key))
|
||||||
|
else:
|
||||||
|
# pref target isn't a current member (orphan/split) — reconcile
|
||||||
|
# on the next lazy rebuild rather than leave it half-applied.
|
||||||
|
self._work_display_dirty = True
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def clear_chart_preferred(self, work_key: str) -> None:
|
||||||
|
"""Reset a work to auto-pick; lazy full rebuild."""
|
||||||
|
with self._lock:
|
||||||
|
self.conn.execute("DELETE FROM chart_group_pref WHERE work_key = ?", (work_key,))
|
||||||
|
self._work_display_dirty = True
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def split_chart(self, filename: str) -> None:
|
||||||
|
"""'These aren't the same' — give a chart a unique split_key so it stands
|
||||||
|
alone as a singleton work. Lazy full rebuild (the old group's membership +
|
||||||
|
sizes shift)."""
|
||||||
|
wk = self.work_key_for(filename) or filename
|
||||||
|
with self._lock:
|
||||||
|
self.conn.execute(
|
||||||
|
"INSERT INTO chart_group_split (filename, split_key, updated_at) "
|
||||||
|
"VALUES (?, ?, datetime('now')) "
|
||||||
|
"ON CONFLICT(filename) DO UPDATE SET "
|
||||||
|
"split_key = excluded.split_key, updated_at = excluded.updated_at",
|
||||||
|
(filename, f"{wk}#split#{filename}"))
|
||||||
|
self._work_display_dirty = True
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def unsplit_chart(self, filename: str) -> None:
|
||||||
|
"""Undo a split — the chart rejoins its work. Lazy full rebuild."""
|
||||||
|
with self._lock:
|
||||||
|
self.conn.execute("DELETE FROM chart_group_split WHERE filename = ?", (filename,))
|
||||||
|
self._work_display_dirty = True
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def work_charts(self, work_key: str) -> dict:
|
||||||
|
"""Every chart in a work (P5b) — the Charts drawer's data. Members are the
|
||||||
|
work's CURRENT (non-split) group: work_display rows whose effective_work_key
|
||||||
|
matches. Each carries its effective title/artist, arrangements, tuning,
|
||||||
|
format, best accuracy, and the representative/preferred flags so the drawer
|
||||||
|
can label 'Preferred — your pick' vs 'Preferred (auto)'."""
|
||||||
|
self._ensure_work_display()
|
||||||
|
amap = self._alias_map_if_exists()
|
||||||
|
pref_row = self.conn.execute(
|
||||||
|
"SELECT preferred_filename FROM chart_group_pref WHERE work_key = ?", (work_key,)).fetchone()
|
||||||
|
pref_fn = pref_row[0] if pref_row else None
|
||||||
|
rows = self.conn.execute(
|
||||||
|
"SELECT wd.filename, wd.is_group_representative, s.title, s.artist, s.album, s.year, "
|
||||||
|
"s.arrangements, s.tuning_name, s.tuning, s.format, "
|
||||||
|
"(SELECT MAX(best_accuracy) FROM song_stats st WHERE st.filename = wd.filename AND st.plays > 0) "
|
||||||
|
"FROM work_display wd JOIN songs s ON s.filename = wd.filename "
|
||||||
|
"WHERE wd.effective_work_key = ? "
|
||||||
|
"ORDER BY wd.is_group_representative DESC, s.title COLLATE NOCASE, s.filename",
|
||||||
|
(work_key,)).fetchall()
|
||||||
|
charts = []
|
||||||
|
for fn, is_rep, title, artist, album, year, arr_json, tuning_name, tuning, fmt, best in rows:
|
||||||
|
try:
|
||||||
|
arrangements = _ensure_smart_names(json.loads(arr_json) if arr_json else [])
|
||||||
|
except Exception:
|
||||||
|
arrangements = []
|
||||||
|
charts.append({
|
||||||
|
"filename": fn,
|
||||||
|
"title": title or fn,
|
||||||
|
"artist": amap.get((artist or "").lower(), artist) or "",
|
||||||
|
"album": album or "", "year": year or "",
|
||||||
|
"arrangements": arrangements,
|
||||||
|
"tuning_name": tuning_name or "", "tuning": tuning or "",
|
||||||
|
"format": fmt or "archive",
|
||||||
|
"best_accuracy": best,
|
||||||
|
"is_representative": bool(is_rep),
|
||||||
|
"is_preferred": (fn == pref_fn),
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
"work_key": work_key,
|
||||||
|
"count": len(charts),
|
||||||
|
"preferred_filename": pref_fn,
|
||||||
|
# Whether the keeper is your explicit pick or the auto-pick — drives the
|
||||||
|
# drawer's "Preferred — your pick" vs "Preferred (auto)" label.
|
||||||
|
"preferred_source": "user" if pref_fn else "auto",
|
||||||
|
"charts": charts,
|
||||||
|
}
|
||||||
|
|
||||||
|
def chart_work(self, filename: str) -> dict:
|
||||||
|
"""The work a chart belongs to (P5d): its EFFECTIVE work_key (a split
|
||||||
|
chart resolves to its own singleton key) + how many charts share it.
|
||||||
|
Lets an opener resolve group membership for rows that didn't come from
|
||||||
|
a grouped query — the tree view's rows ride the ungrouped artists
|
||||||
|
endpoint, so they carry no chart_count/work_key annotation."""
|
||||||
|
key = self._canonical_song_filename(filename)
|
||||||
|
self._ensure_work_display()
|
||||||
|
row = self.conn.execute(
|
||||||
|
"SELECT effective_work_key, group_size FROM work_display WHERE filename = ?",
|
||||||
|
(key,)).fetchone()
|
||||||
|
if not row:
|
||||||
|
return {"filename": key, "work_key": None, "chart_count": 0, "is_split": False}
|
||||||
|
split = self.conn.execute(
|
||||||
|
"SELECT 1 FROM chart_group_split WHERE filename = ?", (key,)).fetchone()
|
||||||
|
return {"filename": key, "work_key": row[0], "chart_count": row[1],
|
||||||
|
"is_split": bool(split)}
|
||||||
|
|
||||||
|
# Predicate that narrows a query to one representative chart per work — the
|
||||||
|
# keyset-safe grouping filter (see query_page / query_stats).
|
||||||
|
_GROUP_REP_PREDICATE = " AND filename IN (SELECT filename FROM work_display WHERE is_group_representative = 1)"
|
||||||
|
|
||||||
def query_page(self, q: str = "", page: int = 0, size: int = 24,
|
def query_page(self, q: str = "", page: int = 0, size: int = 24,
|
||||||
sort: str = "artist", direction: str = "asc",
|
sort: str = "artist", direction: str = "asc",
|
||||||
favorites_only: bool = False,
|
favorites_only: bool = False,
|
||||||
@@ -2608,13 +2949,28 @@ class MetadataDB:
|
|||||||
tags_has: list[str] | None = None,
|
tags_has: list[str] | None = None,
|
||||||
user_difficulty_in: list[str] | None = None,
|
user_difficulty_in: list[str] | None = None,
|
||||||
after: str | None = None,
|
after: str | None = None,
|
||||||
|
group: bool = False,
|
||||||
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).
|
||||||
|
|
||||||
`after` is an opaque keyset cursor (the last row of the previous page).
|
`after` is an opaque keyset cursor (the last row of the previous page).
|
||||||
When supplied and the sort can keyset, the page is fetched with a
|
When supplied and the sort can keyset, the page is fetched with a
|
||||||
WHERE-seek instead of OFFSET — O(page), independent of depth. Unknown
|
WHERE-seek instead of OFFSET — O(page), independent of depth. Unknown
|
||||||
sorts / bad cursors fall back to OFFSET, so it's always safe."""
|
sorts / bad cursors fall back to OFFSET, so it's always safe.
|
||||||
|
|
||||||
|
`group` collapses a work's charts to one card (P5a): it adds a single
|
||||||
|
`WHERE is_group_representative = 1` predicate over the materialized
|
||||||
|
work_display, so the total counts WORKS not charts and the keyset seek /
|
||||||
|
sort / A–Z all stay correct over the representative subset. Each grouped
|
||||||
|
row carries `chart_count` (the ⚑ N).
|
||||||
|
|
||||||
|
Filter law under grouping (P5e, §7.1): work-identity (artist/album/q)
|
||||||
|
+ practice-state (favorites/mastery/tags/difficulty) predicates stay on
|
||||||
|
the representative row (identity ≈ the work; practice-state anchors on
|
||||||
|
the preferred chart), while CHART-INTRINSIC predicates (format/
|
||||||
|
arrangements/stems/lyrics/tuning) match if ANY member of the work does
|
||||||
|
— and when the representative itself doesn't match, the row carries a
|
||||||
|
`display_chart` override so the card can show/play the matching one."""
|
||||||
where, params = self._build_where(
|
where, params = self._build_where(
|
||||||
q=q, favorites_only=favorites_only, format_filter=format_filter,
|
q=q, favorites_only=favorites_only, format_filter=format_filter,
|
||||||
artist_filter=artist_filter, album_filter=album_filter,
|
artist_filter=artist_filter, album_filter=album_filter,
|
||||||
@@ -2622,8 +2978,20 @@ class MetadataDB:
|
|||||||
stems_has=stems_has, stems_lacks=stems_lacks,
|
stems_has=stems_has, stems_lacks=stems_lacks,
|
||||||
has_lyrics=has_lyrics, tunings=tunings, mastery=mastery,
|
has_lyrics=has_lyrics, tunings=tunings, mastery=mastery,
|
||||||
tags_has=tags_has, user_difficulty_in=user_difficulty_in,
|
tags_has=tags_has, user_difficulty_in=user_difficulty_in,
|
||||||
naming_mode=naming_mode,
|
naming_mode=naming_mode, include_intrinsic=not group,
|
||||||
)
|
)
|
||||||
|
ifrag, iparams = "", []
|
||||||
|
if group:
|
||||||
|
self._ensure_work_display()
|
||||||
|
ifrag, iparams = self._build_intrinsic_where(
|
||||||
|
"m", format_filter=format_filter,
|
||||||
|
arrangements_has=arrangements_has, arrangements_lacks=arrangements_lacks,
|
||||||
|
stems_has=stems_has, stems_lacks=stems_lacks,
|
||||||
|
has_lyrics=has_lyrics, tunings=tunings, naming_mode=naming_mode)
|
||||||
|
mfrag, mparams = self._grouped_member_match(ifrag, iparams)
|
||||||
|
where += mfrag
|
||||||
|
params += mparams
|
||||||
|
where += self._GROUP_REP_PREDICATE
|
||||||
|
|
||||||
sort_map = {
|
sort_map = {
|
||||||
"artist": "artist COLLATE NOCASE", "artist-desc": "artist COLLATE NOCASE DESC",
|
"artist": "artist COLLATE NOCASE", "artist-desc": "artist COLLATE NOCASE DESC",
|
||||||
@@ -2678,6 +3046,22 @@ class MetadataDB:
|
|||||||
"(SELECT MAX(best_accuracy) FROM song_stats s WHERE s.filename = songs.filename) DESC"
|
"(SELECT MAX(best_accuracy) FROM song_stats s WHERE s.filename = songs.filename) DESC"
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
if group and sort in ("mastery", "mastery-desc"):
|
||||||
|
# Sort law (§7.1): mastery aggregates MAX across the WHOLE group —
|
||||||
|
# a song surfaces on any chart you've touched, even when the
|
||||||
|
# preferred chart is unplayed. Mastery never keysets (OFFSET
|
||||||
|
# paging), so the aggregate can't disturb a cursor. The recency
|
||||||
|
# ("Recently Added") aggregate is deliberately NOT applied: mtime
|
||||||
|
# IS a keyset sort, so its aggregate would need materializing into
|
||||||
|
# work_display to stay cursor-safe — deferred until wanted (the
|
||||||
|
# auto-pick's `newest` factor already surfaces new charts of
|
||||||
|
# unplayed works; played works stay put by the sticky rule).
|
||||||
|
_gm = ("(SELECT MAX(st.best_accuracy) FROM song_stats st "
|
||||||
|
"JOIN work_display sw ON sw.filename = st.filename "
|
||||||
|
"WHERE sw.effective_work_key = (SELECT w1.effective_work_key "
|
||||||
|
"FROM work_display w1 WHERE w1.filename = songs.filename))")
|
||||||
|
sort_map["mastery"] = f"({_gm} IS NULL) ASC, {_gm} ASC"
|
||||||
|
sort_map["mastery-desc"] = f"({_gm} IS NULL) ASC, {_gm} 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
|
||||||
# clauses that don't already encode a direction. Compound /
|
# clauses that don't already encode a direction. Compound /
|
||||||
@@ -2693,28 +3077,39 @@ class MetadataDB:
|
|||||||
# also what makes keyset seeking correct.
|
# also what makes keyset seeking correct.
|
||||||
order += ", filename"
|
order += ", filename"
|
||||||
|
|
||||||
total = self.conn.execute(f"SELECT COUNT(*) FROM songs {where}", params).fetchone()[0]
|
# Grouped reads filter through the materialized work_display (the
|
||||||
|
# `is_group_representative=1` predicate). rebuild_work_display does
|
||||||
|
# DELETE→INSERT→commit under self._lock, so a lock-free reader on
|
||||||
|
# another thread (shared conn, check_same_thread=False) could land its
|
||||||
|
# SELECT in the mid-rebuild window and see 0 rows. Hold self._lock
|
||||||
|
# across the representative COUNT+SELECT so it can't overlap a rebuild.
|
||||||
|
# _ensure_work_display already rebuilt above under its own lock (and
|
||||||
|
# self._lock is NOT reentrant), so we must NOT nest it here. Ungrouped
|
||||||
|
# reads stay lock-free (WAL) via nullcontext.
|
||||||
|
read_guard = self._lock if group else contextlib.nullcontext()
|
||||||
|
with read_guard:
|
||||||
|
total = self.conn.execute(f"SELECT COUNT(*) FROM songs {where}", params).fetchone()[0]
|
||||||
|
|
||||||
cols = ("SELECT filename, title, artist, album, year, duration, tuning, "
|
cols = ("SELECT filename, title, artist, album, year, duration, tuning, "
|
||||||
"arrangements, has_lyrics, mtime, format, stem_count, stem_ids, "
|
"arrangements, has_lyrics, mtime, format, stem_count, stem_ids, "
|
||||||
"tuning_name, tuning_offsets FROM songs ")
|
"tuning_name, tuning_offsets FROM songs ")
|
||||||
cursor = _decode_cursor(after) if after else None
|
cursor = _decode_cursor(after) if after else None
|
||||||
eff_sort = _effective_keyset_sort(sort, direction)
|
eff_sort = _effective_keyset_sort(sort, direction)
|
||||||
if cursor and eff_sort in _KEYSET_SORTS:
|
if cursor and eff_sort in _KEYSET_SORTS:
|
||||||
# Keyset seek: rows strictly after the cursor in the total order
|
# Keyset seek: rows strictly after the cursor in the total order
|
||||||
# `<col> <dir>, filename ASC` (NULL-aware, so == OFFSET exactly).
|
# `<col> <dir>, filename ASC` (NULL-aware, so == OFFSET exactly).
|
||||||
col, collate, primary_dir = _KEYSET_SORTS[eff_sort]
|
col, collate, primary_dir = _KEYSET_SORTS[eff_sort]
|
||||||
seek, seek_params = _keyset_seek(col, collate, primary_dir, cursor[0], cursor[1])
|
seek, seek_params = _keyset_seek(col, collate, primary_dir, cursor[0], cursor[1])
|
||||||
seek_where = where + (" AND " if where else " WHERE ") + seek
|
seek_where = where + (" AND " if where else " WHERE ") + seek
|
||||||
rows = self.conn.execute(
|
rows = self.conn.execute(
|
||||||
f"{cols}{seek_where} ORDER BY {order} LIMIT ?",
|
f"{cols}{seek_where} ORDER BY {order} LIMIT ?",
|
||||||
params + seek_params + [size],
|
params + seek_params + [size],
|
||||||
).fetchall()
|
).fetchall()
|
||||||
else:
|
else:
|
||||||
rows = self.conn.execute(
|
rows = self.conn.execute(
|
||||||
f"{cols}{where} ORDER BY {order} LIMIT ? OFFSET ?",
|
f"{cols}{where} ORDER BY {order} LIMIT ? OFFSET ?",
|
||||||
params + [size, page * size],
|
params + [size, page * size],
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
|
||||||
estd = self._estd_set()
|
estd = self._estd_set()
|
||||||
favs = self.favorite_set()
|
favs = self.favorite_set()
|
||||||
@@ -2750,8 +3145,69 @@ class MetadataDB:
|
|||||||
s["tags"] = tgm.get(s["filename"], [])
|
s["tags"] = tgm.get(s["filename"], [])
|
||||||
if amap:
|
if amap:
|
||||||
s["artist"] = amap.get((s.get("artist") or "").lower(), s.get("artist"))
|
s["artist"] = amap.get((s.get("artist") or "").lower(), s.get("artist"))
|
||||||
|
# Grouped rows carry the ⚑ N (chart_count) + the work_key from the
|
||||||
|
# materialized read-model, so the card can render the "N charts" chip and
|
||||||
|
# address the Charts drawer (GET /api/work/{work_key}/charts) without a
|
||||||
|
# second request — plus `is_split` (P5e) so the ⋮ menu can offer the
|
||||||
|
# "Rejoin other versions" undo on a split-out chart.
|
||||||
|
if group and fns:
|
||||||
|
ph = ",".join("?" * len(fns))
|
||||||
|
wd = {r[0]: (r[1], r[2], r[3]) for r in self.conn.execute(
|
||||||
|
"SELECT filename, group_size, work_key, effective_work_key "
|
||||||
|
f"FROM work_display WHERE filename IN ({ph})", fns).fetchall()}
|
||||||
|
splits = {r[0] for r in self.conn.execute(
|
||||||
|
f"SELECT filename FROM chart_group_split WHERE filename IN ({ph})", fns).fetchall()}
|
||||||
|
eff_by_fn = {}
|
||||||
|
for s in songs:
|
||||||
|
gs, wk, eff = wd.get(s["filename"], (1, None, None))
|
||||||
|
s["chart_count"] = gs
|
||||||
|
s["work_key"] = wk
|
||||||
|
s["is_split"] = s["filename"] in splits
|
||||||
|
if eff:
|
||||||
|
eff_by_fn[s["filename"]] = eff
|
||||||
|
if ifrag:
|
||||||
|
self._attach_display_charts(songs, eff_by_fn, ifrag, iparams)
|
||||||
return songs, total
|
return songs, total
|
||||||
|
|
||||||
|
def _attach_display_charts(self, songs: list[dict], eff_by_fn: dict,
|
||||||
|
intrinsic_frag: str, intrinsic_params: list) -> None:
|
||||||
|
"""§7.1: when chart-intrinsic filters admit a work through a member the
|
||||||
|
REPRESENTATIVE doesn't itself satisfy, the card 'switches its displayed
|
||||||
|
chart to a matching one'. The row (sort keys, cursor identity, the
|
||||||
|
mastery/favorite anchor) stays the representative's — only the
|
||||||
|
display/play facts ride along under `display_chart`, so keyset paging
|
||||||
|
and the practice-state anchor are untouched. `intrinsic_frag`/`params`
|
||||||
|
are the member-aliased ('m') predicates already built by the caller."""
|
||||||
|
keys = sorted(set(eff_by_fn.values()))
|
||||||
|
if not keys:
|
||||||
|
return
|
||||||
|
ph = ",".join("?" * len(keys))
|
||||||
|
rows = self.conn.execute(
|
||||||
|
"SELECT mw.effective_work_key, m.filename, m.title, m.duration, m.tuning, "
|
||||||
|
"m.arrangements, m.has_lyrics, m.mtime, m.format, m.stem_count, m.stem_ids, "
|
||||||
|
"m.tuning_name, m.tuning_offsets "
|
||||||
|
"FROM songs m JOIN work_display mw ON mw.filename = m.filename "
|
||||||
|
f"WHERE mw.effective_work_key IN ({ph}){intrinsic_frag} "
|
||||||
|
"ORDER BY mw.is_group_representative DESC, m.mtime DESC, m.filename",
|
||||||
|
keys + list(intrinsic_params)).fetchall()
|
||||||
|
best: dict = {}
|
||||||
|
for r in rows:
|
||||||
|
best.setdefault(r[0], r) # rep-first, then newest — one match per work
|
||||||
|
for s in songs:
|
||||||
|
m = best.get(eff_by_fn.get(s["filename"]))
|
||||||
|
if not m or m[1] == s["filename"]:
|
||||||
|
continue # the representative itself matches (or nothing does)
|
||||||
|
s["display_chart"] = {
|
||||||
|
"filename": m[1], "title": m[2] or m[1], "duration": m[3],
|
||||||
|
"tuning": m[4],
|
||||||
|
"arrangements": _ensure_smart_names(json.loads(m[5]) if m[5] else []),
|
||||||
|
"has_lyrics": bool(m[6]), "mtime": m[7],
|
||||||
|
"format": m[8] or "archive",
|
||||||
|
"stem_count": int(m[9] or 0),
|
||||||
|
"stem_ids": json.loads(m[10]) if m[10] else [],
|
||||||
|
"tuning_name": m[11] or "", "tuning_offsets": m[12] or "",
|
||||||
|
}
|
||||||
|
|
||||||
def query_artists(self, letter: str = "", q: str = "",
|
def query_artists(self, letter: str = "", q: str = "",
|
||||||
favorites_only: bool = False,
|
favorites_only: bool = False,
|
||||||
page: int = 0, size: int = 50,
|
page: int = 0, size: int = 50,
|
||||||
@@ -2861,11 +3317,17 @@ class MetadataDB:
|
|||||||
tunings: list[str] | None = None,
|
tunings: list[str] | None = None,
|
||||||
sort: str = "artist",
|
sort: str = "artist",
|
||||||
want_sort_letters: bool = False,
|
want_sort_letters: bool = False,
|
||||||
|
group: bool = False,
|
||||||
naming_mode: str = "legacy") -> dict:
|
naming_mode: str = "legacy") -> dict:
|
||||||
"""Aggregate stats for the letter bar. Accepts the same filter
|
"""Aggregate stats for the letter bar. Accepts the same filter
|
||||||
params as query_page so the letter counts stay synchronized
|
params as query_page so the letter counts stay synchronized
|
||||||
with the grid when filters are active.
|
with the grid when filters are active.
|
||||||
|
|
||||||
|
`group` (P5a) restricts every count to one representative chart per work
|
||||||
|
(the same predicate query_page uses), so `total_songs` and the jump-rail
|
||||||
|
`sort_letters` count WORKS not charts and stay in lockstep with the
|
||||||
|
grouped grid.
|
||||||
|
|
||||||
`sort` selects the column the v3 jump rail's `sort_letters`
|
`sort` selects the column the v3 jump rail's `sort_letters`
|
||||||
breakdown keys on (artist for artist sorts, title for title
|
breakdown keys on (artist for artist sorts, title for title
|
||||||
sorts) so the rail's present-letters match the grid's actual
|
sorts) so the rail's present-letters match the grid's actual
|
||||||
@@ -2883,20 +3345,44 @@ class MetadataDB:
|
|||||||
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, naming_mode=naming_mode,
|
||||||
|
include_intrinsic=not group,
|
||||||
)
|
)
|
||||||
total = self.conn.execute(f"SELECT COUNT(*) FROM songs {where}", params).fetchone()[0]
|
if group:
|
||||||
# NOCASE collation here mirrors `query_artists` and the per-
|
# Same filter law as query_page (§7.1): chart-intrinsic predicates
|
||||||
# letter `COUNT(DISTINCT artist COLLATE NOCASE)` below — without
|
# match-if-ANY-member, applied identically here so the letter-bar
|
||||||
# it, an artist stored under two different casings would inflate
|
# counts stay in lockstep with the grouped grid.
|
||||||
# `total_artists` against the letter-bar breakdown the UI
|
self._ensure_work_display()
|
||||||
# renders next to it.
|
ifrag, iparams = self._build_intrinsic_where(
|
||||||
artist_count = self.conn.execute(
|
"m", format_filter=format_filter,
|
||||||
f"SELECT COUNT(DISTINCT artist COLLATE NOCASE) FROM songs {where}", params
|
arrangements_has=arrangements_has, arrangements_lacks=arrangements_lacks,
|
||||||
).fetchone()[0]
|
stems_has=stems_has, stems_lacks=stems_lacks,
|
||||||
rows = self.conn.execute(
|
has_lyrics=has_lyrics, tunings=tunings, naming_mode=naming_mode)
|
||||||
f"SELECT UPPER(SUBSTR(artist, 1, 1)) as letter, COUNT(DISTINCT artist COLLATE NOCASE) "
|
mfrag, mparams = self._grouped_member_match(ifrag, iparams)
|
||||||
f"FROM songs {where} GROUP BY letter", params
|
where += mfrag
|
||||||
).fetchall()
|
params += mparams
|
||||||
|
where += self._GROUP_REP_PREDICATE
|
||||||
|
# Grouped stat counts filter through work_display (same
|
||||||
|
# is_group_representative=1 predicate as query_page); hold self._lock
|
||||||
|
# across these representative SELECTs so they can't observe a
|
||||||
|
# mid-rebuild empty table (see query_page for the full rationale).
|
||||||
|
# _ensure_work_display already rebuilt above under its own lock, so we
|
||||||
|
# do NOT nest it here (self._lock is non-reentrant). Ungrouped reads
|
||||||
|
# stay lock-free (WAL) via nullcontext.
|
||||||
|
read_guard = self._lock if group else contextlib.nullcontext()
|
||||||
|
with read_guard:
|
||||||
|
total = self.conn.execute(f"SELECT COUNT(*) FROM songs {where}", params).fetchone()[0]
|
||||||
|
# NOCASE collation here mirrors `query_artists` and the per-
|
||||||
|
# letter `COUNT(DISTINCT artist COLLATE NOCASE)` below — without
|
||||||
|
# it, an artist stored under two different casings would inflate
|
||||||
|
# `total_artists` against the letter-bar breakdown the UI
|
||||||
|
# renders next to it.
|
||||||
|
artist_count = self.conn.execute(
|
||||||
|
f"SELECT COUNT(DISTINCT artist COLLATE NOCASE) FROM songs {where}", params
|
||||||
|
).fetchone()[0]
|
||||||
|
rows = self.conn.execute(
|
||||||
|
f"SELECT UPPER(SUBSTR(artist, 1, 1)) as letter, COUNT(DISTINCT artist COLLATE NOCASE) "
|
||||||
|
f"FROM songs {where} GROUP BY letter", params
|
||||||
|
).fetchall()
|
||||||
letters = {}
|
letters = {}
|
||||||
for letter, count in rows:
|
for letter, count in rows:
|
||||||
count = int(count or 0)
|
count = int(count or 0)
|
||||||
@@ -2917,10 +3403,12 @@ class MetadataDB:
|
|||||||
# only when the caller opts in, so non-rail callers skip the scan.
|
# only when the caller opts in, so non-rail callers skip the scan.
|
||||||
if want_sort_letters:
|
if want_sort_letters:
|
||||||
sort_col = "title" if sort in ("title", "title-desc") else "artist"
|
sort_col = "title" if sort in ("title", "title-desc") else "artist"
|
||||||
sort_rows = self.conn.execute(
|
# Same representative-SELECT lock guard as the counts above.
|
||||||
f"SELECT UPPER(SUBSTR(COALESCE({sort_col}, ''), 1, 1)) AS letter, COUNT(*) "
|
with read_guard:
|
||||||
f"FROM songs {where} GROUP BY letter", params
|
sort_rows = self.conn.execute(
|
||||||
).fetchall()
|
f"SELECT UPPER(SUBSTR(COALESCE({sort_col}, ''), 1, 1)) AS letter, COUNT(*) "
|
||||||
|
f"FROM songs {where} GROUP BY letter", params
|
||||||
|
).fetchall()
|
||||||
sort_letters: dict[str, int] = {}
|
sort_letters: dict[str, int] = {}
|
||||||
for letter, count in sort_rows:
|
for letter, count in sort_rows:
|
||||||
count = int(count or 0)
|
count = int(count or 0)
|
||||||
@@ -5410,6 +5898,14 @@ def delete_song(filename: str):
|
|||||||
# Personal difficulty / notes / tags for this song (we hold the
|
# Personal difficulty / notes / tags for this song (we hold the
|
||||||
# lock, so purge is lock-free).
|
# lock, so purge is lock-free).
|
||||||
meta_db.purge_song_user_data(cache_key)
|
meta_db.purge_song_user_data(cache_key)
|
||||||
|
# Multi-chart grouping (P5a): drop this chart's split + read-model rows,
|
||||||
|
# and any preferred-chart pointer that named it (the work re-auto-picks).
|
||||||
|
# work_key-keyed prefs for OTHER charts survive. Mark the read-model
|
||||||
|
# dirty so the affected work regroups on the next grouped query.
|
||||||
|
meta_db.conn.execute("DELETE FROM chart_group_split WHERE filename = ?", (cache_key,))
|
||||||
|
meta_db.conn.execute("DELETE FROM work_display WHERE filename = ?", (cache_key,))
|
||||||
|
meta_db.conn.execute("DELETE FROM chart_group_pref WHERE preferred_filename = ?", (cache_key,))
|
||||||
|
meta_db._work_display_dirty = True
|
||||||
meta_db.conn.commit()
|
meta_db.conn.commit()
|
||||||
|
|
||||||
_invalidate_song_caches(cache_key)
|
_invalidate_song_caches(cache_key)
|
||||||
@@ -5512,7 +6008,7 @@ async def list_library(q: str = "", page: int = 0, size: int = 24, sort: 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",
|
||||||
mastery: str = "", tags: str = "", user_difficulty: str = "",
|
mastery: str = "", tags: str = "", user_difficulty: str = "",
|
||||||
after: str = "", naming_mode: str = "legacy"):
|
after: str = "", group: int = 0, 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
|
||||||
@@ -5535,6 +6031,7 @@ async def list_library(q: str = "", page: int = 0, size: int = 24, sort: str = "
|
|||||||
sort=sort,
|
sort=sort,
|
||||||
direction=dir,
|
direction=dir,
|
||||||
after=((after or None) if is_local else None),
|
after=((after or None) if is_local else None),
|
||||||
|
group=bool(group),
|
||||||
naming_mode=naming_mode,
|
naming_mode=naming_mode,
|
||||||
mastery=_split_csv(mastery),
|
mastery=_split_csv(mastery),
|
||||||
tags_has=_split_csv(tags),
|
tags_has=_split_csv(tags),
|
||||||
@@ -5554,6 +6051,66 @@ async def list_library(q: str = "", page: int = 0, size: int = 24, sort: str = "
|
|||||||
"next_cursor": next_cursor}
|
"next_cursor": next_cursor}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Multi-chart work grouping API (P5b) ──────────────────────────────────────
|
||||||
|
# Read + manage the charts of a work (the P5d Charts drawer consumes this). The
|
||||||
|
# grouping engine lives in MetadataDB (P5a); these are its HTTP surface. Local
|
||||||
|
# library only. NOTE: a scoped "work changed" repaint broadcast for OTHER open
|
||||||
|
# views is deferred to P5d — there's no server-side library event bus today, and
|
||||||
|
# the drawer updates itself from these responses.
|
||||||
|
|
||||||
|
@app.get("/api/work/{work_key:path}/charts")
|
||||||
|
def api_get_work_charts(work_key: str):
|
||||||
|
"""All charts in a work + which is the keeper (your pick vs auto-pick)."""
|
||||||
|
return meta_db.work_charts(work_key)
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/api/work/{work_key:path}/preferred")
|
||||||
|
def api_set_work_preferred(work_key: str, data: dict):
|
||||||
|
"""Set the keeper chart of a work: body {filename}. The filename must be a
|
||||||
|
current member of the work. Returns the refreshed chart list."""
|
||||||
|
fn = (data.get("filename") or "").strip()
|
||||||
|
if not fn:
|
||||||
|
return JSONResponse({"error": "filename is required"}, 400)
|
||||||
|
members = {c["filename"] for c in meta_db.work_charts(work_key)["charts"]}
|
||||||
|
if fn not in members:
|
||||||
|
return JSONResponse({"error": "filename is not a chart of this work"}, 400)
|
||||||
|
meta_db.set_chart_preferred(work_key, fn)
|
||||||
|
return meta_db.work_charts(work_key)
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/work/{work_key:path}/preferred")
|
||||||
|
def api_reset_work_preferred(work_key: str):
|
||||||
|
"""Reset a work to auto-pick (drop the explicit preferred)."""
|
||||||
|
meta_db.clear_chart_preferred(work_key)
|
||||||
|
return meta_db.work_charts(work_key)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/chart/{filename:path}/split")
|
||||||
|
def api_split_chart(filename: str):
|
||||||
|
"""'These aren't the same song' — split this chart out as its own singleton
|
||||||
|
work. Under /api/chart (NOT /api/song) so the DELETE /api/song/{path}
|
||||||
|
catch-all can't shadow it."""
|
||||||
|
key = meta_db._canonical_song_filename(filename)
|
||||||
|
meta_db.split_chart(key)
|
||||||
|
return {"ok": True, "filename": key}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/chart/{filename:path}/unsplit")
|
||||||
|
def api_unsplit_chart(filename: str):
|
||||||
|
"""Undo a split — rejoin the chart to its work."""
|
||||||
|
key = meta_db._canonical_song_filename(filename)
|
||||||
|
meta_db.unsplit_chart(key)
|
||||||
|
return {"ok": True, "filename": key}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/chart/{filename:path}/work")
|
||||||
|
def api_get_chart_work(filename: str):
|
||||||
|
"""Resolve a chart's work membership: {work_key, chart_count}. For openers
|
||||||
|
on rows that came from an ungrouped query (the tree view) — grouped grid
|
||||||
|
rows already carry both fields inline."""
|
||||||
|
return meta_db.chart_work(filename)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/library/artists")
|
@app.get("/api/library/artists")
|
||||||
async def list_artists(letter: str = "", q: str = "", favorites: int = 0, page: int = 0,
|
async def list_artists(letter: str = "", q: str = "", favorites: int = 0, page: int = 0,
|
||||||
size: int = 50, format: str = "",
|
size: int = 50, format: str = "",
|
||||||
@@ -5591,12 +6148,13 @@ async def library_stats(favorites: int = 0, q: str = "", format: 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",
|
||||||
sort: str = "artist", sort_letters: int = 0,
|
sort: str = "artist", sort_letters: int = 0,
|
||||||
naming_mode: str = "legacy"):
|
group: int = 0, naming_mode: str = "legacy"):
|
||||||
"""Aggregate stats for the UI. Accepts the same filter params as
|
"""Aggregate stats for the UI. Accepts the same filter params as
|
||||||
/api/library so the letter bar mirrors the active grid filter set.
|
/api/library so the letter bar mirrors the active grid filter set.
|
||||||
`sort` selects the column the jump rail's `sort_letters` keys on;
|
`sort` selects the column the jump rail's `sort_letters` keys on;
|
||||||
`sort_letters=1` opts into that breakdown (the rail), so non-rail
|
`sort_letters=1` opts into that breakdown (the rail), so non-rail
|
||||||
callers skip the extra per-letter aggregate."""
|
callers skip the extra per-letter aggregate. `group=1` counts works not
|
||||||
|
charts (mirrors the grouped grid)."""
|
||||||
library_provider = _get_library_provider(provider)
|
library_provider = _get_library_provider(provider)
|
||||||
_require_library_provider_capability(library_provider, "library.read")
|
_require_library_provider_capability(library_provider, "library.read")
|
||||||
return await _call_library_provider_async(
|
return await _call_library_provider_async(
|
||||||
@@ -5605,6 +6163,7 @@ async def library_stats(favorites: int = 0, q: str = "", format: str = "",
|
|||||||
naming_mode=naming_mode,
|
naming_mode=naming_mode,
|
||||||
sort=sort,
|
sort=sort,
|
||||||
want_sort_letters=bool(sort_letters),
|
want_sort_letters=bool(sort_letters),
|
||||||
|
group=bool(group),
|
||||||
**_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,
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+365
-15
@@ -57,6 +57,8 @@
|
|||||||
const state = {
|
const state = {
|
||||||
provider: 'local', view: 'grid', sort: 'artist', format: '', q: '',
|
provider: 'local', view: 'grid', sort: 'artist', format: '', q: '',
|
||||||
artist: '', album: '',
|
artist: '', album: '',
|
||||||
|
grouping: true, // one card per song (multi-chart grouping); persisted
|
||||||
|
|
||||||
filters: { arr_has: [], arr_lacks: [], stem_has: [], stem_lacks: [], lyrics: '', tunings: [], mastery: [] },
|
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: '',
|
||||||
@@ -149,6 +151,7 @@
|
|||||||
if (SORTS.some(([v]) => v === saved.sort)) state.sort = saved.sort;
|
if (SORTS.some(([v]) => v === saved.sort)) state.sort = saved.sort;
|
||||||
if (FORMATS.some(([v]) => v === saved.format)) state.format = saved.format;
|
if (FORMATS.some(([v]) => v === saved.format)) state.format = saved.format;
|
||||||
if (saved.view === 'grid' || saved.view === 'tree' || saved.view === 'folder') state.view = saved.view;
|
if (saved.view === 'grid' || saved.view === 'tree' || saved.view === 'folder') state.view = saved.view;
|
||||||
|
if (typeof saved.grouping === 'boolean') state.grouping = saved.grouping;
|
||||||
const f = saved.filters;
|
const f = saved.filters;
|
||||||
if (f && typeof f === 'object') {
|
if (f && typeof f === 'object') {
|
||||||
const arr = (x) => (Array.isArray(x) ? x.slice() : []);
|
const arr = (x) => (Array.isArray(x) ? x.slice() : []);
|
||||||
@@ -167,6 +170,7 @@
|
|||||||
const f = state.filters;
|
const f = state.filters;
|
||||||
localStorage.setItem(PREFS_KEY, JSON.stringify({
|
localStorage.setItem(PREFS_KEY, JSON.stringify({
|
||||||
sort: state.sort, format: state.format, view: state.view,
|
sort: state.sort, format: state.format, view: state.view,
|
||||||
|
grouping: state.grouping !== false,
|
||||||
filters: {
|
filters: {
|
||||||
arr_has: [...f.arr_has], arr_lacks: [...f.arr_lacks],
|
arr_has: [...f.arr_has], arr_lacks: [...f.arr_lacks],
|
||||||
stem_has: [...f.stem_has], stem_lacks: [...f.stem_lacks],
|
stem_has: [...f.stem_has], stem_lacks: [...f.stem_lacks],
|
||||||
@@ -228,6 +232,20 @@
|
|||||||
return !!(tree.querySelector('[data-fn]') || tree.querySelector('details'));
|
return !!(tree.querySelector('[data-fn]') || tree.querySelector('details'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Multi-chart grouping (P5c, design §7.1) ─────────────────────────────
|
||||||
|
// The grid queries with group=1 so charts of the same work collapse to ONE
|
||||||
|
// card — the representative (preferred/auto-pick) chart — with rows carrying
|
||||||
|
// chart_count + work_key from the materialized work_display read-model
|
||||||
|
// (P5a/P5b). group must ride BOTH the page fetch and the rail's stats fetch
|
||||||
|
// so page, total and sort_letters all count works identically — a
|
||||||
|
// works-vs-charts mismatch would break the rail's cumulative-seek math and
|
||||||
|
// the sizer geometry. Grouping is default-ON per the design, with a
|
||||||
|
// persisted toggle in the filter drawer (P5e) — OFF falls back to today's
|
||||||
|
// one-card-per-chart. Only the local provider implements group=; smart
|
||||||
|
// collections and remote providers ignore it and stay flat (their rows
|
||||||
|
// then carry no chart_count, so no ⚑ chips render).
|
||||||
|
function groupingActive() { return state.grouping !== false; }
|
||||||
|
|
||||||
function queryParams(extra, opts) {
|
function queryParams(extra, opts) {
|
||||||
const f = state.filters;
|
const f = state.filters;
|
||||||
const skipArtistAlbum = opts && opts.catalog;
|
const skipArtistAlbum = opts && opts.catalog;
|
||||||
@@ -660,6 +678,19 @@
|
|||||||
'<button data-arr="' + esc(a.index != null ? a.index : '') + '" title="Play ' + esc(a.name) + '" class="text-[10px] px-1.5 py-0.5 rounded bg-gray-800/60 text-fb-textDim hover:bg-fb-primary hover:text-white transition">' + esc(a.name) + '</button>').join('');
|
'<button data-arr="' + esc(a.index != null ? a.index : '') + '" title="Play ' + esc(a.name) + '" class="text-[10px] px-1.5 py-0.5 rounded bg-gray-800/60 text-fb-textDim hover:bg-fb-primary hover:text-white transition">' + esc(a.name) + '</button>').join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ⚑ multi-chart chip (P5c, design §7.1): the persistent "other versions
|
||||||
|
// exist" cue on a grouped card. Rendered ONLY when the grouped query says
|
||||||
|
// this card stands for 2+ charts of one work (chart_count = work_display
|
||||||
|
// group_size, P5a) — a single-chart card emits nothing, so its markup stays
|
||||||
|
// byte-identical to an ungrouped card. First in the chip row + shrink-0 so
|
||||||
|
// the overflow-hidden row clips arrangement chips before it ever clips the
|
||||||
|
// cue. Clicking it opens the Charts drawer (see wireCards).
|
||||||
|
function chartsChipHtml(song) {
|
||||||
|
const n = song.chart_count;
|
||||||
|
if (!(n >= 2) || !song.work_key) return '';
|
||||||
|
return '<button data-charts="' + esc(song.work_key) + '" title="' + n + ' charts of this song" aria-label="' + n + ' charts of this song" class="shrink-0 text-[10px] px-1.5 py-0.5 rounded bg-fb-primary/15 text-fb-primary border border-fb-primary/40 hover:bg-fb-primary hover:text-white transition">⚑ ' + n + ' charts</button>';
|
||||||
|
}
|
||||||
|
|
||||||
// ── Tuning-match flags (working-tuning PR 6) ───────────────────────────────
|
// ── Tuning-match flags (working-tuning PR 6) ───────────────────────────────
|
||||||
// Colour each song's tuning chip by whether your CURRENT working tuning covers
|
// Colour each song's tuning chip by whether your CURRENT working tuning covers
|
||||||
// it: green = play it now, amber = needs a retune. Uses the tuner plugin's
|
// it: green = play it now, amber = needs a retune. Uses the tuner plugin's
|
||||||
@@ -698,16 +729,22 @@
|
|||||||
function songCard(song) {
|
function songCard(song) {
|
||||||
const fav = song.favorite;
|
const fav = song.favorite;
|
||||||
const key = cardKey(song);
|
const key = cardKey(song);
|
||||||
|
// §7.1 display-chart switch (P5e): under a chart-intrinsic filter the
|
||||||
|
// server may attach `display_chart` — the member that MATCHES the
|
||||||
|
// filter when the representative doesn't. The card SHOWS and PLAYS
|
||||||
|
// that chart, while the row identity (sort keys, data-fn, the
|
||||||
|
// accuracy/heart anchor = the preferred chart) stays the rep's.
|
||||||
|
const shown = song.display_chart ? Object.assign({}, song, song.display_chart) : song;
|
||||||
// In select mode the checkbox occupies top-2 left-2, so shift the
|
// In select mode the checkbox occupies top-2 left-2, so shift the
|
||||||
// tuning chip right (left-9) to avoid overlapping it.
|
// tuning chip right (left-9) to avoid overlapping it.
|
||||||
const tuningLabel = (typeof window.displayTuningName === 'function')
|
const tuningLabel = (typeof window.displayTuningName === 'function')
|
||||||
? window.displayTuningName(song.tuning_name || song.tuning)
|
? window.displayTuningName(shown.tuning_name || shown.tuning)
|
||||||
: (song.tuning_name || '');
|
: (shown.tuning_name || '');
|
||||||
let tuning = '';
|
let tuning = '';
|
||||||
if (tuningLabel) {
|
if (tuningLabel) {
|
||||||
const rawOffsets = (typeof window.parseRawTuningOffsets === 'function')
|
const rawOffsets = (typeof window.parseRawTuningOffsets === 'function')
|
||||||
? (window.parseRawTuningOffsets(song.tuning_offsets)
|
? (window.parseRawTuningOffsets(shown.tuning_offsets)
|
||||||
|| window.parseRawTuningOffsets(song.tuning_name || song.tuning))
|
|| window.parseRawTuningOffsets(shown.tuning_name || shown.tuning))
|
||||||
: null;
|
: null;
|
||||||
const targetNotes = (tuningLabel === 'Custom Tuning' && rawOffsets
|
const targetNotes = (tuningLabel === 'Custom Tuning' && rawOffsets
|
||||||
&& typeof window.displayTuningTargets === 'function')
|
&& typeof window.displayTuningTargets === 'function')
|
||||||
@@ -722,7 +759,7 @@
|
|||||||
// Also flag a bass-only song (every arrangement is a bass part) so coverage
|
// Also flag a bass-only song (every arrangement is a bass part) so coverage
|
||||||
// scores its bass tuning against the bass base pitches, not guitar — otherwise
|
// scores its bass tuning against the bass base pitches, not guitar — otherwise
|
||||||
// a 4-string bass tuning read as guitar can false-match a guitar player.
|
// a 4-string bass tuning read as guitar can false-match a guitar player.
|
||||||
const chipArrs = song.arrangements || [];
|
const chipArrs = shown.arrangements || [];
|
||||||
const chipIsBass = chipArrs.length > 0
|
const chipIsBass = chipArrs.length > 0
|
||||||
&& chipArrs.every((a) => /\bbass\b/i.test((a && a.name) || ''));
|
&& chipArrs.every((a) => /\bbass\b/i.test((a && a.name) || ''));
|
||||||
const matchAttr = (rawOffsets && rawOffsets.length)
|
const matchAttr = (rawOffsets && rawOffsets.length)
|
||||||
@@ -740,7 +777,8 @@
|
|||||||
const checkbox = state.selectMode
|
const checkbox = state.selectMode
|
||||||
? '<input type="checkbox" data-select class="absolute top-2 left-2 z-20 w-5 h-5 accent-fb-primary pointer-events-none"' + (state.selected.has(key) ? ' checked' : '') + '>'
|
? '<input type="checkbox" data-select class="absolute top-2 left-2 z-20 w-5 h-5 accent-fb-primary pointer-events-none"' + (state.selected.has(key) ? ' checked' : '') + '>'
|
||||||
: '';
|
: '';
|
||||||
const arrChips = arrChipsHtml(song);
|
const arrChips = arrChipsHtml(shown);
|
||||||
|
const chartsChip = chartsChipHtml(song);
|
||||||
// Plugin-contributed card actions placed 'inline' (in the hover action
|
// Plugin-contributed card actions placed 'inline' (in the hover action
|
||||||
// row) or 'overlay' (centered over the art). Menu-placed actions live in
|
// row) or 'overlay' (centered over the art). Menu-placed actions live in
|
||||||
// the ⋮ menu (openCardMenu); rendering these here means plugins using
|
// the ⋮ menu (openCardMenu); rendering these here means plugins using
|
||||||
@@ -765,20 +803,20 @@
|
|||||||
const selRing = state.selected.has(key) ? ' ring-2 ring-fb-primary' : '';
|
const selRing = state.selected.has(key) ? ' ring-2 ring-fb-primary' : '';
|
||||||
return '<div class="group relative" data-fn="' + esc(key) + '" data-letter="' + esc(songBucket(song)) + '" data-library-song="' + esc(songId(song)) + '" data-library-provider="' + esc(state.provider) + '">' +
|
return '<div class="group relative" data-fn="' + esc(key) + '" data-letter="' + esc(songBucket(song)) + '" data-library-song="' + esc(songId(song)) + '" data-library-provider="' + esc(state.provider) + '">' +
|
||||||
'<div class="relative aspect-square rounded-lg overflow-hidden bg-fb-card cursor-pointer' + selRing + '" data-v3-play>' +
|
'<div class="relative aspect-square rounded-lg overflow-hidden bg-fb-card cursor-pointer' + selRing + '" data-v3-play>' +
|
||||||
'<img src="' + esc(artUrl(song)) + '" alt="" loading="lazy" decoding="async" class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105" onerror="this.style.visibility=\'hidden\'">' +
|
'<img src="' + esc(artUrl(shown)) + '" alt="" loading="lazy" decoding="async" class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105" onerror="this.style.visibility=\'hidden\'">' +
|
||||||
tuning + checkbox + accuracyBadge(key) + fmtBadge(song) + personalBadges(song) + overlay +
|
tuning + checkbox + accuracyBadge(key) + fmtBadge(shown) + personalBadges(song) + overlay +
|
||||||
'<div class="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition">' +
|
'<div class="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition">' +
|
||||||
inlineBtns +
|
inlineBtns +
|
||||||
'<button data-fav title="Favorite" aria-label="Favorite" aria-pressed="' + (fav ? 'true' : 'false') + '" class="w-7 h-7 rounded-full bg-black/50 hover:bg-black/70 flex items-center justify-center text-sm ' + (fav ? 'text-fb-accent' : 'text-white') + '">' + (fav ? '♥' : '♡') + '</button>' +
|
'<button data-fav title="Favorite" aria-label="Favorite" aria-pressed="' + (fav ? 'true' : 'false') + '" class="w-7 h-7 rounded-full bg-black/50 hover:bg-black/70 flex items-center justify-center text-sm ' + (fav ? 'text-fb-accent' : 'text-white') + '">' + (fav ? '♥' : '♡') + '</button>' +
|
||||||
'<button data-save title="Save for later" aria-label="Save for later" class="w-7 h-7 rounded-full bg-black/50 hover:bg-black/70 flex items-center justify-center text-white text-sm">🔖</button>' +
|
'<button data-save title="Save for later" aria-label="Save for later" class="w-7 h-7 rounded-full bg-black/50 hover:bg-black/70 flex items-center justify-center text-white text-sm">🔖</button>' +
|
||||||
'<button data-menu title="More" aria-label="More actions" class="w-7 h-7 rounded-full bg-black/50 hover:bg-black/70 flex items-center justify-center text-white text-sm leading-none">⋮</button>' +
|
'<button data-menu title="More" aria-label="More actions" class="w-7 h-7 rounded-full bg-black/50 hover:bg-black/70 flex items-center justify-center text-white text-sm leading-none">⋮</button>' +
|
||||||
'</div></div>' +
|
'</div></div>' +
|
||||||
'<div class="mt-1 text-sm text-fb-text truncate" title="' + esc(song.title) + '">' + esc(song.title) + '</div>' +
|
'<div class="mt-1 text-sm text-fb-text truncate" title="' + esc(shown.title) + '">' + esc(shown.title) + '</div>' +
|
||||||
'<div class="text-xs text-fb-textDim truncate">' + esc(song.artist) + '</div>' +
|
'<div class="text-xs text-fb-textDim truncate">' + esc(song.artist) + '</div>' +
|
||||||
// Always emit the chip row (even when empty) at a FIXED single-line
|
// Always emit the chip row (even when empty) at a FIXED single-line
|
||||||
// height — uniform card height is what makes the windowed grid's
|
// height — uniform card height is what makes the windowed grid's
|
||||||
// absolute-position math exact (.v3-card-chips in v3.css).
|
// absolute-position math exact (.v3-card-chips in v3.css).
|
||||||
'<div class="v3-card-chips flex gap-1 mt-1">' + arrChips + '</div>' +
|
'<div class="v3-card-chips flex gap-1 mt-1">' + chartsChip + arrChips + '</div>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -796,8 +834,23 @@
|
|||||||
const items = (reg ? reg.list(song) : []).filter((a) => !a.placement || a.placement === 'menu');
|
const items = (reg ? reg.list(song) : []).filter((a) => !a.placement || a.placement === 'menu');
|
||||||
const menu = document.createElement('div');
|
const menu = document.createElement('div');
|
||||||
menu.className = 'v3-card-menu absolute top-10 right-2 z-30 min-w-[10rem] bg-fb-card border border-fb-border/60 rounded-lg shadow-xl py-1 text-sm';
|
menu.className = 'v3-card-menu absolute top-10 right-2 z-30 min-w-[10rem] bg-fb-card border border-fb-border/60 rounded-lg shadow-xl py-1 text-sm';
|
||||||
|
// Multi-chart entries (P5d): a grouped grid row carries chart_count +
|
||||||
|
// work_key (P5a annotation), so the menu knows inline whether this card
|
||||||
|
// stands for versions. Local library only — the work API is local.
|
||||||
|
const canCharts = state.provider === 'local' && song.work_key && song.chart_count >= 2;
|
||||||
|
// Play follows the DISPLAYED chart (see wireCards) — under an intrinsic
|
||||||
|
// filter that's the matching member, not the representative.
|
||||||
|
const playTarget = song.display_chart ? Object.assign({}, song, song.display_chart) : song;
|
||||||
const rows = [
|
const rows = [
|
||||||
{ id: '__play', label: 'Play', run: () => { _saveLibraryScrollSnapshot(); window.playSong && window.playSong(enc(song.filename)); } },
|
{ id: '__play', label: 'Play', run: () => { _saveLibraryScrollSnapshot(); window.playSong && window.playSong(enc(playTarget.filename)); } },
|
||||||
|
...(canCharts ? [
|
||||||
|
{ id: '__charts', label: 'Charts (' + song.chart_count + ')…' },
|
||||||
|
{ id: '__playver', label: 'Play version ▸' },
|
||||||
|
] : []),
|
||||||
|
// Undo for the drawer's "Split out" (P5e) — a split chart is its
|
||||||
|
// own singleton card (no ⚑ chip), so this is its only way back.
|
||||||
|
...(state.provider === 'local' && song.is_split
|
||||||
|
? [{ id: '__unsplit', label: 'Rejoin other versions' }] : []),
|
||||||
{ id: '__playlist', label: 'Add to playlist' },
|
{ id: '__playlist', label: 'Add to playlist' },
|
||||||
...items.map((a) => ({ id: a.id, label: a.label, destructive: a.destructive, enabled: a.enabled, plugin: a.pluginId })),
|
...items.map((a) => ({ id: a.id, label: a.label, destructive: a.destructive, enabled: a.enabled, plugin: a.pluginId })),
|
||||||
];
|
];
|
||||||
@@ -816,30 +869,308 @@
|
|||||||
menu.querySelectorAll('[data-act]').forEach((b) => b.addEventListener('click', async (e) => {
|
menu.querySelectorAll('[data-act]').forEach((b) => b.addEventListener('click', async (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const id = b.getAttribute('data-act');
|
const id = b.getAttribute('data-act');
|
||||||
|
// 'Play version ▸' swaps THIS menu's rows for the work's charts —
|
||||||
|
// it must expand in place, so it's the one entry that doesn't close.
|
||||||
|
if (id === '__playver') { await _expandPlayVersions(menu, song, closeMenu); return; }
|
||||||
closeMenu();
|
closeMenu();
|
||||||
if (id === '__play') { playCard(song); return; }
|
if (id === '__play') { playCard(playTarget); return; }
|
||||||
|
if (id === '__charts') { openChartsDrawer(song.work_key, song); return; }
|
||||||
|
if (id === '__unsplit') {
|
||||||
|
if (await jsend('POST', '/api/chart/' + enc(song.filename) + '/unsplit')) _groupChanged();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (id === '__playlist') { await addFilenamesToPlaylist([song.filename]); return; }
|
if (id === '__playlist') { await addFilenamesToPlaylist([song.filename]); return; }
|
||||||
if (reg) await reg.run(id, song, { source: 'v3-songs' });
|
if (reg) await reg.run(id, song, { source: 'v3-songs' });
|
||||||
}));
|
}));
|
||||||
|
// Tree rows ride the (ungrouped) artists endpoint, so they don't carry
|
||||||
|
// chart_count/work_key — resolve the work lazily and slot a
|
||||||
|
// "Charts (N)…" entry into the still-open menu when versions exist.
|
||||||
|
// Grid rows already carry both (chart_count defined ⇒ no fetch).
|
||||||
|
if (state.provider === 'local' && song.chart_count === undefined && song.filename) {
|
||||||
|
jget('/api/chart/' + enc(song.filename) + '/work').then((w) => {
|
||||||
|
if (!w || !menu.isConnected) return;
|
||||||
|
const addEntry = (label, run) => {
|
||||||
|
const b = document.createElement('button');
|
||||||
|
b.className = 'w-full text-left px-3 py-1.5 hover:bg-fb-card/60 text-fb-text';
|
||||||
|
b.textContent = label;
|
||||||
|
b.addEventListener('click', (e) => { e.stopPropagation(); closeMenu(); run(); });
|
||||||
|
menu.appendChild(b);
|
||||||
|
};
|
||||||
|
if (w.chart_count >= 2 && w.work_key) {
|
||||||
|
addEntry('Charts (' + w.chart_count + ')…', () => openChartsDrawer(w.work_key, song));
|
||||||
|
}
|
||||||
|
if (w.is_split) {
|
||||||
|
addEntry('Rejoin other versions', async () => {
|
||||||
|
if (await jsend('POST', '/api/chart/' + enc(song.filename) + '/unsplit')) _groupChanged();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
setTimeout(() => document.addEventListener('click', closer), 0);
|
setTimeout(() => document.addEventListener('click', closer), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 'Play version ▸' (P5d): swap the ⋮ menu's rows for the work's charts;
|
||||||
|
// picking one plays that chart directly. A one-off alternate play — the
|
||||||
|
// keeper/headline doesn't move (stats record to the played chart's own
|
||||||
|
// filename), which is the design's "casual try ≠ deliberate adopt".
|
||||||
|
async function _expandPlayVersions(menu, song, closeMenu) {
|
||||||
|
const data = await jget('/api/work/' + enc(song.work_key) + '/charts');
|
||||||
|
if (!data || !Array.isArray(data.charts) || !data.charts.length || !menu.isConnected) return;
|
||||||
|
menu.innerHTML = data.charts.map((c) => {
|
||||||
|
const tl = (typeof window.displayTuningName === 'function')
|
||||||
|
? window.displayTuningName(c.tuning_name || c.tuning) : (c.tuning_name || '');
|
||||||
|
return '<button data-ver="' + esc(c.filename) + '" title="' + esc(c.filename) + '" class="w-full text-left px-3 py-1.5 hover:bg-fb-card/60 text-fb-text">' +
|
||||||
|
(c.is_representative ? '<span class="text-fb-primary">●</span> ' : '') + esc(c.title) +
|
||||||
|
(tl ? '<span class="text-[10px] text-fb-textDim ml-1">' + esc(tl) + '</span>' : '') +
|
||||||
|
'</button>';
|
||||||
|
}).join('');
|
||||||
|
menu.querySelectorAll('[data-ver]').forEach((vb) => vb.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const fn = vb.getAttribute('data-ver');
|
||||||
|
closeMenu();
|
||||||
|
_saveLibraryScrollSnapshot();
|
||||||
|
if (window.playSong) window.playSong(enc(fn));
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Charts drawer (P5d, design §7.1 UX-2/3) ────────────────────────────────
|
||||||
|
// The single deep-management surface for a work's charts. A body-appended
|
||||||
|
// slide-in panel (the filter-drawer idiom; body-appended like the playlist
|
||||||
|
// picker so it opens from any view) listing every chart of the work as a
|
||||||
|
// radiogroup — the checked row is the keeper the grid card plays. Clicking
|
||||||
|
// an unchecked row (or Enter/Space on it) = Set as preferred, one tap;
|
||||||
|
// "Reset to auto pick" appears when the keeper is your explicit pick.
|
||||||
|
// Writes go through the work-charts API and the drawer re-renders from the
|
||||||
|
// response (there's no server-side library event bus — the drawer is its
|
||||||
|
// own refresh); the grid re-fetches because the representative may have
|
||||||
|
// flipped. Global mode only — the slot-scoped (curated-album) mode is P6;
|
||||||
|
// the per-row Split escape hatch is P5e.
|
||||||
|
let _chartsPrevFocus = null; // focus to restore when the drawer closes
|
||||||
|
|
||||||
|
function _chartsDrawerEls() {
|
||||||
|
let ov = document.getElementById('v3-charts-overlay');
|
||||||
|
let dr = document.getElementById('v3-charts-drawer');
|
||||||
|
if (!ov) {
|
||||||
|
ov = document.createElement('div');
|
||||||
|
ov.id = 'v3-charts-overlay';
|
||||||
|
ov.className = 'fixed inset-0 bg-black/50 z-40 hidden';
|
||||||
|
ov.addEventListener('click', closeChartsDrawer);
|
||||||
|
document.body.appendChild(ov);
|
||||||
|
}
|
||||||
|
if (!dr) {
|
||||||
|
dr = document.createElement('aside');
|
||||||
|
dr.id = 'v3-charts-drawer';
|
||||||
|
dr.className = 'fixed top-0 right-0 h-full w-full sm:w-96 bg-fb-sidebar border-l border-fb-border/50 z-50 transform translate-x-full transition-transform duration-200 overflow-y-auto v3-scroll';
|
||||||
|
dr.setAttribute('role', 'dialog');
|
||||||
|
dr.setAttribute('aria-modal', 'true');
|
||||||
|
dr.setAttribute('aria-label', 'Charts of this song');
|
||||||
|
// a11y: Escape closes; Tab is trapped inside the open drawer
|
||||||
|
// (aria-modal alone doesn't trap for keyboard users); ArrowUp/Down
|
||||||
|
// move focus between the chart rows (focus only — selection stays
|
||||||
|
// on Enter/Space, since a native-radio "arrow = select" would fire
|
||||||
|
// a preferred write on every keystroke).
|
||||||
|
dr.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape') { e.preventDefault(); closeChartsDrawer(); return; }
|
||||||
|
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||||
|
const rows = [...dr.querySelectorAll('[role="radio"]')];
|
||||||
|
if (!rows.length) return;
|
||||||
|
const i = rows.indexOf(document.activeElement);
|
||||||
|
if (i === -1) return;
|
||||||
|
e.preventDefault();
|
||||||
|
rows[(i + (e.key === 'ArrowDown' ? 1 : rows.length - 1)) % rows.length].focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key !== 'Tab') return;
|
||||||
|
const foci = dr.querySelectorAll('button, [tabindex]:not([tabindex="-1"])');
|
||||||
|
if (!foci.length) return;
|
||||||
|
const first = foci[0], last = foci[foci.length - 1];
|
||||||
|
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
|
||||||
|
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
|
||||||
|
});
|
||||||
|
document.body.appendChild(dr);
|
||||||
|
}
|
||||||
|
return { ov: ov, dr: dr };
|
||||||
|
}
|
||||||
|
|
||||||
|
// One chart row. `checked` = the current keeper (pref or auto-pick). The
|
||||||
|
// filename line is deliberate: duplicate charts usually share title/artist,
|
||||||
|
// so the pack filename is often the only human-readable distinguisher.
|
||||||
|
function _chartRowHtml(c, data) {
|
||||||
|
const checked = c.is_representative;
|
||||||
|
const prefLabel = checked
|
||||||
|
? (data.preferred_source === 'user' ? 'Preferred — your pick' : 'Preferred (auto)')
|
||||||
|
: '';
|
||||||
|
const tuningLabel = (typeof window.displayTuningName === 'function')
|
||||||
|
? window.displayTuningName(c.tuning_name || c.tuning)
|
||||||
|
: (c.tuning_name || '');
|
||||||
|
const meta = [fmtLabel(c), tuningLabel,
|
||||||
|
(c.arrangements || []).map((a) => a.name).join('/'),
|
||||||
|
c.year ? String(c.year) : '']
|
||||||
|
.filter(Boolean).join(' · ');
|
||||||
|
const acc = (typeof c.best_accuracy === 'number')
|
||||||
|
? '<span class="font-bold ' + (c.best_accuracy >= MASTERY_ACCURACY ? 'text-fb-good' : c.best_accuracy >= 0.5 ? 'text-fb-mid' : 'text-fb-low') + '">' + Math.round(c.best_accuracy * 100) + '%</span>'
|
||||||
|
: '<span class="text-fb-textDim/60">not played</span>';
|
||||||
|
return '<div role="radio" aria-checked="' + (checked ? 'true' : 'false') + '" tabindex="0" data-ch="' + esc(c.filename) + '"' +
|
||||||
|
' title="' + (checked ? esc(prefLabel) : 'Make this the preferred chart') + '"' +
|
||||||
|
' class="border rounded-lg p-3 cursor-pointer transition ' + (checked ? 'border-fb-primary/60 bg-fb-primary/5' : 'border-fb-border/40 hover:border-fb-border') + '">' +
|
||||||
|
'<div class="flex items-start justify-between gap-2">' +
|
||||||
|
'<div class="min-w-0">' +
|
||||||
|
'<div class="text-sm text-fb-text truncate" title="' + esc(c.title) + '">' + esc(c.title) + '</div>' +
|
||||||
|
'<div class="text-xs text-fb-textDim truncate">' + esc(meta) + ' · ' + acc + '</div>' +
|
||||||
|
'<div class="text-[10px] text-fb-textDim/60 truncate" title="' + esc(c.filename) + '">' + esc(c.filename) + '</div>' +
|
||||||
|
(prefLabel ? '<div class="text-[10px] font-semibold text-fb-primary mt-0.5">' + esc(prefLabel) + '</div>' : '') +
|
||||||
|
'</div>' +
|
||||||
|
'<button data-ch-play title="Play this chart" aria-label="Play this chart" class="shrink-0 w-8 h-8 rounded-full bg-fb-primary hover:bg-fb-primaryHi text-white text-sm leading-none">▶</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="flex gap-2 mt-2">' +
|
||||||
|
'<button data-ch-pl class="text-xs px-2 py-1 rounded border border-fb-border/50 text-fb-textDim hover:text-fb-text">+ Playlist</button>' +
|
||||||
|
// Split escape hatch (P5e, §7.1): "these aren't the same song".
|
||||||
|
// Only offered while the work still has 2+ charts — splitting the
|
||||||
|
// last member is meaningless. Undo lives in the split-out card's
|
||||||
|
// ⋮ menu ("Rejoin other versions").
|
||||||
|
(data.count >= 2
|
||||||
|
? '<button data-ch-split title="These aren\'t the same song — give this chart its own card" class="text-xs px-2 py-1 rounded border border-fb-border/50 text-fb-textDim hover:text-fb-accent hover:border-fb-accent/50">Split out</button>'
|
||||||
|
: '') +
|
||||||
|
'</div>' +
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// A preferred/auto flip can change which chart the grid's card stands for —
|
||||||
|
// re-fetch the grid in place (scroll preserved; renderWindow refills from
|
||||||
|
// the current scrollTop). The tree lists every chart flat, so it's
|
||||||
|
// unaffected; rare curate action, so a full re-fetch is fine.
|
||||||
|
function _groupChanged() {
|
||||||
|
if (state.view === 'grid' && groupingActive()) loadGrid(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _renderChartsDrawer(data, opts) {
|
||||||
|
const els = _chartsDrawerEls();
|
||||||
|
const dr = els.dr;
|
||||||
|
const head = data.charts.find((c) => c.is_representative) || data.charts[0];
|
||||||
|
// Mastery-anchor heads-up (§7.1): shown once, ambiently, right after a
|
||||||
|
// switch — the headline may drop because history stays with each chart
|
||||||
|
// (motor mastery is arrangement-specific). Text only, no toast/sound.
|
||||||
|
const switchNote = (opts && opts.switched)
|
||||||
|
? '<div class="text-[11px] text-fb-primary/90 border border-fb-primary/30 rounded-md px-2 py-1.5">Practice history stays with each chart — your new pick starts from its own stats.</div>'
|
||||||
|
: '';
|
||||||
|
dr.innerHTML =
|
||||||
|
'<div class="p-5 space-y-4">' +
|
||||||
|
'<div class="flex items-start justify-between gap-2">' +
|
||||||
|
'<div class="min-w-0">' +
|
||||||
|
'<h3 class="text-lg font-semibold text-fb-text truncate" title="' + esc(head.title) + '">' + esc(head.title) + '</h3>' +
|
||||||
|
'<div class="text-xs text-fb-textDim truncate">' + esc(head.artist) + ' · ' + data.count + ' chart' + (data.count === 1 ? '' : 's') + '</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<button data-charts-close aria-label="Close" class="text-fb-textDim hover:text-fb-text text-xl leading-none">✕</button>' +
|
||||||
|
'</div>' +
|
||||||
|
switchNote +
|
||||||
|
'<div role="radiogroup" aria-label="Charts of this song" class="space-y-2">' +
|
||||||
|
data.charts.map((c) => _chartRowHtml(c, data)).join('') +
|
||||||
|
'</div>' +
|
||||||
|
(data.preferred_source === 'user'
|
||||||
|
? '<button data-charts-auto class="w-full text-sm text-fb-textDim hover:text-fb-text border border-fb-border/50 rounded-md py-2">Reset to auto pick</button>'
|
||||||
|
: '<div class="text-[11px] text-fb-textDim">Auto pick sticks with a chart you\'ve practised; otherwise most complete → newest. Tap a chart to pin your keeper.</div>') +
|
||||||
|
'</div>';
|
||||||
|
|
||||||
|
dr.querySelector('[data-charts-close]').addEventListener('click', closeChartsDrawer);
|
||||||
|
dr.querySelector('[data-charts-auto]')?.addEventListener('click', async () => {
|
||||||
|
const fresh = await jsend('DELETE', '/api/work/' + enc(data.work_key) + '/preferred');
|
||||||
|
if (!fresh) return;
|
||||||
|
_renderChartsDrawer(fresh);
|
||||||
|
_groupChanged();
|
||||||
|
dr.querySelector('[role="radio"][aria-checked="true"]')?.focus();
|
||||||
|
});
|
||||||
|
dr.querySelectorAll('[data-ch]').forEach((row) => {
|
||||||
|
const fn = row.getAttribute('data-ch');
|
||||||
|
const setPreferred = async () => {
|
||||||
|
if (row.getAttribute('aria-checked') === 'true') return; // already the keeper
|
||||||
|
const fresh = await jsend('PUT', '/api/work/' + enc(data.work_key) + '/preferred', { filename: fn });
|
||||||
|
if (!fresh) return;
|
||||||
|
_renderChartsDrawer(fresh, { switched: true });
|
||||||
|
_groupChanged();
|
||||||
|
dr.querySelector('[role="radio"][aria-checked="true"]')?.focus();
|
||||||
|
};
|
||||||
|
row.addEventListener('click', setPreferred);
|
||||||
|
row.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setPreferred(); }
|
||||||
|
});
|
||||||
|
row.querySelector('[data-ch-play]')?.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
closeChartsDrawer();
|
||||||
|
_saveLibraryScrollSnapshot();
|
||||||
|
if (window.playSong) window.playSong(enc(fn));
|
||||||
|
});
|
||||||
|
row.querySelector('[data-ch-pl]')?.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
addFilenamesToPlaylist([fn]);
|
||||||
|
});
|
||||||
|
row.querySelector('[data-ch-split]')?.addEventListener('click', async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const ok = await jsend('POST', '/api/chart/' + enc(fn) + '/split');
|
||||||
|
if (!ok) return;
|
||||||
|
_groupChanged();
|
||||||
|
// Re-read the ORIGINAL work — the split chart is gone from it
|
||||||
|
// (now its own card in the grid; its ⋮ menu offers the rejoin).
|
||||||
|
const fresh = await jget('/api/work/' + enc(data.work_key) + '/charts');
|
||||||
|
if (fresh && Array.isArray(fresh.charts) && fresh.charts.length) _renderChartsDrawer(fresh);
|
||||||
|
else closeChartsDrawer();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openChartsDrawer(workKey, _song) {
|
||||||
|
if (!workKey) return;
|
||||||
|
const data = await jget('/api/work/' + enc(workKey) + '/charts');
|
||||||
|
if (!data || !Array.isArray(data.charts) || !data.charts.length) return;
|
||||||
|
const els = _chartsDrawerEls();
|
||||||
|
_chartsPrevFocus = document.activeElement;
|
||||||
|
_renderChartsDrawer(data);
|
||||||
|
els.ov.classList.remove('hidden');
|
||||||
|
els.dr.classList.remove('translate-x-full');
|
||||||
|
els.dr.querySelector('[role="radio"][aria-checked="true"]')?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeChartsDrawer() {
|
||||||
|
document.getElementById('v3-charts-overlay')?.classList.add('hidden');
|
||||||
|
document.getElementById('v3-charts-drawer')?.classList.add('translate-x-full');
|
||||||
|
if (_chartsPrevFocus && typeof _chartsPrevFocus.focus === 'function' && _chartsPrevFocus.isConnected) {
|
||||||
|
_chartsPrevFocus.focus();
|
||||||
|
}
|
||||||
|
_chartsPrevFocus = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global opener — the ⚑ chip routes through this, and other views/plugins
|
||||||
|
// (P2's details drawer, dashboards) can open the drawer without reaching
|
||||||
|
// into this module.
|
||||||
|
window.__fbOpenChartsDrawer = openChartsDrawer;
|
||||||
|
|
||||||
function wireCards(scope) {
|
function wireCards(scope) {
|
||||||
scope.querySelectorAll('[data-fn]').forEach((el) => {
|
scope.querySelectorAll('[data-fn]').forEach((el) => {
|
||||||
if (el.dataset.wired) return; // don't double-bind on append/auto-fill
|
if (el.dataset.wired) return; // don't double-bind on append/auto-fill
|
||||||
el.dataset.wired = '1';
|
el.dataset.wired = '1';
|
||||||
const fn = el.getAttribute('data-fn');
|
const fn = el.getAttribute('data-fn');
|
||||||
const song = state.songsById[fn] || { filename: fn };
|
const song = state.songsById[fn] || { filename: fn };
|
||||||
|
// §7.1: when a chart-intrinsic filter attached a display_chart,
|
||||||
|
// the card SHOWS that member — so play actions target it too
|
||||||
|
// (its arrangement indices match the rendered chips). Identity
|
||||||
|
// actions (heart/save/playlist/menu registry) stay on the rep row.
|
||||||
|
const playTarget = song.display_chart ? Object.assign({}, song, song.display_chart) : song;
|
||||||
el.querySelectorAll('[data-v3-play]').forEach((pe) => pe.addEventListener('click', (e) => {
|
el.querySelectorAll('[data-v3-play]').forEach((pe) => pe.addEventListener('click', (e) => {
|
||||||
if (state.selectMode) { e.preventDefault(); toggleSelect(fn, el); return; }
|
if (state.selectMode) { e.preventDefault(); toggleSelect(fn, el); return; }
|
||||||
playCard(song); // local → play; unsynced remote → sync then play
|
playCard(playTarget); // local → play; unsynced remote → sync then play
|
||||||
}));
|
}));
|
||||||
el.querySelector('[data-menu]')?.addEventListener('click', (e) => { e.stopPropagation(); openCardMenu(el, song, e.currentTarget); });
|
el.querySelector('[data-menu]')?.addEventListener('click', (e) => { e.stopPropagation(); openCardMenu(el, song, e.currentTarget); });
|
||||||
el.querySelectorAll('[data-arr]').forEach((ab) => ab.addEventListener('click', (e) => {
|
el.querySelectorAll('[data-arr]').forEach((ab) => ab.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const idx = ab.getAttribute('data-arr');
|
const idx = ab.getAttribute('data-arr');
|
||||||
playCard(song, idx === '' ? undefined : Number(idx));
|
playCard(playTarget, idx === '' ? undefined : Number(idx));
|
||||||
}));
|
}));
|
||||||
|
// ⚑ charts chip → the Charts drawer (P5d). Never a card play — the
|
||||||
|
// chip row sits outside [data-v3-play]; stopPropagation is
|
||||||
|
// belt-and-braces.
|
||||||
|
el.querySelector('[data-charts]')?.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
openChartsDrawer(e.currentTarget.getAttribute('data-charts'), song);
|
||||||
|
});
|
||||||
el.querySelector('[data-fav]')?.addEventListener('click', async (e) => {
|
el.querySelector('[data-fav]')?.addEventListener('click', async (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const btn = e.currentTarget;
|
const btn = e.currentTarget;
|
||||||
@@ -1234,6 +1565,7 @@
|
|||||||
const epoch = state.epoch;
|
const epoch = state.epoch;
|
||||||
const prom = (async () => {
|
const prom = (async () => {
|
||||||
const extra = { size: PAGE_SIZE };
|
const extra = { size: PAGE_SIZE };
|
||||||
|
if (groupingActive()) extra.group = 1;
|
||||||
const prevCursor = state.keysetOk ? state.pageCursors[p - 1] : null;
|
const prevCursor = state.keysetOk ? state.pageCursors[p - 1] : null;
|
||||||
if (prevCursor) extra.after = prevCursor; else extra.page = p;
|
if (prevCursor) extra.after = prevCursor; else extra.page = p;
|
||||||
const data = await jget('/api/library?' + queryParams(extra).toString());
|
const data = await jget('/api/library?' + queryParams(extra).toString());
|
||||||
@@ -1414,7 +1746,11 @@
|
|||||||
// Present letters for the active sort+filter (filter-synced; counts
|
// Present letters for the active sort+filter (filter-synced; counts
|
||||||
// songs). `sort_letters=1` opts into the active-sort breakdown so the
|
// songs). `sort_letters=1` opts into the active-sort breakdown so the
|
||||||
// dashboard / v2 tree (which read only `letters`) skip the extra query.
|
// dashboard / v2 tree (which read only `letters`) skip the extra query.
|
||||||
const stats = await jget('/api/library/stats?' + queryParams({ sort_letters: 1 }).toString());
|
// group must MATCH the grid's page fetches (see groupingActive) so the
|
||||||
|
// rail's per-letter counts sum to the same works total the sizer uses.
|
||||||
|
const railParams = { sort_letters: 1 };
|
||||||
|
if (groupingActive()) railParams.group = 1;
|
||||||
|
const stats = await jget('/api/library/stats?' + queryParams(railParams).toString());
|
||||||
if (_railToken !== myToken || !railVisible()) { // changed mid-fetch
|
if (_railToken !== myToken || !railVisible()) { // changed mid-fetch
|
||||||
if (_railToken === myToken) rail.classList.add('hidden');
|
if (_railToken === myToken) rail.classList.add('hidden');
|
||||||
return;
|
return;
|
||||||
@@ -1708,6 +2044,15 @@
|
|||||||
}
|
}
|
||||||
return triPill('tuning', val, label + ' (' + t.count + ')', f.tunings.includes(val) ? 'has' : 'any');
|
return triPill('tuning', val, label + ' (' + t.count + ')', f.tunings.includes(val) ? 'has' : 'any');
|
||||||
}).join('') || '<span class="text-xs text-fb-textDim">No tunings</span>') +
|
}).join('') || '<span class="text-xs text-fb-textDim">No tunings</span>') +
|
||||||
|
// Multi-chart grouping toggle (P5e) — a VIEW mode, not a filter
|
||||||
|
// (never counted in the badge, never saved into collection rules).
|
||||||
|
// Local provider only: it's the one that implements group=.
|
||||||
|
(state.provider === 'local'
|
||||||
|
? section('Grouping', '<button data-grouping class="px-2 py-1 rounded-md text-xs border ' +
|
||||||
|
(state.grouping !== false ? 'bg-fb-primary text-white border-fb-primary' : 'bg-gray-800/50 text-fb-textDim border-gray-700') +
|
||||||
|
'" title="Collapse charts of the same song to one card (the ⚑ chip lists the versions)">' +
|
||||||
|
(state.grouping !== false ? '✓ ' : '') + 'One card per song</button>')
|
||||||
|
: '') +
|
||||||
// Collections always replay against the LOCAL library, so only offer
|
// Collections always replay against the LOCAL library, so only offer
|
||||||
// "save" when browsing local with a non-empty filter set.
|
// "save" when browsing local with a non-empty filter set.
|
||||||
(state.provider === 'local' && Object.keys(currentFilterRules()).length
|
(state.provider === 'local' && Object.keys(currentFilterRules()).length
|
||||||
@@ -1730,6 +2075,11 @@
|
|||||||
}));
|
}));
|
||||||
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.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-grouping]')?.addEventListener('click', () => {
|
||||||
|
state.grouping = !(state.grouping !== false);
|
||||||
|
renderDrawer();
|
||||||
|
reload(); // re-fetches grid + rail with/without group=1, saves prefs
|
||||||
|
});
|
||||||
d.querySelector('[data-drawer-save]')?.addEventListener('click', saveCurrentAsCollection);
|
d.querySelector('[data-drawer-save]')?.addEventListener('click', saveCurrentAsCollection);
|
||||||
d.querySelector('[data-drawer-tidy]')?.addEventListener('click', openArtistTidyUp);
|
d.querySelector('[data-drawer-tidy]')?.addEventListener('click', openArtistTidyUp);
|
||||||
d.querySelector('[data-drawer-close]')?.addEventListener('click', closeDrawer);
|
d.querySelector('[data-drawer-close]')?.addEventListener('click', closeDrawer);
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
"""Tests for P5e: the grouped filter law (chart-intrinsic filters match ANY
|
||||||
|
member of a work + the display_chart switch), the group-aggregate mastery
|
||||||
|
sort, the history-sticky auto-pick, and the split annotations."""
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def server(tmp_path, monkeypatch, isolate_logging):
|
||||||
|
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
|
||||||
|
monkeypatch.setenv("FEEDBACK_SKIP_STARTUP_TASKS", "1")
|
||||||
|
sys.modules.pop("server", None)
|
||||||
|
srv = importlib.import_module("server")
|
||||||
|
try:
|
||||||
|
yield srv
|
||||||
|
finally:
|
||||||
|
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
|
||||||
|
if conn is not None:
|
||||||
|
conn.close()
|
||||||
|
sys.modules.pop("server", None)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def client(server):
|
||||||
|
return TestClient(server.app)
|
||||||
|
|
||||||
|
|
||||||
|
def _put(server, fn, title, artist, arrangements=1, tuning="E Standard",
|
||||||
|
mtime=0, lyrics=False):
|
||||||
|
arr = [{"name": "Lead", "index": i} for i in range(arrangements)]
|
||||||
|
server.meta_db.put(fn, mtime, 0, {
|
||||||
|
"title": title, "artist": artist, "arrangements": arr,
|
||||||
|
"tuning_name": tuning, "has_lyrics": lyrics,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _page(client, **params):
|
||||||
|
return client.get("/api/library", params={"group": 1, **params}).json()
|
||||||
|
|
||||||
|
|
||||||
|
# ── chart-intrinsic filters: match if ANY member matches ─────────────────────
|
||||||
|
|
||||||
|
def test_intrinsic_filter_matches_any_member(client, server):
|
||||||
|
_put(server, "a.archive", "Song", "Artist", arrangements=2, tuning="E Standard")
|
||||||
|
_put(server, "b.archive", "Song", "Artist", arrangements=1, tuning="Drop D")
|
||||||
|
body = _page(client, tunings="Drop D")
|
||||||
|
# the work qualifies through member b even though the representative (a,
|
||||||
|
# most arrangements) is E Standard…
|
||||||
|
assert body["total"] == 1
|
||||||
|
row = body["songs"][0]
|
||||||
|
# …and the ROW stays the representative (sort/cursor identity) while the
|
||||||
|
# card's display/play facts switch to the matching member.
|
||||||
|
assert row["filename"] == "a.archive"
|
||||||
|
assert row["display_chart"]["filename"] == "b.archive"
|
||||||
|
assert row["display_chart"]["tuning_name"] == "Drop D"
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_display_chart_when_rep_matches(client, server):
|
||||||
|
_put(server, "a.archive", "Song", "Artist", arrangements=2, tuning="Drop D")
|
||||||
|
_put(server, "b.archive", "Song", "Artist", arrangements=1, tuning="Drop D")
|
||||||
|
row = _page(client, tunings="Drop D")["songs"][0]
|
||||||
|
assert row["filename"] == "a.archive"
|
||||||
|
assert "display_chart" not in row
|
||||||
|
|
||||||
|
|
||||||
|
def test_intrinsic_filter_ungrouped_unchanged(client, server):
|
||||||
|
_put(server, "a.archive", "Song", "Artist", arrangements=2, tuning="E Standard")
|
||||||
|
_put(server, "b.archive", "Song", "Artist", arrangements=1, tuning="Drop D")
|
||||||
|
body = client.get("/api/library", params={"tunings": "Drop D"}).json()
|
||||||
|
assert body["total"] == 1
|
||||||
|
assert body["songs"][0]["filename"] == "b.archive"
|
||||||
|
assert "display_chart" not in body["songs"][0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_lyrics_filter_matches_any_member(client, server):
|
||||||
|
_put(server, "a.archive", "Song", "Artist", arrangements=2, lyrics=False)
|
||||||
|
_put(server, "b.archive", "Song", "Artist", arrangements=1, lyrics=True)
|
||||||
|
body = _page(client, has_lyrics=1)
|
||||||
|
assert body["total"] == 1
|
||||||
|
assert body["songs"][0]["display_chart"]["filename"] == "b.archive"
|
||||||
|
|
||||||
|
|
||||||
|
def test_stats_count_matches_page_under_filter(client, server):
|
||||||
|
_put(server, "a.archive", "Song", "Artist", arrangements=2, tuning="E Standard")
|
||||||
|
_put(server, "b.archive", "Song", "Artist", arrangements=1, tuning="Drop D")
|
||||||
|
_put(server, "c.archive", "Other", "Artist", tuning="E Standard") # no Drop D chart
|
||||||
|
page = _page(client, tunings="Drop D")
|
||||||
|
stats = client.get("/api/library/stats",
|
||||||
|
params={"group": 1, "tunings": "Drop D"}).json()
|
||||||
|
assert page["total"] == 1
|
||||||
|
assert stats["total_songs"] == page["total"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── work-identity + practice-state stay on the representative ────────────────
|
||||||
|
|
||||||
|
def test_practice_state_anchors_on_preferred(client, server):
|
||||||
|
_put(server, "a.archive", "Song", "Artist", arrangements=2) # unplayed
|
||||||
|
_put(server, "b.archive", "Song", "Artist", arrangements=1)
|
||||||
|
server.meta_db.record_session("b.archive", 0, score=950, accuracy=0.95)
|
||||||
|
# pin the unplayed chart as YOUR pick (without a pin the played chart is
|
||||||
|
# the sticky auto-rep, so the anchor would legitimately read mastered)
|
||||||
|
wk = server.meta_db.work_key_for("a.archive")
|
||||||
|
server.meta_db.set_chart_preferred(wk, "a.archive")
|
||||||
|
# mastered band reads the PREFERRED chart (unplayed) — the work is not
|
||||||
|
# "mastered" just because an alternate chart is (§7.1 practice-state law)
|
||||||
|
assert _page(client, mastery="mastered")["total"] == 0
|
||||||
|
# …but the flat view still finds the mastered chart itself
|
||||||
|
flat = client.get("/api/library", params={"mastery": "mastered"}).json()
|
||||||
|
assert [s["filename"] for s in flat["songs"]] == ["b.archive"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── mastery sort aggregates MAX across the group ─────────────────────────────
|
||||||
|
|
||||||
|
def test_mastery_sort_aggregates_across_group(client, server):
|
||||||
|
_put(server, "a1.archive", "One", "Artist", arrangements=2)
|
||||||
|
_put(server, "a2.archive", "One", "Artist", arrangements=1)
|
||||||
|
_put(server, "b.archive", "Two", "Artist") # singleton work 2
|
||||||
|
server.meta_db.record_session("a2.archive", 0, score=950, accuracy=0.95)
|
||||||
|
server.meta_db.record_session("b.archive", 0, score=500, accuracy=0.5)
|
||||||
|
# pin the UNPLAYED chart as work 1's keeper — the aggregate must still
|
||||||
|
# surface the work via its touched member ("a song surfaces on any chart
|
||||||
|
# you've touched"), even though the preferred chart has no stats
|
||||||
|
wk = server.meta_db.work_key_for("a1.archive")
|
||||||
|
server.meta_db.set_chart_preferred(wk, "a1.archive")
|
||||||
|
desc = [s["filename"] for s in _page(client, sort="mastery-desc")["songs"]]
|
||||||
|
assert desc == ["a1.archive", "b.archive"] # 0.95 > 0.5, row = keeper
|
||||||
|
asc = [s["filename"] for s in _page(client, sort="mastery")["songs"]]
|
||||||
|
assert asc == ["b.archive", "a1.archive"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── history-sticky auto-pick ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_autopick_sticks_with_played_chart(client, server):
|
||||||
|
_put(server, "a.archive", "Song", "Artist", arrangements=1, mtime=1)
|
||||||
|
server.meta_db.record_session("a.archive", 0, score=500, accuracy=0.5)
|
||||||
|
# a newer, more complete chart imports — the pick must NOT silently migrate
|
||||||
|
_put(server, "b.archive", "Song", "Artist", arrangements=3, mtime=2)
|
||||||
|
assert _page(client)["songs"][0]["filename"] == "a.archive"
|
||||||
|
|
||||||
|
|
||||||
|
def test_autopick_completeness_still_wins_when_unplayed(client, server):
|
||||||
|
_put(server, "a.archive", "Song", "Artist", arrangements=1, mtime=1)
|
||||||
|
_put(server, "b.archive", "Song", "Artist", arrangements=3, mtime=2)
|
||||||
|
assert _page(client)["songs"][0]["filename"] == "b.archive"
|
||||||
|
|
||||||
|
|
||||||
|
def test_autopick_one_off_try_does_not_steal_pick(client, server):
|
||||||
|
# incumbent: practiced repeatedly, minimal chart; a one-off try of a more
|
||||||
|
# complete alternate must NOT retarget the pick (casual try ≠ adopt)
|
||||||
|
_put(server, "a.archive", "Song", "Artist", arrangements=1, mtime=1)
|
||||||
|
for _ in range(3):
|
||||||
|
server.meta_db.record_session("a.archive", 0, score=500, accuracy=0.5)
|
||||||
|
_put(server, "b.archive", "Song", "Artist", arrangements=3, mtime=2)
|
||||||
|
server.meta_db.record_session("b.archive", 0, score=500, accuracy=0.5)
|
||||||
|
assert _page(client)["songs"][0]["filename"] == "a.archive"
|
||||||
|
|
||||||
|
|
||||||
|
# ── split annotations (the ⋮ "Rejoin other versions" undo) ───────────────────
|
||||||
|
|
||||||
|
def test_split_flags_on_rows_and_chart_work(client, server):
|
||||||
|
_put(server, "a.archive", "Song", "Artist")
|
||||||
|
_put(server, "b.archive", "Song", "Artist")
|
||||||
|
client.post("/api/chart/b.archive/split")
|
||||||
|
rows = {s["filename"]: s for s in _page(client)["songs"]}
|
||||||
|
assert rows["b.archive"]["is_split"] is True
|
||||||
|
assert rows["a.archive"]["is_split"] is False
|
||||||
|
assert client.get("/api/chart/b.archive/work").json()["is_split"] is True
|
||||||
|
assert client.get("/api/chart/a.archive/work").json()["is_split"] is False
|
||||||
|
|
||||||
|
|
||||||
|
# ── keyset paging stays exact under the member-match predicate ────────────────
|
||||||
|
|
||||||
|
def test_grouped_keyset_pagination_with_intrinsic_filter(client, server):
|
||||||
|
# 5 works; each has an E-Standard rep (2 arrangements) + a Drop-D member,
|
||||||
|
# so the Drop-D filter admits every work through a NON-representative chart.
|
||||||
|
for i in range(5):
|
||||||
|
_put(server, f"w{i}rep.archive", f"Song {i}", f"Artist {i}",
|
||||||
|
arrangements=2, tuning="E Standard")
|
||||||
|
_put(server, f"w{i}alt.archive", f"Song {i}", f"Artist {i}",
|
||||||
|
arrangements=1, tuning="Drop D")
|
||||||
|
seen, cursor = [], None
|
||||||
|
for _ in range(10):
|
||||||
|
params = {"group": 1, "tunings": "Drop D", "size": 2, "sort": "artist"}
|
||||||
|
if cursor:
|
||||||
|
params["after"] = cursor
|
||||||
|
body = client.get("/api/library", params=params).json()
|
||||||
|
if not body["songs"]:
|
||||||
|
break
|
||||||
|
seen += [s["filename"] for s in body["songs"]]
|
||||||
|
cursor = body["next_cursor"]
|
||||||
|
if not cursor:
|
||||||
|
break
|
||||||
|
assert seen == [f"w{i}rep.archive" for i in range(5)] # no skip, no dupe
|
||||||
|
assert all(s.startswith("w") and "rep" in s for s in seen)
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
"""Tests for the multi-chart work API (P5b): GET /api/work/{work_key}/charts +
|
||||||
|
PUT/DELETE …/preferred + POST /api/chart/{filename}/split|unsplit. HTTP surface
|
||||||
|
over the P5a grouping engine (what the P5d Charts drawer consumes)."""
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def server(tmp_path, monkeypatch, isolate_logging):
|
||||||
|
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
|
||||||
|
monkeypatch.setenv("FEEDBACK_SKIP_STARTUP_TASKS", "1")
|
||||||
|
sys.modules.pop("server", None)
|
||||||
|
srv = importlib.import_module("server")
|
||||||
|
try:
|
||||||
|
yield srv
|
||||||
|
finally:
|
||||||
|
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
|
||||||
|
if conn is not None:
|
||||||
|
conn.close()
|
||||||
|
sys.modules.pop("server", None)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def client(server):
|
||||||
|
return TestClient(server.app)
|
||||||
|
|
||||||
|
|
||||||
|
def _put(server, fn, title, artist, arrangements=1):
|
||||||
|
arr = [{"name": "Lead", "index": i} for i in range(arrangements)]
|
||||||
|
server.meta_db.put(fn, 0, 0, {"title": title, "artist": artist, "arrangements": arr})
|
||||||
|
|
||||||
|
|
||||||
|
def _wk(server, fn):
|
||||||
|
return server.meta_db.work_key_for(fn)
|
||||||
|
|
||||||
|
|
||||||
|
def _rep(body):
|
||||||
|
return next(c for c in body["charts"] if c["is_representative"])
|
||||||
|
|
||||||
|
|
||||||
|
# ── grouped grid row carries work_key (so the drawer can address it) ─────────
|
||||||
|
|
||||||
|
def test_grouped_row_exposes_work_key(client, server):
|
||||||
|
_put(server, "a.archive", "Song", "Artist")
|
||||||
|
_put(server, "b.archive", "Song", "Artist")
|
||||||
|
row = client.get("/api/library", params={"group": 1}).json()["songs"][0]
|
||||||
|
assert row["work_key"] == _wk(server, "a.archive")
|
||||||
|
assert row["chart_count"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
# ── GET charts ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_get_work_charts(client, server):
|
||||||
|
_put(server, "a.archive", "Song", "Artist", arrangements=2)
|
||||||
|
_put(server, "b.archive", "Song", "Artist", arrangements=1)
|
||||||
|
body = client.get(f"/api/work/{_wk(server, 'a.archive')}/charts").json()
|
||||||
|
assert body["count"] == 2
|
||||||
|
assert {c["filename"] for c in body["charts"]} == {"a.archive", "b.archive"}
|
||||||
|
assert _rep(body)["filename"] == "a.archive" # auto-pick = most arrangements
|
||||||
|
assert body["preferred_source"] == "auto"
|
||||||
|
assert body["preferred_filename"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_charts_include_best_accuracy(client, server):
|
||||||
|
_put(server, "a.archive", "Song", "Artist")
|
||||||
|
_put(server, "b.archive", "Song", "Artist")
|
||||||
|
server.meta_db.record_session("a.archive", 0, score=900, accuracy=0.9)
|
||||||
|
charts = {c["filename"]: c for c in
|
||||||
|
client.get(f"/api/work/{_wk(server, 'a.archive')}/charts").json()["charts"]}
|
||||||
|
assert charts["a.archive"]["best_accuracy"] == 0.9
|
||||||
|
assert charts["b.archive"]["best_accuracy"] is None
|
||||||
|
|
||||||
|
|
||||||
|
# ── PUT / DELETE preferred ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_set_preferred(client, server):
|
||||||
|
_put(server, "a.archive", "Song", "Artist", arrangements=2)
|
||||||
|
_put(server, "b.archive", "Song", "Artist", arrangements=1)
|
||||||
|
wk = _wk(server, "a.archive")
|
||||||
|
body = client.put(f"/api/work/{wk}/preferred", json={"filename": "b.archive"}).json()
|
||||||
|
assert body["preferred_filename"] == "b.archive" and body["preferred_source"] == "user"
|
||||||
|
assert _rep(body)["filename"] == "b.archive" and _rep(body)["is_preferred"]
|
||||||
|
# the grouped grid now shows b as the work's card
|
||||||
|
assert client.get("/api/library", params={"group": 1}).json()["songs"][0]["filename"] == "b.archive"
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_preferred_nonmember_is_400(client, server):
|
||||||
|
_put(server, "a.archive", "Song", "Artist")
|
||||||
|
_put(server, "x.archive", "Other", "Artist")
|
||||||
|
wk = _wk(server, "a.archive")
|
||||||
|
assert client.put(f"/api/work/{wk}/preferred", json={"filename": "x.archive"}).status_code == 400
|
||||||
|
assert client.put(f"/api/work/{wk}/preferred", json={}).status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_reset_preferred_returns_to_auto(client, server):
|
||||||
|
_put(server, "a.archive", "Song", "Artist", arrangements=2)
|
||||||
|
_put(server, "b.archive", "Song", "Artist", arrangements=1)
|
||||||
|
wk = _wk(server, "a.archive")
|
||||||
|
client.put(f"/api/work/{wk}/preferred", json={"filename": "b.archive"})
|
||||||
|
body = client.delete(f"/api/work/{wk}/preferred").json()
|
||||||
|
assert body["preferred_source"] == "auto"
|
||||||
|
assert _rep(body)["filename"] == "a.archive" # auto-pick again
|
||||||
|
|
||||||
|
|
||||||
|
# ── split / unsplit ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_split_and_unsplit_endpoints(client, server):
|
||||||
|
_put(server, "a.archive", "Song", "Artist")
|
||||||
|
_put(server, "b.archive", "Song", "Artist")
|
||||||
|
wk = _wk(server, "a.archive")
|
||||||
|
assert client.get(f"/api/work/{wk}/charts").json()["count"] == 2
|
||||||
|
assert client.post("/api/chart/b.archive/split").json()["ok"] is True
|
||||||
|
assert {c["filename"] for c in client.get(f"/api/work/{wk}/charts").json()["charts"]} == {"a.archive"}
|
||||||
|
assert client.get("/api/library", params={"group": 1}).json()["total"] == 2 # two works now
|
||||||
|
assert client.post("/api/chart/b.archive/unsplit").json()["ok"] is True
|
||||||
|
assert client.get(f"/api/work/{wk}/charts").json()["count"] == 2 # rejoined
|
||||||
|
|
||||||
|
|
||||||
|
# ── GET chart work membership (P5d — tree/ungrouped opener resolve) ──────────
|
||||||
|
|
||||||
|
def test_chart_work_grouped_pair(client, server):
|
||||||
|
_put(server, "a.archive", "Song", "Artist")
|
||||||
|
_put(server, "b.archive", "Song", "Artist")
|
||||||
|
wk = _wk(server, "a.archive")
|
||||||
|
for fn in ("a.archive", "b.archive"):
|
||||||
|
body = client.get(f"/api/chart/{fn}/work").json()
|
||||||
|
assert body == {"filename": fn, "work_key": wk, "chart_count": 2,
|
||||||
|
"is_split": False}
|
||||||
|
|
||||||
|
|
||||||
|
def test_chart_work_singleton(client, server):
|
||||||
|
_put(server, "a.archive", "Song", "Artist")
|
||||||
|
body = client.get("/api/chart/a.archive/work").json()
|
||||||
|
assert body["work_key"] == _wk(server, "a.archive")
|
||||||
|
assert body["chart_count"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_chart_work_split_resolves_to_singleton_key(client, server):
|
||||||
|
_put(server, "a.archive", "Song", "Artist")
|
||||||
|
_put(server, "b.archive", "Song", "Artist")
|
||||||
|
wk = _wk(server, "a.archive")
|
||||||
|
client.post("/api/chart/b.archive/split")
|
||||||
|
body = client.get("/api/chart/b.archive/work").json()
|
||||||
|
# the split chart stands alone under its EFFECTIVE (split) key…
|
||||||
|
assert body["work_key"] != wk and body["chart_count"] == 1
|
||||||
|
# …and its key round-trips into the charts endpoint as its own work.
|
||||||
|
# Split keys contain '#' — clients MUST URL-encode the work_key in the
|
||||||
|
# path (the v3 client encodeURIComponent's it) or the '#' truncates the
|
||||||
|
# URL as a fragment.
|
||||||
|
from urllib.parse import quote
|
||||||
|
charts = client.get(f"/api/work/{quote(body['work_key'], safe='')}/charts").json()
|
||||||
|
assert [c["filename"] for c in charts["charts"]] == ["b.archive"]
|
||||||
|
# the chart left behind is a singleton too
|
||||||
|
assert client.get("/api/chart/a.archive/work").json()["chart_count"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_chart_work_unknown_file(client, server):
|
||||||
|
_put(server, "a.archive", "Song", "Artist")
|
||||||
|
body = client.get("/api/chart/nope.archive/work").json()
|
||||||
|
assert body["work_key"] is None and body["chart_count"] == 0
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
"""Tests for the multi-chart grouping engine (P5a): several charts (feedpak rows)
|
||||||
|
of the same WORK collapse to one representative card via a materialized
|
||||||
|
work_display read-model + a `group=1` `WHERE is_group_representative=1` predicate.
|
||||||
|
Counting works (not charts), auto-pick, chart_group_pref, split, and keyset
|
||||||
|
paging under grouping all stay correct."""
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def server(tmp_path, monkeypatch, isolate_logging):
|
||||||
|
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
|
||||||
|
monkeypatch.setenv("FEEDBACK_SKIP_STARTUP_TASKS", "1")
|
||||||
|
sys.modules.pop("server", None)
|
||||||
|
srv = importlib.import_module("server")
|
||||||
|
try:
|
||||||
|
yield srv
|
||||||
|
finally:
|
||||||
|
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
|
||||||
|
if conn is not None:
|
||||||
|
conn.close()
|
||||||
|
sys.modules.pop("server", None)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def client(server):
|
||||||
|
return TestClient(server.app)
|
||||||
|
|
||||||
|
|
||||||
|
def _put(server, fn, title, artist, arrangements=1, mtime=0.0):
|
||||||
|
arr = [{"name": "Lead", "index": i} for i in range(arrangements)]
|
||||||
|
server.meta_db.put(fn, mtime, 0, {"title": title, "artist": artist, "arrangements": arr})
|
||||||
|
|
||||||
|
|
||||||
|
def _grouped(client, **params):
|
||||||
|
params["group"] = 1
|
||||||
|
return client.get("/api/library", params=params).json()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Baseline: ungrouped shows every chart ────────────────────────────────────
|
||||||
|
|
||||||
|
def test_ungrouped_shows_all_charts(client, server):
|
||||||
|
_put(server, "a.archive", "Song", "Artist")
|
||||||
|
_put(server, "b.archive", "Song", "Artist")
|
||||||
|
body = client.get("/api/library").json()
|
||||||
|
assert body["total"] == 2
|
||||||
|
assert {s["filename"] for s in body["songs"]} == {"a.archive", "b.archive"}
|
||||||
|
# ungrouped rows carry no chart_count (only the grouped path attaches it)
|
||||||
|
assert "chart_count" not in body["songs"][0]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Grouping collapses a work to one representative ───────────────────────────
|
||||||
|
|
||||||
|
def test_grouping_collapses_to_one_representative(client, server):
|
||||||
|
_put(server, "a.archive", "Song", "Artist", arrangements=1)
|
||||||
|
_put(server, "b.archive", "Song", "Artist", arrangements=2)
|
||||||
|
body = _grouped(client)
|
||||||
|
assert body["total"] == 1
|
||||||
|
assert len(body["songs"]) == 1
|
||||||
|
rep = body["songs"][0]
|
||||||
|
assert rep["filename"] == "b.archive" # most arrangements wins auto-pick
|
||||||
|
assert rep["chart_count"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_autopick_plays_tiebreak(client, server):
|
||||||
|
_put(server, "a.archive", "S", "A", arrangements=1)
|
||||||
|
_put(server, "b.archive", "S", "A", arrangements=1)
|
||||||
|
server.meta_db.record_session("a.archive", 0, score=100, accuracy=0.5) # a has a play
|
||||||
|
assert _grouped(client)["songs"][0]["filename"] == "a.archive"
|
||||||
|
|
||||||
|
|
||||||
|
def test_autopick_filename_tiebreak(client, server):
|
||||||
|
_put(server, "b.archive", "S", "A")
|
||||||
|
_put(server, "a.archive", "S", "A")
|
||||||
|
# equal arrangements + plays + mtime → lowest filename
|
||||||
|
assert _grouped(client)["songs"][0]["filename"] == "a.archive"
|
||||||
|
|
||||||
|
|
||||||
|
# ── chart_group_pref override ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_preferred_overrides_autopick(client, server):
|
||||||
|
_put(server, "a.archive", "S", "A", arrangements=2) # would auto-win
|
||||||
|
_put(server, "b.archive", "S", "A", arrangements=1)
|
||||||
|
wk = server.meta_db.work_key_for("b.archive")
|
||||||
|
server.meta_db.set_chart_preferred(wk, "b.archive")
|
||||||
|
assert _grouped(client)["songs"][0]["filename"] == "b.archive"
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_preferred_incremental_reflip(client, server):
|
||||||
|
_put(server, "a.archive", "S", "A", arrangements=2)
|
||||||
|
_put(server, "b.archive", "S", "A", arrangements=1)
|
||||||
|
assert _grouped(client)["songs"][0]["filename"] == "a.archive" # builds read-model
|
||||||
|
wk = server.meta_db.work_key_for("a.archive")
|
||||||
|
server.meta_db.set_chart_preferred(wk, "b.archive") # incremental re-flip
|
||||||
|
body = _grouped(client)
|
||||||
|
assert body["songs"][0]["filename"] == "b.archive"
|
||||||
|
assert body["songs"][0]["chart_count"] == 2 # group_size unchanged
|
||||||
|
|
||||||
|
|
||||||
|
def test_clear_preferred_returns_to_autopick(client, server):
|
||||||
|
_put(server, "a.archive", "S", "A", arrangements=2)
|
||||||
|
_put(server, "b.archive", "S", "A", arrangements=1)
|
||||||
|
wk = server.meta_db.work_key_for("a.archive")
|
||||||
|
server.meta_db.set_chart_preferred(wk, "b.archive")
|
||||||
|
assert _grouped(client)["songs"][0]["filename"] == "b.archive"
|
||||||
|
server.meta_db.clear_chart_preferred(wk)
|
||||||
|
assert _grouped(client)["songs"][0]["filename"] == "a.archive" # auto again
|
||||||
|
|
||||||
|
|
||||||
|
# ── Split / un-split ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_split_makes_singleton(client, server):
|
||||||
|
_put(server, "a.archive", "S", "A")
|
||||||
|
_put(server, "b.archive", "S", "A")
|
||||||
|
assert _grouped(client)["total"] == 1
|
||||||
|
server.meta_db.split_chart("b.archive")
|
||||||
|
body = _grouped(client)
|
||||||
|
assert body["total"] == 2 # two works now
|
||||||
|
assert {s["filename"] for s in body["songs"]} == {"a.archive", "b.archive"}
|
||||||
|
assert all(s["chart_count"] == 1 for s in body["songs"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_unsplit_rejoins(client, server):
|
||||||
|
_put(server, "a.archive", "S", "A")
|
||||||
|
_put(server, "b.archive", "S", "A")
|
||||||
|
server.meta_db.split_chart("b.archive")
|
||||||
|
assert _grouped(client)["total"] == 2
|
||||||
|
server.meta_db.unsplit_chart("b.archive")
|
||||||
|
assert _grouped(client)["total"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ── Stats + A–Z count works, not charts ──────────────────────────────────────
|
||||||
|
|
||||||
|
def test_stats_counts_works_not_charts(client, server):
|
||||||
|
_put(server, "a.archive", "Song", "Artist")
|
||||||
|
_put(server, "b.archive", "Song", "Artist") # same work as a
|
||||||
|
_put(server, "c.archive", "Other", "Artist")
|
||||||
|
assert client.get("/api/library/stats").json()["total_songs"] == 3
|
||||||
|
assert client.get("/api/library/stats", params={"group": 1}).json()["total_songs"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_sort_letters_counts_works(client, server):
|
||||||
|
_put(server, "a.archive", "Song", "Alpha")
|
||||||
|
_put(server, "b.archive", "Song", "Alpha") # same work
|
||||||
|
_put(server, "c.archive", "Track", "Beta")
|
||||||
|
sl = client.get("/api/library/stats",
|
||||||
|
params={"group": 1, "sort_letters": 1, "sort": "artist"}).json()["sort_letters"]
|
||||||
|
assert sl.get("A") == 1 and sl.get("B") == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ── Keyset paging under grouping (the critical integration) ───────────────────
|
||||||
|
|
||||||
|
def test_grouped_keyset_pagination(client, server):
|
||||||
|
for i in range(5):
|
||||||
|
_put(server, f"w{i}_a.archive", f"Song {i}", "Artist")
|
||||||
|
_put(server, f"w{i}_b.archive", f"Song {i}", "Artist") # 2 charts per work
|
||||||
|
body = client.get("/api/library", params={"group": 1, "size": 3}).json()
|
||||||
|
assert body["total"] == 5 and len(body["songs"]) == 3
|
||||||
|
cur = body["next_cursor"]
|
||||||
|
assert cur
|
||||||
|
body2 = client.get("/api/library", params={"group": 1, "size": 3, "after": cur}).json()
|
||||||
|
assert len(body2["songs"]) == 2
|
||||||
|
p1 = {s["filename"] for s in body["songs"]}
|
||||||
|
p2 = {s["filename"] for s in body2["songs"]}
|
||||||
|
assert not (p1 & p2) # no skip / dupe across pages
|
||||||
|
|
||||||
|
|
||||||
|
# ── Freshness: adding a chart regroups; overrides survive reindex ─────────────
|
||||||
|
|
||||||
|
def test_new_chart_updates_group_size(client, server):
|
||||||
|
_put(server, "a.archive", "S", "A")
|
||||||
|
assert _grouped(client)["songs"][0]["chart_count"] == 1
|
||||||
|
_put(server, "b.archive", "S", "A") # dirties read-model
|
||||||
|
body = _grouped(client)
|
||||||
|
assert body["total"] == 1 and body["songs"][0]["chart_count"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_preferred_survives_reindex(client, server):
|
||||||
|
_put(server, "a.archive", "S", "A", arrangements=2)
|
||||||
|
_put(server, "b.archive", "S", "A", arrangements=1)
|
||||||
|
wk = server.meta_db.work_key_for("b.archive")
|
||||||
|
server.meta_db.set_chart_preferred(wk, "b.archive")
|
||||||
|
assert _grouped(client)["songs"][0]["filename"] == "b.archive"
|
||||||
|
# A rescan re-indexes both charts (INSERT OR REPLACE INTO songs).
|
||||||
|
_put(server, "a.archive", "S", "A", arrangements=2)
|
||||||
|
_put(server, "b.archive", "S", "A", arrangements=1)
|
||||||
|
assert _grouped(client)["songs"][0]["filename"] == "b.archive" # pref survived
|
||||||
|
|
||||||
|
|
||||||
|
# ── work_key normalization folds trivial differences ─────────────────────────
|
||||||
|
|
||||||
|
def test_work_key_folds_the_and_punctuation(server):
|
||||||
|
wk = server.meta_db._work_key
|
||||||
|
assert wk("The Beatles", "Hey Jude") == wk("beatles", "hey jude!")
|
||||||
|
assert wk("AC/DC", "T.N.T.") == wk("acdc", "tnt")
|
||||||
|
assert wk("Metallica", "One") != wk("Metallica", "Two")
|
||||||
Reference in New Issue
Block a user