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>
202 lines
7.2 KiB
JavaScript
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();
|
|
}
|
|
});
|