Use precomputed stats cache in UI

This commit is contained in:
Jordan Wages 2025-09-19 03:07:18 -05:00
commit 3374b76838

199
script.js
View file

@ -1132,6 +1132,13 @@
return '%'; return '%';
} }
const baseCountSql = 'SELECT COUNT(*) AS count FROM artists';
const baseRowsSql = `
SELECT id, name
FROM artists
ORDER BY name COLLATE NOCASE
LIMIT ? OFFSET ?
`;
const countSql = 'SELECT COUNT(*) AS count FROM artists WHERE name LIKE ? ESCAPE "\\"'; const countSql = 'SELECT COUNT(*) AS count FROM artists WHERE name LIKE ? ESCAPE "\\"';
const rowsSql = ` const rowsSql = `
SELECT id, name SELECT id, name
@ -1185,6 +1192,7 @@
const typedFilter = state.filter.trim(); const typedFilter = state.filter.trim();
const ftsMatch = buildArtistFtsMatch(typedFilter); const ftsMatch = buildArtistFtsMatch(typedFilter);
const likeTerm = buildLikeTerm(); const likeTerm = buildLikeTerm();
const useUnfilteredQuery = !typedFilter && !state.prefix;
listState.showLoading('Loading…'); listState.showLoading('Loading…');
const offset = (state.page - 1) * state.pageSize; const offset = (state.page - 1) * state.pageSize;
@ -1209,8 +1217,8 @@
} }
if (!usedFts) { if (!usedFts) {
const countStmt = db.prepare(countSql); const countStmt = db.prepare(useUnfilteredQuery ? baseCountSql : countSql);
countStmt.bind([likeTerm]); if (!useUnfilteredQuery) countStmt.bind([likeTerm]);
if (countStmt.step()) total = Number(countStmt.getAsObject().count) || 0; if (countStmt.step()) total = Number(countStmt.getAsObject().count) || 0;
countStmt.free(); countStmt.free();
@ -1222,8 +1230,9 @@
if (offset >= total) state.page = Math.max(1, Math.ceil(total / state.pageSize)); if (offset >= total) state.page = Math.max(1, Math.ceil(total / state.pageSize));
const rowsStmt = db.prepare(rowsSql); const rowsStmt = db.prepare(useUnfilteredQuery ? baseRowsSql : rowsSql);
rowsStmt.bind([likeTerm, state.pageSize, (state.page - 1) * state.pageSize]); if (useUnfilteredQuery) rowsStmt.bind([state.pageSize, (state.page - 1) * state.pageSize]);
else rowsStmt.bind([likeTerm, state.pageSize, (state.page - 1) * state.pageSize]);
while (rowsStmt.step()) rows.push(rowsStmt.getAsObject()); while (rowsStmt.step()) rows.push(rowsStmt.getAsObject());
rowsStmt.free(); rowsStmt.free();
} }
@ -1829,6 +1838,7 @@
const $cards = el.querySelector('[data-ref="cards"]'); const $cards = el.querySelector('[data-ref="cards"]');
const $lists = el.querySelector('[data-ref="lists"]'); const $lists = el.querySelector('[data-ref="lists"]');
const siteStatsSql = 'SELECT name, value FROM site_stats';
const totalsSql = ` const totalsSql = `
SELECT SELECT
(SELECT COUNT(*) FROM artists) AS artists, (SELECT COUNT(*) FROM artists) AS artists,
@ -1860,68 +1870,165 @@
LIMIT 10 LIMIT 10
`; `;
function loadPrecomputedStats() {
try {
const stmt = db.prepare(siteStatsSql);
const map = new Map();
try {
while (stmt.step()) {
const row = stmt.getAsObject();
map.set(String(row.name), String(row.value));
}
} finally {
stmt.free();
}
return map.size ? map : null;
} catch (err) {
console.warn('Failed to load precomputed stats', err);
return null;
}
}
function collectOrderedStats(statsMap, prefix, transform) {
if (!statsMap) return null;
const items = [];
statsMap.forEach((rawValue, name) => {
if (!name.startsWith(prefix)) return;
const rank = Number(name.slice(prefix.length));
if (!Number.isFinite(rank) || rank <= 0) return;
let value;
try {
value = transform(rawValue);
} catch (err) {
console.warn('Failed to parse stat', name, err);
value = null;
}
if (value) items.push({ rank, value });
});
if (!items.length) return null;
items.sort((a, b) => a.rank - b.rank);
return items.map((item) => item.value);
}
const siteStats = loadPrecomputedStats();
function renderTotals() { function renderTotals() {
const hasPrecomputedCounts = siteStats
&& siteStats.has('count.artists')
&& siteStats.has('count.albums')
&& siteStats.has('count.tracks');
if (hasPrecomputedCounts) {
const metrics = [
{ label: 'Artists', value: Number(siteStats.get('count.artists')), action: () => navigateTo('browseArtists') },
{ label: 'Albums', value: Number(siteStats.get('count.albums')), action: () => navigateTo('browseAlbums') },
{ label: 'Tracks', value: Number(siteStats.get('count.tracks')), action: () => navigateTo('search') },
];
renderMetricCards(metrics);
return;
}
try { try {
const stmt = db.prepare(totalsSql); const stmt = db.prepare(totalsSql);
stmt.step(); stmt.step();
const totals = stmt.getAsObject(); const totals = stmt.getAsObject();
stmt.free(); stmt.free();
const metrics = [ const metrics = [
{ label: 'Artists', value: Number(totals.artists), action: () => navigateTo('browseArtists') }, { label: 'Artists', value: Number(totals.artists), action: () => navigateTo('browseArtists') },
{ label: 'Albums', value: Number(totals.albums), action: () => navigateTo('browseAlbums') }, { label: 'Albums', value: Number(totals.albums), action: () => navigateTo('browseAlbums') },
{ label: 'Tracks', value: Number(totals.tracks), action: () => navigateTo('search') }, { label: 'Tracks', value: Number(totals.tracks), action: () => navigateTo('search') },
]; ];
renderMetricCards(metrics);
$cards.innerHTML = '';
metrics.forEach((metric) => {
const column = document.createElement('div');
column.className = 'column';
const box = document.createElement('div');
box.className = 'box has-text-centered';
const value = document.createElement('p');
value.className = 'title is-3';
value.textContent = formatNumber(metric.value);
const label = document.createElement('p');
label.className = 'subtitle is-6';
label.textContent = metric.label;
const button = document.createElement('button');
button.type = 'button';
button.className = 'button is-small is-link';
button.textContent = `Open ${metric.label}`;
button.addEventListener('click', metric.action);
box.appendChild(value);
box.appendChild(label);
box.appendChild(button);
column.appendChild(box);
$cards.appendChild(column);
});
} catch (err) { } catch (err) {
console.error(err); console.error(err);
$cards.innerHTML = '<div class="column"><div class="notification is-danger">Failed to load stats.</div></div>'; $cards.innerHTML = '<div class="column"><div class="notification is-danger">Failed to load stats.</div></div>';
} }
} }
function renderMetricCards(metrics) {
$cards.innerHTML = '';
metrics.forEach((metric) => {
const column = document.createElement('div');
column.className = 'column';
const box = document.createElement('div');
box.className = 'box has-text-centered';
const value = document.createElement('p');
value.className = 'title is-3';
value.textContent = formatNumber(metric.value);
const label = document.createElement('p');
label.className = 'subtitle is-6';
label.textContent = metric.label;
const button = document.createElement('button');
button.type = 'button';
button.className = 'button is-small is-link';
button.textContent = `Open ${metric.label}`;
button.addEventListener('click', metric.action);
box.appendChild(value);
box.appendChild(label);
box.appendChild(button);
column.appendChild(box);
$cards.appendChild(column);
});
}
function renderTopLists() { function renderTopLists() {
let topArtists = []; const parseJson = (value) => {
let topYears = []; if (!value) return null;
let topGenres = []; return JSON.parse(value);
try { };
const artistStmt = db.prepare(topArtistsSql);
while (artistStmt.step()) topArtists.push(artistStmt.getAsObject());
artistStmt.free();
const yearStmt = db.prepare(topYearsSql); const precomputedArtists = collectOrderedStats(siteStats, 'top.artist.', (raw) => {
while (yearStmt.step()) topYears.push(yearStmt.getAsObject()); const data = parseJson(raw);
yearStmt.free(); if (!data) return null;
return {
artist_id: Number(data.artist_id),
artist: data.name,
cnt: Number(data.tracks),
};
});
const precomputedYears = collectOrderedStats(siteStats, 'top.year.', (raw) => {
const data = parseJson(raw);
if (!data) return null;
return {
year: Number(data.year),
cnt: Number(data.tracks),
};
});
const precomputedGenres = collectOrderedStats(siteStats, 'top.genre.', (raw) => {
const data = parseJson(raw);
if (!data) return null;
return {
genre: data.genre,
cnt: Number(data.tracks),
};
});
const genreStmt = db.prepare(topGenresSql); let topArtists = precomputedArtists || [];
while (genreStmt.step()) topGenres.push(genreStmt.getAsObject()); let topYears = precomputedYears || [];
genreStmt.free(); let topGenres = precomputedGenres || [];
} catch (err) {
console.error(err); if (!precomputedArtists || !precomputedYears || !precomputedGenres) {
$lists.innerHTML = '<p class="has-text-danger">Failed to load ranking lists.</p>'; try {
return; if (!precomputedArtists) {
const artistStmt = db.prepare(topArtistsSql);
while (artistStmt.step()) topArtists.push(artistStmt.getAsObject());
artistStmt.free();
}
if (!precomputedYears) {
const yearStmt = db.prepare(topYearsSql);
while (yearStmt.step()) topYears.push(yearStmt.getAsObject());
yearStmt.free();
}
if (!precomputedGenres) {
const genreStmt = db.prepare(topGenresSql);
while (genreStmt.step()) topGenres.push(genreStmt.getAsObject());
genreStmt.free();
}
} catch (err) {
console.error(err);
$lists.innerHTML = '<p class="has-text-danger">Failed to load ranking lists.</p>';
return;
}
} }
$lists.innerHTML = ''; $lists.innerHTML = '';