feat: precompute stream grouping during source sync (#13)

Move variant grouping work out of Live TV rendering into the
catalog pipeline: a liveProjectionService builds the logical channel
projection (schema in liveProjectionSchema) whenever a source is
added, synced or modified (name change, new address), and the
projection is served through a /live route. ChannelList now consumes
the precomputed projection via a liveChannelProjection client
service, recalculating only when the catalog changes. Cover
projection building, incremental recompute and client consumption
with tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
blomios
2026-10-02 15:35:02 +02:00
parent 7b6525708c
commit 9f97020996
16 changed files with 619 additions and 10 deletions

View File

@ -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);

View File

@ -10,7 +10,7 @@
<title>nodecast-tv | IPTV Player</title>
<meta name="description" content="Stream live TV, movies, and series with EPG support">
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="stylesheet" href="css/main.css?v=6">
<link rel="stylesheet" href="css/main.css?v=7">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Outfit:wght@800&display=swap"
@ -1180,13 +1180,14 @@
<!-- JavaScript -->
<script src="/js/icons.js"></script>
<script src="/js/api.js?v=3"></script>
<script src="/js/api.js?v=4"></script>
<script src="/js/components/VideoPlayer.js?v=5"></script>
<script src="/js/services/channelLanguageDetection.js?v=1"></script>
<script src="/js/services/channelLanguageFusion.js?v=1"></script>
<script src="/js/services/layaStatusPresentation.js?v=4"></script>
<script src="/js/services/channelVariantGrouping.js?v=5"></script>
<script src="/js/components/ChannelList.js?v=10"></script>
<script src="/js/services/liveChannelProjection.js?v=1"></script>
<script src="/js/services/channelVariantGrouping.js?v=6"></script>
<script src="/js/components/ChannelList.js?v=11"></script>
<script src="/js/components/SourceManager.js?v=2"></script>
<script src="/js/components/EpgGuide.js?v=2"></script>
<script src="/js/pages/HomePage.js?v=2"></script>

View File

@ -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) => {

View File

@ -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 = '<div class="loading"></div>';
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 = '<div class="loading"></div>';
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
*/

View File

@ -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,

View File

@ -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 };
});