feat(live): paginate catalog and search server-side (#49)
Load Live channels from a filtered SQLite cursor API, keep navigation and guide consumers working across pages, and retain the QA reproduction coverage. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
137
server/services/liveCatalog.js
Normal file
137
server/services/liveCatalog.js
Normal file
@ -0,0 +1,137 @@
|
||||
const { createHash } = require('crypto');
|
||||
|
||||
// Keep these expressions identical for ORDER BY, keyset comparison and cursor
|
||||
// values. SQLite lower() folds ASCII; trim() removes surrounding ASCII spaces.
|
||||
const GROUP_TITLE = "COALESCE(c.name, p.category_id, 'Uncategorized')";
|
||||
const GROUP_ORDER = `lower(trim(${GROUP_TITLE}))`;
|
||||
const CHANNEL_ORDER = 'lower(trim(p.name))';
|
||||
const CATALOG_ORDER = `${GROUP_ORDER} COLLATE BINARY, ${GROUP_TITLE} COLLATE BINARY,
|
||||
${CHANNEL_ORDER} COLLATE BINARY, p.name COLLATE BINARY, p.source_id, p.item_id COLLATE BINARY`;
|
||||
const ORDER_VERSION = 'group-key-raw-channel-key-raw-source-item-v3';
|
||||
|
||||
function badRequest(message) {
|
||||
const error = new Error(message);
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
function parseQuery(query, userId) {
|
||||
for (const key of ['sourceId', 'categoryId', 'q', 'includeHidden', 'favoriteOnly', 'limit', 'cursor']) {
|
||||
if (query[key] !== undefined && typeof query[key] !== 'string') badRequest(`Invalid ${key}`);
|
||||
}
|
||||
const sourceId = query.sourceId === undefined ? null : Number(query.sourceId);
|
||||
if (sourceId !== null && (!Number.isSafeInteger(sourceId) || sourceId <= 0)) badRequest('Invalid sourceId');
|
||||
if (query.categoryId !== undefined && sourceId === null) badRequest('categoryId requires sourceId');
|
||||
const boolean = key => {
|
||||
if (query[key] !== undefined && !['true', 'false'].includes(query[key])) badRequest(`Invalid ${key}`);
|
||||
return query[key] === 'true';
|
||||
};
|
||||
const requestedLimit = query.limit === undefined ? 100 : Number(query.limit);
|
||||
if (!Number.isSafeInteger(requestedLimit) || requestedLimit < 1) badRequest('Invalid limit');
|
||||
const filters = { sourceId, categoryId: query.categoryId ?? null, q: query.q || '',
|
||||
includeHidden: boolean('includeHidden'), favoriteOnly: boolean('favoriteOnly'), userId };
|
||||
const fingerprint = createHash('sha256').update(JSON.stringify({ ...filters, order: ORDER_VERSION })).digest('hex');
|
||||
let after = null;
|
||||
if (query.cursor !== undefined) {
|
||||
try {
|
||||
if (query.cursor.length > 8192 || !/^[A-Za-z0-9_-]+$/.test(query.cursor)) throw new Error();
|
||||
const cursor = JSON.parse(Buffer.from(query.cursor, 'base64url').toString('utf8'));
|
||||
if (cursor.v !== 3 || cursor.f !== fingerprint || !Array.isArray(cursor.k)
|
||||
|| cursor.k.length !== 6 || !cursor.k.slice(0, 4).every(key => typeof key === 'string')
|
||||
|| !Number.isSafeInteger(cursor.k[4]) || typeof cursor.k[5] !== 'string') throw new Error();
|
||||
after = cursor.k;
|
||||
} catch { badRequest('Invalid cursor'); }
|
||||
}
|
||||
return { ...filters, limit: Math.min(requestedLimit, 200), fingerprint, after };
|
||||
}
|
||||
|
||||
// Optional acceleration: LIKE remains the source of truth and also handles short
|
||||
// strings and literal wildcard characters, which trigram indexes cannot accelerate.
|
||||
const searchSupport = new WeakMap();
|
||||
function ensureSearch(db) {
|
||||
if (searchSupport.has(db)) return searchSupport.get(db);
|
||||
try {
|
||||
db.transaction(() => {
|
||||
for (const [table, column] of [['playlist_items', 'name'], ['categories', 'name']]) {
|
||||
const index = `live_catalog_${table}_fts`;
|
||||
const exists = db.prepare("SELECT 1 FROM sqlite_master WHERE name = ?").get(index);
|
||||
db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS ${index} USING fts5(name, tokenize='trigram');
|
||||
CREATE TRIGGER IF NOT EXISTS ${index}_ai AFTER INSERT ON ${table} BEGIN
|
||||
INSERT INTO ${index}(rowid, name) VALUES (new.rowid, new.${column}); END;
|
||||
CREATE TRIGGER IF NOT EXISTS ${index}_ad AFTER DELETE ON ${table} BEGIN
|
||||
DELETE FROM ${index} WHERE rowid = old.rowid; END;
|
||||
CREATE TRIGGER IF NOT EXISTS ${index}_au AFTER UPDATE OF ${column} ON ${table} BEGIN
|
||||
DELETE FROM ${index} WHERE rowid = old.rowid;
|
||||
INSERT INTO ${index}(rowid, name) VALUES (new.rowid, new.${column}); END;`);
|
||||
if (!exists) db.exec(`INSERT INTO ${index}(rowid, name) SELECT rowid, ${column} FROM ${table}`);
|
||||
}
|
||||
})();
|
||||
searchSupport.set(db, true);
|
||||
} catch (error) {
|
||||
console.warn('[Live catalog] Trigram unavailable; using literal LIKE:', error.message);
|
||||
searchSupport.set(db, false);
|
||||
}
|
||||
return searchSupport.get(db);
|
||||
}
|
||||
|
||||
function getLiveCatalog(db, allSources, options) {
|
||||
const trigram = ensureSearch(db);
|
||||
const sources = allSources.filter(s => s.enabled && ['m3u', 'xtream'].includes(s.type)
|
||||
&& (options.sourceId === null || s.id === options.sourceId));
|
||||
if (!sources.length) return { items: [], page: { nextCursor: null, hasMore: false } };
|
||||
const params = { sources: JSON.stringify(sources.map(s => ({ id: s.id, type: s.type }))), user: options.userId };
|
||||
const where = ["p.type = 'live'"];
|
||||
if (!options.includeHidden) where.push('COALESCE(p.is_hidden, 0) = 0 AND COALESCE(c.is_hidden, 0) = 0');
|
||||
if (options.favoriteOnly) where.push('f.id IS NOT NULL');
|
||||
if (options.categoryId !== null) {
|
||||
where.push('p.category_id = @category');
|
||||
params.category = options.categoryId;
|
||||
}
|
||||
if (options.q) {
|
||||
if (trigram && [...options.q].length >= 3 && !/[%_\\]/.test(options.q)) {
|
||||
params.search = `%${options.q}%`;
|
||||
where.push(`(p.rowid IN (SELECT rowid FROM live_catalog_playlist_items_fts WHERE name LIKE @search)
|
||||
OR c.rowid IN (SELECT rowid FROM live_catalog_categories_fts WHERE name LIKE @search)
|
||||
OR (c.rowid IS NULL AND p.category_id LIKE @search))`);
|
||||
} else {
|
||||
params.search = `%${options.q.replace(/[\\%_]/g, '\\$&')}%`;
|
||||
where.push("(p.name LIKE @search ESCAPE '\\' OR COALESCE(c.name, p.category_id, '') LIKE @search ESCAPE '\\')");
|
||||
}
|
||||
}
|
||||
if (options.after) {
|
||||
where.push(`(${CATALOG_ORDER}) > (@groupKey, @groupTitle, @channelKey, @name, @source, @item)`);
|
||||
[params.groupKey, params.groupTitle, params.channelKey, params.name, params.source, params.item] = options.after;
|
||||
}
|
||||
params.limit = options.limit + 1;
|
||||
const rows = db.prepare(`WITH live_sources AS (
|
||||
SELECT json_extract(value, '$.id') AS id, json_extract(value, '$.type') AS type FROM json_each(@sources)
|
||||
) SELECT p.*, s.type AS source_type, ${GROUP_TITLE} AS group_title,
|
||||
${GROUP_ORDER} AS sort_group, ${CHANNEL_ORDER} AS sort_name,
|
||||
(COALESCE(p.is_hidden, 0) != 0 OR COALESCE(c.is_hidden, 0) != 0) AS hidden,
|
||||
(f.id IS NOT NULL) AS favorite
|
||||
FROM playlist_items p JOIN live_sources s ON s.id = p.source_id
|
||||
LEFT JOIN categories c ON c.source_id = p.source_id AND c.type = 'live' AND c.category_id = p.category_id
|
||||
LEFT JOIN favorites f ON f.user_id = @user AND f.source_id = p.source_id
|
||||
AND f.item_id = p.item_id AND f.item_type = 'channel'
|
||||
WHERE ${where.join(' AND ')}
|
||||
ORDER BY ${CATALOG_ORDER} LIMIT @limit`).all(params);
|
||||
const hasMore = rows.length > options.limit;
|
||||
if (hasMore) rows.pop();
|
||||
const items = rows.map(row => {
|
||||
const data = JSON.parse(row.data || '{}');
|
||||
const streamId = data.stream_id ?? row.item_id;
|
||||
return { sourceId: row.source_id, sourceType: row.source_type,
|
||||
id: `${row.source_type}_${row.source_id}_${row.item_id}`, streamId,
|
||||
name: row.name, groupId: row.category_id, groupTitle: row.group_title,
|
||||
tvgId: data.tvgId ?? data.epg_channel_id ?? null,
|
||||
tvgLogo: row.stream_icon ?? null, url: row.stream_url || data.url || null,
|
||||
isHidden: !!row.hidden, isFavorite: !!row.favorite };
|
||||
});
|
||||
const last = rows[rows.length - 1];
|
||||
const nextCursor = hasMore ? Buffer.from(JSON.stringify({ v: 3, f: options.fingerprint,
|
||||
k: [last.sort_group, last.group_title, last.sort_name, last.name, last.source_id, last.item_id]
|
||||
})).toString('base64url') : null;
|
||||
return { items, page: { nextCursor, hasMore } };
|
||||
}
|
||||
|
||||
module.exports = { parseQuery, getLiveCatalog, ensureSearch };
|
||||
Reference in New Issue
Block a user