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`.
|
- 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.
|
- 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.
|
- 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.
|
- HTTP servers used in development or deployment must send `Accept-Ranges: bytes` so the VFS can issue range requests.
|
||||||
|
|
||||||
## Database Schema
|
## 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.
|
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
|
## 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.
|
- 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.
|
- Overlay detail panels for artists, albums, and tracks with keyboard navigation support.
|
||||||
- Zero server dependencies beyond serving static assets with HTTP range 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
|
## 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.
|
- **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.
|
- **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.
|
- **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
|
## Keyboard & Accessibility Notes
|
||||||
- Global shortcuts: `/` for search, `Esc` to close overlays.
|
- Global shortcuts: `/` for search, `Esc` to close overlays.
|
||||||
- Lists expose arrow navigation with `Enter` activation when focused.
|
- Lists expose arrow navigation with `Enter` activation when focused.
|
||||||
|
|
138
script.js
138
script.js
|
@ -1471,38 +1471,6 @@
|
||||||
ORDER BY name COLLATE NOCASE
|
ORDER BY name COLLATE NOCASE
|
||||||
LIMIT ${pageSize} OFFSET ${offset}
|
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() {
|
function updateJumpButtons() {
|
||||||
jumpButtons.forEach((btn) => {
|
jumpButtons.forEach((btn) => {
|
||||||
const letter = btn.dataset.letter || 'all';
|
const letter = btn.dataset.letter || 'all';
|
||||||
|
@ -1514,7 +1482,6 @@
|
||||||
|
|
||||||
function loadArtistsImmediate() {
|
function loadArtistsImmediate() {
|
||||||
const typedFilter = state.filter.trim();
|
const typedFilter = state.filter.trim();
|
||||||
const ftsMatch = buildArtistFtsMatch(typedFilter);
|
|
||||||
const likeTerm = buildLikeTerm();
|
const likeTerm = buildLikeTerm();
|
||||||
const useUnfilteredQuery = !typedFilter && !state.prefix;
|
const useUnfilteredQuery = !typedFilter && !state.prefix;
|
||||||
listState.showLoading('Loading…');
|
listState.showLoading('Loading…');
|
||||||
|
@ -1541,83 +1508,46 @@
|
||||||
({ pageSize, offset } = initialSanitized);
|
({ pageSize, offset } = initialSanitized);
|
||||||
let total = 0;
|
let total = 0;
|
||||||
const rows = [];
|
const rows = [];
|
||||||
let usedFts = false;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (ftsMatch) {
|
const countStmt = prepareForView(
|
||||||
const ftsCountStmt = prepareForView(
|
db,
|
||||||
db,
|
VIEW_NAMES.browseArtists,
|
||||||
VIEW_NAMES.browseArtists,
|
useUnfilteredQuery ? baseCountSql : countSql,
|
||||||
applyFtsMatch(ftsCountSql, ftsMatch),
|
useUnfilteredQuery ? 'count' : 'count-filtered',
|
||||||
'fts-count',
|
);
|
||||||
);
|
if (!useUnfilteredQuery) countStmt.bind([likeTerm]);
|
||||||
if (ftsCountStmt.step()) total = Number(ftsCountStmt.getAsObject().count) || 0;
|
if (countStmt.step()) total = Number(countStmt.getAsObject().count) || 0;
|
||||||
ftsCountStmt.free();
|
countStmt.free();
|
||||||
|
|
||||||
if (total > 0) {
|
if (total === 0) {
|
||||||
if (clampPaginationToTotal({
|
table.clear();
|
||||||
state,
|
listState.showEmpty('No artists found');
|
||||||
total,
|
return;
|
||||||
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) {
|
if (clampPaginationToTotal({
|
||||||
const countStmt = prepareForView(
|
state,
|
||||||
db,
|
total,
|
||||||
VIEW_NAMES.browseArtists,
|
pageSize,
|
||||||
useUnfilteredQuery ? baseCountSql : countSql,
|
view: VIEW_NAMES.browseArtists,
|
||||||
useUnfilteredQuery ? 'count' : 'count-filtered',
|
})) {
|
||||||
);
|
({ page, pageSize, offset } = getNormalizedPagination());
|
||||||
if (!useUnfilteredQuery) countStmt.bind([likeTerm]);
|
const sanitized = sanitizeLimitAndOffset(pageSize, offset);
|
||||||
if (countStmt.step()) total = Number(countStmt.getAsObject().count) || 0;
|
if (!sanitized) return;
|
||||||
countStmt.free();
|
({ pageSize, offset } = sanitized);
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
listState.showError('Failed to load artists.');
|
listState.showError('Failed to load artists.');
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue