Run Local AI language detection during full rebuilds after identity classification and restore variant expansion with a visible playing stream indicator. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
217 lines
8.4 KiB
JavaScript
217 lines
8.4 KiB
JavaScript
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const { JSDOM } = require('jsdom');
|
|
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 root = path.resolve(__dirname, '..');
|
|
const script = relativePath => fs.readFileSync(path.join(root, 'public', relativePath), 'utf8');
|
|
const wait = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds));
|
|
|
|
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 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
|
|
);
|
|
`);
|
|
ensureColumns(db, 'playlist_items', LIVE_PROJECTION_COLUMNS);
|
|
db.exec(LIVE_PROJECTION_SCHEMA_SQL);
|
|
return db;
|
|
}
|
|
|
|
function seedLive(db, id, name) {
|
|
db.prepare(`
|
|
INSERT INTO playlist_items
|
|
(id, source_id, item_id, type, name, category_id, stream_icon, stream_url, data)
|
|
VALUES (?, 1, ?, 'live', ?, 'sports', '', ?, ?)
|
|
`).run(
|
|
`1:${id}`,
|
|
id,
|
|
name,
|
|
`https://streams.test/${id}.m3u8`,
|
|
JSON.stringify({ groupTitle: 'Sports' })
|
|
);
|
|
}
|
|
|
|
function createChannelListDom() {
|
|
const dom = new JSDOM(`<!doctype html>
|
|
<input id="channel-search">
|
|
<select id="source-select"><option value="">All Sources</option></select>
|
|
<input id="show-hidden" type="checkbox">
|
|
<button id="toggle-groups"></button>
|
|
<div id="channel-language-filter"></div>
|
|
<div id="channel-list-status"></div>
|
|
<div id="context-menu"></div>
|
|
<div id="channel-list"></div>
|
|
`, { url: 'http://nodecast.test/', runScripts: 'outside-only', pretendToBeVisual: true });
|
|
const { window } = dom;
|
|
window.IntersectionObserver = class { observe() {} disconnect() {} };
|
|
window.eval(script('js/icons.js'));
|
|
window.eval(script('js/services/channelLanguageDetection.js'));
|
|
window.eval(script('js/services/channelLanguageFusion.js'));
|
|
window.eval(script('js/services/channelVariantGrouping.js'));
|
|
window.eval(script('js/services/layaStatusPresentation.js'));
|
|
window.eval(script('js/services/liveChannelProjection.js'));
|
|
window.eval(`${script('js/components/ChannelList.js')}\nwindow.ChannelList = ChannelList;`);
|
|
window.app = {
|
|
player: { settings: { liveChannelListMode: 'flat', languageDetectionMode: 'algo' } },
|
|
epgGuide: { getCurrentProgram: () => null, loadError: null }
|
|
};
|
|
const channelList = new window.ChannelList();
|
|
channelList.sources = [{ id: 1, type: 'xtream', name: 'Fixture source', enabled: true }];
|
|
return { dom, window, channelList };
|
|
}
|
|
|
|
test('full refresh analyzeAll runs Laya language detection after identity and persists recalculated languages', async () => {
|
|
const db = createDb();
|
|
seedLive(db, 'bein-east', 'beIN Sports 1 East');
|
|
seedLive(db, 'bein-west', 'beIN Sports 1 West');
|
|
|
|
const order = [];
|
|
const languagePayloads = [];
|
|
const layaClient = {
|
|
identityHintBatch: async pairs => {
|
|
order.push('identity');
|
|
return pairs.map(pair => ({
|
|
id: pair.id,
|
|
sameIdentityProbability: 0.9,
|
|
confidence: 0.9,
|
|
family: 'bein sports',
|
|
number: '1'
|
|
}));
|
|
},
|
|
detectBatch: async payload => {
|
|
order.push('language');
|
|
languagePayloads.push(payload);
|
|
return {
|
|
results: payload.items.map(item => ({
|
|
id: item.id,
|
|
language: 'fr',
|
|
confidence: 0.97
|
|
}))
|
|
};
|
|
}
|
|
};
|
|
|
|
try {
|
|
await liveProjection.rebuildWithSignals(db, { mode: 'laya', layaClient, analyzeAll: true });
|
|
const rows = db.prepare(`
|
|
SELECT id, language_code, language_detector, language_confidence
|
|
FROM playlist_items
|
|
WHERE type = 'live'
|
|
ORDER BY id
|
|
`).all();
|
|
|
|
assert.deepEqual(order, ['identity', 'language']);
|
|
assert.equal(languagePayloads.length, 1);
|
|
assert.deepEqual(rows.map(row => row.language_code), ['fr', 'fr']);
|
|
assert.deepEqual(rows.map(row => row.language_detector), ['laya', 'laya']);
|
|
assert.ok(rows.every(row => Number(row.language_confidence) === 0.97));
|
|
} finally {
|
|
db.close();
|
|
}
|
|
});
|
|
|
|
test('search result variant expansion uses logicalChannelId and lists the currently selected stream', async () => {
|
|
const { dom, window, channelList } = createChannelListDom();
|
|
let variantsRequestedFor = null;
|
|
|
|
window.API = {
|
|
live: {
|
|
getChannels: async options => {
|
|
assert.equal(options.q, 'bein sports 1');
|
|
return {
|
|
projection: { stale: false },
|
|
filters: { availableLanguages: [{ code: 'fr', short: 'FR', label: 'French' }] },
|
|
channels: [{
|
|
id: 'logical:bein-sports-1:fr',
|
|
logicalChannelId: 'logical:bein-sports-1',
|
|
displayName: 'beIN Sports 1',
|
|
language: { code: 'fr', short: 'FR', label: 'French', confidence: 97 },
|
|
selectedVariantId: '1:bein-4k',
|
|
variantCount: 3,
|
|
variants: [{
|
|
id: 'bein-hd',
|
|
sourceId: 1,
|
|
name: 'beIN Sports 1 HD',
|
|
categoryName: 'Sports',
|
|
isHidden: false,
|
|
language: { code: 'fr', short: 'FR', label: 'French', confidence: 97 }
|
|
}]
|
|
}]
|
|
};
|
|
},
|
|
getChannelVariants: async logicalChannelId => {
|
|
variantsRequestedFor = logicalChannelId;
|
|
if (logicalChannelId !== 'logical:bein-sports-1') return { variants: [], page: { hasMore: false } };
|
|
return {
|
|
page: { hasMore: false },
|
|
variants: [
|
|
{ id: 'bein-hd', sourceId: 1, name: 'beIN Sports 1 HD', categoryName: 'Sports', isHidden: false },
|
|
{ id: 'bein-4k', sourceId: 1, name: 'beIN Sports 1 4K', categoryName: 'Sports', isHidden: false },
|
|
{ id: 'bein-backup', sourceId: 1, name: 'beIN Sports 1 Backup', categoryName: 'Sports', isHidden: false }
|
|
]
|
|
};
|
|
}
|
|
}
|
|
};
|
|
|
|
try {
|
|
channelList.searchInput.value = 'bein sports 1';
|
|
await channelList.loadChannels();
|
|
|
|
const toggle = window.document.querySelector('.variant-toggle');
|
|
assert.ok(toggle, 'search result should expose a stream list toggle');
|
|
toggle.dispatchEvent(new window.MouseEvent('click', { bubbles: true }));
|
|
await wait(20);
|
|
|
|
assert.equal(variantsRequestedFor, 'logical:bein-sports-1');
|
|
assert.match(channelList.container.textContent, /beIN Sports 1 4K/);
|
|
const playing = channelList.container.querySelector('.variant-playing-badge');
|
|
assert.equal(playing?.textContent, 'Playing');
|
|
assert.equal(playing?.closest('.channel-variant')?.dataset.variantId, 'bein-4k');
|
|
} finally {
|
|
dom.window.close();
|
|
}
|
|
});
|