Merge feature/live-catalog-pagination-server-search into develop

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-10-22 22:43:35 +02:00
13 changed files with 1280 additions and 387 deletions

View File

@ -4820,3 +4820,40 @@ kbd {
max-width: 70px; max-width: 70px;
} }
} }
/* Keep Live catalogue controls reachable alongside the scrolling list. */
.sidebar-header-row .search-wrapper {
min-width: 0;
}
.live-catalog-status,
.live-catalog-more {
flex-shrink: 0;
margin: var(--space-sm);
}
.live-catalog-status:empty {
display: none;
}
.channel-sidebar.collapsed .live-catalog-status,
.channel-sidebar.collapsed .live-catalog-more {
display: none;
}
.channel-tile-action {
min-height: 120px;
justify-content: center;
color: var(--color-text-primary);
background: var(--color-bg-tertiary);
border: 1px solid var(--color-border);
font: inherit;
}
.channel-tile:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
.channel-tile-action[hidden] {
display: none;
}

View File

@ -127,8 +127,9 @@
<div class="sidebar-header-row"> <div class="sidebar-header-row">
<div class="search-wrapper"> <div class="search-wrapper">
<input type="text" id="channel-search" placeholder="Search channels..." class="search-input"> <input type="text" id="channel-search" placeholder="Search channels..." class="search-input">
<button type="button" class="search-clear" title="Clear search">&times;</button> <button type="button" class="search-clear" title="Clear search" aria-label="Clear search">&times;</button>
</div> </div>
<button type="button" id="channel-search-submit" class="btn btn-sm">Search</button>
<button id="sidebar-collapse-btn" class="btn btn-sm btn-ghost sidebar-collapse-btn" <button id="sidebar-collapse-btn" class="btn btn-sm btn-ghost sidebar-collapse-btn"
title="Collapse sidebar"> title="Collapse sidebar">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon">

View File

