Decouple browse views from FTS
This commit is contained in:
parent
9fa2dbbadc
commit
d8ecf5f607
3 changed files with 40 additions and 106 deletions
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
|
138
script.js
138
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.');
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue