Add SQL helper utilities and migrate queries

This commit is contained in:
Jordan Wages 2025-09-24 05:48:06 -05:00
commit fa68916d3e
2 changed files with 218 additions and 136 deletions

View file

@ -113,6 +113,7 @@ CREATE INDEX idx_artists_name_nocase ON artists(name COLLATE NOCASE);
- `createAsyncListState` Manages loading, error, and empty states for async lists. - `createAsyncListState` Manages loading, error, and empty states for async lists.
- `prepareForView` Wraps `db.prepare` to attach view/label metadata for logging. - `prepareForView` Wraps `db.prepare` to attach view/label metadata for logging.
- `normalizePaginationState` / `clampPaginationToTotal` Normalizes and clamps pagination inputs while emitting DEBUG telemetry. - `normalizePaginationState` / `clampPaginationToTotal` Normalizes and clamps pagination inputs while emitting DEBUG telemetry.
- `sqlValue`, `sqlLike`, `sqlIdentifier`, and the `sql` template tag central helpers for manual SQL construction; always use these when interpolating dynamic values.
## Debugging & Logging ## Debugging & Logging
- Runtime diagnostics are gated behind a localStorage flag. Enable with `localStorage.setItem('mp3com.debug', 'true')` (or `'false'`/`removeItem` to disable) before reloading the app. - Runtime diagnostics are gated behind a localStorage flag. Enable with `localStorage.setItem('mp3com.debug', 'true')` (or `'false'`/`removeItem` to disable) before reloading the app.

329
script.js
View file

