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:
@ -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) => {
|
||||
|
||||
@ -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
|
||||
*/
|
||||
|
||||
@ -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,
|
||||
|
||||
50
public/js/services/liveChannelProjection.js
Normal file
50
public/js/services/liveChannelProjection.js
Normal 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 };
|
||||
});
|
||||
Reference in New Issue
Block a user