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