feat: split live analysis actions and page catalog loading
Separate incremental live checks from full catalog rebuilds and page projected channel/variant loading so first live-list display stays bounded. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -884,10 +884,21 @@ h4 {
|
|||||||
gap: var(--space-md);
|
gap: var(--space-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.live-analysis-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
.live-analysis-heading {
|
.live-analysis-heading {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.live-analysis-actions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.laya-model-panel [hidden] {
|
.laya-model-panel [hidden] {
|
||||||
|
|||||||
@ -489,9 +489,12 @@
|
|||||||
<strong>Channel analysis</strong>
|
<strong>Channel analysis</strong>
|
||||||
<div class="setting-hint">Group matching streams into channels and update detected languages.</div>
|
<div class="setting-hint">Group matching streams into channels and update detected languages.</div>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="btn btn-primary" id="live-analysis-button">Analyze channels</button>
|
<div class="live-analysis-actions">
|
||||||
|
<button type="button" class="btn btn-primary" id="live-check-new-button">Check for new streams</button>
|
||||||
|
<button type="button" class="btn btn-secondary" id="live-rebuild-catalog-button">Rebuild entire catalog</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="setting-hint">Use this to analyze channels that were added before automatic analysis was enabled.</p>
|
<p class="setting-hint">Check newly added streams, or rebuild the entire catalog if channel matching needs to be refreshed.</p>
|
||||||
<div id="live-analysis-status" class="setting-hint" role="status">Checking status…</div>
|
<div id="live-analysis-status" class="setting-hint" role="status">Checking status…</div>
|
||||||
<div id="live-analysis-progress" class="laya-progress" hidden>
|
<div id="live-analysis-progress" class="laya-progress" hidden>
|
||||||
<div id="live-analysis-progress-track" class="laya-progress-track" role="progressbar" aria-label="Channel analysis progress">
|
<div id="live-analysis-progress-track" class="laya-progress-track" role="progressbar" aria-label="Channel analysis progress">
|
||||||
|
|||||||
@ -90,14 +90,27 @@ const API = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
live: {
|
live: {
|
||||||
getChannels: ({ sourceId = null, includeHidden = false, q = '', languages = [] } = {}) => {
|
getChannels: ({ sourceId = null, includeHidden = false, q = '', languages = [], limit = 100, offset = 0 } = {}) => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (sourceId) params.set('sourceId', sourceId);
|
if (sourceId) params.set('sourceId', sourceId);
|
||||||
params.set('includeHidden', String(Boolean(includeHidden)));
|
params.set('includeHidden', String(Boolean(includeHidden)));
|
||||||
if (q) params.set('q', q);
|
if (q) params.set('q', q);
|
||||||
if (languages.length) params.set('languages', languages.join(','));
|
if (languages.length) params.set('languages', languages.join(','));
|
||||||
|
params.set('limit', String(limit));
|
||||||
|
params.set('offset', String(offset));
|
||||||
|
params.set('page', String(Math.floor(offset / limit) + 1));
|
||||||
return API.request('GET', `/live/channels?${params.toString()}`);
|
return API.request('GET', `/live/channels?${params.toString()}`);
|
||||||
},
|
},
|
||||||
|
getChannelVariants: (logicalChannelId, { includeHidden = false, limit = 100, offset = 0 } = {}) => {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
includeHidden: String(Boolean(includeHidden)),
|
||||||
|
limit: String(limit),
|
||||||
|
offset: String(offset),
|
||||||
|
page: String(Math.floor(offset / limit) + 1)
|
||||||
|
});
|
||||||
|
return API.request('GET', `/live/channels/${encodeURIComponent(logicalChannelId)}/variants?${params.toString()}`);
|
||||||
|
},
|
||||||
|
checkNewStreams: () => API.request('POST', '/live/projection/check-new'),
|
||||||
rebuildProjection: () => API.request('POST', '/live/projection/rebuild'),
|
rebuildProjection: () => API.request('POST', '/live/projection/rebuild'),
|
||||||
getProjectionStatus: () => API.request('GET', '/live/projection/status')
|
getProjectionStatus: () => API.request('GET', '/live/projection/status')
|
||||||
},
|
},
|
||||||
|
|||||||
@ -28,6 +28,9 @@ class ChannelList {
|
|||||||
this.listMode = 'grouped';
|
this.listMode = 'grouped';
|
||||||
this.logicalChannels = [];
|
this.logicalChannels = [];
|
||||||
this.projectedLogicalChannels = null;
|
this.projectedLogicalChannels = null;
|
||||||
|
this.projectionPage = null;
|
||||||
|
this.projectionLimit = 100;
|
||||||
|
this.variantPages = new Map();
|
||||||
this.availableLanguages = [];
|
this.availableLanguages = [];
|
||||||
this.selectedLanguages = new Set();
|
this.selectedLanguages = new Set();
|
||||||
this.selectedVariants = new Map();
|
this.selectedVariants = new Map();
|
||||||
@ -194,7 +197,8 @@ class ChannelList {
|
|||||||
this.searchInput.addEventListener('input', () => {
|
this.searchInput.addEventListener('input', () => {
|
||||||
clearTimeout(searchTimeout);
|
clearTimeout(searchTimeout);
|
||||||
searchTimeout = setTimeout(() => {
|
searchTimeout = setTimeout(() => {
|
||||||
if (this.projectedLogicalChannels || this.channels.length) this.render();
|
if (this.projectionPage) this.loadChannels();
|
||||||
|
else if (this.projectedLogicalChannels || this.channels.length) this.render();
|
||||||
else this.loadChannels();
|
else this.loadChannels();
|
||||||
}, this.projectedLogicalChannels || this.channels.length ? 75 : 250);
|
}, this.projectedLogicalChannels || this.channels.length ? 75 : 250);
|
||||||
});
|
});
|
||||||
@ -215,7 +219,8 @@ class ChannelList {
|
|||||||
if (this.searchInput?.value) {
|
if (this.searchInput?.value) {
|
||||||
this.searchInput.value = '';
|
this.searchInput.value = '';
|
||||||
clearTimeout(searchTimeout);
|
clearTimeout(searchTimeout);
|
||||||
this.render();
|
if (this.projectionPage) this.loadChannels();
|
||||||
|
else this.render();
|
||||||
this.searchInput.focus();
|
this.searchInput.focus();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -226,7 +231,10 @@ class ChannelList {
|
|||||||
|
|
||||||
// Show hidden toggle
|
// Show hidden toggle
|
||||||
if (this.showHiddenCheckbox) {
|
if (this.showHiddenCheckbox) {
|
||||||
this.showHiddenCheckbox.addEventListener('change', () => this.render());
|
this.showHiddenCheckbox.addEventListener('change', () => {
|
||||||
|
if (this.projectionPage) this.loadChannels();
|
||||||
|
else this.render();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Context menu handlers
|
// Context menu handlers
|
||||||
@ -625,6 +633,7 @@ class ChannelList {
|
|||||||
|
|
||||||
// Start observing loader for additional batches
|
// Start observing loader for additional batches
|
||||||
this.observer.observe(this.loader);
|
this.observer.observe(this.loader);
|
||||||
|
this.appendProjectionPager();
|
||||||
}
|
}
|
||||||
|
|
||||||
renderFlatList(channels, searchTerm) {
|
renderFlatList(channels, searchTerm) {
|
||||||
@ -654,6 +663,41 @@ class ChannelList {
|
|||||||
this.container.appendChild(this.loader);
|
this.container.appendChild(this.loader);
|
||||||
this.renderNextFlatBatch();
|
this.renderNextFlatBatch();
|
||||||
this.observer.observe(this.loader);
|
this.observer.observe(this.loader);
|
||||||
|
this.appendProjectionPager();
|
||||||
|
}
|
||||||
|
|
||||||
|
appendProjectionPager() {
|
||||||
|
if (!this.projectionPage?.hasMore) return;
|
||||||
|
const button = document.createElement('button');
|
||||||
|
button.type = 'button';
|
||||||
|
button.className = 'btn btn-ghost load-more-channels';
|
||||||
|
button.textContent = 'Load more channels';
|
||||||
|
button.addEventListener('click', async () => {
|
||||||
|
button.disabled = true;
|
||||||
|
await this.loadMoreProjectedChannels();
|
||||||
|
});
|
||||||
|
this.container.appendChild(button);
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadMoreProjectedChannels() {
|
||||||
|
if (!this.projectionPage?.hasMore) return;
|
||||||
|
const sourceValue = this.sourceSelect.value;
|
||||||
|
const [, rawSourceId] = sourceValue ? sourceValue.split(':') : [];
|
||||||
|
const response = await API.live.getChannels({
|
||||||
|
sourceId: rawSourceId ? Number.parseInt(rawSourceId, 10) : null,
|
||||||
|
includeHidden: Boolean(this.showHiddenCheckbox?.checked),
|
||||||
|
q: this.searchInput?.value || '',
|
||||||
|
languages: [...this.selectedLanguages],
|
||||||
|
limit: this.projectionPage.limit || this.projectionLimit,
|
||||||
|
offset: this.projectionPage.nextOffset
|
||||||
|
});
|
||||||
|
const mapped = LiveChannelProjection.mapResponse(response, this.sources);
|
||||||
|
this.channels.push(...mapped.channels);
|
||||||
|
this.projectedLogicalChannels.push(...mapped.logicalChannels);
|
||||||
|
this.projectionPage = response.page || null;
|
||||||
|
mapped.favorites.forEach(key => this.visibleFavorites.add(key));
|
||||||
|
mapped.hiddenItems.forEach(key => this.hiddenItems.add(key));
|
||||||
|
this.render();
|
||||||
}
|
}
|
||||||
|
|
||||||
renderNextFlatBatch() {
|
renderNextFlatBatch() {
|
||||||
@ -809,10 +853,20 @@ class ChannelList {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
groupEl.querySelectorAll('.variant-toggle').forEach(button => button.addEventListener('click', (e) => {
|
groupEl.querySelectorAll('.variant-toggle').forEach(button => button.addEventListener('click', async (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const fingerprint = button.closest('.channel-item').dataset.logicalId;
|
const logicalId = button.closest('.channel-item').dataset.logicalId;
|
||||||
this.expandedVariants.has(fingerprint) ? this.expandedVariants.delete(fingerprint) : this.expandedVariants.add(fingerprint);
|
if (this.expandedVariants.has(logicalId)) {
|
||||||
|
this.expandedVariants.delete(logicalId);
|
||||||
|
} else {
|
||||||
|
this.expandedVariants.add(logicalId);
|
||||||
|
await this.loadChannelVariants(logicalId);
|
||||||
|
}
|
||||||
|
this.render();
|
||||||
|
}));
|
||||||
|
groupEl.querySelectorAll('.load-more-variants').forEach(button => button.addEventListener('click', async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
await this.loadChannelVariants(button.dataset.logicalId, true);
|
||||||
this.render();
|
this.render();
|
||||||
}));
|
}));
|
||||||
groupEl.querySelectorAll('.channel-variant').forEach(item => item.addEventListener('click', (e) => {
|
groupEl.querySelectorAll('.channel-variant').forEach(item => item.addEventListener('click', (e) => {
|
||||||
@ -825,7 +879,8 @@ class ChannelList {
|
|||||||
const selected = channel.preferredVariant;
|
const selected = channel.preferredVariant;
|
||||||
const selectedIsFavorite = this.isFavorite(selected.sourceId, selected.id);
|
const selectedIsFavorite = this.isFavorite(selected.sourceId, selected.id);
|
||||||
const expanded = this.expandedVariants.has(channel.fingerprint);
|
const expanded = this.expandedVariants.has(channel.fingerprint);
|
||||||
const variants = channel.variants.map(variant => {
|
const variantState = this.variantPages?.get(channel.fingerprint);
|
||||||
|
const variants = expanded ? channel.variants.map(variant => {
|
||||||
const hidden = this.isHidden('channel', variant.sourceId, variant.streamId || variant.id);
|
const hidden = this.isHidden('channel', variant.sourceId, variant.streamId || variant.id);
|
||||||
if (hidden && !this.showHidden) return '';
|
if (hidden && !this.showHidden) return '';
|
||||||
const source = this.sources.find(item => String(item.id) === String(variant.sourceId));
|
const source = this.sources.find(item => String(item.id) === String(variant.sourceId));
|
||||||
@ -844,7 +899,9 @@ class ChannelList {
|
|||||||
<span class="variant-source">${this.escapeHtml(source?.name || variant.sourceType)}</span>
|
<span class="variant-source">${this.escapeHtml(source?.name || variant.sourceType)}</span>
|
||||||
<span class="variant-name">${this.escapeHtml(variant.name)}</span>
|
<span class="variant-name">${this.escapeHtml(variant.name)}</span>
|
||||||
</button>`;
|
</button>`;
|
||||||
}).join('');
|
}).join('') : '';
|
||||||
|
const variantCount = Number(channel.variantCount) || channel.variants.length;
|
||||||
|
const hasMoreVariants = expanded && variantState?.page?.hasMore;
|
||||||
const channelLanguage = channel.language || { code: 'und', short: 'UND', label: 'Unknown' };
|
const channelLanguage = channel.language || { code: 'und', short: 'UND', label: 'Unknown' };
|
||||||
const isUnknownLanguage = ['und', 'unknown'].includes(channelLanguage.code);
|
const isUnknownLanguage = ['und', 'unknown'].includes(channelLanguage.code);
|
||||||
const languageBadge = isUnknownLanguage ? '?' : (channelLanguage.short || channelLanguage.code.toUpperCase());
|
const languageBadge = isUnknownLanguage ? '?' : (channelLanguage.short || channelLanguage.code.toUpperCase());
|
||||||
@ -859,13 +916,36 @@ class ChannelList {
|
|||||||
<span class="channel-language-badge" aria-label="${this.escapeHtml(languageAccessibleName)}" title="${this.escapeHtml(languageAccessibleName)}">${this.escapeHtml(languageBadge)}</span></div>
|
<span class="channel-language-badge" aria-label="${this.escapeHtml(languageAccessibleName)}" title="${this.escapeHtml(languageAccessibleName)}">${this.escapeHtml(languageBadge)}</span></div>
|
||||||
<div class="channel-info"><div class="channel-name">${this.escapeHtml(channel.displayName)}</div>
|
<div class="channel-info"><div class="channel-name">${this.escapeHtml(channel.displayName)}</div>
|
||||||
<div class="channel-program">${this.escapeHtml(programLabel)}</div></div>
|
<div class="channel-program">${this.escapeHtml(programLabel)}</div></div>
|
||||||
${channel.variants.length > 1 ? `<button class="variant-toggle" type="button" aria-expanded="${expanded}" aria-pressed="${expanded}" title="Show streams"><span>${channel.variants.length} streams</span><span class="variant-chevron">⌄</span></button>` : ''}
|
${variantCount > 1 ? `<button class="variant-toggle" type="button" aria-expanded="${expanded}" aria-pressed="${expanded}" title="Show streams"><span>${variantCount} streams</span><span class="variant-chevron">⌄</span></button>` : ''}
|
||||||
<button class="favorite-btn ${channel.isFavorite ? 'active' : ''}" title="${selectedIsFavorite ? 'Remove selected stream from Favorites' : 'Add selected stream to Favorites'}">${channel.isFavorite ? Icons.favorite : Icons.favoriteOutline}</button>
|
<button class="favorite-btn ${channel.isFavorite ? 'active' : ''}" title="${selectedIsFavorite ? 'Remove selected stream from Favorites' : 'Add selected stream to Favorites'}">${channel.isFavorite ? Icons.favorite : Icons.favoriteOutline}</button>
|
||||||
</div>
|
</div>
|
||||||
${channel.variants.length > 1 ? `<div class="channel-variants" ${expanded ? '' : 'hidden'}>${variants}</div>` : ''}
|
${variantCount > 1 && expanded ? `<div class="channel-variants">${variants}${hasMoreVariants ? `<button type="button" class="btn btn-ghost load-more-variants" data-logical-id="${this.escapeHtml(channel.fingerprint)}">Load more streams</button>` : ''}</div>` : ''}
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async loadChannelVariants(logicalId, append = false) {
|
||||||
|
const channel = this.projectedLogicalChannels?.find(item => item.fingerprint === logicalId);
|
||||||
|
if (!channel || !API.live.getChannelVariants) return;
|
||||||
|
this.variantPages ||= new Map();
|
||||||
|
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, {
|
||||||
|
includeHidden: Boolean(this.showHiddenCheckbox?.checked),
|
||||||
|
limit: 100,
|
||||||
|
offset
|
||||||
|
});
|
||||||
|
const rawVariants = append ? [...(current?.rawVariants || []), ...(response.variants || [])] : (response.variants || []);
|
||||||
|
const mapped = LiveChannelProjection.mapResponse({
|
||||||
|
channels: [{ ...channel, variants: rawVariants }]
|
||||||
|
}, this.sources).logicalChannels?.[0];
|
||||||
|
if (!mapped) return;
|
||||||
|
channel.variants = mapped.variants;
|
||||||
|
channel.visibleVariants = mapped.visibleVariants;
|
||||||
|
channel.preferredVariant = mapped.preferredVariant;
|
||||||
|
this.variantPages.set(logicalId, { loaded: true, rawVariants, page: response.page || null });
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render channels for a specific group (called when expanding a collapsed group)
|
* Render channels for a specific group (called when expanding a collapsed group)
|
||||||
*/
|
*/
|
||||||
@ -1060,9 +1140,11 @@ class ChannelList {
|
|||||||
try {
|
try {
|
||||||
const response = await API.live.getChannels({
|
const response = await API.live.getChannels({
|
||||||
sourceId,
|
sourceId,
|
||||||
includeHidden: true,
|
includeHidden: Boolean(this.showHiddenCheckbox?.checked),
|
||||||
q: '',
|
q: this.searchInput?.value || '',
|
||||||
languages: []
|
languages: [...this.selectedLanguages],
|
||||||
|
limit: this.projectionLimit,
|
||||||
|
offset: 0
|
||||||
});
|
});
|
||||||
if (!LiveChannelProjection.canUse(response)) {
|
if (!LiveChannelProjection.canUse(response)) {
|
||||||
this.projectedLogicalChannels = null;
|
this.projectedLogicalChannels = null;
|
||||||
@ -1072,6 +1154,9 @@ class ChannelList {
|
|||||||
const mapped = LiveChannelProjection.mapResponse(response, this.sources);
|
const mapped = LiveChannelProjection.mapResponse(response, this.sources);
|
||||||
this.channels = mapped.channels;
|
this.channels = mapped.channels;
|
||||||
this.projectedLogicalChannels = mapped.logicalChannels;
|
this.projectedLogicalChannels = mapped.logicalChannels;
|
||||||
|
this.projectionPage = response.page || null;
|
||||||
|
this.variantPages ||= new Map();
|
||||||
|
this.variantPages.clear();
|
||||||
this.availableLanguages = mapped.availableLanguages;
|
this.availableLanguages = mapped.availableLanguages;
|
||||||
this.reconcileLanguageSelection();
|
this.reconcileLanguageSelection();
|
||||||
this.renderLanguageFilter();
|
this.renderLanguageFilter();
|
||||||
@ -1081,6 +1166,7 @@ class ChannelList {
|
|||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.projectedLogicalChannels = null;
|
this.projectedLogicalChannels = null;
|
||||||
|
this.projectionPage = null;
|
||||||
console.warn('[ChannelList] Live projection unavailable; using legacy loading:', err.message);
|
console.warn('[ChannelList] Live projection unavailable; using legacy loading:', err.message);
|
||||||
this.projectionNotice = '';
|
this.projectionNotice = '';
|
||||||
return false;
|
return false;
|
||||||
@ -1117,7 +1203,8 @@ class ChannelList {
|
|||||||
sessionStorage.setItem(this.languageStorageKey(), JSON.stringify([...this.selectedLanguages]));
|
sessionStorage.setItem(this.languageStorageKey(), JSON.stringify([...this.selectedLanguages]));
|
||||||
} catch {}
|
} catch {}
|
||||||
this.renderLanguageFilter();
|
this.renderLanguageFilter();
|
||||||
if (this.projectedLogicalChannels || this.channels.length) this.render();
|
if (this.projectionPage) this.loadChannels();
|
||||||
|
else if (this.projectedLogicalChannels || this.channels.length) this.render();
|
||||||
else this.loadChannels();
|
else this.loadChannels();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -107,7 +107,12 @@ class SettingsPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
initLiveAnalysis() {
|
initLiveAnalysis() {
|
||||||
document.getElementById('live-analysis-button')?.addEventListener('click', () => void this.runLiveAnalysis());
|
document.getElementById('live-check-new-button')?.addEventListener('click', () => void this.runLiveAnalysis('check-new'));
|
||||||
|
document.getElementById('live-rebuild-catalog-button')?.addEventListener('click', () => {
|
||||||
|
if (confirm('Rebuild the entire channel catalog? This can take a while.')) void this.runLiveAnalysis('rebuild');
|
||||||
|
});
|
||||||
|
// Compatibility with pages cached before the actions were split.
|
||||||
|
document.getElementById('live-analysis-button')?.addEventListener('click', () => void this.runLiveAnalysis('rebuild'));
|
||||||
document.getElementById('live-analysis-details-toggle')?.addEventListener('click', () => {
|
document.getElementById('live-analysis-details-toggle')?.addEventListener('click', () => {
|
||||||
const button = document.getElementById('live-analysis-details-toggle');
|
const button = document.getElementById('live-analysis-details-toggle');
|
||||||
const details = document.getElementById('live-analysis-error-details');
|
const details = document.getElementById('live-analysis-error-details');
|
||||||
@ -131,7 +136,11 @@ class SettingsPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
renderLiveAnalysisStatus(status = {}) {
|
renderLiveAnalysisStatus(status = {}) {
|
||||||
const button = document.getElementById('live-analysis-button');
|
const buttons = [
|
||||||
|
document.getElementById('live-check-new-button'),
|
||||||
|
document.getElementById('live-rebuild-catalog-button'),
|
||||||
|
document.getElementById('live-analysis-button')
|
||||||
|
].filter(Boolean);
|
||||||
const statusElement = document.getElementById('live-analysis-status');
|
const statusElement = document.getElementById('live-analysis-status');
|
||||||
const progress = document.getElementById('live-analysis-progress');
|
const progress = document.getElementById('live-analysis-progress');
|
||||||
const track = document.getElementById('live-analysis-progress-track');
|
const track = document.getElementById('live-analysis-progress-track');
|
||||||
@ -140,11 +149,10 @@ class SettingsPage {
|
|||||||
const fallback = document.getElementById('live-analysis-fallback');
|
const fallback = document.getElementById('live-analysis-fallback');
|
||||||
const detailsButton = document.getElementById('live-analysis-details-toggle');
|
const detailsButton = document.getElementById('live-analysis-details-toggle');
|
||||||
const details = document.getElementById('live-analysis-error-details');
|
const details = document.getElementById('live-analysis-error-details');
|
||||||
if (!button || !statusElement) return;
|
if (!buttons.length || !statusElement) return;
|
||||||
|
|
||||||
const presentation = LiveAnalysisPresentation.present(status);
|
const presentation = LiveAnalysisPresentation.present(status);
|
||||||
button.textContent = presentation.buttonLabel;
|
buttons.forEach(button => { button.disabled = presentation.busy; });
|
||||||
button.disabled = presentation.busy;
|
|
||||||
statusElement.textContent = presentation.statusLabel;
|
statusElement.textContent = presentation.statusLabel;
|
||||||
progress.hidden = !presentation.busy;
|
progress.hidden = !presentation.busy;
|
||||||
track.classList.toggle('indeterminate', presentation.progressIndeterminate);
|
track.classList.toggle('indeterminate', presentation.progressIndeterminate);
|
||||||
@ -169,14 +177,19 @@ class SettingsPage {
|
|||||||
details.textContent = presentation.errorDetail;
|
details.textContent = presentation.errorDetail;
|
||||||
}
|
}
|
||||||
|
|
||||||
async runLiveAnalysis() {
|
async runLiveAnalysis(action = 'rebuild') {
|
||||||
const button = document.getElementById('live-analysis-button');
|
const buttons = ['live-check-new-button', 'live-rebuild-catalog-button', 'live-analysis-button']
|
||||||
if (button?.disabled) return;
|
.map(id => document.getElementById(id)).filter(Boolean);
|
||||||
|
if (buttons.some(button => button.disabled)) return;
|
||||||
this.liveAnalysisStatus = { ...(this.liveAnalysisStatus || {}), status: 'queued', processed: 0 };
|
this.liveAnalysisStatus = { ...(this.liveAnalysisStatus || {}), status: 'queued', processed: 0 };
|
||||||
this.renderLiveAnalysisStatus(this.liveAnalysisStatus);
|
this.renderLiveAnalysisStatus(this.liveAnalysisStatus);
|
||||||
try {
|
try {
|
||||||
const response = await API.live.rebuildProjection();
|
const response = action === 'check-new'
|
||||||
this.liveAnalysisStatus = response.projection || response;
|
? await API.live.checkNewStreams()
|
||||||
|
: await API.live.rebuildProjection();
|
||||||
|
this.liveAnalysisStatus = response.projection || (response.started
|
||||||
|
? { status: 'queued', processed: 0 }
|
||||||
|
: response);
|
||||||
this.renderLiveAnalysisStatus(this.liveAnalysisStatus);
|
this.renderLiveAnalysisStatus(this.liveAnalysisStatus);
|
||||||
this.startLiveAnalysisPolling();
|
this.startLiveAnalysisPolling();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@ -36,6 +36,8 @@ router.get('/channels', requireAuth, async (req, res) => {
|
|||||||
const includeHidden = req.query.includeHidden === 'true';
|
const includeHidden = req.query.includeHidden === 'true';
|
||||||
const q = String(req.query.q || '').slice(0, 200);
|
const q = String(req.query.q || '').slice(0, 200);
|
||||||
const languages = String(req.query.languages || '').split(',').map(value => value.trim().toLowerCase()).filter(Boolean);
|
const languages = String(req.query.languages || '').split(',').map(value => value.trim().toLowerCase()).filter(Boolean);
|
||||||
|
const page = Number.parseInt(req.query.page, 10) || 1;
|
||||||
|
const limit = Number.parseInt(req.query.limit, 10) || 100;
|
||||||
if (languages.some(code => !/^(?:[a-z]{2,3}|und)$/.test(code))) {
|
if (languages.some(code => !/^(?:[a-z]{2,3}|und)$/.test(code))) {
|
||||||
return res.status(400).json({ error: 'languages must be comma-separated language codes' });
|
return res.status(400).json({ error: 'languages must be comma-separated language codes' });
|
||||||
}
|
}
|
||||||
@ -46,10 +48,10 @@ router.get('/channels', requireAuth, async (req, res) => {
|
|||||||
state = await liveProjection.startRebuild(null, { mode: 'algo' }).promise;
|
state = await liveProjection.startRebuild(null, { mode: 'algo' }).promise;
|
||||||
}
|
}
|
||||||
const epgSources = await sources.getByType('epg');
|
const epgSources = await sources.getByType('epg');
|
||||||
const { channels, availableLanguages } = liveProjection.getChannelPage({
|
const result = liveProjection.getChannelPage({
|
||||||
sourceId, includeHidden, userId: req.user.id, epgSources, q, languages
|
sourceId, includeHidden, userId: req.user.id, epgSources, q, languages, page, limit
|
||||||
});
|
});
|
||||||
return res.json({ projection: serializeState(state), filters: { availableLanguages }, channels });
|
return res.json({ projection: serializeState(state), filters: { availableLanguages: result.availableLanguages }, channels: result.channels, page: result.page });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[LiveProjection] Failed to serve projected channels:', error.message);
|
console.error('[LiveProjection] Failed to serve projected channels:', error.message);
|
||||||
const state = liveProjection.projectionState();
|
const state = liveProjection.projectionState();
|
||||||
@ -61,6 +63,24 @@ router.get('/channels', requireAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get('/channels/:logicalChannelId/variants', requireAuth, (req, res) => {
|
||||||
|
try {
|
||||||
|
const page = Number.parseInt(req.query.page, 10) || 1;
|
||||||
|
const limit = Number.parseInt(req.query.limit, 10) || 100;
|
||||||
|
const includeHidden = req.query.includeHidden === 'true';
|
||||||
|
return res.json(liveProjection.getChannelVariants({
|
||||||
|
logicalChannelId: req.params.logicalChannelId,
|
||||||
|
includeHidden,
|
||||||
|
userId: req.user.id,
|
||||||
|
page,
|
||||||
|
limit
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[LiveProjection] Failed to serve channel variants:', error.message);
|
||||||
|
return res.status(500).json({ error: 'Unable to read channel variants' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.get('/projection/status', requireAuth, (req, res) => {
|
router.get('/projection/status', requireAuth, (req, res) => {
|
||||||
try {
|
try {
|
||||||
return res.json(serializeState(liveProjection.projectionState()));
|
return res.json(serializeState(liveProjection.projectionState()));
|
||||||
@ -85,6 +105,17 @@ router.post('/projection/rebuild', requireAuth, requireAdmin, (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.post('/projection/check-new', requireAuth, requireAdmin, (req, res) => {
|
||||||
|
try {
|
||||||
|
const check = liveProjection.checkNewStreams();
|
||||||
|
check.catch(error => console.error('[LiveProjection] Incremental check failed:', error.message));
|
||||||
|
return res.status(202).json({ started: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[LiveProjection] Unable to start incremental check:', error.message);
|
||||||
|
return res.status(500).json({ error: 'Unable to start incremental Live analysis' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.post('/projection/standards/refresh', requireAuth, requireAdmin, async (req, res) => {
|
router.post('/projection/standards/refresh', requireAuth, requireAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const result = await iptvOrgCatalog.refresh(getDb());
|
const result = await iptvOrgCatalog.refresh(getDb());
|
||||||
|
|||||||
@ -6,6 +6,8 @@ const iptvOrgCatalog = require('./iptvOrgCatalogService');
|
|||||||
const epgMatching = require('./epgMatchingService');
|
const epgMatching = require('./epgMatchingService');
|
||||||
|
|
||||||
const MAX_SYNCHRONOUS_ENRICHMENTS = 250;
|
const MAX_SYNCHRONOUS_ENRICHMENTS = 250;
|
||||||
|
const DEFAULT_PAGE_LIMIT = 100;
|
||||||
|
const MAX_PAGE_LIMIT = 200;
|
||||||
|
|
||||||
const QUALITY_TOKENS = new Set([
|
const QUALITY_TOKENS = new Set([
|
||||||
'4k', 'uhd', 'fhd', 'fullhd', 'hd', 'sd', 'hevc', 'h265', 'h264',
|
'4k', 'uhd', 'fhd', 'fullhd', 'hd', 'sd', 'hevc', 'h265', 'h264',
|
||||||
@ -395,7 +397,45 @@ function parseData(value) {
|
|||||||
try { return value ? JSON.parse(value) : {}; } catch { return {}; }
|
try { return value ? JSON.parse(value) : {}; } catch { return {}; }
|
||||||
}
|
}
|
||||||
|
|
||||||
function getChannelPage({ sourceId = null, includeHidden = false, userId = null, q = '', languages = [], epgSources = [] }, database = null, dependencies = {}) {
|
function pageBounds(page = 1, limit = DEFAULT_PAGE_LIMIT) {
|
||||||
|
const normalizedPage = Math.max(1, Number.parseInt(page, 10) || 1);
|
||||||
|
const normalizedLimit = Math.min(MAX_PAGE_LIMIT, Math.max(1, Number.parseInt(limit, 10) || DEFAULT_PAGE_LIMIT));
|
||||||
|
return { page: normalizedPage, limit: normalizedLimit, offset: (normalizedPage - 1) * normalizedLimit };
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageMetadata(total, bounds) {
|
||||||
|
const nextOffset = bounds.offset + bounds.limit;
|
||||||
|
const hasMore = nextOffset < total;
|
||||||
|
return {
|
||||||
|
page: bounds.page,
|
||||||
|
limit: bounds.limit,
|
||||||
|
offset: bounds.offset,
|
||||||
|
total,
|
||||||
|
hasMore,
|
||||||
|
nextOffset: hasMore ? nextOffset : null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function listChannel(channel) {
|
||||||
|
const preferredVariant = channel.preferredVariant || channel.variants[0] || null;
|
||||||
|
const variants = [];
|
||||||
|
const addPreview = variant => {
|
||||||
|
if (variant && variants.length < 3 && !variants.some(candidate =>
|
||||||
|
candidate.id === variant.id && candidate.sourceId === variant.sourceId
|
||||||
|
)) variants.push(variant);
|
||||||
|
};
|
||||||
|
addPreview(preferredVariant);
|
||||||
|
channel.variants.filter(variant => variant.isFavorite).forEach(addPreview);
|
||||||
|
channel.variants.filter(variant => !variant.isHidden).forEach(addPreview);
|
||||||
|
return {
|
||||||
|
...channel,
|
||||||
|
preferredVariant,
|
||||||
|
variantCount: channel.variants.length,
|
||||||
|
variants
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getChannelPage({ sourceId = null, includeHidden = false, userId = null, q = '', languages = [], epgSources = [], page = 1, limit = DEFAULT_PAGE_LIMIT }, database = null, dependencies = {}) {
|
||||||
database ||= defaultDatabase();
|
database ||= defaultDatabase();
|
||||||
const standardCatalog = dependencies.iptvOrgCatalog || iptvOrgCatalog;
|
const standardCatalog = dependencies.iptvOrgCatalog || iptvOrgCatalog;
|
||||||
const epgMatcher = dependencies.epgMatching || epgMatching;
|
const epgMatcher = dependencies.epgMatching || epgMatching;
|
||||||
@ -431,11 +471,13 @@ function getChannelPage({ sourceId = null, includeHidden = false, userId = null,
|
|||||||
const filtered = serialized.filter(channel =>
|
const filtered = serialized.filter(channel =>
|
||||||
(!languages.length || languages.includes(channel.language.code)) && matchesSearch(channel, q)
|
(!languages.length || languages.includes(channel.language.code)) && matchesSearch(channel, q)
|
||||||
);
|
);
|
||||||
|
const bounds = pageBounds(page, limit);
|
||||||
|
const paged = filtered.slice(bounds.offset, bounds.offset + bounds.limit);
|
||||||
// Catalog loading must stay bounded: both enrichers perform synchronous SQLite work,
|
// Catalog loading must stay bounded: both enrichers perform synchronous SQLite work,
|
||||||
// and EPG matching is especially expensive against large XMLTV catalogs. Enrich only
|
// and EPG matching is especially expensive against large XMLTV catalogs. Enrich only
|
||||||
// a genuinely targeted, bounded search; base projection data remains usable otherwise.
|
// a genuinely targeted, bounded search; base projection data remains usable otherwise.
|
||||||
const shouldEnrich = Boolean(String(q).trim()) && filtered.length <= MAX_SYNCHRONOUS_ENRICHMENTS;
|
const shouldEnrich = Boolean(String(q).trim()) && filtered.length <= MAX_SYNCHRONOUS_ENRICHMENTS;
|
||||||
const channels = !shouldEnrich ? filtered : filtered.map(channel => {
|
const enriched = !shouldEnrich ? paged : paged.map(channel => {
|
||||||
const match = standardCatalog.findMatch(database, channel);
|
const match = standardCatalog.findMatch(database, channel);
|
||||||
if (match) {
|
if (match) {
|
||||||
channel.standardName = match.name;
|
channel.standardName = match.name;
|
||||||
@ -445,7 +487,37 @@ function getChannelPage({ sourceId = null, includeHidden = false, userId = null,
|
|||||||
if (epgMatch) channel.epg = { ...channel.epg, ...epgMatch, language: channel.language.code };
|
if (epgMatch) channel.epg = { ...channel.epg, ...epgMatch, language: channel.language.code };
|
||||||
return channel;
|
return channel;
|
||||||
});
|
});
|
||||||
return { channels, availableLanguages };
|
return {
|
||||||
|
channels: enriched.map(listChannel),
|
||||||
|
availableLanguages,
|
||||||
|
page: pageMetadata(filtered.length, bounds)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getChannelVariants({ logicalChannelId, includeHidden = false, userId = null, page = 1, limit = DEFAULT_PAGE_LIMIT }, database = null) {
|
||||||
|
database ||= defaultDatabase();
|
||||||
|
const rows = database.prepare(`
|
||||||
|
SELECT p.*, CASE WHEN f.id IS NULL THEN 0 ELSE 1 END AS user_favorite,
|
||||||
|
l.display_name, l.preferred_variant_id, c.name AS category_name,
|
||||||
|
COALESCE(c.is_hidden, 0) AS category_hidden
|
||||||
|
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' AND p.logical_channel_id = ?
|
||||||
|
ORDER BY p.variant_rank, p.id
|
||||||
|
`).all(userId ?? -1, logicalChannelId);
|
||||||
|
const variants = serializeChannels(rows, includeHidden).flatMap(channel => channel.variants);
|
||||||
|
const bounds = pageBounds(page, limit);
|
||||||
|
return {
|
||||||
|
variants: variants.slice(bounds.offset, bounds.offset + bounds.limit),
|
||||||
|
page: pageMetadata(variants.length, bounds)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkNewStreams() {
|
||||||
|
return require('./syncService').syncAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
function getChannels(options, database = null) {
|
function getChannels(options, database = null) {
|
||||||
@ -527,7 +599,9 @@ module.exports = {
|
|||||||
collectIdentityHints,
|
collectIdentityHints,
|
||||||
collectOptionalIdentityHints,
|
collectOptionalIdentityHints,
|
||||||
displayNameForFingerprint,
|
displayNameForFingerprint,
|
||||||
|
checkNewStreams,
|
||||||
getChannelPage,
|
getChannelPage,
|
||||||
|
getChannelVariants,
|
||||||
getChannels,
|
getChannels,
|
||||||
identityCandidatePairs,
|
identityCandidatePairs,
|
||||||
normalizeChannelName,
|
normalizeChannelName,
|
||||||
|
|||||||
@ -256,6 +256,7 @@ test('user analysis click reloads persisted languages without a browser-side Lay
|
|||||||
window.document.getElementById('live-analysis-button').click();
|
window.document.getElementById('live-analysis-button').click();
|
||||||
assert.equal(rebuildClicked, 1);
|
assert.equal(rebuildClicked, 1);
|
||||||
await new Promise(resolve => setTimeout(resolve, 1100));
|
await new Promise(resolve => setTimeout(resolve, 1100));
|
||||||
|
channelList.expandedVariants.add('sports one');
|
||||||
channelList.expandAll();
|
channelList.expandAll();
|
||||||
window.clearInterval(channelList._epgRefreshTimer);
|
window.clearInterval(channelList._epgRefreshTimer);
|
||||||
window.clearInterval(settingsPage.liveAnalysisPollTimer);
|
window.clearInterval(settingsPage.liveAnalysisPollTimer);
|
||||||
@ -441,6 +442,7 @@ test('usable Laya language labels do not leave visible variants as unknown', asy
|
|||||||
|
|
||||||
await channelList.loadChannels();
|
await channelList.loadChannels();
|
||||||
await channelList.refreshLayaLanguages('laya');
|
await channelList.refreshLayaLanguages('laya');
|
||||||
|
channelList.expandedVariants.add('sports one');
|
||||||
channelList.expandAll();
|
channelList.expandAll();
|
||||||
|
|
||||||
const badges = [...window.document.querySelectorAll('.variant-language')].map(item => item.textContent);
|
const badges = [...window.document.querySelectorAll('.variant-language')].map(item => item.textContent);
|
||||||
|
|||||||
@ -19,6 +19,23 @@ test('projection rebuild route requests a complete analysis', () => {
|
|||||||
assert.match(routeSource, /liveProjection\.startRebuild\(null, \{ analyzeAll: true \}\)/);
|
assert.match(routeSource, /liveProjection\.startRebuild\(null, \{ analyzeAll: true \}\)/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('live channels route accepts pagination and forwards page bounds to the projection service', () => {
|
||||||
|
assert.ok(/req\.query\.page/.test(routeSource), 'GET /channels should parse req.query.page');
|
||||||
|
assert.ok(/req\.query\.limit/.test(routeSource), 'GET /channels should parse req.query.limit');
|
||||||
|
assert.ok(/getChannelPage\(\{[^}]*page[^}]*limit/s.test(routeSource), 'GET /channels should forward page and limit to getChannelPage');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('live variants route is paginated and authenticated', () => {
|
||||||
|
assert.ok(/router\.get\('\/channels\/:logicalChannelId\/variants', requireAuth,/.test(routeSource), 'GET /channels/:logicalChannelId/variants should exist and require auth');
|
||||||
|
assert.ok(/getChannelVariants\(\{[^}]*logicalChannelId[^}]*page[^}]*limit/s.test(routeSource), 'variants route should call getChannelVariants with page and limit');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('projection check-new route exists separately from complete rebuild', () => {
|
||||||
|
assert.ok(/router\.post\('\/projection\/check-new', requireAuth, requireAdmin,/.test(routeSource), 'POST /projection/check-new should exist and require admin');
|
||||||
|
assert.ok(/liveProjection\.(?:checkNewStreams|startIncrementalAnalysis|analyzeNewStreams)/.test(routeSource), 'check-new route should call an incremental analysis helper');
|
||||||
|
assert.ok(!/\/projection\/check-new'[\s\S]*analyzeAll: true/.test(routeSource), 'check-new route must not request analyzeAll');
|
||||||
|
});
|
||||||
|
|
||||||
test('projection authorization distinguishes viewer and admin roles', () => {
|
test('projection authorization distinguishes viewer and admin roles', () => {
|
||||||
assert.equal(isAdmin({ role: 'viewer' }), false);
|
assert.equal(isAdmin({ role: 'viewer' }), false);
|
||||||
assert.equal(isAdmin({ role: 'admin' }), true);
|
assert.equal(isAdmin({ role: 'admin' }), true);
|
||||||
|
|||||||
@ -327,7 +327,8 @@ test('full catalog listing skips synchronous per-channel enrichment', () => {
|
|||||||
epgMatching: { findBestMatch: () => { epgCalls += 1; return null; } }
|
epgMatching: { findBestMatch: () => { epgCalls += 1; return null; } }
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(page.channels.length, 500);
|
assert.equal(page.channels.length, 100);
|
||||||
|
assert.deepEqual(page.page, { page: 1, limit: 100, offset: 0, total: 500, hasMore: true, nextOffset: 100 });
|
||||||
assert.equal(standardCalls, 0);
|
assert.equal(standardCalls, 0);
|
||||||
assert.equal(epgCalls, 0);
|
assert.equal(epgCalls, 0);
|
||||||
assert.ok(Date.now() - started < 1000);
|
assert.ok(Date.now() - started < 1000);
|
||||||
@ -342,6 +343,8 @@ test('broad searches above the enrichment guard remain bounded', () => {
|
|||||||
iptvOrgCatalog: { findMatch: () => { calls += 1; return null; } },
|
iptvOrgCatalog: { findMatch: () => { calls += 1; return null; } },
|
||||||
epgMatching: { findBestMatch: () => { calls += 1; return null; } }
|
epgMatching: { findBestMatch: () => { calls += 1; return null; } }
|
||||||
});
|
});
|
||||||
assert.equal(page.channels.length, 300);
|
assert.equal(page.channels.length, 100);
|
||||||
|
assert.equal(page.page.total, 300);
|
||||||
|
assert.equal(page.page.hasMore, true);
|
||||||
assert.equal(calls, 0);
|
assert.equal(calls, 0);
|
||||||
});
|
});
|
||||||
|
|||||||
125
test/t37LiveListPerformanceRegression.test.js
Normal file
125
test/t37LiveListPerformanceRegression.test.js
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const Database = require('better-sqlite3');
|
||||||
|
const liveProjection = require('../server/services/liveProjectionService');
|
||||||
|
const {
|
||||||
|
ensureColumns,
|
||||||
|
LIVE_PROJECTION_COLUMNS,
|
||||||
|
LIVE_PROJECTION_SCHEMA_SQL
|
||||||
|
} = require('../server/db/liveProjectionSchema');
|
||||||
|
|
||||||
|
function createProjectionDb() {
|
||||||
|
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 seedProjectedChannel(db, index) {
|
||||||
|
const logicalId = `logical:${index}`;
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO logical_channels
|
||||||
|
(id, fingerprint, display_name, preferred_variant_id, variant_count, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, 1, 1, 1)
|
||||||
|
`).run(logicalId, `channel ${index}`, `Channel ${index}`, `1:${index}`);
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO playlist_items
|
||||||
|
(id, source_id, item_id, type, name, category_id, stream_icon, stream_url, data,
|
||||||
|
logical_channel_id, grouping_fingerprint, grouping_input_hash, quality_label, quality_rank,
|
||||||
|
variant_rank, language_code, language_confidence, language_detector, language_input_hash)
|
||||||
|
VALUES (?, 1, ?, 'live', ?, 'general', '', ?, '{}', ?, ?, ?, 'HD', 2, 0, 'en', 1, 'laya', ?)
|
||||||
|
`).run(
|
||||||
|
`1:${index}`,
|
||||||
|
String(index),
|
||||||
|
`Channel ${index}`,
|
||||||
|
`https://streams.test/${index}.m3u8`,
|
||||||
|
logicalId,
|
||||||
|
`channel ${index}`,
|
||||||
|
`group-hash-${index}`,
|
||||||
|
`language-hash-${index}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('/api/live/channels projection response is bounded for an oversized catalog', () => {
|
||||||
|
const db = createProjectionDb();
|
||||||
|
for (let index = 0; index < 1500; index++) seedProjectedChannel(db, index);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = liveProjection.getChannelPage({ includeHidden: false, userId: 7 }, db, {
|
||||||
|
iptvOrgCatalog: { findMatch: () => null },
|
||||||
|
epgMatching: { findBestMatch: () => null }
|
||||||
|
});
|
||||||
|
assert.ok(result.channels.length <= 250, `unbounded live channel response returned ${result.channels.length} entries`);
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('projected live list returns only a bounded variant preview', () => {
|
||||||
|
const db = createProjectionDb();
|
||||||
|
const logicalId = 'logical:heavy';
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO logical_channels
|
||||||
|
(id, fingerprint, display_name, preferred_variant_id, variant_count, created_at, updated_at)
|
||||||
|
VALUES (?, 'heavy channel', 'Heavy Channel', '1:0', 2500, 1, 1)
|
||||||
|
`).run(logicalId);
|
||||||
|
const insert = db.prepare(`
|
||||||
|
INSERT INTO playlist_items
|
||||||
|
(id, source_id, item_id, type, name, category_id, stream_icon, stream_url, data,
|
||||||
|
logical_channel_id, grouping_fingerprint, grouping_input_hash, quality_label, quality_rank,
|
||||||
|
variant_rank, language_code, language_confidence, language_detector, language_input_hash)
|
||||||
|
VALUES (?, 1, ?, 'live', ?, 'general', '', ?, '{}', ?, 'heavy channel', ?, 'HD', 2, ?, 'en', 1, 'laya', ?)
|
||||||
|
`);
|
||||||
|
const insertAll = db.transaction(() => {
|
||||||
|
for (let index = 0; index < 2500; index++) {
|
||||||
|
insert.run(`1:${index}`, String(index), `Heavy Channel variant ${index}`,
|
||||||
|
`https://streams.test/${index}.m3u8`, logicalId, `group-${index}`, index, `language-${index}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
insertAll();
|
||||||
|
try {
|
||||||
|
const result = liveProjection.getChannelPage({ includeHidden: false, userId: 7 }, db);
|
||||||
|
assert.equal(result.channels.length, 1);
|
||||||
|
assert.equal(result.channels[0].variantCount, 2500);
|
||||||
|
assert.ok(result.channels[0].variants.length <= 3);
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
20
test/t37SettingsActions.test.js
Normal file
20
test/t37SettingsActions.test.js
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const root = path.resolve(__dirname, '..');
|
||||||
|
const indexHtml = fs.readFileSync(path.join(root, 'public', 'index.html'), 'utf8');
|
||||||
|
const settingsSource = fs.readFileSync(path.join(root, 'public', 'js', 'pages', 'Settings.js'), 'utf8');
|
||||||
|
|
||||||
|
test('Settings exposes separate live check-new and full rebuild actions', () => {
|
||||||
|
assert.ok(/id="live-check-new-button"[^>]*>\s*Check for new streams\s*</.test(indexHtml), 'Settings should expose a Check for new streams button');
|
||||||
|
assert.ok(/id="live-rebuild-catalog-button"[^>]*>\s*Rebuild entire catalog\s*</.test(indexHtml), 'Settings should expose a Rebuild entire catalog button');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Settings wires check-new and full rebuild buttons to distinct API actions', () => {
|
||||||
|
assert.ok(/getElementById\('live-check-new-button'\).*addEventListener/s.test(settingsSource), 'Settings should bind the check-new button');
|
||||||
|
assert.ok(/getElementById\('live-rebuild-catalog-button'\).*addEventListener/s.test(settingsSource), 'Settings should bind the full rebuild button');
|
||||||
|
assert.ok(/projection\/check-new|checkNewStreams|checkNew/.test(settingsSource), 'Settings should call a check-new API action');
|
||||||
|
assert.ok(/projection\/rebuild|rebuildProjection|runLiveAnalysis/.test(settingsSource), 'Settings should keep a full rebuild API action');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user