Files
Nodecast/test/t36SyncIncrementalRegression.test.js
blomios 4319e64e1e feat: persist incremental live channel analysis
Persist live channel language metadata across background projection rebuilds and target Local AI identity analysis to newly detected streams unless a manual full analysis is requested.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-07 23:12:42 +02:00

202 lines
7.2 KiB
JavaScript

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