Add SQL helper utilities and migrate queries
This commit is contained in:
parent
d8ecf5f607
commit
fa68916d3e
2 changed files with 218 additions and 136 deletions
|
@ -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.
|
||||||
|
|
353
script.js
353
script.js
|
@ -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,17 +635,10 @@
|
||||||
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;
|
||||||
return this;
|
if (Object.prototype.hasOwnProperty.call(meta, 'params')) {
|
||||||
}
|
this._paramsForLog = cloneBoundParams(meta.params);
|
||||||
bind(values) {
|
|
||||||
try {
|
|
||||||
if (values !== undefined) this._stmt.bind(values);
|
|
||||||
this._boundParams = cloneBoundParams(values);
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
this._recordError(error, 'bind');
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
|
return this;
|
||||||
}
|
}
|
||||||
step() {
|
step() {
|
||||||
try {
|
try {
|
||||||
|
@ -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 }) {
|
||||||
SELECT t.id, a.name AS artist, t.title, IFNULL(al.title, '') AS album, t.year, t.genre
|
return sql`
|
||||||
FROM fts_tracks
|
SELECT t.id, a.name AS artist, t.title, IFNULL(al.title, '') AS album, t.year, t.genre
|
||||||
JOIN tracks t ON t.id = fts_tracks.rowid
|
FROM fts_tracks
|
||||||
JOIN artists a ON a.id = t.artist_id
|
JOIN tracks t ON t.id = fts_tracks.rowid
|
||||||
LEFT JOIN albums al ON al.id = t.album_id
|
JOIN artists a ON a.id = t.artist_id
|
||||||
WHERE fts_tracks MATCH ?
|
LEFT JOIN albums al ON al.id = t.album_id
|
||||||
ORDER BY rank
|
WHERE fts_tracks MATCH ${sqlValue(matchExpr)}
|
||||||
LIMIT ? OFFSET ?
|
ORDER BY rank
|
||||||
`,
|
LIMIT ${sqlValue(pageSize)} OFFSET ${sqlValue(offset)}
|
||||||
alpha: `
|
`;
|
||||||
SELECT t.id, a.name AS artist, t.title, IFNULL(al.title, '') AS album, t.year, t.genre
|
},
|
||||||
FROM fts_tracks
|
alpha({ matchExpr, pageSize, offset }) {
|
||||||
JOIN tracks t ON t.id = fts_tracks.rowid
|
return sql`
|
||||||
JOIN artists a ON a.id = t.artist_id
|
SELECT t.id, a.name AS artist, t.title, IFNULL(al.title, '') AS album, t.year, t.genre
|
||||||
LEFT JOIN albums al ON al.id = t.album_id
|
FROM fts_tracks
|
||||||
WHERE fts_tracks MATCH ?
|
JOIN tracks t ON t.id = fts_tracks.rowid
|
||||||
ORDER BY a.name COLLATE NOCASE, t.title COLLATE NOCASE, COALESCE(t.year, 0)
|
JOIN artists a ON a.id = t.artist_id
|
||||||
LIMIT ? OFFSET ?
|
LEFT JOIN albums al ON al.id = t.album_id
|
||||||
`,
|
WHERE fts_tracks MATCH ${sqlValue(matchExpr)}
|
||||||
|
ORDER BY a.name COLLATE NOCASE, t.title COLLATE NOCASE, COALESCE(t.year, 0)
|
||||||
|
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';
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue