diff --git a/public/css/main.css b/public/css/main.css index 2c41682..c2cbe27 100644 --- a/public/css/main.css +++ b/public/css/main.css @@ -863,7 +863,8 @@ h4 { margin: var(--space-md) 0; } -.laya-language-notice { +.laya-language-notice, +.live-projection-notice { margin: var(--space-sm) var(--space-md); padding: var(--space-sm); border-radius: var(--radius-sm); diff --git a/public/index.html b/public/index.html index 287b703..0aaee98 100644 --- a/public/index.html +++ b/public/index.html @@ -10,7 +10,7 @@ nodecast-tv | IPTV Player - + - + - - + + + diff --git a/public/js/api.js b/public/js/api.js index 03449c4..5f3a990 100644 --- a/public/js/api.js +++ b/public/js/api.js @@ -77,6 +77,15 @@ const API = { hideAll: (sourceId, contentType) => API.request('POST', '/channels/hide/all', { sourceId, contentType }) }, + live: { + getChannels: ({ sourceId = null, includeHidden = false } = {}) => { + const params = new URLSearchParams(); + if (sourceId) params.set('sourceId', sourceId); + params.set('includeHidden', String(Boolean(includeHidden))); + return API.request('GET', `/live/channels?${params.toString()}`); + } + }, + // Favorites favorites: { getAll: (sourceId = null, itemType = null) => { diff --git a/public/js/components/ChannelList.js b/public/js/components/ChannelList.js index 788fcd7..6e90171 100644 --- a/public/js/components/ChannelList.js +++ b/public/js/components/ChannelList.js @@ -35,6 +35,7 @@ class ChannelList { this.languageDetectionNotice = ''; this.languageDetectionFallback = false; this._languageDetectionRequest = 0; + this.projectionNotice = ''; this.loadCollapsedState(); this.init(); @@ -436,6 +437,13 @@ class ChannelList { this.currentBatch = 0; this.batchSize = 100; // Number of groups to render per batch (increased to handle many hidden groups) this.container.innerHTML = ''; // Clear container + if (this.projectionNotice) { + const notice = document.createElement('div'); + notice.className = 'live-projection-notice'; + notice.setAttribute('role', 'status'); + notice.textContent = this.projectionNotice; + this.container.appendChild(notice); + } if (this.languageDetectionNotice) { const notice = document.createElement('div'); notice.className = 'laya-language-notice'; @@ -913,6 +921,12 @@ class ChannelList { try { this.container.innerHTML = '
'; + if (await this.loadProjectedChannels(parseInt(id, 10))) { + this.render(); + void this.refreshLayaLanguages(); + return; + } + if (type === 'xtream') { await this.loadXtreamChannels(parseInt(id)); } else if (type === 'm3u') { @@ -945,6 +959,12 @@ class ChannelList { try { this.container.innerHTML = '
'; + if (await this.loadProjectedChannels()) { + this.render(); + void this.refreshLayaLanguages(); + return; + } + const xtreamSources = this.sources.filter(s => s.type === 'xtream' && s.enabled); const m3uSources = this.sources.filter(s => s.type === 'm3u' && s.enabled); console.log('[ChannelList] loadAllChannels: xtream=', xtreamSources.length, 'm3u=', m3uSources.length); @@ -968,6 +988,26 @@ class ChannelList { } } + async loadProjectedChannels(sourceId = null) { + try { + const response = await API.live.getChannels({ sourceId, includeHidden: true }); + if (!LiveChannelProjection.canUse(response)) { + this.projectionNotice = 'Channel list is updating. Using the standard channel list for now.'; + return false; + } + const mapped = LiveChannelProjection.mapResponse(response, this.sources); + this.channels = mapped.channels; + this.visibleFavorites = mapped.favorites; + this.hiddenItems = mapped.hiddenItems; + this.projectionNotice = ''; + return true; + } catch (err) { + console.warn('[ChannelList] Live projection unavailable; using legacy loading:', err.message); + this.projectionNotice = ''; + return false; + } + } + /** * Load Xtream channels */ diff --git a/public/js/services/channelVariantGrouping.js b/public/js/services/channelVariantGrouping.js index f7220a8..3372d73 100644 --- a/public/js/services/channelVariantGrouping.js +++ b/public/js/services/channelVariantGrouping.js @@ -93,7 +93,7 @@ const buckets = new Map(); channels.forEach(channel => { - const fingerprint = normalizeChannelName(channel.name) || `channel-${getVariantKey(channel)}`; + const fingerprint = channel.logicalFingerprint || normalizeChannelName(channel.name) || `channel-${getVariantKey(channel)}`; if (!buckets.has(fingerprint)) buckets.set(fingerprint, []); buckets.get(fingerprint).push(channel); }); @@ -139,10 +139,10 @@ ])); return { ...preferredVariant, - id: `logical:${fingerprint}`, + id: preferredVariant.logicalId || `logical:${fingerprint}`, fingerprint, - displayName: displayNameForFingerprint(fingerprint), - name: displayNameForFingerprint(fingerprint), + displayName: preferredVariant.logicalDisplayName || displayNameForFingerprint(fingerprint), + name: preferredVariant.logicalDisplayName || displayNameForFingerprint(fingerprint), variants, visibleVariants, preferredVariant, diff --git a/public/js/services/liveChannelProjection.js b/public/js/services/liveChannelProjection.js new file mode 100644 index 0000000..f2a1e9f --- /dev/null +++ b/public/js/services/liveChannelProjection.js @@ -0,0 +1,50 @@ +(function (root, factory) { + const service = factory(); + if (typeof module === 'object' && module.exports) module.exports = service; + if (root) root.LiveChannelProjection = service; +})(typeof globalThis !== 'undefined' ? globalThis : this, function () { + function mapResponse(response, sources = []) { + if (!response || !Array.isArray(response.channels)) { + throw new Error('Invalid live channel projection response'); + } + + const sourceById = new Map(sources.map(source => [String(source.id), source])); + const favorites = new Set(); + const hiddenItems = new Set(); + const channels = []; + + response.channels.forEach(logicalChannel => { + if (!logicalChannel || !Array.isArray(logicalChannel.variants)) return; + logicalChannel.variants.forEach(variant => { + const source = sourceById.get(String(variant.sourceId)); + const id = String(variant.id); + const mapped = { + ...variant, + id, + logicalId: logicalChannel.id, + logicalFingerprint: logicalChannel.fingerprint || logicalChannel.id, + logicalDisplayName: logicalChannel.displayName || logicalChannel.name, + streamId: variant.streamId ?? id, + tvgId: variant.tvgId ?? variant.epg_channel_id ?? null, + tvgLogo: variant.tvgLogo ?? variant.streamIcon ?? variant.stream_icon ?? '', + url: variant.url ?? variant.streamUrl ?? variant.stream_url ?? '', + groupTitle: variant.groupTitle ?? variant.categoryName ?? variant.categoryId ?? 'Uncategorized', + sourceType: variant.sourceType ?? source?.type ?? 'xtream', + sourceName: variant.sourceName ?? source?.name ?? '' + }; + channels.push(mapped); + const key = `${mapped.sourceId}:${id}`; + if (variant.isFavorite) favorites.add(key); + if (variant.isHidden) hiddenItems.add(`channel:${mapped.sourceId}:${mapped.streamId || id}`); + }); + }); + + return { channels, favorites, hiddenItems, projection: response.projection || {} }; + } + + function canUse(response) { + return Boolean(response && Array.isArray(response.channels) && !response.projection?.stale); + } + + return { canUse, mapResponse }; +}); diff --git a/server/db/liveProjectionSchema.js b/server/db/liveProjectionSchema.js new file mode 100644 index 0000000..7be2e5e --- /dev/null +++ b/server/db/liveProjectionSchema.js @@ -0,0 +1,49 @@ +const LIVE_PROJECTION_COLUMNS = { + logical_channel_id: 'TEXT', + grouping_fingerprint: 'TEXT', + grouping_input_hash: 'TEXT', + quality_label: 'TEXT', + quality_rank: 'INTEGER', + variant_rank: 'INTEGER', + language_code: 'TEXT', + language_confidence: 'REAL', + language_scores: 'JSON', + language_evidence: 'JSON', + language_detector: 'TEXT', + language_input_hash: 'TEXT' +}; + +const LIVE_PROJECTION_SCHEMA_SQL = ` + CREATE INDEX IF NOT EXISTS idx_items_logical_channel ON playlist_items(logical_channel_id); + CREATE INDEX IF NOT EXISTS idx_items_grouping_fingerprint ON playlist_items(grouping_fingerprint); + + CREATE TABLE IF NOT EXISTS logical_channels ( + id TEXT PRIMARY KEY, + fingerprint TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL, + preferred_variant_id TEXT, + variant_count INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_logical_channels_fingerprint ON logical_channels(fingerprint); + + CREATE TABLE IF NOT EXISTS catalog_projection_state ( + projection TEXT PRIMARY KEY, + status TEXT NOT NULL, + started_at INTEGER, + completed_at INTEGER, + input_hash TEXT, + projected_count INTEGER NOT NULL DEFAULT 0, + error TEXT + ); +`; + +function ensureColumns(database, table, columns) { + const existing = new Set(database.prepare(`PRAGMA table_info(${table})`).all().map(column => column.name)); + for (const [name, definition] of Object.entries(columns)) { + if (!existing.has(name)) database.exec(`ALTER TABLE ${table} ADD COLUMN ${name} ${definition}`); + } +} + +module.exports = { ensureColumns, LIVE_PROJECTION_COLUMNS, LIVE_PROJECTION_SCHEMA_SQL }; diff --git a/server/db/sqlite.js b/server/db/sqlite.js index e73e4f0..be6cc63 100644 --- a/server/db/sqlite.js +++ b/server/db/sqlite.js @@ -1,6 +1,11 @@ const Database = require('better-sqlite3'); const path = require('path'); const fs = require('fs'); +const { + ensureColumns, + LIVE_PROJECTION_COLUMNS, + LIVE_PROJECTION_SCHEMA_SQL +} = require('./liveProjectionSchema'); const dataDir = path.join(__dirname, '..', '..', 'data'); const dbPath = path.join(dataDir, 'content.db'); @@ -73,6 +78,9 @@ function initSchema() { CREATE INDEX IF NOT EXISTS idx_items_category ON playlist_items(source_id, category_id); `); + ensureColumns(db, 'playlist_items', LIVE_PROJECTION_COLUMNS); + db.exec(LIVE_PROJECTION_SCHEMA_SQL); + // EPG Programs // Optimized for range queries db.exec(` @@ -247,6 +255,8 @@ const layaLanguageCache = { module.exports = { getDb, initSchema, + ensureColumns, + LIVE_PROJECTION_COLUMNS, favorites, layaLanguageCache }; diff --git a/server/index.js b/server/index.js index 4dd1fa1..c580887 100644 --- a/server/index.js +++ b/server/index.js @@ -181,6 +181,7 @@ app.use('/api/subtitle', require('./routes/subtitle')); app.use('/api/settings', require('./routes/settings')); app.use('/api/history', require('./routes/history')); app.use('/api/laya', require('./routes/laya')); +app.use('/api/live', require('./routes/live')); // Version endpoint app.get('/api/version', (req, res) => { diff --git a/server/routes/live.js b/server/routes/live.js new file mode 100644 index 0000000..3c6848c --- /dev/null +++ b/server/routes/live.js @@ -0,0 +1,46 @@ +const express = require('express'); +const { requireAuth } = require('../auth'); +const liveProjection = require('../services/liveProjectionService'); + +const router = express.Router(); + +function serializeState(state) { + return { + status: state.status, + stale: state.status !== 'success', + startedAt: state.started_at, + completedAt: state.completed_at, + inputHash: state.input_hash, + projectedCount: state.projected_count, + error: state.error + }; +} + +router.get('/channels', requireAuth, async (req, res) => { + const sourceId = req.query.sourceId === undefined ? null : Number(req.query.sourceId); + if (sourceId !== null && (!Number.isInteger(sourceId) || sourceId <= 0)) { + return res.status(400).json({ error: 'sourceId must be a positive integer' }); + } + if (req.query.includeHidden !== undefined && !['true', 'false'].includes(req.query.includeHidden)) { + return res.status(400).json({ error: 'includeHidden must be true or false' }); + } + const includeHidden = req.query.includeHidden === 'true'; + + try { + let state = liveProjection.projectionState(); + if (state.status !== 'success') state = liveProjection.rebuild(); + const channels = liveProjection.getChannels({ sourceId, includeHidden, userId: req.user.id }); + return res.json({ projection: serializeState(state), channels }); + } catch (error) { + console.error('[LiveProjection] Failed to serve projected channels:', error.message); + const state = liveProjection.projectionState(); + return res.status(503).json({ + projection: serializeState(state), + channels: [], + error: 'Live channel projection is unavailable' + }); + } +}); + +module.exports = router; +module.exports.serializeState = serializeState; diff --git a/server/routes/sources.js b/server/routes/sources.js index 3184fc2..c27cdd0 100644 --- a/server/routes/sources.js +++ b/server/routes/sources.js @@ -5,6 +5,7 @@ const { getDb } = require('../db/sqlite'); const xtreamApi = require('../services/xtreamApi'); const syncService = require('../services/syncService'); const m3uParser = require('../services/m3uParser'); +const liveProjection = require('../services/liveProjectionService'); // Get all sources router.get('/', async (req, res) => { @@ -132,6 +133,7 @@ router.delete('/:id', async (req, res) => { // Delete source config and related hidden items (favorites handled by db.js) await sources.delete(sourceId); + await liveProjection.queueRebuild(); res.json({ success: true }); } catch (err) { @@ -278,4 +280,3 @@ router.post('/sync-all', async (req, res) => { }); module.exports = router; - diff --git a/server/services/liveProjectionService.js b/server/services/liveProjectionService.js new file mode 100644 index 0000000..1b0d270 --- /dev/null +++ b/server/services/liveProjectionService.js @@ -0,0 +1,251 @@ +const crypto = require('crypto'); + +const QUALITY_TOKENS = new Set([ + '4k', 'uhd', 'fhd', 'fullhd', 'hd', 'sd', 'hevc', 'h265', 'h264', + '1080p', '1080i', '720p', '576p', '480p' +]); +const REGION_PREFIX = /^(?:(?:\|\s*[a-z]{2,3}\s*\|)|(?:[a-z]{2,3}(?:-[a-z]{2,3})?\s*\|))\s*/i; + +function words(value) { + return String(value || '') + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .replace(REGION_PREFIX, '') + .replace(/[|()[\]{}_.,:;+/\\-]+/g, ' ') + .split(/\s+/) + .filter(Boolean); +} + +function normalizeChannelName(name) { + return words(name).filter(token => !QUALITY_TOKENS.has(token)).join(' ').trim(); +} + +function displayNameForFingerprint(fingerprint) { + return fingerprint.split(' ').map(token => { + if (token === 'bein') return 'beIN'; + if (/^\d+$/.test(token)) return token; + return token.charAt(0).toUpperCase() + token.slice(1); + }).join(' '); +} + +function qualityForName(name) { + const tokens = words(name); + if (tokens.includes('4k') || tokens.includes('uhd')) return { label: '4K', rank: 4 }; + if (tokens.includes('fhd') || tokens.includes('fullhd') || tokens.includes('1080p') || tokens.includes('1080i')) { + return { label: 'Full HD', rank: 3 }; + } + if (tokens.includes('hd') || tokens.includes('720p')) return { label: 'HD', rank: 2 }; + if (tokens.includes('sd') || tokens.includes('576p') || tokens.includes('480p')) return { label: 'SD', rank: 0 }; + return { label: 'Standard', rank: 1 }; +} + +function stableHash(value) { + return crypto.createHash('sha256').update(value).digest('hex'); +} + +function projectCatalog(items) { + const buckets = new Map(); + for (const item of items) { + const fallback = `channel-${item.source_id}:${item.item_id}`; + const fingerprint = normalizeChannelName(item.name) || fallback; + const quality = qualityForName(item.name); + const projected = { + ...item, + fingerprint, + groupingInputHash: stableHash(`${item.name || ''}\0${item.category_id || ''}`), + qualityLabel: quality.label, + qualityRank: quality.rank + }; + if (!buckets.has(fingerprint)) buckets.set(fingerprint, []); + buckets.get(fingerprint).push(projected); + } + + return Array.from(buckets, ([fingerprint, variants]) => { + variants.sort((a, b) => b.qualityRank - a.qualityRank || a.source_id - b.source_id || String(a.item_id).localeCompare(String(b.item_id))); + variants.forEach((variant, index) => { variant.variantRank = index; }); + return { + id: `logical:${stableHash(fingerprint).slice(0, 24)}`, + fingerprint, + displayName: displayNameForFingerprint(fingerprint), + preferredVariantId: variants[0].id, + variants + }; + }).sort((a, b) => a.displayName.localeCompare(b.displayName)); +} + +function catalogInputHash(items) { + return stableHash(items + .map(item => `${item.id}\0${item.name}\0${item.category_id || ''}`) + .sort() + .join('\n')); +} + +function defaultDatabase() { + return require('../db/sqlite').getDb(); +} + +function projectionState(database = null) { + database ||= defaultDatabase(); + return database.prepare('SELECT * FROM catalog_projection_state WHERE projection = ?').get('live') || { + projection: 'live', status: 'missing', started_at: null, completed_at: null, + input_hash: null, projected_count: 0, error: null + }; +} + +function rebuild(database = null) { + database ||= defaultDatabase(); + const items = database.prepare(` + SELECT id, source_id, item_id, name, category_id + FROM playlist_items WHERE type = 'live' + `).all(); + const groups = projectCatalog(items); + const inputHash = catalogInputHash(items); + const startedAt = Date.now(); + database.prepare(` + INSERT INTO catalog_projection_state (projection, status, started_at, completed_at, input_hash, projected_count, error) + VALUES ('live', 'building', ?, NULL, NULL, 0, NULL) + ON CONFLICT(projection) DO UPDATE SET status = 'building', started_at = excluded.started_at, + completed_at = NULL, error = NULL + `).run(startedAt); + + try { + const apply = database.transaction(() => { + database.prepare('DELETE FROM logical_channels').run(); + database.prepare(` + UPDATE playlist_items SET logical_channel_id = NULL, grouping_fingerprint = NULL, + grouping_input_hash = NULL, quality_label = NULL, quality_rank = NULL, variant_rank = NULL + WHERE type = 'live' + `).run(); + const insertLogical = database.prepare(` + INSERT INTO logical_channels + (id, fingerprint, display_name, preferred_variant_id, variant_count, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + `); + const updateVariant = database.prepare(` + UPDATE playlist_items SET logical_channel_id = ?, grouping_fingerprint = ?, + grouping_input_hash = ?, quality_label = ?, quality_rank = ?, variant_rank = ? + WHERE id = ? AND type = 'live' + `); + const now = Date.now(); + for (const group of groups) { + insertLogical.run(group.id, group.fingerprint, group.displayName, group.preferredVariantId, group.variants.length, now, now); + for (const variant of group.variants) { + updateVariant.run(group.id, group.fingerprint, variant.groupingInputHash, + variant.qualityLabel, variant.qualityRank, variant.variantRank, variant.id); + } + } + database.prepare(` + UPDATE catalog_projection_state SET status = 'success', completed_at = ?, + input_hash = ?, projected_count = ?, error = NULL WHERE projection = 'live' + `).run(Date.now(), inputHash, groups.length); + }); + apply(); + return projectionState(database); + } catch (error) { + database.prepare(` + UPDATE catalog_projection_state SET status = 'error', completed_at = ?, error = ? + WHERE projection = 'live' + `).run(Date.now(), 'Live catalog projection failed'); + throw error; + } +} + +let rebuildQueue = Promise.resolve(); +function queueRebuild() { + const next = rebuildQueue.catch(() => {}).then(() => rebuild()); + rebuildQueue = next; + return next; +} + +function parseData(value) { + try { return value ? JSON.parse(value) : {}; } catch { return {}; } +} + +function getChannels({ sourceId = null, includeHidden = false, userId = null }, database = null) { + database ||= defaultDatabase(); + const params = []; + let sourceFilter = ''; + if (sourceId !== null) { + sourceFilter = 'AND p.source_id = ?'; + params.push(sourceId); + } + const rows = database.prepare(` + SELECT p.*, c.name AS category_name, COALESCE(c.is_hidden, 0) AS category_hidden, + CASE WHEN f.id IS NULL THEN 0 ELSE 1 END AS user_favorite, + l.display_name, l.preferred_variant_id + FROM playlist_items p + JOIN logical_channels l ON l.id = p.logical_channel_id + LEFT JOIN categories c ON c.source_id = p.source_id AND c.type = 'live' AND c.category_id = p.category_id + LEFT JOIN favorites f ON f.user_id = ? AND f.source_id = p.source_id + AND f.item_id = p.item_id AND f.item_type = 'channel' + WHERE p.type = 'live' ${sourceFilter} + ORDER BY l.display_name, p.variant_rank + `).all(userId ?? -1, ...params); + return serializeChannels(rows, includeHidden); +} + +function serializeChannels(rows, includeHidden = false) { + const groups = new Map(); + for (const row of rows) { + const hidden = Boolean(row.is_hidden || row.category_hidden); + if (hidden && !includeHidden) continue; + const raw = parseData(row.data); + const categoryName = raw.groupTitle || row.category_name || 'Uncategorized'; + const variant = { + ...raw, + id: row.item_id, + sourceId: row.source_id, + name: row.name, + categoryId: row.category_id, + categoryName, + groupTitle: categoryName, + streamIcon: row.stream_icon, + streamUrl: row.stream_url, + qualityLabel: row.quality_label, + qualityRank: row.quality_rank, + variantRank: row.variant_rank, + isHidden: hidden, + isFavorite: Boolean(row.user_favorite) + }; + if (!groups.has(row.logical_channel_id)) { + groups.set(row.logical_channel_id, { + id: row.logical_channel_id, + fingerprint: row.grouping_fingerprint, + displayName: row.display_name, + name: row.display_name, + variants: [], + isFavorite: false, + isHidden: true + }); + } + const group = groups.get(row.logical_channel_id); + group.variants.push(variant); + group.isFavorite ||= variant.isFavorite; + group.isHidden &&= variant.isHidden; + if (row.id === row.preferred_variant_id) group.preferredVariant = variant; + } + return Array.from(groups.values()).map(group => { + group.preferredVariant ||= group.variants[0]; + group.categoryId = group.preferredVariant?.categoryId || null; + group.categoryName = group.preferredVariant?.categoryName || 'Uncategorized'; + group.groupTitle = group.preferredVariant?.groupTitle || group.categoryName; + group.selectedVariantId = group.preferredVariant + ? `${group.preferredVariant.sourceId}:${group.preferredVariant.id}` + : null; + return group; + }); +} + +module.exports = { + catalogInputHash, + displayNameForFingerprint, + getChannels, + normalizeChannelName, + projectCatalog, + projectionState, + qualityForName, + queueRebuild, + rebuild, + serializeChannels +}; diff --git a/server/services/syncService.js b/server/services/syncService.js index 8ac35f5..94c36f3 100644 --- a/server/services/syncService.js +++ b/server/services/syncService.js @@ -3,6 +3,7 @@ const { sources, settings } = require('../db'); // For source config and setting const xtreamApi = require('./xtreamApi'); const m3uParser = require('./m3uParser'); const epgParser = require('./epgParser'); +const liveProjection = require('./liveProjectionService'); // Sync tracking const activeSyncs = new Set(); // sourceId @@ -183,6 +184,7 @@ class SyncService { console.log(`[Sync] Fetching Live Streams for ${source.name}`); const liveStreams = await api.getLiveStreams(); await this.saveStreams(source.id, 'live', liveStreams); + await liveProjection.queueRebuild(); // 3. VOD Categories console.log(`[Sync] Fetching VOD Categories for ${source.name}`); @@ -559,6 +561,7 @@ class SyncService { })); await this.saveCategories(source.id, 'live', categories); + await liveProjection.queueRebuild(); console.log(`[Sync] M3U sync complete for ${source.name}`); } diff --git a/test/channelVariantGrouping.test.js b/test/channelVariantGrouping.test.js index 925ab60..1ef82c8 100644 --- a/test/channelVariantGrouping.test.js +++ b/test/channelVariantGrouping.test.js @@ -21,6 +21,17 @@ test('does not merge distinct numbered channels', () => { assert.deepEqual(logical.map(item => item.fingerprint), ['bein sports 1', 'bein sports 2']); }); +test('preserves precomputed projection boundaries and display names', () => { + const logical = grouping.groupChannels([ + channel('1', 'Shared Name HD', { logicalId: 'logical:a', logicalFingerprint: 'a', logicalDisplayName: 'Backend A' }), + channel('2', 'Shared Name 4K', { logicalId: 'logical:b', logicalFingerprint: 'b', logicalDisplayName: 'Backend B' }) + ]); + assert.deepEqual(logical.map(item => [item.id, item.displayName]), [ + ['logical:a', 'Backend A'], + ['logical:b', 'Backend B'] + ]); +}); + test('logical favorite and hidden state are derived from individual streams', () => { const variants = [channel('1', 'BEIN SPORTS 1 HD'), channel('2', 'BEIN SPORTS 1 4K')]; const logical = grouping.groupChannels(variants, { diff --git a/test/liveChannelProjection.test.js b/test/liveChannelProjection.test.js new file mode 100644 index 0000000..9141d8f --- /dev/null +++ b/test/liveChannelProjection.test.js @@ -0,0 +1,34 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const projection = require('../public/js/services/liveChannelProjection.js'); + +test('maps projected logical channels to the frontend variant format', () => { + const response = { + projection: { status: 'success', stale: false }, + channels: [{ + id: 'logical:one', fingerprint: 'news', displayName: 'News', + variants: [{ + id: 42, sourceId: 7, name: 'News HD', categoryId: 'News', + streamIcon: 'logo.png', streamUrl: 'https://stream.test/live', + tvgId: 'news.fr', isFavorite: true, isHidden: true + }] + }] + }; + const mapped = projection.mapResponse(response, [{ id: 7, type: 'm3u', name: 'Local' }]); + assert.deepEqual(mapped.channels[0], { + id: '42', sourceId: 7, name: 'News HD', categoryId: 'News', + streamIcon: 'logo.png', streamUrl: 'https://stream.test/live', tvgId: 'news.fr', + isFavorite: true, isHidden: true, logicalId: 'logical:one', + logicalFingerprint: 'news', logicalDisplayName: 'News', + streamId: '42', tvgLogo: 'logo.png', + url: 'https://stream.test/live', groupTitle: 'News', sourceType: 'm3u', sourceName: 'Local' + }); + assert.equal(mapped.favorites.has('7:42'), true); + assert.equal(mapped.hiddenItems.has('channel:7:42'), true); +}); + +test('rejects stale projections so ChannelList can use its legacy fallback', () => { + assert.equal(projection.canUse({ projection: { stale: true }, channels: [] }), false); + assert.equal(projection.canUse({ projection: { stale: false }, channels: [] }), true); + assert.equal(projection.canUse(null), false); +}); diff --git a/test/liveProjectionService.test.js b/test/liveProjectionService.test.js new file mode 100644 index 0000000..8293ba1 --- /dev/null +++ b/test/liveProjectionService.test.js @@ -0,0 +1,102 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { + projectCatalog, + serializeChannels +} = require('../server/services/liveProjectionService'); +const { + LIVE_PROJECTION_COLUMNS, + LIVE_PROJECTION_SCHEMA_SQL +} = require('../server/db/liveProjectionSchema'); + +const item = (id, name, extra = {}) => ({ + id: `1:${id}`, + source_id: 1, + item_id: String(id), + name, + category_id: 'sports', + ...extra +}); + +test('projection migration declares grouping and language columns', () => { + assert.deepEqual(Object.keys(LIVE_PROJECTION_COLUMNS), [ + 'logical_channel_id', 'grouping_fingerprint', 'grouping_input_hash', + 'quality_label', 'quality_rank', 'variant_rank', 'language_code', + 'language_confidence', 'language_scores', 'language_evidence', + 'language_detector', 'language_input_hash' + ]); + assert.match(LIVE_PROJECTION_SCHEMA_SQL, /CREATE TABLE IF NOT EXISTS logical_channels/); + assert.match(LIVE_PROJECTION_SCHEMA_SQL, /CREATE TABLE IF NOT EXISTS catalog_projection_state/); + assert.match(LIVE_PROJECTION_SCHEMA_SQL, /idx_items_logical_channel/); +}); + +test('projects beIN quality variants into one logical channel', () => { + const groups = projectCatalog([ + item('uhd', '|FR| BEIN SPORTS 1 UHD'), + item('4k', '|FR| BEIN SPORTS 1 4K'), + item('car', 'FR-CAR| bein sports 1') + ]); + assert.equal(groups.length, 1); + assert.equal(groups[0].fingerprint, 'bein sports 1'); + assert.equal(groups[0].displayName, 'beIN Sports 1'); + assert.equal(groups[0].variants.length, 3); + assert.deepEqual(groups[0].variants.map(variant => variant.variantRank), [0, 1, 2]); +}); + +test('does not merge distinct numbered beIN channels', () => { + const groups = projectCatalog([ + item('1', 'beIN Sports 1 HD'), + item('2', 'beIN Sports 2 HD') + ]); + assert.deepEqual(groups.map(group => group.fingerprint), ['bein sports 1', 'bein sports 2']); +}); + +test('renaming a stream changes its projection membership and input hash', () => { + const before = projectCatalog([item('1', 'beIN Sports 1 HD'), item('2', 'beIN Sports 1 4K')]); + const after = projectCatalog([item('1', 'beIN Sports 2 HD'), item('2', 'beIN Sports 1 4K')]); + assert.equal(before.length, 1); + assert.equal(after.length, 2); + const beforeRename = before.flatMap(group => group.variants).find(variant => variant.id === '1:1'); + const afterRename = after.flatMap(group => group.variants).find(variant => variant.id === '1:1'); + assert.notEqual(beforeRename.groupingInputHash, afterRename.groupingInputHash); +}); + +test('API serialization filters source rows upstream and hidden variants by option', () => { + const rows = [ + { + ...item('1', 'beIN Sports 1 HD'), logical_channel_id: 'logical:1', + grouping_fingerprint: 'bein sports 1', display_name: 'beIN Sports 1', + preferred_variant_id: '1:1', quality_label: 'HD', quality_rank: 2, + variant_rank: 0, is_hidden: 0, category_hidden: 0, category_name: 'Sports Premium', + user_favorite: 1, data: '{}' + }, + { + ...item('2', 'beIN Sports 1 4K'), logical_channel_id: 'logical:1', + grouping_fingerprint: 'bein sports 1', display_name: 'beIN Sports 1', + preferred_variant_id: '1:1', quality_label: '4K', quality_rank: 4, + variant_rank: 1, is_hidden: 1, category_hidden: 0, category_name: 'Sports Premium', + user_favorite: 0, data: '{}' + } + ]; + const visible = serializeChannels(rows, false); + assert.equal(visible[0].variants.length, 1); + assert.equal(visible[0].isFavorite, true); + assert.equal(visible[0].groupTitle, 'Sports Premium'); + assert.equal(visible[0].categoryName, 'Sports Premium'); + assert.equal(visible[0].variants[0].groupTitle, 'Sports Premium'); + assert.equal(serializeChannels(rows, true)[0].variants.length, 2); +}); + +test('API serialization preserves an M3U groupTitle over the stored category fallback', () => { + const rows = [{ + ...item('m3u', 'M3U Sports HD'), logical_channel_id: 'logical:m3u', + grouping_fingerprint: 'm3u sports', display_name: 'M3U Sports', + preferred_variant_id: '1:m3u', quality_label: 'HD', quality_rank: 2, + variant_rank: 0, is_hidden: 0, category_hidden: 0, + category_name: 'Fallback category', user_favorite: 0, + data: JSON.stringify({ groupTitle: 'M3U Sports France' }) + }]; + const [channel] = serializeChannels(rows, false); + assert.equal(channel.groupTitle, 'M3U Sports France'); + assert.equal(channel.variants[0].categoryName, 'M3U Sports France'); +});