Keep server-side search and cursor pagination for Home and Guide while allowing Live to load the complete filtered catalogue. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
142 lines
8.2 KiB
JavaScript
142 lines
8.2 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', 'full']) {
|
|
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 full = boolean('full');
|
|
if (full && (query.limit !== undefined || query.cursor !== undefined)) {
|
|
badRequest('full=true cannot be combined with limit or cursor');
|
|
}
|
|
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, full, 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;
|
|
}
|
|
if (!options.full) 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}${options.full ? '' : ' LIMIT @limit'}`).all(params);
|
|
const hasMore = !options.full && 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 };
|