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>
This commit is contained in:
201
test/t36SyncIncrementalRegression.test.js
Normal file
201
test/t36SyncIncrementalRegression.test.js
Normal file
@ -0,0 +1,201 @@
|
||||
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();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user