Add backend language detection, flexible live channel search, iptv-org catalog caching, and protected built-in EPG source registration. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
167 lines
6.8 KiB
JavaScript
167 lines
6.8 KiB
JavaScript
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const Database = require('better-sqlite3');
|
|
const {
|
|
ensureColumns,
|
|
LIVE_PROJECTION_COLUMNS,
|
|
LIVE_PROJECTION_SCHEMA_SQL
|
|
} = require('../server/db/liveProjectionSchema');
|
|
const liveProjection = require('../server/services/liveProjectionService');
|
|
const frontendProjection = require('../public/js/services/liveChannelProjection.js');
|
|
const grouping = require('../public/js/services/channelVariantGrouping.js');
|
|
const { channelListWarning } = require('../public/js/services/layaStatusPresentation.js');
|
|
|
|
function createFunctionalDb() {
|
|
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 favorites (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL,
|
|
source_id INTEGER NOT NULL,
|
|
item_id TEXT NOT NULL,
|
|
item_type TEXT NOT NULL,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
UNIQUE(user_id, source_id, item_id, item_type)
|
|
);
|
|
`);
|
|
ensureColumns(db, 'playlist_items', LIVE_PROJECTION_COLUMNS);
|
|
db.exec(LIVE_PROJECTION_SCHEMA_SQL);
|
|
return db;
|
|
}
|
|
|
|
function seedCategory(db, categoryId, name, extra = {}) {
|
|
db.prepare(`
|
|
INSERT INTO categories (id, source_id, category_id, type, name, is_hidden, data)
|
|
VALUES (?, 1, ?, 'live', ?, ?, '{}')
|
|
`).run(`1:${categoryId}`, categoryId, name, extra.isHidden ? 1 : 0);
|
|
}
|
|
|
|
function seedChannel(db, itemId, name, categoryId, extra = {}) {
|
|
db.prepare(`
|
|
INSERT INTO playlist_items
|
|
(id, source_id, item_id, type, name, category_id, stream_icon, stream_url, is_hidden, data)
|
|
VALUES (?, 1, ?, 'live', ?, ?, ?, ?, ?, ?)
|
|
`).run(
|
|
`1:${itemId}`,
|
|
itemId,
|
|
name,
|
|
categoryId,
|
|
`${itemId}.png`,
|
|
`https://streams.test/${itemId}.m3u8`,
|
|
extra.isHidden ? 1 : 0,
|
|
JSON.stringify({
|
|
tvgId: extra.tvgId || null,
|
|
groupTitle: extra.groupTitle || undefined
|
|
})
|
|
);
|
|
}
|
|
|
|
test('functional live detection chain keeps categories and language selection correct after Laya fallback', async () => {
|
|
const db = createFunctionalDb();
|
|
seedCategory(db, 'sports-fr', 'Sports France');
|
|
seedCategory(db, 'sports-uk', 'UK Sports');
|
|
seedCategory(db, 'events', 'Sports Events');
|
|
seedChannel(db, 'fr-sd', '|FR| Sports One SD', 'sports-fr', { tvgId: 'sports.one.fr' });
|
|
seedChannel(db, 'fr-4k-hidden', '|FR| Sports One 4K', 'sports-fr', { isHidden: true, tvgId: 'sports.one.fr.4k' });
|
|
seedChannel(db, 'en-4k', '|UK| Sports One 4K', 'sports-uk', { tvgId: 'sports.one.uk' });
|
|
seedChannel(db, 'bein-main', 'beIN Sports 1 HD', 'events');
|
|
seedChannel(db, 'bein-event', 'Partner beIN Sports 1 Event', 'events');
|
|
db.prepare(`
|
|
INSERT INTO favorites (user_id, source_id, item_id, item_type)
|
|
VALUES (7, 1, 'fr-sd', 'channel')
|
|
`).run();
|
|
|
|
let layaRequests = 0;
|
|
const state = await liveProjection.rebuildWithSignals(db, {
|
|
mode: 'hybrid',
|
|
layaClient: {
|
|
identityHintBatch: async () => {
|
|
layaRequests += 1;
|
|
throw new Error('simulated local AI failure');
|
|
}
|
|
}
|
|
});
|
|
|
|
assert.equal(layaRequests, 1);
|
|
assert.equal(state.status, 'success');
|
|
assert.equal(state.error, null);
|
|
|
|
const rawRows = db.prepare(`
|
|
SELECT item_id, logical_channel_id, grouping_fingerprint
|
|
FROM playlist_items
|
|
WHERE item_id IN ('fr-sd', 'fr-4k-hidden', 'en-4k')
|
|
ORDER BY item_id
|
|
`).all();
|
|
assert.deepEqual(rawRows.map(row => row.item_id), ['en-4k', 'fr-4k-hidden', 'fr-sd']);
|
|
assert.equal(new Set(rawRows.map(row => row.logical_channel_id)).size, 1);
|
|
assert.deepEqual([...new Set(rawRows.map(row => row.grouping_fingerprint))], ['sports one']);
|
|
|
|
const apiChannels = liveProjection.getChannels({ includeHidden: true, userId: 7 }, db);
|
|
const sportsOneViews = apiChannels.filter(channel => channel.fingerprint === 'sports one');
|
|
assert.deepEqual(sportsOneViews.map(channel => channel.language.code).sort(), ['en', 'fr']);
|
|
assert.deepEqual(
|
|
sportsOneViews.flatMap(channel => channel.variants)
|
|
.map(variant => [variant.id, variant.categoryName, variant.isHidden, variant.isFavorite])
|
|
.sort((left, right) => left[0].localeCompare(right[0])),
|
|
[
|
|
['en-4k', 'UK Sports', false, false],
|
|
['fr-4k-hidden', 'Sports France', true, false],
|
|
['fr-sd', 'Sports France', false, true]
|
|
]
|
|
);
|
|
|
|
const mapped = frontendProjection.mapResponse({
|
|
projection: { status: 'success', stale: false },
|
|
channels: apiChannels
|
|
}, [{ id: 1, type: 'm3u', name: 'Fixture Provider France' }]);
|
|
const warning = channelListWarning('hybrid', { modelPresent: true, runtimeAvailable: true }, true);
|
|
const logical = grouping.groupChannels(mapped.channels, {
|
|
languageMode: warning ? 'algo' : 'hybrid',
|
|
preferredLanguage: 'fr',
|
|
isFavorite: variant => mapped.favorites.has(`${variant.sourceId}:${variant.id}`),
|
|
isHidden: variant => mapped.hiddenItems.has(`channel:${variant.sourceId}:${variant.streamId || variant.id}`)
|
|
});
|
|
const frontendSportsOne = logical.find(channel => channel.fingerprint === 'sports one');
|
|
|
|
assert.equal(
|
|
warning,
|
|
'Local AI language detection failed. Channels were grouped with Standard matching and remain available.'
|
|
);
|
|
assert.ok(frontendSportsOne);
|
|
assert.equal(frontendSportsOne.variants.length, 3);
|
|
assert.deepEqual(frontendSportsOne.availableLanguages.map(language => language.code).sort(), ['en', 'fr']);
|
|
assert.equal(frontendSportsOne.preferredVariant.id, 'fr-sd');
|
|
assert.equal(frontendSportsOne.preferredVariant.groupTitle, 'Sports France');
|
|
assert.equal(frontendSportsOne.isFavorite, true);
|
|
assert.equal(frontendSportsOne.isHidden, false);
|
|
assert.equal(grouping.orderFlatChannels(logical).length, logical.length);
|
|
});
|