feat(live): paginate catalog and search server-side (#49)
Load Live channels from a filtered SQLite cursor API, keep navigation and guide consumers working across pages, and retain the QA reproduction coverage. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@ -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">×</button>
|
<button type="button" class="search-clear" title="Clear search" aria-label="Clear search">×</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">
|
||||||
|
|||||||
@ -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 }),
|
||||||
|
|||||||
@ -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');
|
||||||
|
|||||||
@ -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, "&")
|
.replace(/&/g, "&")
|
||||||
.replace(/</g, "<")
|
.replace(/</g, "<")
|
||||||
.replace(/>/g, ">")
|
.replace(/>/g, ">")
|
||||||
@ -289,62 +309,84 @@ class ChannelList {
|
|||||||
.replace(/'/g, "'");
|
.replace(/'/g, "'");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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,163 +742,97 @@ class ChannelList {
|
|||||||
* Load channels from selected source
|
* Load channels from selected source
|
||||||
*/
|
*/
|
||||||
async loadChannels() {
|
async loadChannels() {
|
||||||
if (this.isLoading) return;
|
clearTimeout(this.searchTimeout);
|
||||||
this.isLoading = true;
|
this.catalogVersion++;
|
||||||
this.currentRenderId = null; // Reset render tracking
|
this.catalogController?.abort();
|
||||||
|
this.catalogScopeVersion = this.catalogVersion;
|
||||||
const sourceValue = this.sourceSelect.value;
|
this.catalogQuery = this.searchInput.value.trim();
|
||||||
const self = this;
|
this.catalogParams = {
|
||||||
|
sourceId: this.sourceSelect.value.split(':')[1],
|
||||||
if (!sourceValue) {
|
q: this.catalogQuery,
|
||||||
// Load from all sources
|
includeHidden: this.showHiddenCheckbox?.checked || false,
|
||||||
await this.loadAllChannels();
|
limit: 200
|
||||||
this.isLoading = false;
|
};
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [type, id] = sourceValue.split(':');
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.container.innerHTML = '<div class="loading"></div>';
|
|
||||||
|
|
||||||
if (type === 'xtream') {
|
|
||||||
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()
|
|
||||||
]);
|
|
||||||
|
|
||||||
this.render();
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error loading channels:', err);
|
|
||||||
this.container.innerHTML = `<div class="empty-state"><p>Error loading channels</p><p class="hint">${err.message}</p></div>`;
|
|
||||||
} finally {
|
|
||||||
this.isLoading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load channels from all enabled sources
|
|
||||||
*/
|
|
||||||
async loadAllChannels() {
|
|
||||||
this.channels = [];
|
this.channels = [];
|
||||||
this.groups = [];
|
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.catalogStatus.textContent = this.catalogQuery ? 'Searching…' : 'Loading…';
|
||||||
|
this.container.setAttribute('aria-busy', 'true');
|
||||||
|
this.loadMoreButton.disabled = true;
|
||||||
|
if (!append) this.container.replaceChildren();
|
||||||
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),
|
||||||
const xtreamSources = this.sources.filter(s => s.type === 'xtream' && s.enabled);
|
...(append ? [] : [this.loadHiddenItems(), this.loadFavorites()])
|
||||||
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()
|
|
||||||
]);
|
]);
|
||||||
|
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 all channels:', err);
|
if (version !== this.catalogVersion || err.name === 'AbortError') return false;
|
||||||
|
this.catalogStatus.textContent = 'Error loading channels';
|
||||||
|
this.loadMoreButton.textContent = 'Retry';
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
if (version === this.catalogVersion) {
|
||||||
|
this.isLoading = false;
|
||||||
|
this.container.setAttribute('aria-busy', 'false');
|
||||||
|
this.loadMoreButton.disabled = false;
|
||||||
|
this.loadMoreButton.hidden = !this.hasMore && this.loadMoreButton.textContent !== 'Retry';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load hidden items
|
* Load hidden items
|
||||||
*/
|
*/
|
||||||
@ -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.
|
||||||
// Fallback: Find matching channel ID, prioritizing same render group
|
if (this.catalogPagePromise?.version === version) {
|
||||||
if (currentIndex === -1) {
|
if (!await this.catalogPagePromise.promise) return;
|
||||||
// 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 (version !== this.catalogVersion || this.isLoading) return;
|
||||||
if (currentIndex === -1) {
|
const current = window.app?.player?.currentChannel || this.currentChannel;
|
||||||
currentIndex = this.renderedChannels.findIndex(c =>
|
const selection = this.currentChannel;
|
||||||
c.id === this.currentChannel.id && c.sourceId === this.currentChannel.sourceId
|
const streamId = current?.streamId ?? current?.id;
|
||||||
);
|
if (current?.sourceId == null || streamId == null) return;
|
||||||
|
const matches = channel => String(channel.sourceId) === String(current.sourceId) &&
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
}
|
if (!isCurrent()) return;
|
||||||
|
if (index === -1) {
|
||||||
if (currentIndex === -1) return;
|
this.catalogStatus.textContent = 'Current channel is outside the Live filters. Select a channel in the list to change channels.';
|
||||||
|
return;
|
||||||
const nextIndex = (currentIndex + 1) % this.renderedChannels.length;
|
}
|
||||||
const nextChannel = this.renderedChannels[nextIndex];
|
// 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.
|
||||||
this.selectChannel({
|
while (this.hasMore && (direction < 0 ? index <= 0 : index >= channels.length - 1)) {
|
||||||
channelId: nextChannel.id,
|
const cursor = this.nextCursor;
|
||||||
sourceId: nextChannel.sourceId,
|
if (!await this.loadCatalogPage(true)) return;
|
||||||
sourceType: nextChannel.sourceType,
|
if (!isCurrent()) return;
|
||||||
streamId: nextChannel.streamId,
|
channels = this.getVisibleChannels();
|
||||||
url: nextChannel.url,
|
index = channels.findIndex(matches);
|
||||||
renderId: nextChannel._renderId // Pass the unique render ID
|
if (index === -1 || (this.hasMore && this.nextCursor === cursor)) return;
|
||||||
});
|
}
|
||||||
}
|
if (!isCurrent() || index === -1 || !channels.length) return;
|
||||||
|
const neighbor = direction < 0
|
||||||
/**
|
? channels[index === 0 ? channels.length - 1 : index - 1]
|
||||||
* Select previous channel in the current list
|
: channels[index >= channels.length - 1 ? 0 : index + 1];
|
||||||
*/
|
try {
|
||||||
selectPrevChannel() {
|
await this.selectChannel({ channelId: neighbor.id, sourceId: neighbor.sourceId, catalogVersion: version });
|
||||||
if (!this.currentChannel || !this.renderedChannels || this.renderedChannels.length === 0) return;
|
} catch (err) {
|
||||||
|
if (this.currentChannel === neighbor) this.currentChannel = current;
|
||||||
let currentIndex = -1;
|
throw err;
|
||||||
|
}
|
||||||
if (this.currentRenderId) {
|
};
|
||||||
currentIndex = this.renderedChannels.findIndex(c => c._renderId === this.currentRenderId);
|
this.zapQueue = (this.zapQueue || Promise.resolve()).then(run).catch(err => {
|
||||||
}
|
console.error('Error changing channel:', err);
|
||||||
|
|
||||||
// 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;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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;
|
||||||
await this.app.epgGuide.loadEpg();
|
const container = this.app.epgGuide.container;
|
||||||
} else {
|
const channelList = this.app.channelList;
|
||||||
// Just re-render with existing data (updates time position)
|
const needsCatalog = !channelList.guideCatalog || channelList.guideCatalogRevision !== (API.liveCatalogRevision || 0);
|
||||||
this.app.epgGuide.render();
|
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();
|
||||||
|
} else {
|
||||||
|
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;
|
||||||
|
|||||||
@ -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;
|
const channel = channelList.favoriteCatalog?.find(ch =>
|
||||||
if (channelList) {
|
String(ch.id) === String(channelId) && String(ch.sourceId) === String(sourceId));
|
||||||
// Find and select the channel
|
if (!channel) return;
|
||||||
const channel = channelList.channels.find(ch =>
|
this.app.navigateTo('live');
|
||||||
String(ch.id) === String(channelId) && String(ch.sourceId) === String(sourceId)
|
// selectChannel resolves Home DTOs independently of the active Live filters.
|
||||||
);
|
channelList.selectChannel({ channelId: channel.id, sourceId: channel.sourceId });
|
||||||
if (channel) {
|
|
||||||
channelList.selectChannel({
|
|
||||||
channelId: channel.id,
|
|
||||||
sourceId: channel.sourceId,
|
|
||||||
sourceType: channel.sourceType,
|
|
||||||
streamId: channel.streamId || '',
|
|
||||||
url: channel.url || ''
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
renderHistory(items) {
|
renderHistory(items) {
|
||||||
|
|||||||
@ -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');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
137
server/services/liveCatalog.js
Normal file
137
server/services/liveCatalog.js
Normal 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 };
|
||||||
685
test/live-catalog-initial-load.test.js
Normal file
685
test/live-catalog-initial-load.test.js
Normal 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();
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user