const test = require('node:test'); const assert = require('node:assert/strict'); const Database = require('better-sqlite3'); function createDb() { const db = new Database(':memory:'); db.exec(` CREATE TABLE categories ( id TEXT PRIMARY KEY, source_id INTEGER NOT NULL, category_id TEXT NOT NULL, type TEXT NOT NULL, name TEXT NOT NULL, parent_id TEXT, is_hidden INTEGER DEFAULT 0, data JSON ); CREATE TABLE playlist_items ( id TEXT PRIMARY KEY, source_id INTEGER NOT NULL, item_id TEXT NOT NULL, type TEXT NOT NULL, name TEXT NOT NULL, category_id TEXT, parent_id TEXT, stream_icon TEXT, stream_url TEXT, container_extension TEXT, rating REAL, year TEXT, added_at TEXT, is_hidden INTEGER DEFAULT 0, is_favorite INTEGER DEFAULT 0, data JSON ); CREATE TABLE sync_status ( source_id INTEGER NOT NULL, type TEXT NOT NULL, last_sync INTEGER NOT NULL, status TEXT, error TEXT, PRIMARY KEY (source_id, type) ); `); return db; } function seedLive(db, sourceId, streamId, name = `Channel ${streamId}`) { db.prepare(` INSERT INTO playlist_items (id, source_id, item_id, type, name, category_id, stream_icon, stream_url, data) VALUES (?, ?, ?, 'live', ?, 'sports', '', ?, '{}') `).run(`${sourceId}:${streamId}`, sourceId, String(streamId), name, `https://streams.test/${streamId}.m3u8`); } function loadSyncServiceWithDb(db) { const sqlite = require('../server/db/sqlite'); const originalGetDb = sqlite.getDb; sqlite.getDb = () => db; const syncServicePath = require.resolve('../server/services/syncService'); delete require.cache[syncServicePath]; const syncService = require('../server/services/syncService'); return { syncService, restore() { sqlite.getDb = originalGetDb; delete require.cache[syncServicePath]; } }; } function stream(streamId, name = `Channel ${streamId}`) { return { stream_id: streamId, name, category_id: 'sports', stream_icon: '', added: 'now' }; } function patchLiveProjection() { const liveProjection = require('../server/services/liveProjectionService'); const originalQueueRebuild = liveProjection.queueRebuild; const originalStartRebuild = liveProjection.startRebuild; const requests = []; liveProjection.queueRebuild = async (...args) => { requests.push({ method: 'queueRebuild', args }); return { status: 'success' }; }; liveProjection.startRebuild = (...args) => { requests.push({ method: 'startRebuild', args }); return { promise: Promise.resolve({ status: 'success' }) }; }; return { requests, restore() { liveProjection.queueRebuild = originalQueueRebuild; liveProjection.startRebuild = originalStartRebuild; } }; } function patchLanguageMode(mode) { const appDb = require('../server/db'); const originalGet = appDb.settings.get; appDb.settings.get = async () => ({ ...appDb.getDefaultSettings(), languageDetectionMode: mode }); return () => { appDb.settings.get = originalGet; }; } test('saveStreams returns only newly inserted live stream ids, not updated ids', async () => { const db = createDb(); seedLive(db, 1, 'old', 'Old Sports'); const { syncService, restore } = loadSyncServiceWithDb(db); try { const result = await syncService.saveStreams(1, 'live', [ stream('old', 'Old Sports Updated'), stream('new', 'New Sports') ], { skipPurge: true }); const newIds = result?.newIds || new Set(); assert.deepEqual([...newIds].sort(), ['1:new']); } finally { restore(); db.close(); } }); test('syncXtream with new live ids requests one targeted analysis for only those ids', async () => { const db = createDb(); seedLive(db, 1, 'old', 'Old Sports'); const { syncService, restore } = loadSyncServiceWithDb(db); const liveProjectionPatch = patchLiveProjection(); const restoreLanguageMode = patchLanguageMode('laya'); const xtreamApi = require('../server/services/xtreamApi'); const originalCreateFromSource = xtreamApi.createFromSource; xtreamApi.createFromSource = () => ({ getLiveCategories: async () => [{ category_id: 'sports', category_name: 'Sports' }], getLiveStreams: async () => [stream('old', 'Old Sports Updated'), stream('new', 'New Sports')], getVodCategories: async () => [], getVodStreams: async () => [], getSeriesCategories: async () => [], getSeries: async () => [], getXmltvUrl: () => { throw new Error('no epg'); } }); try { await syncService.syncXtream({ id: 1, name: 'Xtream Fixture' }); assert.deepEqual(liveProjectionPatch.requests, [{ method: 'startRebuild', args: [undefined, { mode: 'laya', scopeItemIds: ['1:new'] }] }]); } finally { xtreamApi.createFromSource = originalCreateFromSource; restoreLanguageMode(); liveProjectionPatch.restore(); restore(); db.close(); } }); test('syncM3u aggregates new ids from all batches and requests one final targeted analysis', async () => { const db = createDb(); seedLive(db, 1, 'old-a', 'Old A'); seedLive(db, 1, 'old-b', 'Old B'); const { syncService, restore } = loadSyncServiceWithDb(db); const liveProjectionPatch = patchLiveProjection(); const restoreLanguageMode = patchLanguageMode('laya'); const m3uParser = require('../server/services/m3uParser'); const originalFetchAndParseStreaming = m3uParser.fetchAndParseStreaming; m3uParser.fetchAndParseStreaming = async function* () { yield { channels: [ { id: 'old-a', name: 'Old A Updated', groupTitle: 'Sports', tvgLogo: '', url: 'https://streams.test/old-a.m3u8' }, { id: 'new-a', name: 'New A', groupTitle: 'Sports', tvgLogo: '', url: 'https://streams.test/new-a.m3u8' } ], groups: ['Sports'] }; yield { channels: [ { id: 'old-b', name: 'Old B Updated', groupTitle: 'News', tvgLogo: '', url: 'https://streams.test/old-b.m3u8' }, { id: 'new-b', name: 'New B', groupTitle: 'News', tvgLogo: '', url: 'https://streams.test/new-b.m3u8' } ], groups: ['News'] }; }; try { await syncService.syncM3u({ id: 1, name: 'M3U Fixture', url: 'https://playlist.test/live.m3u' }); assert.deepEqual(liveProjectionPatch.requests, [{ method: 'startRebuild', args: [undefined, { mode: 'laya', scopeItemIds: ['1:new-a', '1:new-b'] }] }]); } finally { m3uParser.fetchAndParseStreaming = originalFetchAndParseStreaming; restoreLanguageMode(); liveProjectionPatch.restore(); restore(); db.close(); } });