Files
NodecastV2/server/services/liveCatalog.js
Blomios e2249154a5 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>
2026-10-22 22:43:29 +02:00

138 lines
7.9 KiB
JavaScript

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 };