Use precomputed stats cache in UI
This commit is contained in:
parent
d462b67758
commit
3374b76838
1 changed files with 153 additions and 46 deletions
199
script.js
199
script.js
|
@ -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 = '';
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue