diff --git a/AGENTS.md b/AGENTS.md index d28387e..a9a29ce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/README.md b/README.md index 3d986cf..fef38ef 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/script.js b/script.js index 6d6b221..05ffae4 100644 --- a/script.js +++ b/script.js @@ -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,83 +1508,46 @@ ({ 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(); + const countStmt = prepareForView( + db, + VIEW_NAMES.browseArtists, + useUnfilteredQuery ? baseCountSql : countSql, + useUnfilteredQuery ? 'count' : 'count-filtered', + ); + if (!useUnfilteredQuery) countStmt.bind([likeTerm]); + if (countStmt.step()) total = Number(countStmt.getAsObject().count) || 0; + countStmt.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 (total === 0) { + table.clear(); + listState.showEmpty('No artists found'); + return; } - if (!usedFts) { - const countStmt = prepareForView( - db, - VIEW_NAMES.browseArtists, - useUnfilteredQuery ? baseCountSql : countSql, - useUnfilteredQuery ? 'count' : 'count-filtered', - ); - if (!useUnfilteredQuery) countStmt.bind([likeTerm]); - if (countStmt.step()) total = Number(countStmt.getAsObject().count) || 0; - countStmt.free(); - - if (total === 0) { - table.clear(); - listState.showEmpty('No artists found'); - return; - } - - 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 rowsStmt = prepareForView( - db, - VIEW_NAMES.browseArtists, - useUnfilteredQuery - ? buildBaseRowsSql(pageSize, offset) - : buildRowsSql(likeTerm, pageSize, offset), - useUnfilteredQuery ? 'rows' : 'rows-filtered', - ); - while (rowsStmt.step()) rows.push(rowsStmt.getAsObject()); - rowsStmt.free(); + 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 rowsStmt = prepareForView( + db, + VIEW_NAMES.browseArtists, + useUnfilteredQuery + ? buildBaseRowsSql(pageSize, offset) + : buildRowsSql(likeTerm, pageSize, offset), + useUnfilteredQuery ? 'rows' : 'rows-filtered', + ); + while (rowsStmt.step()) rows.push(rowsStmt.getAsObject()); + rowsStmt.free(); } catch (err) { console.error(err); listState.showError('Failed to load artists.');