diff --git a/public/css/main.css b/public/css/main.css index 1e52f53..5859ddc 100644 --- a/public/css/main.css +++ b/public/css/main.css @@ -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; diff --git a/public/js/components/ChannelList.js b/public/js/components/ChannelList.js index 3cb5ea4..284bb87 100644 --- a/public/js/components/ChannelList.js +++ b/public/js/components/ChannelList.js @@ -891,13 +891,25 @@ class ChannelList { const languageDescription = language.code === 'unknown' ? 'Unknown language' : `${language.label} — ${language.confidence}% confidence`; - return ``; }).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 diff --git a/public/js/services/liveChannelProjection.js b/public/js/services/liveChannelProjection.js index c55feaa..2fc4f55 100644 --- a/public/js/services/liveChannelProjection.js +++ b/public/js/services/liveChannelProjection.js @@ -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, diff --git a/server/services/liveProjectionService.js b/server/services/liveProjectionService.js index f26e362..b3ff970 100644 --- a/server/services/liveProjectionService.js +++ b/server/services/liveProjectionService.js @@ -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 }); } diff --git a/test/t39FullRefreshAndVariantRegression.test.js b/test/t39FullRefreshAndVariantRegression.test.js new file mode 100644 index 0000000..6d9d20b --- /dev/null +++ b/test/t39FullRefreshAndVariantRegression.test.js @@ -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(` + + + + +
+ + + + `, { 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(); + } +});