Decouple browse views from FTS

This commit is contained in:
Jordan Wages 2025-09-24 05:12:56 -05:00
commit d8ecf5f607
3 changed files with 40 additions and 106 deletions

View file

@ -30,6 +30,7 @@ This document orients automation agents and contributors to the mp3-com-meta-bro
- Expect to run entirely client-side; the database is opened with read-only mode via `sql.js`.
- Always page results with `LIMIT ? OFFSET ?`; avoid `SELECT *` unless fetching a single record by ID.
- Prefer prepared statements or cached query strings when querying repeatedly.
- Reserve FTS (`fts_tracks`) for the free-text Track Search overlay; browse views should stick to indexed base tables to keep fan-out predictable.
- HTTP servers used in development or deployment must send `Accept-Ranges: bytes` so the VFS can issue range requests.
## Database Schema

View file

@ -3,7 +3,8 @@
mp3-com-meta-browser is a static single-page web app for exploring the salvaged mp3.com metadata catalog. All queries run entirely in the browser against a read-only SQLite database compiled to WebAssembly, so the site can be hosted on any static file server.
## Highlights
- Client-side full-text search (FTS5) across track titles, artists, albums, and genres.
- Client-side full-text search (FTS5) powers the dedicated track search overlay.
- Browse views query indexed base tables directly for predictable pagination (no FTS fan-out).
- Responsive Bulma-based UI with dedicated views for search, browse (artists, albums, years, genres), and dataset stats.
- Overlay detail panels for artists, albums, and tracks with keyboard navigation support.
- Zero server dependencies beyond serving static assets with HTTP range support.
@ -39,10 +40,12 @@ Add future static assets (icons, fonts, etc.) under `assets/`.
## Using the App
- **Search:** Free-text search with 250 ms debounce. Toggle between relevance and alphabetical sorting. Keyboard: `/` focuses search, `Enter` activates the highlighted row.
- **Browse Artists / Albums / Years / Genres:** Paginated listings with quick filters (artists leverage FTS prefix queries for fast prefix matching). Selecting an entry opens the corresponding overlay.
- **Browse Artists / Albums / Years / Genres:** Paginated listings with quick filters backed by indexed table queries (`artists`, `albums`, `tracks`). Selecting an entry opens the corresponding overlay.
- **Stats:** Summary cards pull from pre-computed counters in `site_stats` and link into browse views with preset filters.
- **Overlays:** Artist, album, and track overlays show detailed metadata. `Esc` closes the top overlay and focus returns to the invoker.
Only the Track Search overlay issues FTS queries. Browse views stay on indexed base tables so pagination remains predictable and light on resources.
## Keyboard & Accessibility Notes
- Global shortcuts: `/` for search, `Esc` to close overlays.
- Lists expose arrow navigation with `Enter` activation when focused.

View file

@ -1471,38 +1471,6 @@
ORDER BY name COLLATE NOCASE
LIMIT ${pageSize} OFFSET ${offset}
`;
const ftsCountSql = `
SELECT COUNT(*) AS count FROM (
SELECT a.id
FROM fts_tracks
JOIN tracks t ON t.id = fts_tracks.rowid
JOIN artists a ON a.id = t.artist_id
WHERE fts_tracks MATCH ?
GROUP BY a.id
) AS matches
`;
const buildFtsRowsSql = (pageSize, offset) => `
SELECT a.id, a.name
FROM fts_tracks
JOIN tracks t ON t.id = fts_tracks.rowid
JOIN artists a ON a.id = t.artist_id
WHERE fts_tracks MATCH ?
GROUP BY a.id
ORDER BY a.name COLLATE NOCASE
LIMIT ${pageSize} OFFSET ${offset}
`;
function buildArtistFtsMatch(input) {
const tokens = String(input)
.trim()
.toLowerCase()
.split(/\s+/)
.map((token) => token.replace(/[^0-9a-z]/gi, '').slice(0, 32))
.filter((token) => token.length >= 2);
if (!tokens.length) return null;
return tokens.map((token) => `artist:${token}*`).join(' AND ');
}
function updateJumpButtons() {
jumpButtons.forEach((btn) => {
const letter = btn.dataset.letter || 'all';
@ -1514,7 +1482,6 @@
function loadArtistsImmediate() {
const typedFilter = state.filter.trim();
const ftsMatch = buildArtistFtsMatch(typedFilter);
const likeTerm = buildLikeTerm();
const useUnfilteredQuery = !typedFilter && !state.prefix;
listState.showLoading('Loading…');
@ -1541,44 +1508,8 @@
({ pageSize, offset } = initialSanitized);
let total = 0;
const rows = [];
let usedFts = false;
try {
if (ftsMatch) {
const ftsCountStmt = prepareForView(
db,
VIEW_NAMES.browseArtists,
applyFtsMatch(ftsCountSql, ftsMatch),
'fts-count',
);
if (ftsCountStmt.step()) total = Number(ftsCountStmt.getAsObject().count) || 0;
ftsCountStmt.free();
if (total > 0) {
if (clampPaginationToTotal({
state,
total,
pageSize,
view: VIEW_NAMES.browseArtists,
})) {
({ page, pageSize, offset } = getNormalizedPagination());
const sanitized = sanitizeLimitAndOffset(pageSize, offset);
if (!sanitized) return;
({ pageSize, offset } = sanitized);
}
const ftsRowsStmt = prepareForView(
db,
VIEW_NAMES.browseArtists,
applyFtsMatch(buildFtsRowsSql(pageSize, offset), ftsMatch),
'fts-rows',
);
while (ftsRowsStmt.step()) rows.push(ftsRowsStmt.getAsObject());
ftsRowsStmt.free();
usedFts = true;
}
}
if (!usedFts) {
const countStmt = prepareForView(
db,
VIEW_NAMES.browseArtists,
@ -1617,7 +1548,6 @@
);
while (rowsStmt.step()) rows.push(rowsStmt.getAsObject());
rowsStmt.free();
}
} catch (err) {
console.error(err);
listState.showError('Failed to load artists.');