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');
+});