@ -124,6 +124,89 @@
}); });
} }
const SQL_FRAGMENT = Symbol('sqlFragment');
function createSqlFragment(text, raw) {
return {
text,
raw,
[SQL_FRAGMENT]: true,
};
}
function isSqlFragment(value) {
return Boolean(value && value[SQL_FRAGMENT]);
}
function quoteSqlString(value) {
return `'${String(value).replace(/'/g, "''")}'`;
}
function sqlValue(value) {
if (value === null || value === undefined) {
return createSqlFragment('NULL', value);
}
const type = typeof value;
if (type === 'number') {
if (!Number.isFinite(value)) throw new Error(`Invalid numeric SQL value: ${value}`);
return createSqlFragment(String(value), value);
}
if (type === 'boolean') {
return createSqlFragment(value ? '1' : '0', value);
}
if (type === 'string') {
return createSqlFragment(quoteSqlString(value), value);
}
throw new Error(`Unsupported SQL value type: ${type}`);
}
function sqlLike(value, { escapeChar = '\\' } = {}) {
if (escapeChar === null || escapeChar === undefined) throw new Error('escapeChar required');
if (typeof escapeChar !== 'string' || escapeChar.length !== 1) throw new Error('escapeChar must be a single character');
if (value === null || value === undefined) {
return createSqlFragment('NULL', { value, escapeChar });
}
const raw = String(value);
const regex = new RegExp(`[${escapeChar.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}%_]`, 'g');
const escaped = raw.replace(regex, (match) => escapeChar + match);
return createSqlFragment(quoteSqlString(escaped), { value, escapeChar });
}
function sqlIdentifier(name, { allowlist } = {}) {
if (typeof name !== 'string' || !name) throw new Error('Identifier must be a non-empty string');
if (!Array.isArray(allowlist) || allowlist.length === 0) throw new Error('allowlist required for sqlIdentifier');
if (!allowlist.includes(name)) {
throw new Error(`Identifier not in allowlist: ${name}`);
}
return createSqlFragment(name, name);
}
function buildSql(strings, ...values) {
if (!Array.isArray(strings) || strings.length === 0) throw new Error('sql tag requires template literals');
let sqlText = '';
const params = [];
for (let i = 0; i < strings.length; i += 1) {
sqlText += strings[i];
if (i >= values.length) continue;
const part = values[i];
if (isSqlFragment(part)) {
sqlText += part.text;
params.push(part.raw);
continue;
}
if (part && typeof part === 'object' && typeof part.sql === 'string') {
sqlText += part.sql;
if (Array.isArray(part.params)) params.push(...part.params);
else if (part.params !== undefined) params.push(part.params);
continue;
}
throw new Error('sql template values must be created with sqlValue/sqlLike/sqlIdentifier or other sql fragments');
}
return { sql: sqlText, params };
}
const sql = buildSql;
function logPaginationState({ view, rawPage, rawPageSize, page, pageSize, offset, flags }) { function logPaginationState({ view, rawPage, rawPageSize, page, pageSize, offset, flags }) {
logDebug('[Pagination]', { logDebug('[Pagination]', {
view: view || 'unknown', view: view || 'unknown',
@ -136,6 +219,20 @@
}); });
} }
function normalizeSqlInput(sqlInput) {
if (typeof sqlInput === 'string') {
return { sql: sqlInput, params: undefined };
}
if (sqlInput && typeof sqlInput === 'object') {
const sqlText = sqlInput.sql;
if (typeof sqlText !== 'string' || !sqlText) {
throw new Error('Invalid SQL input: missing sql string');
}
return { sql: sqlText, params: cloneBoundParams(sqlInput.params) };
}
throw new Error('Invalid SQL input');
}
function normalizePagination({ page, pageSize, defaultPageSize, view }) { function normalizePagination({ page, pageSize, defaultPageSize, view }) {
const safeDefault = Number.isFinite(defaultPageSize) && defaultPageSize > 0 ? Math.max(1, Math.floor(defaultPageSize)) : 25; const safeDefault = Number.isFinite(defaultPageSize) && defaultPageSize > 0 ? Math.max(1, Math.floor(defaultPageSize)) : 25;
const rawPage = page; const rawPage = page;
@ -492,16 +589,6 @@
return `${hz.toLocaleString()} Hz`; return `${hz.toLocaleString()} Hz`;
} }
function escapeSqlText(value) {
return `'${String(value).replace(/'/g, "''")}'`;
}
function applyFtsMatch(sql, matchExpr) {
const placeholder = 'MATCH ?';
if (!sql.includes(placeholder)) throw new Error('Expected FTS MATCH placeholder');
return sql.replace(placeholder, `MATCH ${escapeSqlText(matchExpr)}`);
}
async function unzipSqlite(zipBytes) { async function unzipSqlite(zipBytes) {
loader.setStep('Unpacking database…'); loader.setStep('Unpacking database…');
loader.setDetail('Decompressing ZIP'); loader.setDetail('Decompressing ZIP');
@ -538,7 +625,7 @@
view: meta.view || 'unknown', view: meta.view || 'unknown',
label: meta.label || undefined, label: meta.label || undefined,
}; };
this._boundParams = undefined; this._paramsForLog = cloneBoundParams(meta.params);
this._rowCount = 0; this._rowCount = 0;
this._startedAt = 0; this._startedAt = 0;
this._errored = false; this._errored = false;
@ -548,18 +635,11 @@
if (meta.sql) this._meta.sql = meta.sql; if (meta.sql) this._meta.sql = meta.sql;
if (meta.view) this._meta.view = meta.view; if (meta.view) this._meta.view = meta.view;
if (Object.prototype.hasOwnProperty.call(meta, 'label')) this._meta.label = meta.label; if (Object.prototype.hasOwnProperty.call(meta, 'label')) this._meta.label = meta.label;
if (Object.prototype.hasOwnProperty.call(meta, 'params')) {
this._paramsForLog = cloneBoundParams(meta.params);
}
return this; return this;
} }
bind(values) {
try {
if (values !== undefined) this._stmt.bind(values);
this._boundParams = cloneBoundParams(values);
return true;
} catch (error) {
this._recordError(error, 'bind');
throw error;
}
}
step() { step() {
try { try {
this._ensureBegun(); this._ensureBegun();
@ -602,7 +682,7 @@
logQueryBegin({ logQueryBegin({
view: this._meta.view, view: this._meta.view,
sql: this._meta.sql, sql: this._meta.sql,
params: this._boundParams, params: this._paramsForLog,
label: this._meta.label, label: this._meta.label,
}); });
} }
@ -612,7 +692,7 @@
logQueryEnd({ logQueryEnd({
view: this._meta.view, view: this._meta.view,
sql: this._meta.sql, sql: this._meta.sql,
params: this._boundParams, params: this._paramsForLog,
rowCount: this._rowCount, rowCount: this._rowCount,
durationMs: duration, durationMs: duration,
label: this._meta.label, label: this._meta.label,
@ -627,7 +707,7 @@
logSqlError({ logSqlError({
view: this._meta.view, view: this._meta.view,
sql: this._meta.sql, sql: this._meta.sql,
params: this._boundParams, params: this._paramsForLog,
error, error,
phase, phase,
}); });
@ -639,9 +719,10 @@
const source = bytes instanceof Uint8Array ? bytes : bytes ? new Uint8Array(bytes) : undefined; const source = bytes instanceof Uint8Array ? bytes : bytes ? new Uint8Array(bytes) : undefined;
this._db = source ? new SQL.Database(source) : new SQL.Database(); this._db = source ? new SQL.Database(source) : new SQL.Database();
} }
prepare(sql, context) { prepare(sqlInput, context) {
const meta = context ? { ...context, sql } : { sql }; const { sql: sqlText, params } = normalizeSqlInput(sqlInput);
return new Statement(this._db.prepare(sql), meta); const meta = context ? { ...context, sql: sqlText, params } : { sql: sqlText, params };
return new Statement(this._db.prepare(sqlText), meta);
} }
exec(sql) { exec(sql) {
return this._db.exec(sql); return this._db.exec(sql);
@ -1179,34 +1260,38 @@
} }
const searchSql = { const searchSql = {
rank: ` rank({ matchExpr, pageSize, offset }) {
return sql`
SELECT t.id, a.name AS artist, t.title, IFNULL(al.title, '') AS album, t.year, t.genre SELECT t.id, a.name AS artist, t.title, IFNULL(al.title, '') AS album, t.year, t.genre
FROM fts_tracks FROM fts_tracks
JOIN tracks t ON t.id = fts_tracks.rowid JOIN tracks t ON t.id = fts_tracks.rowid
JOIN artists a ON a.id = t.artist_id JOIN artists a ON a.id = t.artist_id
LEFT JOIN albums al ON al.id = t.album_id LEFT JOIN albums al ON al.id = t.album_id
WHERE fts_tracks MATCH ? WHERE fts_tracks MATCH ${sqlValue(matchExpr)}
ORDER BY rank ORDER BY rank
LIMIT ? OFFSET ? LIMIT ${sqlValue(pageSize)} OFFSET ${sqlValue(offset)}
`, `;
alpha: ` },
alpha({ matchExpr, pageSize, offset }) {
return sql`
SELECT t.id, a.name AS artist, t.title, IFNULL(al.title, '') AS album, t.year, t.genre SELECT t.id, a.name AS artist, t.title, IFNULL(al.title, '') AS album, t.year, t.genre
FROM fts_tracks FROM fts_tracks
JOIN tracks t ON t.id = fts_tracks.rowid JOIN tracks t ON t.id = fts_tracks.rowid
JOIN artists a ON a.id = t.artist_id JOIN artists a ON a.id = t.artist_id
LEFT JOIN albums al ON al.id = t.album_id LEFT JOIN albums al ON al.id = t.album_id
WHERE fts_tracks MATCH ? WHERE fts_tracks MATCH ${sqlValue(matchExpr)}
ORDER BY a.name COLLATE NOCASE, t.title COLLATE NOCASE, COALESCE(t.year, 0) ORDER BY a.name COLLATE NOCASE, t.title COLLATE NOCASE, COALESCE(t.year, 0)
LIMIT ? OFFSET ? LIMIT ${sqlValue(pageSize)} OFFSET ${sqlValue(offset)}
`, `;
},
}; };
const countSql = ` const countSql = ({ matchExpr }) => sql`
SELECT COUNT(*) AS count SELECT COUNT(*) AS count
FROM fts_tracks FROM fts_tracks
JOIN tracks t ON t.id = fts_tracks.rowid JOIN tracks t ON t.id = fts_tracks.rowid
JOIN artists a ON a.id = t.artist_id JOIN artists a ON a.id = t.artist_id
LEFT JOIN albums al ON al.id = t.album_id LEFT JOIN albums al ON al.id = t.album_id
WHERE fts_tracks MATCH ? WHERE fts_tracks MATCH ${sqlValue(matchExpr)}
`; `;
function enableSearch() { function enableSearch() {
@ -1252,20 +1337,17 @@
defaultPageSize, defaultPageSize,
view: VIEW_NAMES.search, view: VIEW_NAMES.search,
}); });
const effectiveSearchSql = searchSql[state.sort] || searchSql.rank; const effectiveSearchBuilder = searchSql[state.sort] || searchSql.rank;
let total = 0; let total = 0;
let rows = []; let rows = [];
try { try {
const countQuery = countSql({ matchExpr });
logDebug('[Search] count SQL', { logDebug('[Search] count SQL', {
matchExpr, matchExpr,
sql: countSql.trim(), sql: formatSql(countQuery.sql),
params: formatParamsForLog(countQuery.params),
}); });
const countStmt = prepareForView( const countStmt = prepareForView(db, VIEW_NAMES.search, countQuery, 'count');
db,
VIEW_NAMES.search,
applyFtsMatch(countSql, matchExpr),
'count',
);
if (countStmt.step()) { if (countStmt.step()) {
const row = countStmt.getAsObject(); const row = countStmt.getAsObject();
total = Number(row.count) || 0; total = Number(row.count) || 0;
@ -1290,20 +1372,13 @@
view: VIEW_NAMES.search, view: VIEW_NAMES.search,
})); }));
} }
const rowsQuery = effectiveSearchBuilder({ matchExpr, pageSize, offset });
const searchParams = [pageSize, offset];
logDebug('[Search] row SQL', { logDebug('[Search] row SQL', {
matchExpr, matchExpr,
sql: effectiveSearchSql.trim(), sql: formatSql(rowsQuery.sql),
params: searchParams, params: formatParamsForLog(rowsQuery.params),
}); });
const searchStmt = prepareForView( const searchStmt = prepareForView(db, VIEW_NAMES.search, rowsQuery, 'rows');
db,
VIEW_NAMES.search,
applyFtsMatch(effectiveSearchSql, matchExpr),
'rows',
);
searchStmt.bind(searchParams);
const nextRows = []; const nextRows = [];
while (searchStmt.step()) { while (searchStmt.step()) {
nextRows.push(searchStmt.getAsObject()); nextRows.push(searchStmt.getAsObject());
@ -1445,31 +1520,34 @@
}); });
} }
function escapeLike(str) {
return String(str).replace(/[\\%_]/g, (m) => `\\${m}`);
}
function buildLikeTerm() { function buildLikeTerm() {
const typed = state.filter.trim(); const typed = state.filter.trim();
if (typed) return `%${escapeLike(typed)}%`; if (typed) return `%${typed}%`;
if (state.prefix) return `${escapeLike(state.prefix)}%`; if (state.prefix) return `${state.prefix}%`;
return '%'; return '%';
} }
const baseCountSql = 'SELECT COUNT(*) AS count FROM artists'; const baseCountSql = sql`
const buildBaseRowsSql = (pageSize, offset) => ` SELECT COUNT(*) AS count
SELECT id, name
FROM artists FROM artists
ORDER BY name COLLATE NOCASE
LIMIT ${pageSize} OFFSET ${offset}
`; `;
const countSql = 'SELECT COUNT(*) AS count FROM artists WHERE name LIKE ? ESCAPE "\\"'; const buildBaseRowsSql = (pageSize, offset) => sql`
const buildRowsSql = (likeValue, pageSize, offset) => `
SELECT id, name SELECT id, name
FROM artists FROM artists
WHERE name LIKE ${escapeSqlText(likeValue)} ESCAPE "\\"
ORDER BY name COLLATE NOCASE ORDER BY name COLLATE NOCASE
LIMIT ${pageSize} OFFSET ${offset} LIMIT ${sqlValue(pageSize)} OFFSET ${sqlValue(offset)}
`;
const buildFilteredCountSql = (likeValue) => sql`
SELECT COUNT(*) AS count
FROM artists
WHERE name LIKE ${sqlLike(likeValue)} ESCAPE '\\'
`;
const buildFilteredRowsSql = (likeValue, pageSize, offset) => sql`
SELECT id, name
FROM artists
WHERE name LIKE ${sqlLike(likeValue)} ESCAPE '\\'
ORDER BY name COLLATE NOCASE
LIMIT ${sqlValue(pageSize)} OFFSET ${sqlValue(offset)}
`; `;
function updateJumpButtons() { function updateJumpButtons() {
jumpButtons.forEach((btn) => { jumpButtons.forEach((btn) => {
@ -1510,13 +1588,13 @@
const rows = []; const rows = [];
try { try {
const countQuery = useUnfilteredQuery ? baseCountSql : buildFilteredCountSql(likeTerm);
const countStmt = prepareForView( const countStmt = prepareForView(
db, db,
VIEW_NAMES.browseArtists, VIEW_NAMES.browseArtists,
useUnfilteredQuery ? baseCountSql : countSql, countQuery,
useUnfilteredQuery ? 'count' : 'count-filtered', useUnfilteredQuery ? 'count' : 'count-filtered',
); );
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();
@ -1538,12 +1616,13 @@
({ pageSize, offset } = sanitized); ({ pageSize, offset } = sanitized);
} }
const rowsQuery = useUnfilteredQuery
? buildBaseRowsSql(pageSize, offset)
: buildFilteredRowsSql(likeTerm, pageSize, offset);
const rowsStmt = prepareForView( const rowsStmt = prepareForView(
db, db,
VIEW_NAMES.browseArtists, VIEW_NAMES.browseArtists,
useUnfilteredQuery rowsQuery,
? buildBaseRowsSql(pageSize, offset)
: buildRowsSql(likeTerm, pageSize, offset),
useUnfilteredQuery ? 'rows' : 'rows-filtered', useUnfilteredQuery ? 'rows' : 'rows-filtered',
); );
while (rowsStmt.step()) rows.push(rowsStmt.getAsObject()); while (rowsStmt.step()) rows.push(rowsStmt.getAsObject());
@ -1666,13 +1745,17 @@
title: 'al.title COLLATE NOCASE, a.name COLLATE NOCASE, COALESCE(al.year, 0)', title: 'al.title COLLATE NOCASE, a.name COLLATE NOCASE, COALESCE(al.year, 0)',
}; };
const countSql = 'SELECT COUNT(*) AS count FROM albums'; const countSql = sql`
const baseSql = ` SELECT COUNT(*) AS count
FROM albums
`;
const buildRowsSql = (orderExpr, pageSize, offset) => sql`
SELECT al.id, al.title, al.year, a.name AS artist SELECT al.id, al.title, al.year, a.name AS artist
FROM albums al FROM albums al
JOIN artists a ON a.id = al.artist_id JOIN artists a ON a.id = al.artist_id
ORDER BY %ORDER% ORDER BY ${sqlIdentifier(orderExpr, { allowlist: Object.values(orderMap) })}
LIMIT ? OFFSET ? LIMIT ${sqlValue(pageSize)} OFFSET ${sqlValue(offset)}
`; `;
function loadAlbumsImmediate() { function loadAlbumsImmediate() {
@ -1710,13 +1793,13 @@
} }
const order = orderMap[state.sort] || orderMap.artist; const order = orderMap[state.sort] || orderMap.artist;
const rowsQuery = buildRowsSql(order, pageSize, offset);
const stmt = prepareForView( const stmt = prepareForView(
db, db,
VIEW_NAMES.browseAlbums, VIEW_NAMES.browseAlbums,
baseSql.replace('%ORDER%', order), rowsQuery,
'rows', 'rows',
); );
stmt.bind([pageSize, offset]);
while (stmt.step()) rows.push(stmt.getAsObject()); while (stmt.step()) rows.push(stmt.getAsObject());
stmt.free(); stmt.free();
} catch (err) { } catch (err) {
@ -1820,22 +1903,26 @@
let yearButtons = []; let yearButtons = [];
const yearsSql = ` const yearsSql = sql`
SELECT year, COUNT(*) AS cnt SELECT year, COUNT(*) AS cnt
FROM tracks FROM tracks
WHERE year IS NOT NULL WHERE year IS NOT NULL
GROUP BY year GROUP BY year
ORDER BY year ORDER BY year
`; `;
const tracksCountSql = 'SELECT COUNT(*) AS count FROM tracks WHERE year = ?'; const tracksCountSql = (year) => sql`
const tracksRowsSql = ` SELECT COUNT(*) AS count
FROM tracks
WHERE year = ${sqlValue(year)}
`;
const tracksRowsSql = (year, pageSize, offset) => sql`
SELECT t.id, t.title, a.name AS artist, IFNULL(al.title, '') AS album, t.genre SELECT t.id, t.title, a.name AS artist, IFNULL(al.title, '') AS album, t.genre
FROM tracks t FROM tracks t
JOIN artists a ON a.id = t.artist_id JOIN artists a ON a.id = t.artist_id
LEFT JOIN albums al ON al.id = t.album_id LEFT JOIN albums al ON al.id = t.album_id
WHERE t.year = ? WHERE t.year = ${sqlValue(year)}
ORDER BY a.name COLLATE NOCASE, t.title COLLATE NOCASE ORDER BY a.name COLLATE NOCASE, t.title COLLATE NOCASE
LIMIT ? OFFSET ? LIMIT ${sqlValue(pageSize)} OFFSET ${sqlValue(offset)}
`; `;
function updateYearButtons() { function updateYearButtons() {
@ -1919,10 +2006,9 @@
const countStmt = prepareForView( const countStmt = prepareForView(
db, db,
VIEW_NAMES.browseYears, VIEW_NAMES.browseYears,
tracksCountSql, tracksCountSql(state.selectedYear),
'tracks-count', 'tracks-count',
); );
countStmt.bind([state.selectedYear]);
if (countStmt.step()) total = Number(countStmt.getAsObject().count) || 0; if (countStmt.step()) total = Number(countStmt.getAsObject().count) || 0;
countStmt.free(); countStmt.free();
@ -1948,10 +2034,9 @@
const stmt = prepareForView( const stmt = prepareForView(
db, db,
VIEW_NAMES.browseYears, VIEW_NAMES.browseYears,
tracksRowsSql, tracksRowsSql(state.selectedYear, pageSize, offset),
'tracks-rows', 'tracks-rows',
); );
stmt.bind([state.selectedYear, pageSize, offset]);
while (stmt.step()) rows.push(stmt.getAsObject()); while (stmt.step()) rows.push(stmt.getAsObject());
stmt.free(); stmt.free();
} catch (err) { } catch (err) {
@ -2051,22 +2136,26 @@
let genreButtons = []; let genreButtons = [];
const genresSql = ` const genresSql = sql`
SELECT genre, COUNT(*) AS cnt SELECT genre, COUNT(*) AS cnt
FROM tracks FROM tracks
WHERE genre IS NOT NULL AND genre != '' WHERE genre IS NOT NULL AND genre != ''
GROUP BY genre GROUP BY genre
ORDER BY cnt DESC, genre COLLATE NOCASE ORDER BY cnt DESC, genre COLLATE NOCASE
`; `;
const tracksCountSql = 'SELECT COUNT(*) AS count FROM tracks WHERE genre = ?'; const tracksCountSql = (genre) => sql`
const tracksRowsSql = ` SELECT COUNT(*) AS count
FROM tracks
WHERE genre = ${sqlValue(genre)}
`;
const tracksRowsSql = (genre, pageSize, offset) => sql`
SELECT t.id, t.title, a.name AS artist, IFNULL(al.title, '') AS album, t.year SELECT t.id, t.title, a.name AS artist, IFNULL(al.title, '') AS album, t.year
FROM tracks t FROM tracks t
JOIN artists a ON a.id = t.artist_id JOIN artists a ON a.id = t.artist_id
LEFT JOIN albums al ON al.id = t.album_id LEFT JOIN albums al ON al.id = t.album_id
WHERE t.genre = ? WHERE t.genre = ${sqlValue(genre)}
ORDER BY a.name COLLATE NOCASE, t.title COLLATE NOCASE ORDER BY a.name COLLATE NOCASE, t.title COLLATE NOCASE
LIMIT ? OFFSET ? LIMIT ${sqlValue(pageSize)} OFFSET ${sqlValue(offset)}
`; `;
function renderGenres() { function renderGenres() {
@ -2145,10 +2234,9 @@
const countStmt = prepareForView( const countStmt = prepareForView(
db, db,
VIEW_NAMES.browseGenres, VIEW_NAMES.browseGenres,
tracksCountSql, tracksCountSql(state.selectedGenre),
'tracks-count', 'tracks-count',
); );
countStmt.bind([state.selectedGenre]);
if (countStmt.step()) total = Number(countStmt.getAsObject().count) || 0; if (countStmt.step()) total = Number(countStmt.getAsObject().count) || 0;
countStmt.free(); countStmt.free();
@ -2174,10 +2262,9 @@
const stmt = prepareForView( const stmt = prepareForView(
db, db,
VIEW_NAMES.browseGenres, VIEW_NAMES.browseGenres,
tracksRowsSql, tracksRowsSql(state.selectedGenre, pageSize, offset),
'tracks-rows', 'tracks-rows',
); );
stmt.bind([state.selectedGenre, pageSize, offset]);
while (stmt.step()) rows.push(stmt.getAsObject()); while (stmt.step()) rows.push(stmt.getAsObject());
stmt.free(); stmt.free();
} catch (err) { } catch (err) {
@ -2507,23 +2594,23 @@
let artistInfo = null; let artistInfo = null;
const headerSql = ` const headerSql = (id) => sql`
SELECT a.name, SELECT a.name,
(SELECT COUNT(*) FROM albums WHERE artist_id = a.id) AS album_count, (SELECT COUNT(*) FROM albums WHERE artist_id = a.id) AS album_count,
(SELECT COUNT(*) FROM tracks WHERE artist_id = a.id) AS track_count (SELECT COUNT(*) FROM tracks WHERE artist_id = a.id) AS track_count
FROM artists a FROM artists a
WHERE a.id = ? WHERE a.id = ${sqlValue(id)}
`; `;
const albumsSql = ` const albumsSql = (id) => sql`
SELECT id, title, year SELECT id, title, year
FROM albums FROM albums
WHERE artist_id = ? WHERE artist_id = ${sqlValue(id)}
ORDER BY COALESCE(year, 0), title COLLATE NOCASE ORDER BY COALESCE(year, 0), title COLLATE NOCASE
`; `;
const tracksSql = ` const tracksSql = (id) => sql`
SELECT id, title, year, genre SELECT id, title, year, genre
FROM tracks FROM tracks
WHERE artist_id = ? WHERE artist_id = ${sqlValue(id)}
ORDER BY COALESCE(year, 0), title COLLATE NOCASE ORDER BY COALESCE(year, 0), title COLLATE NOCASE
LIMIT 100 LIMIT 100
`; `;
@ -2567,8 +2654,7 @@
function loadHeader() { function loadHeader() {
try { try {
const stmt = prepareForView(db, VIEW_NAMES.artistOverlay, headerSql, 'artist-header'); const stmt = prepareForView(db, VIEW_NAMES.artistOverlay, headerSql(artistId), 'artist-header');
stmt.bind([artistId]);
if (stmt.step()) { if (stmt.step()) {
artistInfo = stmt.getAsObject(); artistInfo = stmt.getAsObject();
$name.textContent = artistInfo.name; $name.textContent = artistInfo.name;
@ -2591,8 +2677,7 @@
loaded.add('albums'); loaded.add('albums');
try { try {
const rows = []; const rows = [];
const stmt = prepareForView(db, VIEW_NAMES.artistOverlay, albumsSql, 'artist-albums'); const stmt = prepareForView(db, VIEW_NAMES.artistOverlay, albumsSql(artistId), 'artist-albums');
stmt.bind([artistId]);
while (stmt.step()) rows.push(stmt.getAsObject()); while (stmt.step()) rows.push(stmt.getAsObject());
stmt.free(); stmt.free();
albumTable.setRows(rows); albumTable.setRows(rows);
@ -2609,8 +2694,7 @@
loaded.add('tracks'); loaded.add('tracks');
try { try {
const rows = []; const rows = [];
const stmt = prepareForView(db, VIEW_NAMES.artistOverlay, tracksSql, 'artist-tracks'); const stmt = prepareForView(db, VIEW_NAMES.artistOverlay, tracksSql(artistId), 'artist-tracks');
stmt.bind([artistId]);
while (stmt.step()) rows.push(stmt.getAsObject()); while (stmt.step()) rows.push(stmt.getAsObject());
stmt.free(); stmt.free();
trackTable.setRows(rows); trackTable.setRows(rows);
@ -2684,16 +2768,16 @@
const $close = el.querySelector('[data-action="close"]'); const $close = el.querySelector('[data-action="close"]');
const $tracks = el.querySelector('[data-ref="tracks"]'); const $tracks = el.querySelector('[data-ref="tracks"]');
const headerSql = ` const headerSql = (id) => sql`
SELECT al.title, al.year, a.name AS artist, a.id AS artist_id SELECT al.title, al.year, a.name AS artist, a.id AS artist_id
FROM albums al FROM albums al
JOIN artists a ON a.id = al.artist_id JOIN artists a ON a.id = al.artist_id
WHERE al.id = ? WHERE al.id = ${sqlValue(id)}
`; `;
const tracksSql = ` const tracksSql = (id) => sql`
SELECT id, track_no, title, duration_sec, bitrate_kbps SELECT id, track_no, title, duration_sec, bitrate_kbps
FROM tracks FROM tracks
WHERE album_id = ? WHERE album_id = ${sqlValue(id)}
ORDER BY COALESCE(track_no, 999), title COLLATE NOCASE ORDER BY COALESCE(track_no, 999), title COLLATE NOCASE
`; `;
@ -2720,8 +2804,7 @@
function loadHeader() { function loadHeader() {
try { try {
const stmt = prepareForView(db, VIEW_NAMES.albumOverlay, headerSql, 'album-header'); const stmt = prepareForView(db, VIEW_NAMES.albumOverlay, headerSql(albumId), 'album-header');
stmt.bind([albumId]);
if (stmt.step()) { if (stmt.step()) {
const info = stmt.getAsObject(); const info = stmt.getAsObject();
artistId = Number(info.artist_id); artistId = Number(info.artist_id);
@ -2744,8 +2827,7 @@
function loadTracks() { function loadTracks() {
try { try {
const rows = []; const rows = [];
const stmt = prepareForView(db, VIEW_NAMES.albumOverlay, tracksSql, 'album-tracks'); const stmt = prepareForView(db, VIEW_NAMES.albumOverlay, tracksSql(albumId), 'album-tracks');
stmt.bind([albumId]);
while (stmt.step()) rows.push(stmt.getAsObject()); while (stmt.step()) rows.push(stmt.getAsObject());
stmt.free(); stmt.free();
trackTable.setRows(rows); trackTable.setRows(rows);
@ -2816,7 +2898,7 @@
const $close = el.querySelector('[data-action="close"]'); const $close = el.querySelector('[data-action="close"]');
const $copy = el.querySelector('[data-action="copy-path"]'); const $copy = el.querySelector('[data-action="copy-path"]');
const sql = ` const trackSql = (id) => sql`
SELECT t.id, t.title, t.year, t.genre, t.duration_sec, t.bitrate_kbps, t.samplerate_hz, SELECT t.id, t.title, t.year, t.genre, t.duration_sec, t.bitrate_kbps, t.samplerate_hz,
t.channels, t.filesize_bytes, t.sha1, t.relpath, t.channels, t.filesize_bytes, t.sha1, t.relpath,
a.id AS artist_id, a.name AS artist, a.id AS artist_id, a.name AS artist,
@ -2824,15 +2906,14 @@
FROM tracks t FROM tracks t
JOIN artists a ON a.id = t.artist_id JOIN artists a ON a.id = t.artist_id
LEFT JOIN albums al ON al.id = t.album_id LEFT JOIN albums al ON al.id = t.album_id
WHERE t.id = ? WHERE t.id = ${sqlValue(id)}
`; `;
let track = null; let track = null;
function loadTrack() { function loadTrack() {
try { try {
const stmt = prepareForView(db, VIEW_NAMES.trackOverlay, sql, 'track-detail'); const stmt = prepareForView(db, VIEW_NAMES.trackOverlay, trackSql(trackId), 'track-detail');
stmt.bind([trackId]);
if (stmt.step()) { if (stmt.step()) {
track = stmt.getAsObject(); track = stmt.getAsObject();
$title.textContent = track.title || 'Untitled track'; $title.textContent = track.title || 'Untitled track';