fix: restore Laya language analysis and stream variants
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>
This commit is contained in:
@ -789,7 +789,7 @@ h4 {
|
||||
.channel-variant {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(52px, auto) minmax(64px, auto) minmax(64px, auto) 1fr;
|
||||
grid-template-columns: minmax(52px, auto) minmax(64px, auto) minmax(64px, auto) 1fr auto;
|
||||
gap: var(--space-sm);
|
||||
padding: 6px var(--space-sm);
|
||||
border: 0;
|
||||
@ -813,6 +813,17 @@ h4 {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.variant-playing-badge {
|
||||
align-self: center;
|
||||
padding: 2px 6px;
|
||||
border-radius: 999px;
|
||||
background: var(--color-accent);
|
||||
color: var(--color-bg-primary);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.variant-language {
|
||||
justify-self: start;
|
||||
padding: 1px 5px;
|
||||
|
||||
@ -891,13 +891,25 @@ class ChannelList {
|
||||
const languageDescription = language.code === 'unknown'
|
||||
? 'Unknown language'
|
||||
: `${language.label} — ${language.confidence}% confidence`;
|
||||
return `<button class="channel-variant ${hidden ? 'is-hidden' : ''}" type="button"
|
||||
const variantKey = `${variant.sourceId}:${variant.id}`;
|
||||
const playerVariant = window.app?.player?.currentChannel;
|
||||
const currentVariant = this.currentChannel?.preferredVariant;
|
||||
const playingKeys = new Set([
|
||||
channel.selectedVariantId,
|
||||
playerVariant?.sourceId !== undefined && playerVariant?.id !== undefined
|
||||
? `${playerVariant.sourceId}:${playerVariant.id}` : '',
|
||||
currentVariant?.sourceId !== undefined && currentVariant?.id !== undefined
|
||||
? `${currentVariant.sourceId}:${currentVariant.id}` : ''
|
||||
].filter(Boolean).map(String));
|
||||
const isPlaying = playingKeys.has(variantKey);
|
||||
return `<button class="channel-variant ${hidden ? 'is-hidden' : ''} ${isPlaying ? 'is-playing' : ''}" type="button"
|
||||
data-logical-id="${this.escapeHtml(channel.fingerprint)}" data-variant-id="${this.escapeHtml(String(variant.id))}"
|
||||
data-source-id="${variant.sourceId}" data-render-id="${renderId}" data-render-group="${this.escapeHtml(renderGroup)}">
|
||||
<span class="variant-quality">${ChannelVariantGrouping.getQuality(variant)}</span>
|
||||
<span class="variant-language" title="${this.escapeHtml(languageDescription)}" aria-label="${this.escapeHtml(languageDescription)}">${languageLabel}</span>
|
||||
<span class="variant-source">${this.escapeHtml(source?.name || variant.sourceType)}</span>
|
||||
<span class="variant-name">${this.escapeHtml(variant.name)}</span>
|
||||
${isPlaying ? '<span class="variant-playing-badge">Playing</span>' : ''}
|
||||
</button>`;
|
||||
}).join('') : '';
|
||||
const variantCount = Number(channel.variantCount) || channel.variants.length;
|
||||
@ -930,7 +942,7 @@ class ChannelList {
|
||||
const current = this.variantPages.get(logicalId);
|
||||
if (!append && current?.loaded) return;
|
||||
const offset = append ? (current?.page?.nextOffset || 0) : 0;
|
||||
const response = await API.live.getChannelVariants(logicalId, {
|
||||
const response = await API.live.getChannelVariants(channel.logicalChannelId || logicalId, {
|
||||
includeHidden: Boolean(this.showHiddenCheckbox?.checked),
|
||||
limit: 100,
|
||||
offset
|
||||
|
||||
@ -98,7 +98,7 @@
|
||||
visibleVariants: mappedVariants.filter(variant => !variant.isHidden),
|
||||
preferredVariant,
|
||||
language,
|
||||
selectedVariantId: `${preferredVariant.sourceId}:${preferredVariant.id}`,
|
||||
selectedVariantId: logicalChannel.selectedVariantId || `${preferredVariant.sourceId}:${preferredVariant.id}`,
|
||||
groupTitle: preferredVariant.groupTitle,
|
||||
tvgLogo: preferredVariant.tvgLogo,
|
||||
tvgId: logicalChannel.epg?.channelId || preferredVariant.tvgId,
|
||||
|
||||
@ -218,6 +218,55 @@ async function collectOptionalIdentityHints(items, mode, layaClient, options = {
|
||||
}
|
||||
}
|
||||
|
||||
function analysisScopeItems(items, options = {}) {
|
||||
if (options.analyzeAll === true) return items;
|
||||
if (Object.hasOwn(options, 'scopeItemIds')) {
|
||||
const ids = new Set(Array.from(options.scopeItemIds || [], String));
|
||||
return items.filter(item => ids.has(String(item.id)) || ids.has(String(item.item_id)));
|
||||
}
|
||||
return items.filter(item => !hasPersistedLanguage(item));
|
||||
}
|
||||
|
||||
function languageDescriptor(item) {
|
||||
const parts = [item.name, item.category_name, item.tvg_id].filter(Boolean).map(String);
|
||||
return { id: item.id, text: parts.join(' | ').slice(0, 4096) || String(item.id) };
|
||||
}
|
||||
|
||||
async function collectOptionalLayaLanguages(items, layaClient, options = {}) {
|
||||
if (!layaClient?.detectBatch || items.length === 0) return;
|
||||
const total = Math.ceil(items.length / 64);
|
||||
let processed = 0;
|
||||
options.onProgress?.({ processed, total });
|
||||
try {
|
||||
for (let index = 0; index < items.length; index += 64) {
|
||||
const batch = items.slice(index, index + 64);
|
||||
const response = await layaClient.detectBatch({
|
||||
items: batch.map(languageDescriptor),
|
||||
model: 'multilingual'
|
||||
});
|
||||
const results = new Map((response?.results || []).map(result => [String(result.id), result]));
|
||||
for (const item of batch) {
|
||||
const result = results.get(String(item.id));
|
||||
const code = String(result?.language || '').toLowerCase();
|
||||
if (!/^[a-z]{2,3}$/.test(code) || code === 'und') continue;
|
||||
const confidence = Number(result.confidence) || 0;
|
||||
item.language_code = code;
|
||||
item.language_confidence = confidence;
|
||||
item.language_scores = JSON.stringify({ [code]: confidence });
|
||||
item.language_evidence = JSON.stringify([
|
||||
{ code, weight: confidence, type: 'laya', value: 'Local AI model' }
|
||||
]);
|
||||
item.language_detector = 'laya';
|
||||
item.language_input_hash = languageInputHash(item);
|
||||
}
|
||||
processed += 1;
|
||||
options.onProgress?.({ processed, total });
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[LiveProjection] Laya language detection unavailable; keeping persisted or algorithmic languages');
|
||||
}
|
||||
}
|
||||
|
||||
function catalogInputHash(items) {
|
||||
return stableHash(items
|
||||
.map(item => `${item.id}\0${item.name}\0${item.category_id || ''}`)
|
||||
@ -359,11 +408,12 @@ async function rebuildWithSignals(database = null, options = {}) {
|
||||
let identityHints = new Map();
|
||||
if (mode !== 'algo') {
|
||||
const layaClient = options.layaClient || new (require('./layaService').LayaService)();
|
||||
const scopedItems = analysisScopeItems(items, options);
|
||||
const identityOptions = options.analyzeAll === true
|
||||
? {}
|
||||
: Object.hasOwn(options, 'scopeItemIds')
|
||||
? { scopeItemIds: options.scopeItemIds }
|
||||
: { scopeItemIds: items.filter(item => !hasPersistedLanguage(item)).map(item => item.id) };
|
||||
: { scopeItemIds: scopedItems.map(item => item.id) };
|
||||
identityHints = await collectOptionalIdentityHints(items, mode, layaClient, {
|
||||
...identityOptions,
|
||||
onProgress: ({ processed, total }) => updateProjectionProgress(database, 'laya_identity', {
|
||||
@ -371,6 +421,12 @@ async function rebuildWithSignals(database = null, options = {}) {
|
||||
layaTotal: total
|
||||
})
|
||||
});
|
||||
await collectOptionalLayaLanguages(scopedItems, layaClient, {
|
||||
onProgress: ({ processed, total }) => updateProjectionProgress(database, 'laya_language', {
|
||||
layaProcessed: processed,
|
||||
layaTotal: total
|
||||
})
|
||||
});
|
||||
}
|
||||
return rebuild(database, { ...options, items, mode, identityHints });
|
||||
}
|
||||
|
||||
216
test/t39FullRefreshAndVariantRegression.test.js
Normal file
216
test/t39FullRefreshAndVariantRegression.test.js
Normal file
@ -0,0 +1,216 @@
|
||||
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();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user