@ -6,9 +6,10 @@ const API = {
/** /**
* Make API request * Make API request
*/ */
async request(method, endpoint, data = null) { async request(method, endpoint, data = null, signal = undefined) {
const options = { const options = {
method, method,
signal,
credentials: 'same-origin', credentials: 'same-origin',
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
@ -40,6 +41,10 @@ const API = {
throw new Error(result.error || `Server responded with ${response.status}`); throw new Error(result.error || `Server responded with ${response.status}`);
} }
if (method !== 'GET' && /^\/(sources|channels)(\/|$)/.test(endpoint)) {
API.liveCatalogRevision = (API.liveCatalogRevision || 0) + 1;
if (window.app?.channelList) window.app.channelList.guideCatalog = null;
}
return result; return result;
}, },
@ -61,6 +66,13 @@ const API = {
// Channels (hidden items) // Channels (hidden items)
channels: { channels: {
liveCatalog: (params = {}, signal) => {
const query = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') query.set(key, value);
});
return API.request('GET', `/channels/live-catalog?${query}`, null, signal);
},
getHidden: (sourceId = null) => API.request('GET', `/channels/hidden${sourceId ? `?sourceId=${sourceId}` : ''}`), getHidden: (sourceId = null) => API.request('GET', `/channels/hidden${sourceId ? `?sourceId=${sourceId}` : ''}`),
hide: (sourceId, itemType, itemId) => API.request('POST', '/channels/hide', { sourceId, itemType, itemId }), hide: (sourceId, itemType, itemId) => API.request('POST', '/channels/hide', { sourceId, itemType, itemId }),
show: (sourceId, itemType, itemId) => API.request('POST', '/channels/show', { sourceId, itemType, itemId }), show: (sourceId, itemType, itemId) => API.request('POST', '/channels/show', { sourceId, itemType, itemId }),

View File

@ -130,6 +130,7 @@ class App {
// Search clear buttons (global handler for all) // Search clear buttons (global handler for all)
document.querySelectorAll('.search-clear').forEach(btn => { document.querySelectorAll('.search-clear').forEach(btn => {
if (btn.parentElement.querySelector('#channel-search')) return;
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
const wrapper = btn.closest('.search-wrapper'); const wrapper = btn.closest('.search-wrapper');
const input = wrapper?.querySelector('.search-input'); const input = wrapper?.querySelector('.search-input');

View File

@ -23,6 +23,20 @@ class ChannelList {
this.sources = []; this.sources = [];
this.isLoading = false; this.isLoading = false;
this.renderedChannels = []; this.renderedChannels = [];
this.catalogVersion = 0;
this.hasMore = false;
this.catalogQuery = '';
this.catalogStatus = document.createElement('div');
this.catalogStatus.setAttribute('role', 'status');
this.catalogStatus.className = 'live-catalog-status';
this.container.after(this.catalogStatus);
this.loadMoreButton = document.createElement('button');
this.loadMoreButton.type = 'button';
this.loadMoreButton.className = 'btn btn-sm live-catalog-more';
this.loadMoreButton.textContent = 'Load more';
this.loadMoreButton.hidden = true;
this.catalogStatus.after(this.loadMoreButton);
this.loadMoreButton.addEventListener('click', () => this.loadCatalogPage(this.channels.length > 0));
this.loadCollapsedState(); this.loadCollapsedState();
this.init(); this.init();
@ -145,21 +159,28 @@ class ChannelList {
init() { init() {
// Search handler (debounced) // Search handler (debounced)
let searchTimeout; const searchNow = () => {
clearTimeout(this.searchTimeout);
this.loadChannels();
};
this.searchInput.addEventListener('input', () => { this.searchInput.addEventListener('input', () => {
clearTimeout(searchTimeout); clearTimeout(this.searchTimeout);
searchTimeout = setTimeout(() => { this.catalogVersion++;
this.render(); this.catalogController?.abort();
}, 300); this.loadMoreButton.hidden = true;
this.searchTimeout = setTimeout(searchNow, 300);
}); });
this.searchInput.addEventListener('keydown', e => {
// Source filter handler if (e.key === 'Enter') { e.preventDefault(); searchNow(); }
this.sourceSelect.addEventListener('change', () => this.loadChannels()); });
document.getElementById('channel-search-submit')?.addEventListener('click', searchNow);
// Show hidden toggle this.searchInput.parentElement.querySelector('.search-clear')?.addEventListener('click', () => {
if (this.showHiddenCheckbox) { this.searchInput.value = '';
this.showHiddenCheckbox.addEventListener('change', () => this.render()); searchNow();
} this.searchInput.focus();
});
this.sourceSelect.addEventListener('change', searchNow);
this.showHiddenCheckbox?.addEventListener('change', searchNow);
// Context menu handlers // Context menu handlers
document.addEventListener('click', (e) => { document.addEventListener('click', (e) => {
@ -235,7 +256,6 @@ class ChannelList {
}); });
} }
// ... (loadSources, loadChannels, loadAllChannels, loadXtreamChannels, loadM3uChannels, loadHiddenItems, isHidden, loadFavorites, isFavorite, toggleFavorite methods remain same)
/** /**
* Get current program info string - cached for performance * Get current program info string - cached for performance
@ -281,7 +301,7 @@ class ChannelList {
escapeHtml(text) { escapeHtml(text) {
if (!text) return ''; if (!text) return '';
return text return String(text)
.replace(/&/g, "&amp;") .replace(/&/g, "&amp;")
.replace(/</g, "&lt;") .replace(/</g, "&lt;")
.replace(/>/g, "&gt;") .replace(/>/g, "&gt;")
@ -289,62 +309,84 @@ class ChannelList {
.replace(/'/g, "&#039;"); .replace(/'/g, "&#039;");
} }
compareBinary(left, right) {
const a = Array.from(String(left ?? ''));
const b = Array.from(String(right ?? ''));
for (let i = 0; i < Math.min(a.length, b.length); i++) {
const difference = a[i].codePointAt(0) - b[i].codePointAt(0);
if (difference) return difference;
}
return a.length - b.length;
}
catalogSortKey(value) {
return String(value ?? '').replace(/^ +| +$/g, '').replace(/[A-Z]/g, character => character.toLowerCase());
}
compareCatalogTitles(a, b) {
return this.compareBinary(this.catalogSortKey(a), this.catalogSortKey(b)) || this.compareBinary(a, b);
}
compareCatalogChannels(a, b) {
return this.compareCatalogTitles(a.name, b.name) || Number(a.sourceId) - Number(b.sourceId) ||
this.compareBinary(a.streamId ?? a.id, b.streamId ?? b.id);
}
/** /**
* Render channel list * Render channel list
*/ */
render() { render() {
const searchTerm = this.searchInput.value.toLowerCase(); const searchTerm = this.catalogQuery;
const showHidden = this.showHiddenCheckbox ? this.showHiddenCheckbox.checked : false; const showHidden = this.showHiddenCheckbox ? this.showHiddenCheckbox.checked : false;
// Reset batching // Reset batching
this.currentBatch = 0; this.currentBatch = 0;
this.batchSize = 100; // Number of groups to render per batch (increased to handle many hidden groups) this.batchSize = 100; // Number of groups to render per batch (increased to handle many hidden groups)
this.observer.disconnect();
this.container.innerHTML = ''; // Clear container this.container.innerHTML = ''; // Clear container
// Filter and Group channels // Filter and Group channels
const groupedChannels = {}; const groupedChannels = Object.create(null);
// 1. Filter // 1. Filter
this.filteredChannels = this.channels; this.filteredChannels = this.channels;
if (searchTerm) {
this.filteredChannels = this.channels.filter(ch =>
String(ch.name ?? "").toLowerCase().includes(searchTerm) ||
String(ch.groupTitle ?? "").toLowerCase().includes(searchTerm)
);
}
let filteredChannels = this.filteredChannels; let filteredChannels = this.filteredChannels;
// 2. Group // 2. Group
filteredChannels.forEach(ch => { filteredChannels.forEach(ch => {
const groupKey = ch.groupTitle || 'Uncategorized'; const groupKey = ch.groupTitle ?? 'Uncategorized';
if (!groupedChannels[groupKey]) { if (!groupedChannels[groupKey]) {
groupedChannels[groupKey] = []; groupedChannels[groupKey] = [];
} }
groupedChannels[groupKey].push(ch); groupedChannels[groupKey].push(ch);
}); });
Object.values(groupedChannels).forEach(channels => channels.sort((a, b) => this.compareCatalogChannels(a, b)));
// Keep the synthetic Favorites group separate even from a provider's "Favorites" group.
this.favoriteGroupKey = '__nodecast_favorites__';
while (Object.hasOwn(groupedChannels, this.favoriteGroupKey)) this.favoriteGroupKey += '_';
// 3. Add Favorites // 3. Add Favorites
const favoritedChannels = this.channels.filter(ch => this.isFavorite(ch.sourceId, ch.id)); const favoritedChannels = this.channels.filter(ch => this.isFavorite(ch.sourceId, ch.id));
if (favoritedChannels.length > 0) { if (favoritedChannels.length > 0) {
favoritedChannels.sort((a, b) => a.name.localeCompare(b.name)); favoritedChannels.sort((a, b) => this.compareCatalogChannels(a, b));
groupedChannels['Favorites'] = favoritedChannels; groupedChannels[this.favoriteGroupKey] = favoritedChannels;
} }
// 4. Sort Groups and filter to only those with visible channels // 4. Sort Groups and filter to only those with visible channels
const allGroups = Object.keys(groupedChannels).sort((a, b) => { const allGroups = Object.keys(groupedChannels).sort((a, b) => {
if (a === 'Favorites') return -1; if (a === this.favoriteGroupKey) return -1;
if (b === 'Favorites') return 1; if (b === this.favoriteGroupKey) return 1;
return a.localeCompare(b); return this.compareCatalogTitles(a, b);
}); });
// Pre-filter to only include groups with visible channels (so hidden groups don't consume batch slots) // Pre-filter to only include groups with visible channels (so hidden groups don't consume batch slots)
this.sortedGroups = allGroups.filter(groupName => { this.sortedGroups = allGroups.filter(groupName => {
if (groupName === 'Favorites') return true; if (groupName === this.favoriteGroupKey) return true;
const channels = groupedChannels[groupName]; const channels = groupedChannels[groupName];
// Check if any channel in this group is visible // Check if any channel in this group is visible
return channels.some(channel => { return channels.some(channel => {
const rawChannelId = channel.streamId || channel.id; const rawChannelId = channel.streamId ?? channel.id;
const isHidden = this.isHidden('channel', channel.sourceId, rawChannelId); const isHidden = this.isHidden('channel', channel.sourceId, rawChannelId);
return !isHidden || showHidden; return !isHidden || showHidden;
}); });
@ -357,7 +399,7 @@ class ChannelList {
// This prevents rendering 100K+ channel items on initial load // This prevents rendering 100K+ channel items on initial load
if (!this._hasCollapsedState && this.sortedGroups.length > 0) { if (!this._hasCollapsedState && this.sortedGroups.length > 0) {
this.sortedGroups.forEach(groupName => { this.sortedGroups.forEach(groupName => {
if (groupName !== 'Favorites') { if (groupName !== this.favoriteGroupKey) {
this.collapsedGroups.add(groupName); this.collapsedGroups.add(groupName);
} }
}); });
@ -369,11 +411,11 @@ class ChannelList {
this.renderedChannels = []; this.renderedChannels = [];
this.sortedGroups.forEach(groupName => { this.sortedGroups.forEach(groupName => {
const channels = this.groupedChannels[groupName]; const channels = this.groupedChannels[groupName];
const isFavoritesGroup = groupName === 'Favorites'; const isFavoritesGroup = groupName === this.favoriteGroupKey;
const visibleChannels = channels.filter(channel => { const visibleChannels = channels.filter(channel => {
if (isFavoritesGroup) return true; if (isFavoritesGroup) return true;
const rawChannelId = channel.streamId || channel.id; const rawChannelId = channel.streamId ?? channel.id;
const channelHidden = this.isHidden('channel', channel.sourceId, rawChannelId); const channelHidden = this.isHidden('channel', channel.sourceId, rawChannelId);
return !channelHidden || this.showHidden; return !channelHidden || this.showHidden;
}); });
@ -384,7 +426,7 @@ class ChannelList {
// ensuring no side effects on the main channel object // ensuring no side effects on the main channel object
const renderedCh = { const renderedCh = {
...ch, ...ch,
_renderId: `rid_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, _renderId: `rid_${encodeURIComponent(groupName)}_${ch.sourceId}_${encodeURIComponent(ch.id)}`,
_renderGroup: groupName // Track visual group for navigation _renderGroup: groupName // Track visual group for navigation
}; };
this.renderedChannels.push(renderedCh); this.renderedChannels.push(renderedCh);
@ -443,18 +485,18 @@ class ChannelList {
this.loader.style.opacity = '1'; this.loader.style.opacity = '1';
let html = ''; let html = '';
let renderIndex = start; // Keep track of global index for mapping to renderedChannels let renderIndex = this.sortedGroups.slice(0, start).reduce((count, name) => count + this.renderedChannels.filter(c => c._renderGroup === name).length, 0); // Keep track of global index for mapping to renderedChannels
for (const groupName of groupsToRender) { for (const groupName of groupsToRender) {
const channels = this.groupedChannels[groupName]; const channels = this.groupedChannels[groupName];
if (channels.length === 0) continue; if (channels.length === 0) continue;
const isFavoritesGroup = groupName === 'Favorites'; const isFavoritesGroup = groupName === this.favoriteGroupKey;
// Pre-filter visible channels for this group // Pre-filter visible channels for this group
const visibleChannels = channels.filter(channel => { const visibleChannels = channels.filter(channel => {
if (isFavoritesGroup) return true; if (isFavoritesGroup) return true;
const rawChannelId = channel.streamId || channel.id; const rawChannelId = channel.streamId ?? channel.id;
const channelHidden = this.isHidden('channel', channel.sourceId, rawChannelId); const channelHidden = this.isHidden('channel', channel.sourceId, rawChannelId);
return !channelHidden || this.showHidden; return !channelHidden || this.showHidden;
}); });
@ -470,9 +512,9 @@ class ChannelList {
html += ` html += `
<div class="channel-group"> <div class="channel-group">
<div class="group-header ${this.collapsedGroups.has(groupName) ? 'collapsed' : ''} ${isFavoritesGroup ? 'favorites-group' : ''}" data-group="${groupName}"> <div class="group-header ${this.collapsedGroups.has(groupName) ? 'collapsed' : ''} ${isFavoritesGroup ? 'favorites-group' : ''}" data-group="${this.escapeHtml(groupName)}">
<span class="group-toggle">${Icons.chevronDown}</span> <span class="group-toggle">${Icons.chevronDown}</span>
<span class="group-name">${groupName}</span> <span class="group-name">${this.escapeHtml(isFavoritesGroup ? 'Favorites' : groupName)}</span>
<span class="group-count">${visibleChannels.length}</span> <span class="group-count">${visibleChannels.length}</span>
</div> </div>
<div class="group-channels"> <div class="group-channels">
@ -481,6 +523,7 @@ class ChannelList {
// Skip rendering channel items if group is collapsed (major performance optimization) // Skip rendering channel items if group is collapsed (major performance optimization)
// Channels will be rendered when user expands the group // Channels will be rendered when user expands the group
if (this.collapsedGroups.has(groupName)) { if (this.collapsedGroups.has(groupName)) {
renderIndex += visibleChannels.length;
html += '</div></div>'; html += '</div></div>';
continue; continue;
} }
@ -488,7 +531,7 @@ class ChannelList {
for (const channel of visibleChannels) { for (const channel of visibleChannels) {
// Check hidden again for styling (showHidden mode) // Check hidden again for styling (showHidden mode)
const rawChannelId = channel.streamId || channel.id; const rawChannelId = channel.streamId ?? channel.id;
const channelHidden = !isFavoritesGroup && this.isHidden('channel', channel.sourceId, rawChannelId); const channelHidden = !isFavoritesGroup && this.isHidden('channel', channel.sourceId, rawChannelId);
const isActive = this.currentChannel?.id === channel.id; const isActive = this.currentChannel?.id === channel.id;
@ -502,13 +545,13 @@ class ChannelList {
html += ` html += `
<div class="channel-item ${isActive ? 'active' : ''} ${isRenderActive ? 'nav-active' : ''} ${channelHidden ? 'hidden' : ''}" <div class="channel-item ${isActive ? 'active' : ''} ${isRenderActive ? 'nav-active' : ''} ${channelHidden ? 'hidden' : ''}"
data-channel-id="${channel.id}" data-channel-id="${this.escapeHtml(String(channel.id))}"
data-source-id="${channel.sourceId}" data-source-id="${channel.sourceId}"
data-source-type="${channel.sourceType}" data-source-type="${channel.sourceType}"
data-stream-id="${channel.streamId || ''}" data-stream-id="${this.escapeHtml(String(channel.streamId ?? ''))}"
data-url="${channel.url || ''}" data-url="${channel.url || ''}"
data-render-id="${renderId}" data-render-id="${renderId}"
data-render-group="${renderGroup}"> data-render-group="${this.escapeHtml(renderGroup)}">
<img class="channel-logo" src="${this.getProxiedImageUrl(channel.tvgLogo)}" <img class="channel-logo" src="${this.getProxiedImageUrl(channel.tvgLogo)}"
alt="" onerror="this.onerror=null;this.src='/img/placeholder.png'"> alt="" onerror="this.onerror=null;this.src='/img/placeholder.png'">
<div class="channel-info"> <div class="channel-info">
@ -589,19 +632,19 @@ class ChannelList {
const channels = this.groupedChannels[groupName]; const channels = this.groupedChannels[groupName];
if (!channels || channels.length === 0) return; if (!channels || channels.length === 0) return;
const isFavoritesGroup = groupName === 'Favorites'; const isFavoritesGroup = groupName === this.favoriteGroupKey;
// Filter visible channels // Filter visible channels
const visibleChannels = channels.filter(channel => { const visibleChannels = channels.filter(channel => {
if (isFavoritesGroup) return true; if (isFavoritesGroup) return true;
const rawChannelId = channel.streamId || channel.id; const rawChannelId = channel.streamId ?? channel.id;
const channelHidden = this.isHidden('channel', channel.sourceId, rawChannelId); const channelHidden = this.isHidden('channel', channel.sourceId, rawChannelId);
return !channelHidden || this.showHidden; return !channelHidden || this.showHidden;
}); });
let html = ''; let html = '';
for (const channel of visibleChannels) { for (const channel of visibleChannels) {
const rawChannelId = channel.streamId || channel.id; const rawChannelId = channel.streamId ?? channel.id;
const channelHidden = !isFavoritesGroup && this.isHidden('channel', channel.sourceId, rawChannelId); const channelHidden = !isFavoritesGroup && this.isHidden('channel', channel.sourceId, rawChannelId);
const isActive = this.currentChannel?.id === channel.id; const isActive = this.currentChannel?.id === channel.id;
const isFavorite = this.isFavorite(channel.sourceId, channel.id); const isFavorite = this.isFavorite(channel.sourceId, channel.id);
@ -615,13 +658,13 @@ class ChannelList {
html += ` html += `
<div class="channel-item ${isActive ? 'active' : ''} ${channelHidden ? 'hidden' : ''}" <div class="channel-item ${isActive ? 'active' : ''} ${channelHidden ? 'hidden' : ''}"
data-channel-id="${channel.id}" data-channel-id="${this.escapeHtml(String(channel.id))}"
data-source-id="${channel.sourceId}" data-source-id="${channel.sourceId}"
data-source-type="${channel.sourceType}" data-source-type="${channel.sourceType}"
data-stream-id="${channel.streamId || ''}" data-stream-id="${this.escapeHtml(String(channel.streamId ?? ''))}"
data-url="${channel.url || ''}" data-url="${channel.url || ''}"
data-render-id="${renderId}" data-render-id="${renderId}"
data-render-group="${renderGroup}"> data-render-group="${this.escapeHtml(renderGroup)}">
<img class="channel-logo" src="${this.getProxiedImageUrl(channel.tvgLogo)}" <img class="channel-logo" src="${this.getProxiedImageUrl(channel.tvgLogo)}"
alt="" onerror="this.onerror=null;this.src='/img/placeholder.png'"> alt="" onerror="this.onerror=null;this.src='/img/placeholder.png'">
<div class="channel-info"> <div class="channel-info">
@ -699,161 +742,95 @@ class ChannelList {
* Load channels from selected source * Load channels from selected source
*/ */
async loadChannels() { async loadChannels() {
if (this.isLoading) return; clearTimeout(this.searchTimeout);
this.catalogVersion++;
this.catalogController?.abort();
this.catalogScopeVersion = this.catalogVersion;
this.catalogQuery = this.searchInput.value.trim();
this.catalogParams = {
sourceId: this.sourceSelect.value.split(':')[1],
q: this.catalogQuery,
includeHidden: this.showHiddenCheckbox?.checked || false,
limit: 200
};
this.channels = [];
this.renderedChannels = [];
this.nextCursor = null;
this.hasMore = false;
this.currentRenderId = null;
return this.loadCatalogPage(false);
}
async loadAllChannels() { return this.loadChannels(); }
loadCatalogPage(append) {
if (append && this.catalogPagePromise?.version === this.catalogVersion) {
return this.catalogPagePromise.promise;
}
const version = this.catalogVersion;
const promise = this.fetchCatalogPage(append);
this.catalogPagePromise = { version, promise };
promise.finally(() => {
if (this.catalogPagePromise?.promise === promise) this.catalogPagePromise = null;
});
return promise;
}
async fetchCatalogPage(append) {
if (append && (this.isLoading || !this.hasMore)) return false;
const version = this.catalogVersion;
const controller = new AbortController();
this.catalogController = controller;
this.isLoading = true; this.isLoading = true;
this.currentRenderId = null; // Reset render tracking this.catalogStatus.textContent = this.catalogQuery ? 'Searching…' : 'Loading…';
this.container.setAttribute('aria-busy', 'true');
const sourceValue = this.sourceSelect.value; this.loadMoreButton.disabled = true;
const self = this; if (!append) this.container.replaceChildren();
if (!sourceValue) {
// Load from all sources
await this.loadAllChannels();
this.isLoading = false;
return;
}
const [type, id] = sourceValue.split(':');
try { try {
this.container.innerHTML = '<div class="loading"></div>'; const [result] = await Promise.all([
API.channels.liveCatalog({ ...this.catalogParams, cursor: append ? this.nextCursor : undefined }, controller.signal),
if (type === 'xtream') { ...(append ? [] : [this.loadHiddenItems(), this.loadFavorites()])
await this.loadXtreamChannels(parseInt(id));
} else if (type === 'm3u') {
await this.loadM3uChannels(parseInt(id));
}
// Load hidden items and favorites
await Promise.all([
this.loadHiddenItems(),
this.loadFavorites()
]); ]);
if (version !== this.catalogVersion) return false;
if (result.page.hasMore && (!result.page.nextCursor || (append && result.page.nextCursor === this.nextCursor))) {
throw new Error('Invalid catalogue cursor');
}
this.channels = append ? this.channels.concat(result.items) : result.items;
this.nextCursor = result.page.nextCursor;
this.hasMore = result.page.hasMore;
const scrollTop = this.container.scrollTop;
const active = document.activeElement;
const item = active?.closest('.channel-item');
const groupName = item?.closest('.channel-group')?.querySelector('.group-header')?.dataset.group;
this.render(); this.render();
if (item) {
const replacement = [...this.container.querySelectorAll('.channel-item')].find(el =>
el.dataset.channelId === item.dataset.channelId && el.dataset.sourceId === item.dataset.sourceId &&
el.closest('.channel-group')?.querySelector('.group-header')?.dataset.group === groupName);
(active.classList.contains('favorite-btn') ? replacement?.querySelector('.favorite-btn') : replacement)?.focus({ preventScroll: true });
}
this.container.scrollTop = append ? scrollTop : 0;
this.catalogStatus.textContent = '';
if (!this.hasMore && document.activeElement === this.loadMoreButton) {
this.catalogStatus.tabIndex = -1;
this.catalogStatus.textContent = 'All channels loaded';
this.catalogStatus.focus({ preventScroll: true });
}
this.loadMoreButton.textContent = 'Load more';
return true;
} catch (err) { } catch (err) {
console.error('Error loading channels:', err); if (version !== this.catalogVersion || err.name === 'AbortError') return false;
this.container.innerHTML = `<div class="empty-state"><p>Error loading channels</p><p class="hint">${err.message}</p></div>`; this.catalogStatus.textContent = 'Error loading channels';
this.loadMoreButton.textContent = 'Retry';
return false;
} finally { } finally {
if (version === this.catalogVersion) {
this.isLoading = false; this.isLoading = false;
this.container.setAttribute('aria-busy', 'false');
this.loadMoreButton.disabled = false;
this.loadMoreButton.hidden = !this.hasMore && this.loadMoreButton.textContent !== 'Retry';
} }
} }
/**
* Load channels from all enabled sources
*/
async loadAllChannels() {
this.channels = [];
this.groups = [];
try {
this.container.innerHTML = '<div class="loading"></div>';
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);
for (const source of xtreamSources) {
await this.loadXtreamChannels(source.id, true);
}
for (const source of m3uSources) {
await this.loadM3uChannels(source.id, true);
}
await Promise.all([
this.loadHiddenItems(),
this.loadFavorites()
]);
this.render();
} catch (err) {
console.error('Error loading all channels:', err);
}
}
/**
* Load Xtream channels
*/
async loadXtreamChannels(sourceId, append = false) {
if (!append) {
this.channels = [];
this.groups = [];
}
const categories = await API.proxy.xtream.liveCategories(sourceId);
const streams = await API.proxy.xtream.liveStreams(sourceId);
// Map categories to groups
const categoryGroups = categories.map(cat => ({
id: `xtream_${sourceId}_${cat.category_id}`,
name: cat.category_name,
sourceId,
sourceType: 'xtream'
}));
this.groups = this.groups.concat(categoryGroups);
// Map streams to channels
const channelList = streams.map(stream => ({
id: `xtream_${sourceId}_${stream.stream_id}`,
streamId: stream.stream_id,
name: stream.name,
tvgId: stream.epg_channel_id,
tvgLogo: stream.stream_icon,
groupId: `xtream_${sourceId}_${stream.category_id}`,
// Use string comparison to handle type mismatches (number vs string category_id)
groupTitle: categories.find(c => String(c.category_id) === String(stream.category_id))?.category_name || 'Uncategorized',
sourceId,
sourceType: 'xtream'
}));
this.channels = this.channels.concat(channelList);
}
/**
* Load M3U channels
* Now uses unified Xtream-style API endpoints (backend supports both source types)
*/
async loadM3uChannels(sourceId, append = false) {
if (!append) {
this.channels = [];
this.groups = [];
}
const source = await API.sources.getById(sourceId);
// Use Xtream API endpoints - backend now supports M3U sources too
const categories = await API.proxy.xtream.liveCategories(sourceId);
const streams = await API.proxy.xtream.liveStreams(sourceId);
// Map categories to groups (keeping m3u sourceType for downstream compatibility)
const m3uGroups = categories.map(cat => ({
id: `m3u_${sourceId}_${cat.category_id}`,
name: cat.category_name,
sourceId,
sourceType: 'm3u'
}));
this.groups = this.groups.concat(m3uGroups);
// Map streams to channels
const channelList = streams.map(stream => ({
id: `m3u_${sourceId}_${stream.stream_id}`,
streamId: stream.stream_id,
name: stream.name,
tvgId: stream.epg_channel_id,
tvgLogo: stream.stream_icon,
url: stream.stream_url, // M3U has direct URLs
program: stream.program || null,
zapBeforeStreaming: source?.zapBeforeStreaming || false,
zapDelay: source?.zapDelay ?? 1500,
groupId: `m3u_${sourceId}_${stream.category_id}`,
groupTitle: categories.find(c => String(c.category_id) === String(stream.category_id))?.category_name || 'Uncategorized',
sourceId,
sourceType: 'm3u'
}));
this.channels = this.channels.concat(channelList);
} }
/** /**
@ -907,7 +884,7 @@ class ChannelList {
const wasFavorite = this.visibleFavorites.has(key); const wasFavorite = this.visibleFavorites.has(key);
// Find all buttons for this channel in the DOM (it may appear in multiple groups) // Find all buttons for this channel in the DOM (it may appear in multiple groups)
const btns = document.querySelectorAll(`.channel-item[data-channel-id="${channelId}"][data-source-id="${sourceId}"] .favorite-btn`); const btns = document.querySelectorAll(`.channel-item[data-channel-id="${CSS.escape(String(channelId))}"][data-source-id="${CSS.escape(String(sourceId))}"] .favorite-btn`);
try { try {
// Optimistic update // Optimistic update
@ -975,11 +952,11 @@ class ChannelList {
*/ */
updateFavoritesGroup(channel, isAdded) { updateFavoritesGroup(channel, isAdded) {
// 1. Update Data // 1. Update Data
if (!this.groupedChannels['Favorites']) { if (!this.groupedChannels[this.favoriteGroupKey]) {
this.groupedChannels['Favorites'] = []; this.groupedChannels[this.favoriteGroupKey] = [];
} }
const favArray = this.groupedChannels['Favorites']; const favArray = this.groupedChannels[this.favoriteGroupKey];
const existingIdx = favArray.findIndex(c => c.id === channel.id && c.sourceId === channel.sourceId); const existingIdx = favArray.findIndex(c => c.id === channel.id && c.sourceId === channel.sourceId);
if (isAdded) { if (isAdded) {
@ -988,8 +965,10 @@ class ChannelList {
if (existingIdx !== -1) favArray.splice(existingIdx, 1); if (existingIdx !== -1) favArray.splice(existingIdx, 1);
} }
favArray.sort((a, b) => this.compareCatalogChannels(a, b));
// 2. Update DOM // 2. Update DOM
const groupHeader = this.listContainer.querySelector('.group-header[data-group="Favorites"]'); const groupHeader = this.listContainer?.querySelector('.group-header.favorites-group');
if (!groupHeader) { if (!groupHeader) {
// If group doesn't exist and we're adding, we ideally should create it // If group doesn't exist and we're adding, we ideally should create it
@ -1006,13 +985,16 @@ class ChannelList {
if (isAdded) { if (isAdded) {
// Check if already in DOM (to avoid dupes) // Check if already in DOM (to avoid dupes)
const existingEl = groupChannels.querySelector(`.channel-item[data-channel-id="${channel.id}"][data-source-id="${channel.sourceId}"]`); const existingEl = groupChannels.querySelector(`.channel-item[data-channel-id="${CSS.escape(String(channel.id))}"][data-source-id="${CSS.escape(String(channel.sourceId))}"]`);
if (!existingEl) { if (!existingEl) {
const newEl = this.createChannelElement(channel); const newEl = this.createChannelElement(channel);
groupChannels.appendChild(newEl); const next = favArray[favArray.indexOf(channel) + 1];
const nextElement = next && [...groupChannels.children].find(element =>
element.dataset.channelId === String(next.id) && element.dataset.sourceId === String(next.sourceId));
groupChannels.insertBefore(newEl, nextElement || null);
} }
} else { } else {
const existingEl = groupChannels.querySelector(`.channel-item[data-channel-id="${channel.id}"][data-source-id="${channel.sourceId}"]`); const existingEl = groupChannels.querySelector(`.channel-item[data-channel-id="${CSS.escape(String(channel.id))}"][data-source-id="${CSS.escape(String(channel.sourceId))}"]`);
if (existingEl) { if (existingEl) {
existingEl.remove(); existingEl.remove();
} }
@ -1041,7 +1023,7 @@ class ChannelList {
div.dataset.channelId = channel.id; div.dataset.channelId = channel.id;
div.dataset.sourceId = channel.sourceId; div.dataset.sourceId = channel.sourceId;
div.dataset.sourceType = channel.sourceType; div.dataset.sourceType = channel.sourceType;
div.dataset.streamId = channel.streamId || ''; div.dataset.streamId = channel.streamId ?? '';
div.dataset.url = channel.url || ''; div.dataset.url = channel.url || '';
div.innerHTML = ` div.innerHTML = `
@ -1079,9 +1061,10 @@ class ChannelList {
* Select and play a channel * Select and play a channel
*/ */
async selectChannel(dataset) { async selectChannel(dataset) {
const channel = this.channels.find(c => c.id === dataset.channelId); const channel = [...this.channels, ...(this.favoriteCatalog || []), ...(this.guideCatalog || [])].find(c => String(c.id) === String(dataset.channelId) && (dataset.sourceId === undefined || String(c.sourceId) === String(dataset.sourceId)));
if (!channel) return; if (!channel) return;
const previousChannel = this.currentChannel;
this.currentChannel = channel; this.currentChannel = channel;
this.currentRenderId = dataset.renderId; // Track which visual instance is active this.currentRenderId = dataset.renderId; // Track which visual instance is active
this.currentRenderGroup = dataset.renderGroup; // Track which group the selection came from this.currentRenderGroup = dataset.renderGroup; // Track which group the selection came from
@ -1111,7 +1094,8 @@ class ChannelList {
// Fallback checks if still not found // Fallback checks if still not found
if (!activeItem) { if (!activeItem) {
activeItem = this.container.querySelector(`[data-channel-id="${channel.id}"]`); activeItem = [...this.container.querySelectorAll('.channel-item')].find(item =>
item.dataset.channelId === String(channel.id) && item.dataset.sourceId === String(channel.sourceId));
// If we fell back to channel ID, update currentRenderId to match what we found // If we fell back to channel ID, update currentRenderId to match what we found
if (activeItem && activeItem.dataset.renderId) { if (activeItem && activeItem.dataset.renderId) {
this.currentRenderId = activeItem.dataset.renderId; this.currentRenderId = activeItem.dataset.renderId;
@ -1171,9 +1155,13 @@ class ChannelList {
streamUrl = channel.url; streamUrl = channel.url;
} }
if (dataset.catalogVersion !== undefined && dataset.catalogVersion !== this.catalogVersion) {
if (this.currentChannel === channel) this.currentChannel = previousChannel;
return;
}
// Play channel // Play channel
if (window.app?.player) { if (window.app?.player) {
window.app.player.play(channel, streamUrl); await window.app.player.play(channel, streamUrl);
} }
} }
@ -1331,7 +1319,7 @@ class ChannelList {
} }
// Update DOM (All instances) // Update DOM (All instances)
const btns = document.querySelectorAll(`.channel-item[data-channel-id="${channelId}"][data-source-id="${sourceId}"] .favorite-btn`); const btns = document.querySelectorAll(`.channel-item[data-channel-id="${CSS.escape(String(channelId))}"][data-source-id="${CSS.escape(String(sourceId))}"] .favorite-btn`);
btns.forEach(btn => { btns.forEach(btn => {
if (isFavorite) { if (isFavorite) {
@ -1355,88 +1343,66 @@ class ChannelList {
/** /**
* Select next channel in the current list * Select next channel in the current list
*/ */
selectNextChannel() { selectNextChannel() { return window.app?.player?.channelDown(); }
if (!this.currentChannel || !this.renderedChannels || this.renderedChannels.length === 0) return;
let currentIndex = -1; selectPrevChannel() { return window.app?.player?.channelUp(); }
// Try to find by render ID first (strict visual order) queueZap(direction) {
if (this.currentRenderId) { const version = this.catalogVersion;
currentIndex = this.renderedChannels.findIndex(c => c._renderId === this.currentRenderId); const run = async () => {
if (version !== this.catalogVersion || version !== this.catalogScopeVersion) return;
// A page already requested by Load more belongs to this same scope.
if (this.catalogPagePromise?.version === version) {
if (!await this.catalogPagePromise.promise) return;
} }
if (version !== this.catalogVersion || this.isLoading) return;
// Fallback: Find matching channel ID, prioritizing same render group const current = window.app?.player?.currentChannel || this.currentChannel;
if (currentIndex === -1) { const selection = this.currentChannel;
// First try to find in same group (for Favorites containing duplicates) const streamId = current?.streamId ?? current?.id;
if (this.currentRenderGroup) { if (current?.sourceId == null || streamId == null) return;
currentIndex = this.renderedChannels.findIndex(c => const matches = channel => String(channel.sourceId) === String(current.sourceId) &&
c.id === this.currentChannel.id && c.sourceId === this.currentChannel.sourceId && c._renderGroup === this.currentRenderGroup String(channel.streamId ?? channel.id) === String(streamId);
); const isCurrent = () => version === this.catalogVersion && this.currentChannel === selection &&
(window.app?.player?.currentChannel || this.currentChannel) === current;
let channels = this.getVisibleChannels();
let index = channels.findIndex(matches);
// Home can start playback outside the loaded Live prefix. Resolve its
// source/stream identity in this exact scope before choosing any neighbour.
while (index === -1 && this.hasMore) {
if (!await this.loadCatalogPage(true) || !isCurrent()) return;
channels = this.getVisibleChannels();
index = channels.findIndex(matches);
} }
// Final fallback: any matching channel if (!isCurrent()) return;
if (currentIndex === -1) { if (index === -1) {
currentIndex = this.renderedChannels.findIndex(c => this.catalogStatus.textContent = 'Current channel is outside the Live filters. Select a channel in the list to change channels.';
c.id === this.currentChannel.id && c.sourceId === this.currentChannel.sourceId return;
);
} }
// Keep every fetched page: the previous neighbour is in the retained prefix.
// The API exposes a forward cursor only; wrapping backwards needs the true end.
while (this.hasMore && (direction < 0 ? index <= 0 : index >= channels.length - 1)) {
const cursor = this.nextCursor;
if (!await this.loadCatalogPage(true)) return;
if (!isCurrent()) return;
channels = this.getVisibleChannels();
index = channels.findIndex(matches);
if (index === -1 || (this.hasMore && this.nextCursor === cursor)) return;
} }
if (!isCurrent() || index === -1 || !channels.length) return;
if (currentIndex === -1) return; const neighbor = direction < 0
? channels[index === 0 ? channels.length - 1 : index - 1]
const nextIndex = (currentIndex + 1) % this.renderedChannels.length; : channels[index >= channels.length - 1 ? 0 : index + 1];
const nextChannel = this.renderedChannels[nextIndex]; try {
await this.selectChannel({ channelId: neighbor.id, sourceId: neighbor.sourceId, catalogVersion: version });
this.selectChannel({ } catch (err) {
channelId: nextChannel.id, if (this.currentChannel === neighbor) this.currentChannel = current;
sourceId: nextChannel.sourceId, throw err;
sourceType: nextChannel.sourceType, }
streamId: nextChannel.streamId, };
url: nextChannel.url, this.zapQueue = (this.zapQueue || Promise.resolve()).then(run).catch(err => {
renderId: nextChannel._renderId // Pass the unique render ID console.error('Error changing channel:', err);
});
}
/**
* Select previous channel in the current list
*/
selectPrevChannel() {
if (!this.currentChannel || !this.renderedChannels || this.renderedChannels.length === 0) return;
let currentIndex = -1;
if (this.currentRenderId) {
currentIndex = this.renderedChannels.findIndex(c => c._renderId === this.currentRenderId);
}
// Fallback: Find matching channel ID, prioritizing same render group
if (currentIndex === -1) {
// First try to find in same group (for Favorites containing duplicates)
if (this.currentRenderGroup) {
currentIndex = this.renderedChannels.findIndex(c =>
c.id === this.currentChannel.id && c.sourceId === this.currentChannel.sourceId && c._renderGroup === this.currentRenderGroup
);
}
// Final fallback: any matching channel
if (currentIndex === -1) {
currentIndex = this.renderedChannels.findIndex(c =>
c.id === this.currentChannel.id && c.sourceId === this.currentChannel.sourceId
);
}
}
if (currentIndex === -1) return;
const prevIndex = (currentIndex - 1 + this.renderedChannels.length) % this.renderedChannels.length;
const prevChannel = this.renderedChannels[prevIndex];
this.selectChannel({
channelId: prevChannel.id,
sourceId: prevChannel.sourceId,
sourceType: prevChannel.sourceType,
streamId: prevChannel.streamId,
url: prevChannel.url,
renderId: prevChannel._renderId
}); });
return this.zapQueue;
} }
/** /**
@ -1454,13 +1420,11 @@ class ChannelList {
* Get list of visible (non-hidden) channels in display order * Get list of visible (non-hidden) channels in display order
*/ */
getVisibleChannels() { getVisibleChannels() {
const showHidden = this.showHiddenCheckbox?.checked ?? false; // Use the same groups and child order as render(), without the synthetic duplicates.
return this.channels.filter(ch => { return (this.sortedGroups || []).filter(group => group !== this.favoriteGroupKey).flatMap(group =>
if (showHidden) return true; (this.groupedChannels[group] || []).filter(channel =>
const channelHidden = this.isHidden('channel', ch.sourceId, ch.id); this.showHidden || !this.isHidden('channel', channel.sourceId, channel.streamId ?? channel.id)));
const groupHidden = this.isHidden('group', ch.sourceId, ch.groupTitle);
return !channelHidden && !groupHidden;
});
} }
} }

View File

@ -348,7 +348,7 @@ class EpgGuide {
// Get channels and filter out hidden ones (always enforce hidden in EPG) // Get channels and filter out hidden ones (always enforce hidden in EPG)
// Note: We only check individual channel visibility, not group visibility // Note: We only check individual channel visibility, not group visibility
// A group is implicitly visible if it has any visible children // A group is implicitly visible if it has any visible children
const playableChannels = (channelList.channels || []).filter(ch => { const playableChannels = (channelList.guideCatalog || channelList.channels || []).filter(ch => {
// Use streamId (raw ID) for hidden check since that's what SourceManager stores // Use streamId (raw ID) for hidden check since that's what SourceManager stores
const rawChannelId = ch.streamId || ch.id; const rawChannelId = ch.streamId || ch.id;
const isChannelHidden = channelList.isHidden('channel', ch.sourceId, rawChannelId); const isChannelHidden = channelList.isHidden('channel', ch.sourceId, rawChannelId);
@ -915,7 +915,7 @@ class EpgGuide {
async playChannel(channelName) { async playChannel(channelName) {
// Find channel in channel list and play // Find channel in channel list and play
if (window.app?.channelList) { if (window.app?.channelList) {
const channel = window.app.channelList.channels.find(c => const channel = (window.app.channelList.guideCatalog || window.app.channelList.channels).find(c =>
c.name === channelName || c.tvgName === channelName c.name === channelName || c.tvgName === channelName
); );
if (channel) { if (channel) {

View File

@ -1259,32 +1259,12 @@ class VideoPlayer {
* Go to previous channel * Go to previous channel
*/ */
channelUp() { channelUp() {
if (!window.app?.channelList) return; return window.app?.channelList?.queueZap(-1);
const channels = window.app.channelList.getVisibleChannels();
if (channels.length === 0) return;
const currentIdx = this.currentChannel
? channels.findIndex(c => c.id === this.currentChannel.id)
: -1;
const prevIdx = currentIdx <= 0 ? channels.length - 1 : currentIdx - 1;
window.app.channelList.selectChannel({ channelId: channels[prevIdx].id });
} }
/** /** Go to the next channel, waiting for a catalogue page when needed. */
* Go to next channel
*/
channelDown() { channelDown() {
if (!window.app?.channelList) return; return window.app?.channelList?.queueZap(1);
const channels = window.app.channelList.getVisibleChannels();
if (channels.length === 0) return;
const currentIdx = this.currentChannel
? channels.findIndex(c => c.id === this.currentChannel.id)
: -1;
const nextIdx = currentIdx >= channels.length - 1 ? 0 : currentIdx + 1;
window.app.channelList.selectChannel({ channelId: channels[nextIdx].id });
} }
async zapBeforeStreaming(channel, streamUrl, signal) { async zapBeforeStreaming(channel, streamUrl, signal) {

View File

@ -11,28 +11,71 @@ class GuidePage {
// EPG guide will lazy load when shown // EPG guide will lazy load when shown
} }
async show() { async loadCatalog() {
// Ensure channel data is loaded before rendering EPG
// This fixes a race condition where navigating directly to the Guide page
// before visiting Live TV would result in an empty EPG.
const channelList = this.app.channelList; const channelList = this.app.channelList;
if (!channelList.channels || channelList.channels.length === 0) { const revision = API.liveCatalogRevision || 0;
await channelList.loadSources(); if (channelList.guideCatalog && channelList.guideCatalogRevision === revision) return;
await channelList.loadChannels(); if (this.catalogLoad?.revision === revision) return this.catalogLoad.promise;
const promise = (async () => {
const channels = [];
let cursor;
do {
// Guide ignores group visibility; EpgGuide filters individually hidden channels.
const result = await API.channels.liveCatalog({ limit: 500, cursor, includeHidden: true });
if (revision !== (API.liveCatalogRevision || 0)) throw new Error('Catalogue changed');
channels.push(...result.items);
const next = result.page.hasMore ? result.page.nextCursor : null;
if (result.page.hasMore && (!next || next === cursor)) throw new Error('Invalid catalogue cursor');
cursor = next;
} while (cursor);
await channelList.loadHiddenItems();
if (revision !== (API.liveCatalogRevision || 0)) throw new Error('Catalogue changed');
channelList.guideCatalog = channels;
channelList.guideCatalogRevision = revision;
})();
this.catalogLoad = { revision, promise };
try { await promise; }
finally {
if (this.catalogLoad?.promise === promise) this.catalogLoad = null;
}
} }
// Only load EPG data if not already loaded async show() {
if (!this.app.epgGuide.programmes || this.app.epgGuide.programmes.length === 0) { const visit = this.visit = (this.visit || 0) + 1;
const container = this.app.epgGuide.container;
const channelList = this.app.channelList;
const needsCatalog = !channelList.guideCatalog || channelList.guideCatalogRevision !== (API.liveCatalogRevision || 0);
if (needsCatalog) {
container.textContent = 'Loading…';
container.setAttribute('aria-busy', 'true');
}
try {
await this.loadCatalog();
if (visit !== this.visit) return;
if (!this.app.epgGuide.programmes?.length) {
await this.app.epgGuide.loadEpg(); await this.app.epgGuide.loadEpg();
} else { } else {
// Just re-render with existing data (updates time position)
this.app.epgGuide.render(); this.app.epgGuide.render();
} }
} catch (err) {
if (visit !== this.visit) return;
container.textContent = 'Error loading channels';
const retry = document.createElement('button');
retry.type = 'button';
retry.className = 'btn btn-sm';
retry.textContent = 'Retry';
retry.addEventListener('click', () => this.show());
container.appendChild(retry);
} finally {
if (visit === this.visit) container.setAttribute('aria-busy', 'false');
}
} }
hide() { hide() {
// Page is hidden this.visit = (this.visit || 0) + 1;
this.app.epgGuide.container.setAttribute('aria-busy', 'false');
} }
} }
window.GuidePage = GuidePage; window.GuidePage = GuidePage;

View File

@ -195,34 +195,13 @@ class HomePage {
const section = document.getElementById('favorite-channels-section'); const section = document.getElementById('favorite-channels-section');
if (!list || !section) return; if (!list || !section) return;
const version = this.favoriteCatalogVersion = (this.favoriteCatalogVersion || 0) + 1;
try { try {
// Fetch favorite channels for current user
const favorites = await window.API.request('GET', '/favorites?itemType=channel');
if (!favorites || favorites.length === 0) {
list.innerHTML = '<div class="empty-state hint">Add channels to favorites from Live TV</div>';
return;
}
// Ensure channel list is loaded to resolve channel details
const channelList = this.app.channelList; const channelList = this.app.channelList;
if (!channelList.channels || channelList.channels.length === 0) { const result = await API.channels.liveCatalog({ favoriteOnly: true, limit: 200 });
await channelList.loadSources(); if (version !== this.favoriteCatalogVersion) return;
await channelList.loadChannels(); const channels = result.items;
} channelList.favoriteCatalog = channels;
// Match favorites to channel data
const channels = [];
for (const fav of favorites) {
// Find channel in loaded channel list
const channel = channelList.channels.find(ch =>
String(ch.sourceId) === String(fav.source_id) &&
(String(ch.id) === String(fav.item_id) || String(ch.streamId) === String(fav.item_id))
);
if (channel) {
channels.push({ ...channel, favoriteId: fav.id });
}
}
if (channels.length === 0) { if (channels.length === 0) {
list.innerHTML = '<div class="empty-state hint">Add channels to favorites from Live TV</div>'; list.innerHTML = '<div class="empty-state hint">Add channels to favorites from Live TV</div>';
@ -234,6 +213,7 @@ class HomePage {
// Attach click handlers // Attach click handlers
list.querySelectorAll('.channel-tile').forEach(tile => { list.querySelectorAll('.channel-tile').forEach(tile => {
tile.addEventListener('keydown', event => this.activateFavoriteTile(event, tile.dataset.channelId, tile.dataset.sourceId));
tile.addEventListener('click', () => { tile.addEventListener('click', () => {
const channelId = tile.dataset.channelId; const channelId = tile.dataset.channelId;
const sourceId = tile.dataset.sourceId; const sourceId = tile.dataset.sourceId;
@ -241,6 +221,48 @@ class HomePage {
}); });
}); });
let nextCursor = result.page.nextCursor;
if (result.page.hasMore) {
const more = document.createElement('button');
more.type = 'button';
more.className = 'channel-tile channel-tile-action';
more.textContent = 'Load more';
list.appendChild(more);
more.addEventListener('click', async event => {
const keyboard = event.detail === 0;
const scrollLeft = list.scrollLeft;
more.disabled = true;
more.textContent = 'Loading…';
let firstNewTile;
try {
const page = await API.channels.liveCatalog({ favoriteOnly: true, limit: 200, cursor: nextCursor });
if (version !== this.favoriteCatalogVersion) return;
channelList.favoriteCatalog.push(...page.items);
for (const channel of page.items) {
const wrapper = document.createElement('div');
wrapper.innerHTML = this.createChannelTile(channel);
const tile = wrapper.firstElementChild;
tile.addEventListener('click', () => this.playChannel(channel.id, channel.sourceId));
tile.addEventListener('keydown', event => this.activateFavoriteTile(event, channel.id, channel.sourceId));
firstNewTile ||= tile;
list.insertBefore(tile, more);
}
nextCursor = page.page.nextCursor;
if (keyboard && firstNewTile) {
firstNewTile.focus({ preventScroll: true });
firstNewTile.scrollIntoView({ block: 'nearest', inline: 'nearest' });
} else {
list.scrollLeft = scrollLeft;
}
more.hidden = !page.page.hasMore;
more.textContent = 'Load more';
this.updateScrollArrows();
} catch (err) {
more.textContent = 'Retry';
} finally { more.disabled = false; }
});
}
// Update scroll arrows after content renders // Update scroll arrows after content renders
this.updateScrollArrows(); this.updateScrollArrows();
@ -254,9 +276,11 @@ class HomePage {
const logo = channel.tvgLogo || '/img/placeholder.png'; const logo = channel.tvgLogo || '/img/placeholder.png';
const logoUrl = logo.startsWith('http') ? `/api/proxy/image?url=${encodeURIComponent(logo)}` : logo; const logoUrl = logo.startsWith('http') ? `/api/proxy/image?url=${encodeURIComponent(logo)}` : logo;
const name = channel.name || 'Unknown'; const name = channel.name || 'Unknown';
const channelId = this.app.channelList.escapeHtml(String(channel.id));
const sourceId = this.app.channelList.escapeHtml(String(channel.sourceId));
return ` return `
<div class="channel-tile" data-channel-id="${channel.id}" data-source-id="${channel.sourceId}"> <div class="channel-tile" role="button" tabindex="0" data-channel-id="${channelId}" data-source-id="${sourceId}">
<div class="tile-logo"> <div class="tile-logo">
<img src="${logoUrl}" alt="${name}" loading="lazy" onerror="this.onerror=null;this.src='/img/placeholder.png'"> <img src="${logoUrl}" alt="${name}" loading="lazy" onerror="this.onerror=null;this.src='/img/placeholder.png'">
</div> </div>
@ -265,29 +289,21 @@ class HomePage {
`; `;
} }
playChannel(channelId, sourceId) { activateFavoriteTile(event, channelId, sourceId) {
// Navigate to Live TV and select the channel if (event.key === 'Enter' || event.key === ' ') {
this.app.navigateTo('live'); event.preventDefault();
this.playChannel(channelId, sourceId);
}
}
// Small delay to ensure page is ready playChannel(channelId, sourceId) {
setTimeout(() => {
const channelList = this.app.channelList; const channelList = this.app.channelList;
if (channelList) { const channel = channelList.favoriteCatalog?.find(ch =>
// Find and select the channel String(ch.id) === String(channelId) && String(ch.sourceId) === String(sourceId));
const channel = channelList.channels.find(ch => if (!channel) return;
String(ch.id) === String(channelId) && String(ch.sourceId) === String(sourceId) this.app.navigateTo('live');
); // selectChannel resolves Home DTOs independently of the active Live filters.
if (channel) { channelList.selectChannel({ channelId: channel.id, sourceId: channel.sourceId });
channelList.selectChannel({
channelId: channel.id,
sourceId: channel.sourceId,
sourceType: channel.sourceType,
streamId: channel.streamId || '',
url: channel.url || ''
});
}
}
}, 100);
} }
renderHistory(items) { renderHistory(items) {

View File

@ -73,6 +73,8 @@ function initSchema() {
); );
CREATE INDEX IF NOT EXISTS idx_items_source_type ON playlist_items(source_id, type); CREATE INDEX IF NOT EXISTS idx_items_source_type ON playlist_items(source_id, type);
CREATE INDEX IF NOT EXISTS idx_items_category ON playlist_items(source_id, category_id); CREATE INDEX IF NOT EXISTS idx_items_category ON playlist_items(source_id, category_id);
CREATE INDEX IF NOT EXISTS idx_live_catalog_order
ON playlist_items(name COLLATE NOCASE, source_id, id) WHERE type = 'live';
`); `);
// EPG Programs // EPG Programs
@ -152,6 +154,7 @@ function initSchema() {
// Column already exists, ignore // Column already exists, ignore
} }
require('../services/liveCatalog').ensureSearch(db);
console.log('[SQLite] Schema initialized'); console.log('[SQLite] Schema initialized');
} }

View File

@ -1,6 +1,20 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const { getDb } = require('../db/sqlite'); const { getDb } = require('../db/sqlite');
const { sources } = require('../db');
const { parseQuery, getLiveCatalog } = require('../services/liveCatalog');
router.get('/live-catalog', async (req, res) => {
try {
const options = parseQuery(req.query, req.user.id);
const result = getLiveCatalog(getDb(), await sources.getAll(), options);
res.json(result);
} catch (error) {
if (error.status === 400) return res.status(400).json({ error: error.message });
console.error('Error getting live catalog:', error);
res.status(500).json({ error: 'Failed to get live catalog' });
}
});
// Helper to map API item types to DB types and tables // Helper to map API item types to DB types and tables
function mapItemType(apiType) { function mapItemType(apiType) {

View File

@ -0,0 +1,137 @@
const { createHash } = require('crypto');
// Keep these expressions identical for ORDER BY, keyset comparison and cursor
// values. SQLite lower() folds ASCII; trim() removes surrounding ASCII spaces.
const GROUP_TITLE = "COALESCE(c.name, p.category_id, 'Uncategorized')";
const GROUP_ORDER = `lower(trim(${GROUP_TITLE}))`;
const CHANNEL_ORDER = 'lower(trim(p.name))';
const CATALOG_ORDER = `${GROUP_ORDER} COLLATE BINARY, ${GROUP_TITLE} COLLATE BINARY,
${CHANNEL_ORDER} COLLATE BINARY, p.name COLLATE BINARY, p.source_id, p.item_id COLLATE BINARY`;
const ORDER_VERSION = 'group-key-raw-channel-key-raw-source-item-v3';
function badRequest(message) {
const error = new Error(message);
error.status = 400;
throw error;
}
function parseQuery(query, userId) {
for (const key of ['sourceId', 'categoryId', 'q', 'includeHidden', 'favoriteOnly', 'limit', 'cursor']) {
if (query[key] !== undefined && typeof query[key] !== 'string') badRequest(`Invalid ${key}`);
}
const sourceId = query.sourceId === undefined ? null : Number(query.sourceId);
if (sourceId !== null && (!Number.isSafeInteger(sourceId) || sourceId <= 0)) badRequest('Invalid sourceId');
if (query.categoryId !== undefined && sourceId === null) badRequest('categoryId requires sourceId');
const boolean = key => {
if (query[key] !== undefined && !['true', 'false'].includes(query[key])) badRequest(`Invalid ${key}`);
return query[key] === 'true';
};
const requestedLimit = query.limit === undefined ? 100 : Number(query.limit);
if (!Number.isSafeInteger(requestedLimit) || requestedLimit < 1) badRequest('Invalid limit');
const filters = { sourceId, categoryId: query.categoryId ?? null, q: query.q || '',
includeHidden: boolean('includeHidden'), favoriteOnly: boolean('favoriteOnly'), userId };
const fingerprint = createHash('sha256').update(JSON.stringify({ ...filters, order: ORDER_VERSION })).digest('hex');
let after = null;
if (query.cursor !== undefined) {
try {
if (query.cursor.length > 8192 || !/^[A-Za-z0-9_-]+$/.test(query.cursor)) throw new Error();
const cursor = JSON.parse(Buffer.from(query.cursor, 'base64url').toString('utf8'));
if (cursor.v !== 3 || cursor.f !== fingerprint || !Array.isArray(cursor.k)
|| cursor.k.length !== 6 || !cursor.k.slice(0, 4).every(key => typeof key === 'string')
|| !Number.isSafeInteger(cursor.k[4]) || typeof cursor.k[5] !== 'string') throw new Error();
after = cursor.k;
} catch { badRequest('Invalid cursor'); }
}
return { ...filters, limit: Math.min(requestedLimit, 200), fingerprint, after };
}
// Optional acceleration: LIKE remains the source of truth and also handles short
// strings and literal wildcard characters, which trigram indexes cannot accelerate.
const searchSupport = new WeakMap();
function ensureSearch(db) {
if (searchSupport.has(db)) return searchSupport.get(db);
try {
db.transaction(() => {
for (const [table, column] of [['playlist_items', 'name'], ['categories', 'name']]) {
const index = `live_catalog_${table}_fts`;
const exists = db.prepare("SELECT 1 FROM sqlite_master WHERE name = ?").get(index);
db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS ${index} USING fts5(name, tokenize='trigram');
CREATE TRIGGER IF NOT EXISTS ${index}_ai AFTER INSERT ON ${table} BEGIN
INSERT INTO ${index}(rowid, name) VALUES (new.rowid, new.${column}); END;
CREATE TRIGGER IF NOT EXISTS ${index}_ad AFTER DELETE ON ${table} BEGIN
DELETE FROM ${index} WHERE rowid = old.rowid; END;
CREATE TRIGGER IF NOT EXISTS ${index}_au AFTER UPDATE OF ${column} ON ${table} BEGIN
DELETE FROM ${index} WHERE rowid = old.rowid;
INSERT INTO ${index}(rowid, name) VALUES (new.rowid, new.${column}); END;`);
if (!exists) db.exec(`INSERT INTO ${index}(rowid, name) SELECT rowid, ${column} FROM ${table}`);
}
})();
searchSupport.set(db, true);
} catch (error) {
console.warn('[Live catalog] Trigram unavailable; using literal LIKE:', error.message);
searchSupport.set(db, false);
}
return searchSupport.get(db);
}
function getLiveCatalog(db, allSources, options) {
const trigram = ensureSearch(db);
const sources = allSources.filter(s => s.enabled && ['m3u', 'xtream'].includes(s.type)
&& (options.sourceId === null || s.id === options.sourceId));
if (!sources.length) return { items: [], page: { nextCursor: null, hasMore: false } };
const params = { sources: JSON.stringify(sources.map(s => ({ id: s.id, type: s.type }))), user: options.userId };
const where = ["p.type = 'live'"];
if (!options.includeHidden) where.push('COALESCE(p.is_hidden, 0) = 0 AND COALESCE(c.is_hidden, 0) = 0');
if (options.favoriteOnly) where.push('f.id IS NOT NULL');
if (options.categoryId !== null) {
where.push('p.category_id = @category');
params.category = options.categoryId;
}
if (options.q) {
if (trigram && [...options.q].length >= 3 && !/[%_\\]/.test(options.q)) {
params.search = `%${options.q}%`;
where.push(`(p.rowid IN (SELECT rowid FROM live_catalog_playlist_items_fts WHERE name LIKE @search)
OR c.rowid IN (SELECT rowid FROM live_catalog_categories_fts WHERE name LIKE @search)
OR (c.rowid IS NULL AND p.category_id LIKE @search))`);
} else {
params.search = `%${options.q.replace(/[\\%_]/g, '\\$&')}%`;
where.push("(p.name LIKE @search ESCAPE '\\' OR COALESCE(c.name, p.category_id, '') LIKE @search ESCAPE '\\')");
}
}
if (options.after) {
where.push(`(${CATALOG_ORDER}) > (@groupKey, @groupTitle, @channelKey, @name, @source, @item)`);
[params.groupKey, params.groupTitle, params.channelKey, params.name, params.source, params.item] = options.after;
}
params.limit = options.limit + 1;
const rows = db.prepare(`WITH live_sources AS (
SELECT json_extract(value, '$.id') AS id, json_extract(value, '$.type') AS type FROM json_each(@sources)
) SELECT p.*, s.type AS source_type, ${GROUP_TITLE} AS group_title,
${GROUP_ORDER} AS sort_group, ${CHANNEL_ORDER} AS sort_name,
(COALESCE(p.is_hidden, 0) != 0 OR COALESCE(c.is_hidden, 0) != 0) AS hidden,
(f.id IS NOT NULL) AS favorite
FROM playlist_items p JOIN live_sources s ON s.id = p.source_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 = @user AND f.source_id = p.source_id
AND f.item_id = p.item_id AND f.item_type = 'channel'
WHERE ${where.join(' AND ')}
ORDER BY ${CATALOG_ORDER} LIMIT @limit`).all(params);
const hasMore = rows.length > options.limit;
if (hasMore) rows.pop();
const items = rows.map(row => {
const data = JSON.parse(row.data || '{}');
const streamId = data.stream_id ?? row.item_id;
return { sourceId: row.source_id, sourceType: row.source_type,
id: `${row.source_type}_${row.source_id}_${row.item_id}`, streamId,
name: row.name, groupId: row.category_id, groupTitle: row.group_title,
tvgId: data.tvgId ?? data.epg_channel_id ?? null,
tvgLogo: row.stream_icon ?? null, url: row.stream_url || data.url || null,
isHidden: !!row.hidden, isFavorite: !!row.favorite };
});
const last = rows[rows.length - 1];
const nextCursor = hasMore ? Buffer.from(JSON.stringify({ v: 3, f: options.fingerprint,
k: [last.sort_group, last.group_title, last.sort_name, last.name, last.source_id, last.item_id]
})).toString('base64url') : null;
return { items, page: { nextCursor, hasMore } };
}
module.exports = { parseQuery, getLiveCatalog, ensureSearch };

View File

@ -0,0 +1,685 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const vm = require('node:vm');
const { spawn } = require('node:child_process');
const Database = require('better-sqlite3');
async function startApp(t) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'nodecast-live-catalog-'));
fs.writeFileSync(path.join(dir, 'db.json'), JSON.stringify({
sources: [
{ id: 1, type: 'm3u', name: 'Primary', url: 'http://127.0.0.1:1/unused.m3u', enabled: true },
{ id: 2, type: 'xtream', name: 'Secondary', url: 'http://127.0.0.1:1', enabled: true },
{ id: 3, type: 'm3u', name: 'Disabled', url: 'http://127.0.0.1:1/unused.m3u', enabled: false },
{ id: 4, type: 'm3u', name: 'Tertiary', url: 'http://127.0.0.1:1/unused.m3u', enabled: true }
], hiddenItems: [], favorites: [], users: [], nextId: 5
}));
const child = spawn(process.execPath, ['server/index.js'], {
cwd: path.join(__dirname, '..'),
env: { ...process.env, PORT: '0', NODECAST_DATA_DIR: dir,
NODECAST_DISABLE_BACKGROUND_JOBS: 'true', JWT_SECRET: '', SESSION_SECRET: '',
OIDC_ISSUER_URL: '', AUTH_COOKIE_SECURE: 'false' },
stdio: ['ignore', 'pipe', 'pipe']
});
t.after(async () => {
if (child.exitCode === null) {
const closed = new Promise(resolve => child.once('close', resolve));
child.kill();
await closed;
}
fs.rmSync(dir, { recursive: true, force: true });
});
let output = '';
const base = await new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(`Server startup timed out: ${output}`)), 15000);
child.stdout.on('data', chunk => {
output += chunk;
const match = output.match(/server running on http:\/\/localhost:(\d+)/);
if (match) { clearTimeout(timer); resolve(`http://127.0.0.1:${match[1]}`); }
});
child.stderr.on('data', chunk => { output += chunk; });
child.once('exit', code => { clearTimeout(timer); reject(new Error(`Server exited ${code}: ${output}`)); });
});
const setup = await fetch(`${base}/api/auth/setup`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: 'Admin', password: 'password123' })
});
assert.equal(setup.status, 201);
const setupBody = await setup.json();
const cookie = setup.headers.getSetCookie().find(value => value.startsWith('nodecast_auth_'));
return { base, cookie: cookie.split(';')[0], userId: setupBody.user.id, dir };
}
async function request(base, cookie, query = {}) {
const params = new URLSearchParams(Object.entries(query).filter(([, value]) => value !== undefined));
const response = await fetch(`${base}/api/channels/live-catalog?${params}`, { headers: { Cookie: cookie } });
const text = await response.text();
return { status: response.status, text, bytes: Buffer.byteLength(text), body: JSON.parse(text) };
}
async function requestAllPages(base, cookie, query = {}) {
const items = [];
let cursor;
let pageCount = 0;
do {
const result = await request(base, cookie, { ...query, cursor });
assert.equal(result.status, 200);
items.push(...result.body.items);
cursor = result.body.page.nextCursor;
pageCount++;
assert.ok(pageCount < 30, 'cursor pagination did not terminate');
} while (cursor);
return items;
}
function seedCatalog(dir, userId) {
const db = new Database(path.join(dir, 'content.db'));
const insertCategory = db.prepare(`
INSERT INTO categories (id, source_id, category_id, type, name, is_hidden)
VALUES (?, ?, ?, 'live', ?, ?)
`);
insertCategory.run('1:alpha', 1, 'alpha', 'Alpha Group', 0);
insertCategory.run('1:beta', 1, 'beta', 'Beta Group', 0);
insertCategory.run('1:hidden', 1, 'hidden', 'Hidden Group', 1);
insertCategory.run('2:other', 2, 'other', 'Other Group', 0);
insertCategory.run('1:key-a', 1, 'key-a', 'A Group', 0);
insertCategory.run('1:key-b', 1, 'key-b', 'B Group', 0);
insertCategory.run('2:key-a', 2, 'key-a', 'A Group', 0);
insertCategory.run('4:key-a', 4, 'key-a', 'A Group', 0);
insertCategory.run('4:key-b', 4, 'key-b', 'B Group', 0);
const insertItem = db.prepare(`
INSERT INTO playlist_items (id, source_id, item_id, type, name, category_id, stream_url, is_hidden, data)
VALUES (?, ?, ?, 'live', ?, ?, ?, ?, ?)
`);
const insert = db.transaction(() => {
for (let i = 0; i < 1197; i++) {
const id = `primary-${String(i).padStart(4, '0')}`;
const categoryId = i < 600 ? 'alpha' : 'beta';
insertItem.run(`1:${id}`, 1, id, `Channel ${String(i).padStart(4, '0')}`, categoryId,
`http://streams.test/${id}`, 0, '{}');
}
insertItem.run('1:hidden-item', 1, 'hidden-item', 'Hidden Item Channel', 'alpha',
'http://streams.test/hidden-item', 1, '{}');
insertItem.run('1:hidden-group', 1, 'hidden-group', 'Hidden Group Channel', 'hidden',
'http://streams.test/hidden-group', 0, '{}');
insertItem.run('1:needle', 1, 'needle', 'ZZZ Needle Far Away', 'beta',
'http://streams.test/needle', 0, '{}');
for (let i = 0; i < 3; i++) {
const id = `secondary-${i}`;
insertItem.run(`2:${id}`, 2, id, `Other Channel ${i}`, 'other',
`http://streams.test/${id}`, 0, '{}');
}
insertItem.run('1:a-head', 1, 'a-head', 'A', 'key-a', 'http://streams.test/a-head', 0, '{}');
insertItem.run('1:a-tie', 1, 'a-tie', 'Tie', 'key-a', 'http://streams.test/a-tie', 0, '{}');
insertItem.run('1:z-tie', 1, 'z-tie', 'Tie', 'key-a', 'http://streams.test/z-tie', 0, '{}');
insertItem.run('2:b-tie', 2, 'b-tie', 'Tie', 'key-a', 'http://streams.test/b-tie', 0, '{}');
insertItem.run('4:c-tie', 4, 'c-tie', 'Tie', 'key-a', 'http://streams.test/c-tie', 0, '{}');
insertItem.run('1:z-last', 1, 'z-last', 'Z', 'key-a', 'http://streams.test/z-last', 0, '{}');
insertItem.run('4:b-1', 4, 'b-1', 'Tie', 'key-b', 'http://streams.test/b-1', 0, '{}');
insertItem.run('4:b-2', 4, 'b-2', 'Tie', 'key-b', 'http://streams.test/b-2', 0, '{}');
db.prepare(`INSERT INTO favorites (user_id, source_id, item_id, item_type) VALUES (?, 1, 'needle', 'channel')`).run(userId);
db.prepare(`INSERT INTO favorites (user_id, source_id, item_id, item_type) VALUES (9999, 1, 'primary-0000', 'channel')`).run();
});
insert();
db.close();
}
function seedIdentityAndOrderCases(dir) {
const db = new Database(path.join(dir, 'content.db'));
const insertCategory = db.prepare(`
INSERT INTO categories (id, source_id, category_id, type, name, is_hidden)
VALUES (?, ?, ?, 'live', ?, 0)
`);
insertCategory.run('1:shared', 1, 'shared', 'Shared Collision Group');
insertCategory.run('4:shared', 4, 'shared', 'Shared Collision Group');
insertCategory.run('1:eclair-upper', 1, 'eclair-upper', 'Eclair');
insertCategory.run('1:eclair-lower', 1, 'eclair-lower', 'eclair');
insertCategory.run('1:eclair-accent', 1, 'eclair-accent', 'Éclair');
insertCategory.run('4:ws-tab', 4, 'ws-tab', '\tWhitespaceGroup\t');
insertCategory.run('4:ws-ascii', 4, 'ws-ascii', ' WhitespaceGroup ');
insertCategory.run('4:ws-unicode', 4, 'ws-unicode', '\u00a0WhitespaceGroup\u00a0');
const insertItem = db.prepare(`
INSERT INTO playlist_items (id, source_id, item_id, type, name, category_id, stream_url, is_hidden, data)
VALUES (?, ?, ?, 'live', ?, ?, ?, 0, '{}')
`);
const insert = db.transaction(() => {
// Same provider stream identifier in two sources; source-scoped IDs must remain distinct.
insertItem.run('1:same-provider-id', 1, 'same-provider-id', 'Shared Collision Channel', 'shared',
'http://streams.test/shared-primary');
insertItem.run('4:same-provider-id', 4, 'same-provider-id', 'Shared Collision Channel', 'shared',
'http://streams.test/shared-tertiary');
// Case-folded ties and an accented group exercise the normalized and binary tie-breaks.
for (const [categoryId, suffix] of [['eclair-upper', 'upper'], ['eclair-lower', 'lower'], ['eclair-accent', 'accent']]) {
insertItem.run(`1:${suffix}-upper`, 1, `${suffix}-upper`, 'CaseVariant', categoryId,
`http://streams.test/${suffix}-upper`);
insertItem.run(`1:${suffix}-lower`, 1, `${suffix}-lower`, 'casevariant', categoryId,
`http://streams.test/${suffix}-lower`);
}
for (const [categoryId, groupKind] of [['ws-tab', 'tab'], ['ws-ascii', 'ascii'], ['ws-unicode', 'unicode']]) {
insertItem.run(`4:${groupKind}-tab`, 4, `${groupKind}-tab`, '\tWhitespaceProbe\t', categoryId,
`http://streams.test/${groupKind}-tab`);
insertItem.run(`4:${groupKind}-ascii`, 4, `${groupKind}-ascii`, ' WhitespaceProbe ', categoryId,
`http://streams.test/${groupKind}-ascii`);
insertItem.run(`4:${groupKind}-unicode`, 4, `${groupKind}-unicode`, '\u00a0WhitespaceProbe\u00a0', categoryId,
`http://streams.test/${groupKind}-unicode`);
}
});
insert();
db.close();
}
function clientPageFixture() {
const played = [];
const context = {
window: {},
document: { querySelectorAll: () => [] },
console: { log() {}, warn() {}, error() {} },
setTimeout, clearTimeout, AbortController, Promise
};
vm.createContext(context);
vm.runInContext(fs.readFileSync(path.join(__dirname, '../public/js/components/ChannelList.js'), 'utf8'), context);
vm.runInContext(fs.readFileSync(path.join(__dirname, '../public/js/pages/HomePage.js'), 'utf8'), context);
const channelList = Object.create(context.window.ChannelList.prototype);
Object.assign(channelList, {
container: { querySelectorAll: () => [], querySelector: () => null },
channels: [], renderedChannels: [], currentChannel: null,
catalogVersion: 1, catalogScopeVersion: 1, hasMore: false, nextCursor: null,
catalogPagePromise: null, isLoading: false, favoriteCatalog: [], hiddenItems: new Set(), showHidden: false
});
channelList.setVisibleTestChannels = channels => {
channelList.channels = channels;
channelList.sortedGroups = ['Test'];
channelList.groupedChannels = { Test: channels };
};
context.window.app = {
channelList,
navigateTo() {},
player: { currentChannel: null, async play(channel, streamUrl) { played.push({ channel, streamUrl }); } }
};
return { context, channelList, played };
}
test('live catalog pages bound the initial response and preserve filters across cursor pages', async t => {
const fixture = await startApp(t);
// Initialize the SQLite schema and search indexes before seeding deterministic rows.
const initialized = await request(fixture.base, fixture.cookie);
assert.equal(initialized.status, 200);
seedCatalog(fixture.dir, fixture.userId);
const first = await request(fixture.base, fixture.cookie);
assert.equal(first.status, 200);
assert.equal(first.body.items.length, 100);
assert.equal(first.body.page.hasMore, true);
assert.ok(!first.body.items.some(item => item.streamId === 'needle'));
const all = await requestAllPages(fixture.base, fixture.cookie);
const identities = all.map(item => `${item.sourceId}:${item.streamId}`);
assert.equal(all.length, 1209);
assert.equal(new Set(identities).size, all.length, 'cursor pages must not repeat channels');
const completeCatalogBytes = Buffer.byteLength(JSON.stringify(all));
assert.ok(first.bytes < completeCatalogBytes,
`first page ${first.bytes} bytes should be smaller than complete catalog ${completeCatalogBytes} bytes`);
assert.ok(all.some(item => item.streamId === 'needle'));
assert.ok(!all.some(item => item.sourceId === 3), 'disabled sources are excluded');
const limited = await request(fixture.base, fixture.cookie, { limit: '37' });
assert.equal(limited.status, 200);
assert.equal(limited.body.items.length, 37);
assert.equal(limited.body.page.hasMore, true);
const beyondFirstPage = await request(fixture.base, fixture.cookie, { q: 'Needle Far Away' });
assert.equal(beyondFirstPage.status, 200);
assert.deepEqual(beyondFirstPage.body.items.map(item => item.streamId), ['needle']);
const groupSearch = await requestAllPages(fixture.base, fixture.cookie, { q: 'Alpha Group' });
assert.equal(groupSearch.length, 600);
assert.ok(groupSearch.every(item => item.groupTitle === 'Alpha Group'));
const sourceFiltered = await request(fixture.base, fixture.cookie, { sourceId: '2' });
assert.equal(sourceFiltered.status, 200);
assert.deepEqual(sourceFiltered.body.items.map(item => item.sourceId), [2, 2, 2, 2]);
const categoryFiltered = await requestAllPages(fixture.base, fixture.cookie, { sourceId: '1', categoryId: 'beta' });
assert.equal(categoryFiltered.length, 598);
assert.ok(categoryFiltered.every(item => item.groupId === 'beta'));
const includeHidden = await requestAllPages(fixture.base, fixture.cookie, { sourceId: '1', includeHidden: 'true' });
assert.equal(includeHidden.length, 1204);
assert.equal(includeHidden.filter(item => item.isHidden).length, 2);
const hiddenGroup = await request(fixture.base, fixture.cookie, { sourceId: '1', categoryId: 'hidden' });
assert.equal(hiddenGroup.body.items.length, 0);
const hiddenGroupIncluded = await request(fixture.base, fixture.cookie,
{ sourceId: '1', categoryId: 'hidden', includeHidden: 'true' });
assert.equal(hiddenGroupIncluded.body.items.length, 1);
assert.equal(hiddenGroupIncluded.body.items[0].isHidden, true);
const favoriteOnly = await request(fixture.base, fixture.cookie, { favoriteOnly: 'true' });
assert.equal(favoriteOnly.status, 200);
assert.deepEqual(favoriteOnly.body.items.map(item => item.streamId), ['needle']);
const otherUserFavorite = await request(fixture.base, fixture.cookie,
{ sourceId: '1', favoriteOnly: 'true', q: 'Channel 0000' });
assert.equal(otherUserFavorite.body.items.length, 0);
});
test('keyset cursors retain group, channel, source, and item ordering at page boundaries', async t => {
const fixture = await startApp(t);
const initialized = await request(fixture.base, fixture.cookie);
assert.equal(initialized.status, 200);
seedCatalog(fixture.dir, fixture.userId);
const ordered = await requestAllPages(fixture.base, fixture.cookie, { limit: '2', q: 'Tie' });
assert.deepEqual(ordered.map(item => `${item.groupTitle}:${item.sourceId}:${item.streamId}`), [
'A Group:1:a-tie', 'A Group:1:z-tie', 'A Group:2:b-tie',
'A Group:4:c-tie', 'B Group:4:b-1', 'B Group:4:b-2'
]);
});
test('case and accent group ties plus case-matched channels have deterministic order across every cursor page', async t => {
const fixture = await startApp(t);
const initialized = await request(fixture.base, fixture.cookie);
assert.equal(initialized.status, 200);
seedCatalog(fixture.dir, fixture.userId);
seedIdentityAndOrderCases(fixture.dir);
const ordered = await requestAllPages(fixture.base, fixture.cookie, { limit: '2', q: 'CaseVariant' });
assert.deepEqual(ordered.map(item => `${item.groupTitle}:${item.streamId}`), [
'Eclair:upper-upper', 'Eclair:upper-lower',
'eclair:lower-upper', 'eclair:lower-lower',
'Éclair:accent-upper', 'Éclair:accent-lower'
]);
const identities = ordered.map(item => `${item.sourceId}:${item.streamId}`);
assert.equal(new Set(identities).size, ordered.length, 'all cursor pages must return each channel exactly once');
});
test('tab and Unicode whitespace sort like SQLite ASCII trim rules across every cursor page', async t => {
const fixture = await startApp(t);
const initialized = await request(fixture.base, fixture.cookie);
assert.equal(initialized.status, 200);
seedCatalog(fixture.dir, fixture.userId);
seedIdentityAndOrderCases(fixture.dir);
const ordered = await requestAllPages(fixture.base, fixture.cookie, { limit: '2', q: 'WhitespaceProbe' });
assert.deepEqual(ordered.map(item => `${item.groupTitle}|${item.name}`), [
...['\tWhitespaceGroup\t', ' WhitespaceGroup ', '\u00a0WhitespaceGroup\u00a0'].flatMap(group => [
`${group}|\tWhitespaceProbe\t`, `${group}| WhitespaceProbe `, `${group}|\u00a0WhitespaceProbe\u00a0`
])
]);
const identities = ordered.map(item => `${item.sourceId}:${item.streamId}`);
assert.equal(new Set(identities).size, 9, 'all whitespace variants must appear exactly once across cursor pages');
});
test('the same provider stream ID in two sources keeps distinct identities and selects the requested source in Chromium',
{ skip: process.env.NODECAST_BROWSER_TESTS === '1' ? false : 'Browser tests enabled in CI', timeout: 60000 }, async t => {
const { chromium } = require('playwright');
const fixture = await startApp(t);
const initialized = await request(fixture.base, fixture.cookie);
assert.equal(initialized.status, 200);
seedCatalog(fixture.dir, fixture.userId);
seedIdentityAndOrderCases(fixture.dir);
const duplicates = await requestAllPages(fixture.base, fixture.cookie, { q: 'Shared Collision Channel' });
assert.equal(duplicates.length, 2);
assert.deepEqual(duplicates.map(channel => channel.streamId), ['same-provider-id', 'same-provider-id']);
assert.deepEqual(duplicates.map(channel => channel.sourceId), [1, 4]);
assert.equal(new Set(duplicates.map(channel => channel.id)).size, 2,
'the API identity must distinguish the same provider stream ID from different sources');
const browser = await chromium.launch({ headless: true,
executablePath: process.env.NODECAST_BROWSER_EXECUTABLE || undefined });
t.after(() => browser.close());
const context = await browser.newContext();
const separator = fixture.cookie.indexOf('=');
await context.addCookies([{ name: fixture.cookie.slice(0, separator),
value: fixture.cookie.slice(separator + 1), url: fixture.base }]);
const page = await context.newPage();
const pageErrors = [];
page.on('pageerror', error => pageErrors.push(error.message));
await page.route('https://cdn.jsdelivr.net/npm/hls.js@1.5.7/dist/hls.min.js', route => route.abort());
await page.route('https://fonts.**', route => route.abort());
await page.goto(`${fixture.base}/#live`);
await page.waitForFunction(() => window.app?.channelList?.channels?.length === 200
&& !window.app.channelList.isLoading);
await page.locator('#channel-search').fill('Shared Collision Channel');
await page.waitForFunction(() => window.app?.channelList?.channels?.length === 2
&& !window.app.channelList.isLoading);
await page.evaluate(() => {
window.__played = null;
window.app.player.play = async (channel, streamUrl) => {
window.__played = { id: channel.id, sourceId: channel.sourceId, streamUrl };
};
});
const target = duplicates.find(channel => channel.sourceId === 4);
await page.evaluate(async targetChannel => {
await window.app.channelList.selectChannel({ channelId: targetChannel.id, sourceId: targetChannel.sourceId });
window.app.channelList.expandAll();
}, target);
const result = await page.evaluate(() => ({
played: window.__played,
current: { id: window.app.channelList.currentChannel.id, sourceId: window.app.channelList.currentChannel.sourceId },
active: [...document.querySelectorAll('.channel-item.active')].map(item => ({
id: item.dataset.channelId, sourceId: Number(item.dataset.sourceId)
}))
}));
assert.deepEqual(result.played, {
id: target.id, sourceId: 4, streamUrl: 'http://streams.test/shared-tertiary'
});
assert.deepEqual(result.current, { id: target.id, sourceId: 4 });
assert.deepEqual(result.active, [{ id: target.id, sourceId: 4 }]);
await page.locator('#channel-search').fill('CaseVariant');
await page.waitForFunction(() => window.app?.channelList?.channels?.length === 6
&& !window.app.channelList.isLoading);
await page.evaluate(() => window.app.channelList.expandAll());
const orderedDisplay = await page.evaluate(() => ({
visible: [...document.querySelectorAll('.channel-item')].map(item => item.dataset.channelId),
navigation: window.app.channelList.getVisibleChannels().map(channel => channel.id)
}));
const expectedOrder = (await requestAllPages(fixture.base, fixture.cookie,
{ limit: '2', q: 'CaseVariant' })).map(channel => channel.id);
assert.deepEqual(orderedDisplay.visible, expectedOrder);
assert.deepEqual(orderedDisplay.navigation, expectedOrder,
'zap navigation order must match the channels displayed across cursor pages');
await page.evaluate(async () => {
const list = window.app.channelList;
list.currentChannel = list.getVisibleChannels()[0];
await list.queueZap(1);
});
const zapped = await page.evaluate(() => window.__played);
assert.equal(zapped.id, expectedOrder[1]);
assert.equal(zapped.sourceId, 1);
await page.locator('#channel-search').fill('WhitespaceProbe');
await page.waitForFunction(() => window.app?.channelList?.channels?.length === 9
&& !window.app.channelList.isLoading);
await page.evaluate(() => window.app.channelList.expandAll());
const whitespaceDisplay = await page.evaluate(() => ({
visible: [...document.querySelectorAll('.channel-item')].map(item => item.dataset.channelId),
navigation: window.app.channelList.getVisibleChannels().map(channel => channel.id)
}));
const whitespaceOrder = (await requestAllPages(fixture.base, fixture.cookie,
{ limit: '2', q: 'WhitespaceProbe' })).map(channel => channel.id);
assert.deepEqual(whitespaceDisplay.visible, whitespaceOrder);
assert.deepEqual(whitespaceDisplay.navigation, whitespaceOrder,
'display and zap order must retain tab and Unicode whitespace distinctions from ASCII spaces');
await page.evaluate(async () => {
const list = window.app.channelList;
list.currentChannel = list.getVisibleChannels()[0];
await list.queueZap(1);
});
assert.equal((await page.evaluate(() => window.__played)).id, whitespaceOrder[1]);
assert.deepEqual(pageErrors, []);
await context.close();
});
test('zapping forward from the last channel in page one fetches and plays page two first', async () => {
const { channelList, played } = clientPageFixture();
const pages = [
[0, 1].map(id => ({ id: `ch${id}`, sourceId: 1, sourceType: 'm3u', url: `http://streams.test/ch${id}` })),
[2, 3].map(id => ({ id: `ch${id}`, sourceId: 1, sourceType: 'm3u', url: `http://streams.test/ch${id}` }))
];
channelList.setVisibleTestChannels([...pages[0]]);
channelList.currentChannel = pages[0][1];
channelList.contextPageLoads = [];
channelList.hasMore = true;
channelList.nextCursor = 'page-2';
channelList.loadCatalogPage = async append => {
assert.equal(append, true);
channelList.contextPageLoads.push(channelList.nextCursor);
channelList.channels.push(...pages[1]);
channelList.hasMore = false;
channelList.nextCursor = null;
return true;
};
await channelList.queueZap(1);
assert.deepEqual(channelList.contextPageLoads, ['page-2']);
assert.equal(played.length, 1);
assert.equal(played[0].channel.id, 'ch2');
assert.equal(channelList.currentChannel.id, 'ch2');
});
test('zapping previous from page one loads through the true end before wrapping', async () => {
const { channelList, played } = clientPageFixture();
const pages = [
[0, 1], [2, 3], [4]
].map(page => page.map(id => ({
id: `ch${id}`, sourceId: 1, sourceType: 'm3u', url: `http://streams.test/ch${id}`
})));
channelList.setVisibleTestChannels([...pages[0]]);
channelList.currentChannel = pages[0][0];
channelList.contextPageLoads = [];
channelList.hasMore = true;
channelList.nextCursor = 'page-2';
channelList.loadCatalogPage = async append => {
assert.equal(append, true);
channelList.contextPageLoads.push(channelList.nextCursor);
const nextPage = channelList.contextPageLoads.length;
channelList.channels.push(...pages[nextPage]);
channelList.hasMore = nextPage < pages.length - 1;
channelList.nextCursor = channelList.hasMore ? `page-${nextPage + 2}` : null;
return true;
};
await channelList.queueZap(-1);
assert.deepEqual(channelList.contextPageLoads, ['page-2', 'page-3']);
assert.equal(played.length, 1);
assert.equal(played[0].channel.id, 'ch4');
assert.equal(channelList.currentChannel.id, 'ch4');
});
test('failed cross-page fetch or playback keeps the current channel selected', async () => {
const { channelList, played } = clientPageFixture();
const current = { id: 'last-on-page', sourceId: 1, sourceType: 'm3u', url: 'http://streams.test/current' };
channelList.setVisibleTestChannels([current]);
channelList.currentChannel = current;
channelList.hasMore = true;
channelList.nextCursor = 'page-2';
channelList.loadCatalogPage = async () => false;
await channelList.queueZap(1);
assert.equal(channelList.currentChannel, current);
assert.deepEqual(played, []);
const playbackFixture = clientPageFixture();
const playbackCurrent = { ...current, id: 'before-playback-failure' };
const next = { id: 'next', sourceId: 1, sourceType: 'm3u', url: 'http://streams.test/next' };
playbackFixture.channelList.setVisibleTestChannels([playbackCurrent]);
playbackFixture.channelList.currentChannel = playbackCurrent;
playbackFixture.channelList.hasMore = true;
playbackFixture.channelList.nextCursor = 'page-2';
playbackFixture.channelList.loadCatalogPage = async () => {
playbackFixture.channelList.channels.push(next);
playbackFixture.channelList.hasMore = false;
playbackFixture.channelList.nextCursor = null;
return true;
};
playbackFixture.channelList.selectChannel = async () => {
playbackFixture.channelList.currentChannel = next;
throw new Error('simulated playback failure');
};
await playbackFixture.channelList.queueZap(1);
assert.equal(playbackFixture.channelList.currentChannel, playbackCurrent);
assert.deepEqual(playbackFixture.played, []);
});
test('Home favorite can launch a channel absent from the loaded Live page', () => {
const { context, channelList, played } = clientPageFixture();
const favorite = { id: 'home-only', sourceId: 2, sourceType: 'm3u', url: 'http://streams.test/home-only' };
channelList.channels = [{ id: 'live-page-item', sourceId: 1, sourceType: 'm3u', url: 'http://streams.test/live' }];
channelList.favoriteCatalog = [favorite];
let navigatedTo = null;
context.window.app.navigateTo = page => { navigatedTo = page; };
context.window.HomePage.prototype.playChannel.call({ app: context.window.app }, favorite.id, favorite.sourceId);
assert.equal(navigatedTo, 'live');
assert.equal(played.length, 1);
assert.equal(played[0].channel.id, favorite.id);
assert.equal(played[0].channel.sourceId, favorite.sourceId);
assert.equal(played[0].streamUrl, favorite.url);
});
test('Live search debounce, Enter, Search button, Load more, and channel selection work in Chromium',
{ skip: process.env.NODECAST_BROWSER_TESTS === '1' ? false : 'Browser tests enabled in CI', timeout: 60000 }, async t => {
const { chromium } = require('playwright');
const fixture = await startApp(t);
const initialized = await request(fixture.base, fixture.cookie);
assert.equal(initialized.status, 200);
seedCatalog(fixture.dir, fixture.userId);
const browser = await chromium.launch({ headless: true,
executablePath: process.env.NODECAST_BROWSER_EXECUTABLE || undefined });
t.after(() => browser.close());
const context = await browser.newContext();
const separator = fixture.cookie.indexOf('=');
await context.addCookies([{ name: fixture.cookie.slice(0, separator),
value: fixture.cookie.slice(separator + 1), url: fixture.base }]);
const page = await context.newPage();
const catalogRequests = [];
const pageErrors = [];
page.on('request', req => {
const url = new URL(req.url());
if (url.pathname === '/api/channels/live-catalog') catalogRequests.push(url.searchParams);
});
page.on('pageerror', error => pageErrors.push(error.message));
await page.route('https://cdn.jsdelivr.net/npm/hls.js@1.5.7/dist/hls.min.js', route => route.abort());
await page.route('https://fonts.**', route => route.abort());
await page.goto(`${fixture.base}/#live`);
await page.waitForFunction(() => window.app?.channelList?.channels?.length === 200
&& !window.app.channelList.isLoading);
assert.equal(catalogRequests[0].get('limit'), '200');
assert.equal(await page.locator('#channel-search-submit').isVisible(), true);
assert.equal(await page.locator('.live-catalog-more').isVisible(), true);
const initialRequestCount = catalogRequests.length;
await page.locator('#channel-search').fill('Needle Far Away');
await page.waitForTimeout(100);
assert.equal(catalogRequests.length, initialRequestCount, 'debounce should defer the request');
await page.waitForFunction(() => window.app?.channelList?.channels?.some(channel => channel.streamId === 'needle'));
assert.equal(catalogRequests.at(-1).get('q'), 'Needle Far Away');
assert.equal(await page.evaluate(() => window.app.channelList.channels.length), 1);
await page.locator('#channel-search').fill('Alpha Group');
await page.waitForTimeout(50);
const enterCount = catalogRequests.length;
const enterRequest = page.waitForRequest(req => {
const url = new URL(req.url());
return url.pathname === '/api/channels/live-catalog' && url.searchParams.get('q') === 'Alpha Group';
});
await page.locator('#channel-search').press('Enter');
await enterRequest;
await page.waitForFunction(() => window.app?.channelList?.channels?.length > 0
&& window.app.channelList.channels.every(channel => channel.groupTitle === 'Alpha Group'));
assert.ok(catalogRequests.length > enterCount);
assert.equal(catalogRequests.at(-1).get('q'), 'Alpha Group');
await page.locator('#channel-search').fill('Other Group');
const buttonRequest = page.waitForRequest(req => {
const url = new URL(req.url());
return url.pathname === '/api/channels/live-catalog' && url.searchParams.get('q') === 'Other Group';
});
await page.locator('#channel-search-submit').click();
await buttonRequest;
await page.waitForFunction(() => window.app?.channelList?.channels?.length === 3
&& window.app.channelList.channels.every(channel => channel.groupTitle === 'Other Group'));
assert.equal(catalogRequests.at(-1).get('q'), 'Other Group');
await page.locator('#channel-search').fill('');
await page.waitForFunction(() => window.app?.channelList?.channels?.length === 200
&& !window.app.channelList.isLoading);
const beforeMore = catalogRequests.length;
await page.locator('.live-catalog-more').click();
await page.waitForFunction(() => window.app?.channelList?.channels?.length === 400
&& !window.app.channelList.isLoading);
assert.ok(catalogRequests.length > beforeMore);
assert.ok(catalogRequests.at(-1).get('cursor'));
await page.evaluate(() => {
window.__selectedChannel = null;
window.app.player.play = async (channel, streamUrl) => {
window.__selectedChannel = { id: channel.id, sourceId: channel.sourceId, streamUrl };
};
window.app.channelList.expandAll();
});
const firstItem = page.locator('.channel-item').first();
await firstItem.waitFor({ state: 'visible' });
await firstItem.click();
await page.waitForFunction(() => window.__selectedChannel !== null);
const selected = await page.evaluate(() => window.__selectedChannel);
assert.ok(selected.id);
assert.ok(selected.sourceId);
assert.match(selected.streamUrl, /^http:\/\/streams\.test\//);
assert.deepEqual(pageErrors, []);
await context.close();
});
test('Guide includes visible channels in hidden groups and excludes individually hidden channels',
{ skip: process.env.NODECAST_BROWSER_TESTS === '1' ? false : 'Browser tests enabled in CI', timeout: 60000 }, async t => {
const { chromium } = require('playwright');
const fixture = await startApp(t);
const initialized = await request(fixture.base, fixture.cookie);
assert.equal(initialized.status, 200);
seedCatalog(fixture.dir, fixture.userId);
const browser = await chromium.launch({ headless: true,
executablePath: process.env.NODECAST_BROWSER_EXECUTABLE || undefined });
t.after(() => browser.close());
const context = await browser.newContext();
const separator = fixture.cookie.indexOf('=');
await context.addCookies([{ name: fixture.cookie.slice(0, separator),
value: fixture.cookie.slice(separator + 1), url: fixture.base }]);
const page = await context.newPage();
const catalogRequests = [];
const pageErrors = [];
page.on('request', req => {
const url = new URL(req.url());
if (url.pathname === '/api/channels/live-catalog') catalogRequests.push(url.searchParams);
});
page.on('pageerror', error => pageErrors.push(error.message));
await page.route('**/api/proxy/epg/**', route => route.fulfill({
status: 200, contentType: 'application/json',
body: JSON.stringify({ channels: [], programmes: [] })
}));
await page.route('https://cdn.jsdelivr.net/npm/hls.js@1.5.7/dist/hls.min.js', route => route.abort());
await page.route('https://fonts.**', route => route.abort());
await page.goto(`${fixture.base}/#guide`);
await page.waitForFunction(() => window.app?.channelList?.guideCatalog?.length >= 1200
&& window.app?.epgGuide?.allMatchedChannels?.length > 0);
assert.ok(catalogRequests.some(params => params.get('includeHidden') === 'true'));
const guideState = await page.evaluate(() => {
const guide = window.app.epgGuide;
const ids = guide.allMatchedChannels.map(match => match.sourceChannel.streamId || match.sourceChannel.id);
return {
hiddenGroupChannelIncluded: ids.includes('hidden-group'),
individuallyHiddenChannelIncluded: ids.includes('hidden-item'),
groupOptions: [...document.querySelector('#epg-group-select').options].map(option => option.value)
};
});
assert.equal(guideState.hiddenGroupChannelIncluded, true);
assert.equal(guideState.individuallyHiddenChannelIncluded, false);
assert.ok(guideState.groupOptions.includes('Hidden Group'));
await page.locator('#epg-group-select').selectOption({ label: 'Hidden Group' });
await page.waitForFunction(() => window.app.epgGuide.filteredChannels.length === 1
&& window.app.epgGuide.filteredChannels[0].sourceChannel.streamId === 'hidden-group');
await page.locator('#epg-search').fill('Hidden Item Channel');
await page.waitForFunction(() => window.app.epgGuide.filteredChannels.length === 0);
assert.deepEqual(pageErrors, []);
await context.close();
});