Merge develop into main

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-10-23 00:04:48 +02:00
21 changed files with 1948 additions and 394 deletions

View File

@ -7,6 +7,7 @@
.env*
node_modules
data
user_data
transcode-cache
coverage
dist

View File

@ -5,7 +5,7 @@
# - NVIDIA NVENC: Requires nvidia-container-toolkit on host + --gpus flag
# - Intel QSV: Mount /dev/dri
#
# Build: docker compose build
# Build and publish manually: ./build-push.sh [TAG]
# Run with VAAPI: docker run --device /dev/dri:/dev/dri --group-add video ...
FROM ubuntu:24.04

View File

@ -66,14 +66,36 @@ nodecast-tv is a modern, web-based IPTV player featuring Live TV, EPG, Movies (V
You can run nodecast-tv easily using Docker.
Container images are published to `ghcr.io/technomancer702/nodecast-tv` when a GitHub release is published. Use a version tag such as `v2.2.1` for the release: stable releases publish image tags `2.2.1`, `2.2`, and `latest`; prereleases publish their full version tag without updating `latest` or the minor-version alias. Branch pushes, tag pushes alone, and draft releases do not publish images.
This fork uses `gitea.anthonybouteiller.ovh/blomios/nodecast-tv:latest`. Images are built and published manually from the local checkout; Compose pulls the published image and does not build from GitHub.
Before publishing, log in interactively (enter your password or personal access token at the prompt; do not put credentials in this repository):
```bash
docker login gitea.anthonybouteiller.ovh --username blomios
./build-push.sh # Build this checkout and push :latest
./build-push.sh v2.2.7-local # Build and push only this tag
```
The script can be invoked by absolute path from any working directory. It requires Docker and a running Docker daemon, stops if the build fails, and performs a real push when invoked. It uses local files, including uncommitted application changes. `.dockerignore` excludes runtime data, caches, local environment files and credentials. Keep any other secrets outside the build context. Builds target the builder's platform; they do not publish a multi-platform manifest. Publishing a version tag does not also update `latest`.
Gitea's package registry must be enabled and reachable through the reverse proxy at `/v2/`. Your account must be allowed to publish packages under `blomios`. With 2FA/OAuth, use a personal access token with package read/write permissions; private image pulls also require login with package read access. Authentication, package activation and remote push have not been verified by adding this script. See the [Gitea container registry documentation](https://docs.gitea.com/usage/packages/container/).
To use another image repository or deployment tag, set `NODECAST_IMAGE` (registry/path without a tag) and `NODECAST_TAG`. Both the script and Compose support these variables; the script's positional tag takes precedence. Export them in your shell for both commands (the script does not load `.env`):
```bash
export NODECAST_IMAGE=gitea.anthonybouteiller.ovh/blomios/nodecast-tv
export NODECAST_TAG=v2.2.7-local
./build-push.sh
docker compose pull
docker compose up -d
```
1. Create a `docker-compose.yml` file (or copy the one from this repo):
```yaml
services:
nodecast-tv:
build: https://github.com/technomancer702/nodecast-tv.git#main
image: ${NODECAST_IMAGE:-gitea.anthonybouteiller.ovh/blomios/nodecast-tv}:${NODECAST_TAG:-latest}
container_name: nodecast-tv
ports:
- "3000:3000" # Host:Container
@ -87,7 +109,8 @@ Container images are published to `ghcr.io/technomancer702/nodecast-tv` when a G
2. Run the container:
```bash
docker-compose up -d
docker compose pull
docker compose up -d
```
The application will be available at `http://localhost:3000`.

39
build-push.sh Executable file
View File

@ -0,0 +1,39 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
printf '%s\n' 'Usage: build-push.sh [TAG]' \
'Build this checkout and push it to the Gitea container registry.' \
'TAG defaults to NODECAST_TAG or latest; NODECAST_IMAGE overrides the image repository.'
}
if [[ $# -eq 1 && ( $1 == --help || $1 == -h ) ]]; then
usage
exit 0
fi
if [[ $# -gt 1 ]]; then
usage >&2
exit 1
fi
repo_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
image="${NODECAST_IMAGE:-gitea.anthonybouteiller.ovh/blomios/nodecast-tv}"
tag="${1-${NODECAST_TAG:-latest}}"
if [[ ! $tag =~ ^[a-zA-Z0-9_][a-zA-Z0-9_.-]{0,127}$ ]]; then
printf '%s\n' 'Error: Invalid Docker tag (1–128 letters, digits, underscores, dots or hyphens; cannot start with dot/hyphen).' >&2
exit 1
fi
if [[ $image != */* || $image == *://* || $image == *@* || $image =~ [[:space:]] || ${image##*/} == *:* ]]; then
printf '%s\n' 'Error: NODECAST_IMAGE must be a registry/repository path without a tag or digest.' >&2
exit 1
fi
if ! command -v docker >/dev/null 2>&1; then
printf '%s\n' 'Error: Docker is required to build and push the image.' >&2
exit 1
fi
reference="${image}:${tag}"
printf 'Building %s from %s\n' "$reference" "$repo_dir"
docker build --file "$repo_dir/Dockerfile" --tag "$reference" "$repo_dir"
printf 'Pushing %s\n' "$reference"
docker push "$reference"

View File

@ -1,7 +1,6 @@
services:
nodecast-tv:
image: ghcr.io/technomancer702/nodecast-tv:latest
build: https://github.com/technomancer702/nodecast-tv.git#main
image: ${NODECAST_IMAGE:-gitea.anthonybouteiller.ovh/blomios/nodecast-tv}:${NODECAST_TAG:-latest}
container_name: nodecast-tv
ports:
- "3000:3000"

View File

@ -4820,3 +4820,44 @@ kbd {
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-retry {
flex-shrink: 0;
margin: var(--space-sm);
}
.live-catalog-status:empty {
display: none;
}
.channel-sidebar.collapsed .live-catalog-status,
.channel-sidebar.collapsed .live-catalog-retry {
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;
}
.live-catalog-retry[hidden] {
display: none;
}

View File

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

View File

@ -6,9 +6,10 @@ const API = {
/**
* Make API request
*/
async request(method, endpoint, data = null) {
async request(method, endpoint, data = null, signal = undefined) {
const options = {
method,
signal,
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json'
@ -40,6 +41,10 @@ const API = {
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;
},
@ -61,6 +66,13 @@ const API = {
// Channels (hidden items)
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}` : ''}`),
hide: (sourceId, itemType, itemId) => API.request('POST', '/channels/hide', { sourceId, itemType, itemId }),
show: (sourceId, itemType, itemId) => API.request('POST', '/channels/show', { sourceId, itemType, itemId }),

View File

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

View File

@ -23,6 +23,19 @@ class ChannelList {
this.sources = [];
this.isLoading = false;
this.renderedChannels = [];
this.catalogVersion = 0;
this.catalogQuery = '';
this.catalogStatus = document.createElement('div');
this.catalogStatus.setAttribute('role', 'status');
this.catalogStatus.className = 'live-catalog-status';
this.container.after(this.catalogStatus);
this.catalogRetryButton = document.createElement('button');
this.catalogRetryButton.type = 'button';
this.catalogRetryButton.className = 'btn btn-sm live-catalog-retry';
this.catalogRetryButton.textContent = 'Retry';
this.catalogRetryButton.hidden = true;
this.catalogStatus.after(this.catalogRetryButton);
this.catalogRetryButton.addEventListener('click', () => this.loadChannels());
this.loadCollapsedState();
this.init();
@ -145,21 +158,28 @@ class ChannelList {
init() {
// Search handler (debounced)
let searchTimeout;
const searchNow = () => {
clearTimeout(this.searchTimeout);
this.loadChannels();
};
this.searchInput.addEventListener('input', () => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
this.render();
}, 300);
clearTimeout(this.searchTimeout);
this.catalogVersion++;
this.catalogController?.abort();
this.catalogRetryButton.hidden = true;
this.searchTimeout = setTimeout(searchNow, 300);
});
// Source filter handler
this.sourceSelect.addEventListener('change', () => this.loadChannels());
// Show hidden toggle
if (this.showHiddenCheckbox) {
this.showHiddenCheckbox.addEventListener('change', () => this.render());
}
this.searchInput.addEventListener('keydown', e => {
if (e.key === 'Enter') { e.preventDefault(); searchNow(); }
});
document.getElementById('channel-search-submit')?.addEventListener('click', searchNow);
this.searchInput.parentElement.querySelector('.search-clear')?.addEventListener('click', () => {
this.searchInput.value = '';
searchNow();
this.searchInput.focus();
});
this.sourceSelect.addEventListener('change', searchNow);
this.showHiddenCheckbox?.addEventListener('change', searchNow);
// Context menu handlers
document.addEventListener('click', (e) => {
@ -235,7 +255,6 @@ class ChannelList {
});
}
// ... (loadSources, loadChannels, loadAllChannels, loadXtreamChannels, loadM3uChannels, loadHiddenItems, isHidden, loadFavorites, isFavorite, toggleFavorite methods remain same)
/**
* Get current program info string - cached for performance
@ -281,7 +300,7 @@ class ChannelList {
escapeHtml(text) {
if (!text) return '';
return text
return String(text)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
@ -289,62 +308,84 @@ class ChannelList {
.replace(/'/g, "&#039;");
}
compareBinary(left, right) {
const a = Array.from(String(left ?? ''));
const b = Array.from(String(right ?? ''));
for (let i = 0; i < Math.min(a.length, b.length); i++) {
const difference = a[i].codePointAt(0) - b[i].codePointAt(0);
if (difference) return difference;
}
return a.length - b.length;
}
catalogSortKey(value) {
return String(value ?? '').replace(/^ +| +$/g, '').replace(/[A-Z]/g, character => character.toLowerCase());
}
compareCatalogTitles(a, b) {
return this.compareBinary(this.catalogSortKey(a), this.catalogSortKey(b)) || this.compareBinary(a, b);
}
compareCatalogChannels(a, b) {
return this.compareCatalogTitles(a.name, b.name) || Number(a.sourceId) - Number(b.sourceId) ||
this.compareBinary(a.streamId ?? a.id, b.streamId ?? b.id);
}
/**
* Render channel list
*/
render() {
const searchTerm = this.searchInput.value.toLowerCase();
const searchTerm = this.catalogQuery;
const showHidden = this.showHiddenCheckbox ? this.showHiddenCheckbox.checked : false;
// Reset batching
this.currentBatch = 0;
this.batchSize = 100; // Number of groups to render per batch (increased to handle many hidden groups)
this.observer.disconnect();
this.container.innerHTML = ''; // Clear container
// Filter and Group channels
const groupedChannels = {};
const groupedChannels = Object.create(null);
// 1. Filter
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;
// 2. Group
filteredChannels.forEach(ch => {
const groupKey = ch.groupTitle || 'Uncategorized';
const groupKey = ch.groupTitle ?? 'Uncategorized';
if (!groupedChannels[groupKey]) {
groupedChannels[groupKey] = [];
}
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
const favoritedChannels = this.channels.filter(ch => this.isFavorite(ch.sourceId, ch.id));
if (favoritedChannels.length > 0) {
favoritedChannels.sort((a, b) => a.name.localeCompare(b.name));
groupedChannels['Favorites'] = favoritedChannels;
favoritedChannels.sort((a, b) => this.compareCatalogChannels(a, b));
groupedChannels[this.favoriteGroupKey] = favoritedChannels;
}
// 4. Sort Groups and filter to only those with visible channels
const allGroups = Object.keys(groupedChannels).sort((a, b) => {
if (a === 'Favorites') return -1;
if (b === 'Favorites') return 1;
return a.localeCompare(b);
if (a === this.favoriteGroupKey) return -1;
if (b === this.favoriteGroupKey) return 1;
return this.compareCatalogTitles(a, b);
});
// Pre-filter to only include groups with visible channels (so hidden groups don't consume batch slots)
this.sortedGroups = allGroups.filter(groupName => {
if (groupName === 'Favorites') return true;
if (groupName === this.favoriteGroupKey) return true;
const channels = groupedChannels[groupName];
// Check if any channel in this group is visible
return channels.some(channel => {
const rawChannelId = channel.streamId || channel.id;
const rawChannelId = channel.streamId ?? channel.id;
const isHidden = this.isHidden('channel', channel.sourceId, rawChannelId);
return !isHidden || showHidden;
});
@ -357,7 +398,7 @@ class ChannelList {
// This prevents rendering 100K+ channel items on initial load
if (!this._hasCollapsedState && this.sortedGroups.length > 0) {
this.sortedGroups.forEach(groupName => {
if (groupName !== 'Favorites') {
if (groupName !== this.favoriteGroupKey) {
this.collapsedGroups.add(groupName);
}
});
@ -369,11 +410,11 @@ class ChannelList {
this.renderedChannels = [];
this.sortedGroups.forEach(groupName => {
const channels = this.groupedChannels[groupName];
const isFavoritesGroup = groupName === 'Favorites';
const isFavoritesGroup = groupName === this.favoriteGroupKey;
const visibleChannels = channels.filter(channel => {
if (isFavoritesGroup) return true;
const rawChannelId = channel.streamId || channel.id;
const rawChannelId = channel.streamId ?? channel.id;
const channelHidden = this.isHidden('channel', channel.sourceId, rawChannelId);
return !channelHidden || this.showHidden;
});
@ -384,7 +425,7 @@ class ChannelList {
// ensuring no side effects on the main channel object
const renderedCh = {
...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
};
this.renderedChannels.push(renderedCh);
@ -443,18 +484,18 @@ class ChannelList {
this.loader.style.opacity = '1';
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) {
const channels = this.groupedChannels[groupName];
if (channels.length === 0) continue;
const isFavoritesGroup = groupName === 'Favorites';
const isFavoritesGroup = groupName === this.favoriteGroupKey;
// Pre-filter visible channels for this group
const visibleChannels = channels.filter(channel => {
if (isFavoritesGroup) return true;
const rawChannelId = channel.streamId || channel.id;
const rawChannelId = channel.streamId ?? channel.id;
const channelHidden = this.isHidden('channel', channel.sourceId, rawChannelId);
return !channelHidden || this.showHidden;
});
@ -470,9 +511,9 @@ class ChannelList {
html += `
<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-name">${groupName}</span>
<span class="group-name">${this.escapeHtml(isFavoritesGroup ? 'Favorites' : groupName)}</span>
<span class="group-count">${visibleChannels.length}</span>
</div>
<div class="group-channels">
@ -481,6 +522,7 @@ class ChannelList {
// Skip rendering channel items if group is collapsed (major performance optimization)
// Channels will be rendered when user expands the group
if (this.collapsedGroups.has(groupName)) {
renderIndex += visibleChannels.length;
html += '</div></div>';
continue;
}
@ -488,7 +530,7 @@ class ChannelList {
for (const channel of visibleChannels) {
// 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 isActive = this.currentChannel?.id === channel.id;
@ -502,13 +544,13 @@ class ChannelList {
html += `
<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-type="${channel.sourceType}"
data-stream-id="${channel.streamId || ''}"
data-stream-id="${this.escapeHtml(String(channel.streamId ?? ''))}"
data-url="${channel.url || ''}"
data-render-id="${renderId}"
data-render-group="${renderGroup}">
data-render-group="${this.escapeHtml(renderGroup)}">
<img class="channel-logo" src="${this.getProxiedImageUrl(channel.tvgLogo)}"
alt="" onerror="this.onerror=null;this.src='/img/placeholder.png'">
<div class="channel-info">
@ -589,19 +631,19 @@ class ChannelList {
const channels = this.groupedChannels[groupName];
if (!channels || channels.length === 0) return;
const isFavoritesGroup = groupName === 'Favorites';
const isFavoritesGroup = groupName === this.favoriteGroupKey;
// Filter visible channels
const visibleChannels = channels.filter(channel => {
if (isFavoritesGroup) return true;
const rawChannelId = channel.streamId || channel.id;
const rawChannelId = channel.streamId ?? channel.id;
const channelHidden = this.isHidden('channel', channel.sourceId, rawChannelId);
return !channelHidden || this.showHidden;
});
let html = '';
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 isActive = this.currentChannel?.id === channel.id;
const isFavorite = this.isFavorite(channel.sourceId, channel.id);
@ -615,13 +657,13 @@ class ChannelList {
html += `
<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-type="${channel.sourceType}"
data-stream-id="${channel.streamId || ''}"
data-stream-id="${this.escapeHtml(String(channel.streamId ?? ''))}"
data-url="${channel.url || ''}"
data-render-id="${renderId}"
data-render-group="${renderGroup}">
data-render-group="${this.escapeHtml(renderGroup)}">
<img class="channel-logo" src="${this.getProxiedImageUrl(channel.tvgLogo)}"
alt="" onerror="this.onerror=null;this.src='/img/placeholder.png'">
<div class="channel-info">
@ -698,164 +740,86 @@ class ChannelList {
/**
* Load channels from selected source
*/
async loadChannels() {
if (this.isLoading) return;
this.isLoading = true;
this.currentRenderId = null; // Reset render tracking
const sourceValue = this.sourceSelect.value;
const self = this;
if (!sourceValue) {
// Load from all sources
await this.loadAllChannels();
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() {
loadChannels() {
clearTimeout(this.searchTimeout);
this.catalogVersion++;
this.catalogController?.abort();
this.catalogScopeVersion = this.catalogVersion;
this.catalogQuery = this.searchInput.value.trim();
this.catalogParams = {
sourceId: this.sourceSelect.value.split(':')[1],
q: this.catalogQuery,
includeHidden: this.showHiddenCheckbox?.checked || false,
full: true
};
this.channels = [];
this.groups = [];
this.renderedChannels = [];
this.sortedGroups = [];
this.groupedChannels = Object.create(null);
this.currentRenderId = null;
const version = this.catalogVersion;
const promise = this.fetchCatalog();
this.catalogRequest = { version, promise };
promise.finally(() => {
if (this.catalogRequest?.promise === promise) this.catalogRequest = null;
});
return promise;
}
async loadAllChannels() { return this.loadChannels(); }
async fetchCatalog() {
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');
const active = document.activeElement;
const item = active?.closest('.channel-item');
const groupName = item?.closest('.channel-group')?.querySelector('.group-header')?.dataset.group;
const scrollTop = this.container.scrollTop;
const retryHadFocus = active === this.catalogRetryButton;
this.catalogRetryButton.hidden = true;
this.container.replaceChildren();
try {
this.container.innerHTML = '<div class="loading"></div>';
const xtreamSources = this.sources.filter(s => s.type === 'xtream' && s.enabled);
const m3uSources = this.sources.filter(s => s.type === 'm3u' && s.enabled);
console.log('[ChannelList] loadAllChannels: xtream=', xtreamSources.length, 'm3u=', m3uSources.length);
for (const source of xtreamSources) {
await this.loadXtreamChannels(source.id, true);
}
for (const source of m3uSources) {
await this.loadM3uChannels(source.id, true);
}
await Promise.all([
this.loadHiddenItems(),
this.loadFavorites()
const [result] = await Promise.all([
API.channels.liveCatalog(this.catalogParams, controller.signal),
this.loadHiddenItems(), this.loadFavorites()
]);
if (version !== this.catalogVersion) return false;
this.channels = result.items;
this.render();
if (item && document.activeElement === document.body) {
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);
const focusTarget = active.classList.contains('favorite-btn') ? replacement?.querySelector('.favorite-btn') : replacement;
if (focusTarget) {
focusTarget.focus({ preventScroll: true });
this.container.scrollTop = scrollTop;
} else {
this.searchInput.focus({ preventScroll: true });
}
} else if (retryHadFocus && (document.activeElement === this.catalogRetryButton || document.activeElement === document.body)) {
this.searchInput.focus({ preventScroll: true });
}
this.catalogStatus.textContent = '';
return true;
} 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.catalogRetryButton.hidden = false;
if (item && document.activeElement === document.body) this.catalogRetryButton.focus({ preventScroll: true });
return false;
} finally {
if (version === this.catalogVersion) {
this.isLoading = false;
this.container.setAttribute('aria-busy', 'false');
}
}
}
/**
* 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
*/
@ -907,7 +871,7 @@ class ChannelList {
const wasFavorite = this.visibleFavorites.has(key);
// 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 {
// Optimistic update
@ -975,11 +939,11 @@ class ChannelList {
*/
updateFavoritesGroup(channel, isAdded) {
// 1. Update Data
if (!this.groupedChannels['Favorites']) {
this.groupedChannels['Favorites'] = [];
if (!this.groupedChannels[this.favoriteGroupKey]) {
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);
if (isAdded) {
@ -988,8 +952,10 @@ class ChannelList {
if (existingIdx !== -1) favArray.splice(existingIdx, 1);
}
favArray.sort((a, b) => this.compareCatalogChannels(a, b));
// 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 group doesn't exist and we're adding, we ideally should create it
@ -1006,13 +972,16 @@ class ChannelList {
if (isAdded) {
// 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) {
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 {
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) {
existingEl.remove();
}
@ -1041,7 +1010,7 @@ class ChannelList {
div.dataset.channelId = channel.id;
div.dataset.sourceId = channel.sourceId;
div.dataset.sourceType = channel.sourceType;
div.dataset.streamId = channel.streamId || '';
div.dataset.streamId = channel.streamId ?? '';
div.dataset.url = channel.url || '';
div.innerHTML = `
@ -1079,9 +1048,10 @@ class ChannelList {
* Select and play a channel
*/
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;
const previousChannel = this.currentChannel;
this.currentChannel = channel;
this.currentRenderId = dataset.renderId; // Track which visual instance is active
this.currentRenderGroup = dataset.renderGroup; // Track which group the selection came from
@ -1111,7 +1081,8 @@ class ChannelList {
// Fallback checks if still not found
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 (activeItem && activeItem.dataset.renderId) {
this.currentRenderId = activeItem.dataset.renderId;
@ -1171,9 +1142,13 @@ class ChannelList {
streamUrl = channel.url;
}
if (dataset.catalogVersion !== undefined && dataset.catalogVersion !== this.catalogVersion) {
if (this.currentChannel === channel) this.currentChannel = previousChannel;
return;
}
// Play channel
if (window.app?.player) {
window.app.player.play(channel, streamUrl);
await window.app.player.play(channel, streamUrl);
}
}
@ -1331,7 +1306,7 @@ class ChannelList {
}
// 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 => {
if (isFavorite) {
@ -1355,88 +1330,48 @@ class ChannelList {
/**
* Select next channel in the current list
*/
selectNextChannel() {
if (!this.currentChannel || !this.renderedChannels || this.renderedChannels.length === 0) return;
selectNextChannel() { return window.app?.player?.channelDown(); }
let currentIndex = -1;
selectPrevChannel() { return window.app?.player?.channelUp(); }
// Try to find by render ID first (strict visual order)
if (this.currentRenderId) {
currentIndex = this.renderedChannels.findIndex(c => c._renderId === this.currentRenderId);
}
// Fallback: Find matching channel ID, prioritizing same render group
if (currentIndex === -1) {
// First try to find in same group (for Favorites containing duplicates)
if (this.currentRenderGroup) {
currentIndex = this.renderedChannels.findIndex(c =>
c.id === this.currentChannel.id && c.sourceId === this.currentChannel.sourceId && c._renderGroup === this.currentRenderGroup
);
queueZap(direction) {
const version = this.catalogVersion;
const run = async () => {
if (version !== this.catalogVersion || version !== this.catalogScopeVersion) return;
if (this.catalogRequest?.version === version) {
if (!await this.catalogRequest.promise) return;
}
// Final fallback: any matching channel
if (currentIndex === -1) {
currentIndex = this.renderedChannels.findIndex(c =>
c.id === this.currentChannel.id && c.sourceId === this.currentChannel.sourceId
);
if (version !== this.catalogVersion || this.isLoading) return;
const current = window.app?.player?.currentChannel || this.currentChannel;
const selection = this.currentChannel;
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;
const channels = this.getVisibleChannels();
const index = channels.findIndex(matches);
if (!isCurrent()) return;
if (index === -1) {
this.catalogStatus.textContent = 'Current channel is outside the Live filters. Select a channel in the list to change channels.';
return;
}
}
if (currentIndex === -1) return;
const nextIndex = (currentIndex + 1) % this.renderedChannels.length;
const nextChannel = this.renderedChannels[nextIndex];
this.selectChannel({
channelId: nextChannel.id,
sourceId: nextChannel.sourceId,
sourceType: nextChannel.sourceType,
streamId: nextChannel.streamId,
url: nextChannel.url,
renderId: nextChannel._renderId // Pass the unique render ID
});
}
/**
* Select previous channel in the current list
*/
selectPrevChannel() {
if (!this.currentChannel || !this.renderedChannels || this.renderedChannels.length === 0) return;
let currentIndex = -1;
if (this.currentRenderId) {
currentIndex = this.renderedChannels.findIndex(c => c._renderId === this.currentRenderId);
}
// Fallback: Find matching channel ID, prioritizing same render group
if (currentIndex === -1) {
// First try to find in same group (for Favorites containing duplicates)
if (this.currentRenderGroup) {
currentIndex = this.renderedChannels.findIndex(c =>
c.id === this.currentChannel.id && c.sourceId === this.currentChannel.sourceId && c._renderGroup === this.currentRenderGroup
);
}
// Final fallback: any matching channel
if (currentIndex === -1) {
currentIndex = this.renderedChannels.findIndex(c =>
c.id === this.currentChannel.id && c.sourceId === this.currentChannel.sourceId
);
}
}
if (currentIndex === -1) return;
const prevIndex = (currentIndex - 1 + this.renderedChannels.length) % this.renderedChannels.length;
const prevChannel = this.renderedChannels[prevIndex];
this.selectChannel({
channelId: prevChannel.id,
sourceId: prevChannel.sourceId,
sourceType: prevChannel.sourceType,
streamId: prevChannel.streamId,
url: prevChannel.url,
renderId: prevChannel._renderId
if (!isCurrent() || index === -1 || !channels.length) return;
const neighbor = direction < 0
? channels[index === 0 ? channels.length - 1 : index - 1]
: channels[index >= channels.length - 1 ? 0 : index + 1];
try {
await this.selectChannel({ channelId: neighbor.id, sourceId: neighbor.sourceId, catalogVersion: version });
} catch (err) {
if (this.currentChannel === neighbor) this.currentChannel = current;
throw err;
}
};
this.zapQueue = (this.zapQueue || Promise.resolve()).then(run).catch(err => {
console.error('Error changing channel:', err);
});
return this.zapQueue;
}
/**
@ -1454,13 +1389,11 @@ class ChannelList {
* Get list of visible (non-hidden) channels in display order
*/
getVisibleChannels() {
const showHidden = this.showHiddenCheckbox?.checked ?? false;
return this.channels.filter(ch => {
if (showHidden) return true;
const channelHidden = this.isHidden('channel', ch.sourceId, ch.id);
const groupHidden = this.isHidden('group', ch.sourceId, ch.groupTitle);
return !channelHidden && !groupHidden;
});
// Use the same groups and child order as render(), without the synthetic duplicates.
return (this.sortedGroups || []).filter(group => group !== this.favoriteGroupKey).flatMap(group =>
(this.groupedChannels[group] || []).filter(channel =>
this.showHidden || !this.isHidden('channel', channel.sourceId, channel.streamId ?? channel.id)));
}
}

View File

@ -348,7 +348,7 @@ class EpgGuide {
// Get channels and filter out hidden ones (always enforce hidden in EPG)
// Note: We only check individual channel visibility, not group visibility
// 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
const rawChannelId = ch.streamId || ch.id;
const isChannelHidden = channelList.isHidden('channel', ch.sourceId, rawChannelId);
@ -915,7 +915,7 @@ class EpgGuide {
async playChannel(channelName) {
// Find channel in channel list and play
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
);
if (channel) {

View File

@ -1259,32 +1259,12 @@ class VideoPlayer {
* Go to previous channel
*/
channelUp() {
if (!window.app?.channelList) return;
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 });
return window.app?.channelList?.queueZap(-1);
}
/**
* Go to next channel
*/
/** Go to the next channel, waiting for a catalogue page when needed. */
channelDown() {
if (!window.app?.channelList) return;
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 });
return window.app?.channelList?.queueZap(1);
}
async zapBeforeStreaming(channel, streamUrl, signal) {

View File

@ -11,28 +11,71 @@ class GuidePage {
// EPG guide will lazy load when shown
}
async show() {
// 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.
async loadCatalog() {
const channelList = this.app.channelList;
if (!channelList.channels || channelList.channels.length === 0) {
await channelList.loadSources();
await channelList.loadChannels();
const revision = API.liveCatalogRevision || 0;
if (channelList.guideCatalog && channelList.guideCatalogRevision === revision) return;
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
if (!this.app.epgGuide.programmes || this.app.epgGuide.programmes.length === 0) {
await this.app.epgGuide.loadEpg();
} else {
// Just re-render with existing data (updates time position)
this.app.epgGuide.render();
async show() {
const visit = this.visit = (this.visit || 0) + 1;
const container = this.app.epgGuide.container;
const channelList = this.app.channelList;
const needsCatalog = !channelList.guideCatalog || channelList.guideCatalogRevision !== (API.liveCatalogRevision || 0);
if (needsCatalog) {
container.textContent = 'Loading…';
container.setAttribute('aria-busy', 'true');
}
try {
await this.loadCatalog();
if (visit !== this.visit) return;
if (!this.app.epgGuide.programmes?.length) {
await this.app.epgGuide.loadEpg();
} 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() {
// Page is hidden
this.visit = (this.visit || 0) + 1;
this.app.epgGuide.container.setAttribute('aria-busy', 'false');
}
}
window.GuidePage = GuidePage;

View File

@ -195,34 +195,13 @@ class HomePage {
const section = document.getElementById('favorite-channels-section');
if (!list || !section) return;
const version = this.favoriteCatalogVersion = (this.favoriteCatalogVersion || 0) + 1;
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;
if (!channelList.channels || channelList.channels.length === 0) {
await channelList.loadSources();
await channelList.loadChannels();
}
// 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 });
}
}
const result = await API.channels.liveCatalog({ favoriteOnly: true, limit: 200 });
if (version !== this.favoriteCatalogVersion) return;
const channels = result.items;
channelList.favoriteCatalog = channels;
if (channels.length === 0) {
list.innerHTML = '<div class="empty-state hint">Add channels to favorites from Live TV</div>';
@ -234,6 +213,7 @@ class HomePage {
// Attach click handlers
list.querySelectorAll('.channel-tile').forEach(tile => {
tile.addEventListener('keydown', event => this.activateFavoriteTile(event, tile.dataset.channelId, tile.dataset.sourceId));
tile.addEventListener('click', () => {
const channelId = tile.dataset.channelId;
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
this.updateScrollArrows();
@ -254,9 +276,11 @@ class HomePage {
const logo = channel.tvgLogo || '/img/placeholder.png';
const logoUrl = logo.startsWith('http') ? `/api/proxy/image?url=${encodeURIComponent(logo)}` : logo;
const name = channel.name || 'Unknown';
const channelId = this.app.channelList.escapeHtml(String(channel.id));
const sourceId = this.app.channelList.escapeHtml(String(channel.sourceId));
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">
<img src="${logoUrl}" alt="${name}" loading="lazy" onerror="this.onerror=null;this.src='/img/placeholder.png'">
</div>
@ -265,29 +289,21 @@ class HomePage {
`;
}
playChannel(channelId, sourceId) {
// Navigate to Live TV and select the channel
this.app.navigateTo('live');
activateFavoriteTile(event, channelId, sourceId) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
this.playChannel(channelId, sourceId);
}
}
// Small delay to ensure page is ready
setTimeout(() => {
const channelList = this.app.channelList;
if (channelList) {
// Find and select the channel
const channel = channelList.channels.find(ch =>
String(ch.id) === String(channelId) && String(ch.sourceId) === String(sourceId)
);
if (channel) {
channelList.selectChannel({
channelId: channel.id,
sourceId: channel.sourceId,
sourceType: channel.sourceType,
streamId: channel.streamId || '',
url: channel.url || ''
});
}
}
}, 100);
playChannel(channelId, sourceId) {
const channelList = this.app.channelList;
const channel = channelList.favoriteCatalog?.find(ch =>
String(ch.id) === String(channelId) && String(ch.sourceId) === String(sourceId));
if (!channel) return;
this.app.navigateTo('live');
// selectChannel resolves Home DTOs independently of the active Live filters.
channelList.selectChannel({ channelId: channel.id, sourceId: channel.sourceId });
}
renderHistory(items) {

View File

@ -42,6 +42,8 @@ function initSchema() {
data JSON -- Extra provider data
);
CREATE INDEX IF NOT EXISTS idx_categories_source_type ON categories(source_id, type);
CREATE INDEX IF NOT EXISTS idx_categories_source_type_category
ON categories(source_id, type, category_id);
`);
// Playlist Items (Channels, Movies, Series, Episodes)
@ -73,6 +75,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_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
@ -152,6 +156,7 @@ function initSchema() {
// Column already exists, ignore
}
require('../services/liveCatalog').ensureSearch(db);
console.log('[SQLite] Schema initialized');
}

View File

@ -1,6 +1,20 @@
const express = require('express');
const router = express.Router();
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
function mapItemType(apiType) {

View File

@ -0,0 +1,141 @@
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', 'full']) {
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 full = boolean('full');
if (full && (query.limit !== undefined || query.cursor !== undefined)) {
badRequest('full=true cannot be combined with limit or cursor');
}
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, full, 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;
}
if (!options.full) 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}${options.full ? '' : ' LIMIT @limit'}`).all(params);
const hasMore = !options.full && 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 };

29
start.sh Executable file
View File

@ -0,0 +1,29 @@
#!/usr/bin/env bash
set -euo pipefail
repo_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
cd -- "$repo_dir"
if ! command -v node >/dev/null 2>&1; then
printf '%s\n' 'Error: Node.js >=18 is required. Install Node.js and try again.' >&2
exit 1
fi
if ! node -e 'process.exit(Number(process.versions.node.split(".")[0]) >= 18 ? 0 : 1)'; then
printf '%s\n' 'Error: Node.js >=18 is required. Upgrade Node.js and try again.' >&2
exit 1
fi
if ! command -v npm >/dev/null 2>&1; then
printf '%s\n' 'Error: npm is required. Install npm and try again.' >&2
exit 1
fi
if [[ ! -d node_modules ]] || ! npm ls --depth=0 --omit=optional >/dev/null 2>&1; then
printf '%s\n' 'Error: Dependencies are missing or invalid. Run:' >&2
printf ' cd %q && npm ci\n' "$repo_dir" >&2
exit 1
fi
printf '%s\n' 'Starting Nodecast TV: http://localhost:3000'
exec npm run dev

View File

@ -0,0 +1,741 @@
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;
}
async function assertFullCatalogMatchesPages(base, cookie, query = {}) {
const paged = await requestAllPages(base, cookie, query);
const response = await request(base, cookie, { ...query, full: 'true' });
assert.equal(response.status, 200);
assert.deepEqual(response.body.items, paged,
`full catalogue ordering and rows should equal concatenated pages for ${JSON.stringify(query)}`);
assert.deepEqual(response.body.page, { nextCursor: null, hasMore: false });
return response.body.items;
}
test('application startup creates the composite category lookup index', async t => {
const app = await startApp(t);
const response = await request(app.base, app.cookie);
assert.equal(response.status, 200);
const db = new Database(path.join(app.dir, 'content.db'), { fileMustExist: true });
t.after(() => db.close());
const index = db.prepare(`SELECT sql FROM sqlite_master
WHERE type='index' AND name='idx_categories_source_type_category'`).get();
assert.ok(index, 'startup schema should create idx_categories_source_type_category');
assert.match(index.sql, /\(source_id,\s*type,\s*category_id\)/i);
});
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 seedManyFavorites(dir, userId, count) {
const db = new Database(path.join(dir, 'content.db'));
const insert = db.prepare(`INSERT OR IGNORE INTO favorites (user_id, source_id, item_id, item_type)
VALUES (?, 1, ?, 'channel')`);
db.transaction(() => {
for (let i = 0; i < count; i++) insert.run(userId, `primary-${String(i).padStart(4, '0')}`);
})();
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 complete = await assertFullCatalogMatchesPages(fixture.base, fixture.cookie);
assert.equal(complete.length, all.length);
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']);
assert.deepEqual(await assertFullCatalogMatchesPages(fixture.base, fixture.cookie,
{ q: 'Needle Far Away' }), beyondFirstPage.body.items);
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'));
assert.deepEqual(await assertFullCatalogMatchesPages(fixture.base, fixture.cookie,
{ q: 'Alpha Group' }), groupSearch);
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]);
assert.deepEqual(await assertFullCatalogMatchesPages(fixture.base, fixture.cookie,
{ sourceId: '2' }), await requestAllPages(fixture.base, fixture.cookie, { sourceId: '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'));
assert.deepEqual(await assertFullCatalogMatchesPages(fixture.base, fixture.cookie,
{ sourceId: '1', categoryId: 'beta' }), categoryFiltered);
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);
assert.deepEqual(await assertFullCatalogMatchesPages(fixture.base, fixture.cookie,
{ sourceId: '1', includeHidden: 'true' }), includeHidden);
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']);
assert.deepEqual(await assertFullCatalogMatchesPages(fixture.base, fixture.cookie,
{ favoriteOnly: 'true' }), favoriteOnly.body.items);
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 >= 1209
&& !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 at either end of the full Live list wraps without requesting another page', async () => {
const { channelList, played } = clientPageFixture();
const channels = [0, 1, 2].map(id => ({
id: `ch${id}`, sourceId: 1, sourceType: 'm3u', url: `http://streams.test/ch${id}`
}));
channelList.setVisibleTestChannels(channels);
channelList.currentChannel = channels[2];
channelList.contextPageLoads = [];
channelList.loadCatalogPage = async () => { channelList.contextPageLoads.push('unexpected'); };
await channelList.queueZap(1);
assert.deepEqual(channelList.contextPageLoads, []);
assert.equal(played.length, 1);
assert.equal(played[0].channel.id, 'ch0');
assert.equal(channelList.currentChannel.id, 'ch0');
channelList.currentChannel = channels[0];
await channelList.queueZap(-1);
assert.deepEqual(channelList.contextPageLoads, []);
assert.equal(played.length, 2);
assert.equal(played[1].channel.id, 'ch2');
assert.equal(channelList.currentChannel.id, 'ch2');
});
test('failed zap playback keeps the current channel selected without fetching a page', async () => {
const { channelList, played } = clientPageFixture();
const current = { id: 'current', sourceId: 1, sourceType: 'm3u', url: 'http://streams.test/current' };
const next = { id: 'next', sourceId: 1, sourceType: 'm3u', url: 'http://streams.test/next' };
channelList.setVisibleTestChannels([current, next]);
channelList.currentChannel = current;
channelList.contextPageLoads = [];
channelList.loadCatalogPage = async () => { channelList.contextPageLoads.push('unexpected'); };
channelList.selectChannel = async () => {
throw new Error('simulated playback failure');
};
await channelList.queueZap(1);
assert.deepEqual(channelList.contextPageLoads, []);
assert.equal(channelList.currentChannel, current);
assert.deepEqual(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 full catalogue search, zapping list, and channel selection work without Load more 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 === 1209
&& !window.app.channelList.isLoading);
assert.equal(catalogRequests[0].get('full'), 'true');
assert.equal(catalogRequests[0].get('limit'), null);
assert.equal(catalogRequests[0].get('cursor'), null);
assert.equal(await page.locator('#channel-search-submit').isVisible(), true);
assert.equal(await page.locator('.live-catalog-more').count(), 0,
'Live should not expose a Load more button when it loads the full catalogue');
const initialRequestCount = catalogRequests.length;
const needleRequest = page.waitForRequest(req => {
const url = new URL(req.url());
return url.pathname === '/api/channels/live-catalog' && url.searchParams.get('q') === 'Needle Far Away';
});
await page.locator('#channel-search').fill('Needle Far Away');
await page.waitForTimeout(100);
assert.equal(catalogRequests.length, initialRequestCount, 'debounce should defer the request');
await needleRequest;
await page.waitForFunction(() => window.app?.channelList?.channels?.length === 1
&& window.app.channelList.channels[0].streamId === 'needle' && !window.app.channelList.isLoading);
assert.equal(catalogRequests.at(-1).get('q'), 'Needle Far Away');
assert.equal(catalogRequests.at(-1).get('full'), 'true');
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 === 600
&& window.app.channelList.channels.every(channel => channel.groupTitle === 'Alpha Group'));
assert.ok(catalogRequests.length > enterCount);
assert.equal(catalogRequests.at(-1).get('q'), 'Alpha Group');
assert.equal(catalogRequests.at(-1).get('full'), 'true');
assert.equal(catalogRequests.at(-1).get('cursor'), null);
assert.equal(await page.locator('.live-catalog-more').count(), 0);
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');
assert.equal(catalogRequests.at(-1).get('full'), 'true');
await page.locator('#channel-search').fill('');
await page.waitForFunction(() => window.app?.channelList?.channels?.length === 1209
&& !window.app.channelList.isLoading);
assert.equal(catalogRequests.at(-1).get('full'), 'true');
assert.equal(catalogRequests.at(-1).get('cursor'), null);
assert.equal(await page.locator('.live-catalog-more').count(), 0);
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('Home favorites remain paginated with a cursor-backed Load more action 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);
seedManyFavorites(fixture.dir, fixture.userId, 205);
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 favoriteRequests = [];
page.on('request', req => {
const url = new URL(req.url());
if (url.pathname === '/api/channels/live-catalog'
&& url.searchParams.get('favoriteOnly') === 'true') favoriteRequests.push(url.searchParams);
});
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}/#home`);
await page.waitForFunction(() => window.app?.channelList?.favoriteCatalog?.length === 200);
assert.equal(favoriteRequests[0].get('limit'), '200');
assert.equal(favoriteRequests[0].get('cursor'), null);
assert.equal(favoriteRequests[0].get('full'), null);
const loadMore = page.locator('#favorite-channels-list .channel-tile-action');
assert.equal(await loadMore.count(), 1);
const beforeMore = favoriteRequests.length;
await loadMore.click();
await page.waitForFunction(() => window.app?.channelList?.favoriteCatalog?.length === 206);
assert.ok(favoriteRequests.length > beforeMore);
assert.equal(favoriteRequests.at(-1).get('limit'), '200');
assert.ok(favoriteRequests.at(-1).get('cursor'));
assert.equal(favoriteRequests.at(-1).get('full'), null);
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);
const guideRequests = catalogRequests.filter(params => params.get('includeHidden') === 'true');
assert.ok(guideRequests.length >= 3, 'Guide should retrieve every fixture row through multiple pages');
assert.equal(guideRequests[0].get('limit'), '500');
assert.equal(guideRequests[0].get('cursor'), null);
assert.equal(guideRequests[0].get('full'), null);
assert.ok(guideRequests.slice(1).every(params => params.get('limit') === '500'
&& params.get('cursor') && params.get('full') === null),
'Guide continuation requests should use cursors and remain paginated');
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();
});

View File

@ -0,0 +1,360 @@
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 { performance } = require('node:perf_hooks');
const { spawn } = require('node:child_process');
const Database = require('better-sqlite3');
const { parseQuery, getLiveCatalog, ensureSearch } = require('../server/services/liveCatalog');
const ROOT = path.join(__dirname, '..');
const SCALE = process.env.NODECAST_LIVE_SEARCH_SIZES
? process.env.NODECAST_LIVE_SEARCH_SIZES.split(',').map(Number) : [1200, 10000, 50000, 100000];
const TERMS = ['news', 'sports', 'local', 'kids', 'world'];
const SEARCH_LIMIT_MS = 10000;
const BROWSER_TERMS = ['news', 'sports', 'local', 'kids', 'world'];
async function launchServer(t, dir) {
const child = spawn(process.execPath, ['server/index.js'], {
cwd: ROOT,
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 && child.signalCode === null) {
const closed = new Promise(resolve => child.once('close', resolve));
child.kill();
await closed;
}
});
let output = '';
const startedAt = performance.now();
const base = await new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(`Startup timed out: ${output}`)), 30000);
child.stdout.on('data', chunk => {
output += chunk;
const match = output.match(/server running on http:\/\/localhost:(\d+)/i);
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}`));
});
});
return { child, base, processReadyMs: performance.now() - startedAt };
}
async function stopServer(child) {
if (child.exitCode !== null || child.signalCode !== null) return;
const closed = new Promise(resolve => child.once('close', resolve));
child.kill('SIGTERM');
await closed;
}
async function apiRequest(base, cookie, query = {}) {
const params = new URLSearchParams(query);
const start = performance.now();
const response = await fetch(`${base}/api/channels/live-catalog?${params}`, {
headers: { Cookie: cookie }
});
const text = await response.text();
return { status: response.status, totalMs: performance.now() - start,
bytes: Buffer.byteLength(text), body: JSON.parse(text) };
}
function seedDelta(dir, from, to) {
const db = new Database(path.join(dir, 'content.db'));
const insertCategory = db.prepare(`
INSERT OR IGNORE INTO categories (id, source_id, category_id, type, name, is_hidden)
VALUES (?, 1, ?, 'live', ?, 0)
`);
const insertItem = db.prepare(`
INSERT INTO playlist_items (id, source_id, item_id, type, name, category_id, stream_url, is_hidden, data)
VALUES (?, 1, ?, 'live', ?, ?, ?, 0, '{}')
`);
const themes = ['News', 'Sports', 'Local', 'Kids', 'World'];
const groupCount = 20;
const insert = db.transaction(() => {
for (let group = 0; group < groupCount; group++) {
const categoryId = `bouquet-${String(group).padStart(2, '0')}`;
insertCategory.run(`1:${categoryId}`, categoryId, `Bouquet ${String(group + 1).padStart(2, '0')}`);
}
for (let index = from; index < to; index++) {
const id = `live-${String(index).padStart(6, '0')}`;
const theme = themes[index % themes.length];
const categoryId = `bouquet-${String(index % groupCount).padStart(2, '0')}`;
const region = `Region ${String(index % 12 + 1).padStart(2, '0')}`;
insertItem.run(`1:${id}`, id, `${theme} ${region} Channel ${String(index).padStart(6, '0')} HD`,
categoryId, `http://streams.test/${id}`);
}
});
insert();
db.close();
}
function removeSearchIndexes(dir) {
const db = new Database(path.join(dir, 'content.db'));
db.exec(`DROP TRIGGER IF EXISTS live_catalog_playlist_items_fts_ai;
DROP TRIGGER IF EXISTS live_catalog_playlist_items_fts_ad;
DROP TRIGGER IF EXISTS live_catalog_playlist_items_fts_au;
DROP TRIGGER IF EXISTS live_catalog_categories_fts_ai;
DROP TRIGGER IF EXISTS live_catalog_categories_fts_ad;
DROP TRIGGER IF EXISTS live_catalog_categories_fts_au;
DROP TABLE IF EXISTS live_catalog_playlist_items_fts;
DROP TABLE IF EXISTS live_catalog_categories_fts;`);
db.close();
}
function searchIndexSnapshot(dir) {
const db = new Database(path.join(dir, 'content.db'), { readonly: true });
const schemaVersion = db.pragma('schema_version', { simple: true });
const definitions = db.prepare(`SELECT name, sql FROM sqlite_master WHERE name LIKE 'live_catalog_%'
ORDER BY name`).all();
const playlistRows = db.prepare('SELECT COUNT(*) AS n FROM live_catalog_playlist_items_fts').get().n;
const categoryRows = db.prepare('SELECT COUNT(*) AS n FROM live_catalog_categories_fts').get().n;
db.close();
return { schemaVersion, definitions, playlistRows, categoryRows };
}
function endpointKind(url) {
if (url.pathname === '/api/channels/live-catalog') return 'live-catalog';
if (url.pathname === '/api/channels/hidden') return 'channels/hidden';
if (url.pathname === '/api/favorites') return 'favorites';
if (url.pathname.startsWith('/api/proxy/epg/')) return 'epg';
if (url.pathname === '/api/history') return 'history';
return null;
}
async function runBrowserJourney(chromium, base, dir, size) {
const context = await chromium.newContext();
const page = await context.newPage();
const requests = [];
const byRequest = new Map();
const responseTasks = [];
page.on('request', request => {
const url = new URL(request.url());
const kind = endpointKind(url);
if (!kind) return;
const entry = { kind, method: request.method(), path: url.pathname,
query: Object.fromEntries(url.searchParams), startedAt: performance.now() };
byRequest.set(request, entry);
requests.push(entry);
});
page.on('response', response => {
const entry = byRequest.get(response.request());
if (!entry) return;
entry.status = response.status();
entry.durationMs = Number((performance.now() - entry.startedAt).toFixed(2));
const task = response.body().then(body => { entry.responseBytes = body.length; }, () => { entry.responseBytes = null; });
responseTasks.push(task);
});
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());
const blankContext = { cookies: (await context.cookies()).length,
localStorage: await page.evaluate(() => Object.keys(localStorage).length).catch(() => 0) };
const homeStarted = performance.now();
await page.goto(`${base}/login.html`);
await page.locator('#username').fill('SearchPerf');
await page.locator('#password').fill('password123');
await page.locator('#password').press('Enter');
await page.waitForURL(url => url.pathname === '/');
await page.waitForFunction(() => window.app?.currentPage === 'home'
&& document.querySelector('#favorite-channels-list')
&& !document.querySelector('#favorite-channels-list .loading-state'));
await page.waitForFunction(() => window.app?.epgGuide?.lastRefreshTime ||
document.querySelector('#epg-container')?.textContent?.includes('No EPG'));
const homeReadyMs = performance.now() - homeStarted;
const homeNetwork = requests.slice();
const liveStarted = performance.now();
const liveRequestIndex = requests.length;
await page.locator('.nav-link[data-page="live"]').click();
await page.waitForFunction(() => window.app?.currentPage === 'live'
&& window.app.channelList.channels.length === 200
&& !window.app.channelList.isLoading
&& document.querySelector('#channel-list')?.getAttribute('aria-busy') === 'false');
const liveReadyMs = performance.now() - liveStarted;
const initialLiveNetwork = requests.slice(liveRequestIndex);
const beforeIndexes = searchIndexSnapshot(dir);
const searches = [];
for (const term of BROWSER_TERMS) {
const firstRequest = requests.length;
const startedAt = performance.now();
await page.locator('#channel-search').fill(term);
try { await page.waitForFunction(value => {
const list = window.app?.channelList;
const container = document.querySelector('#channel-list');
return list?.catalogQuery === value && !list.isLoading && list.channels.length === 200
&& container?.getAttribute('aria-busy') === 'false';
}, term, { timeout: 5000 }); } catch (error) {
const browserState = await page.evaluate(() => ({ currentPage: window.app?.currentPage,
searchValue: document.querySelector('#channel-search')?.value,
catalogQuery: window.app?.channelList?.catalogQuery,
channelCount: window.app?.channelList?.channels?.length,
isLoading: window.app?.channelList?.isLoading,
status: document.querySelector('#channel-list')?.getAttribute('aria-busy'),
groups: window.app?.channelList?.sortedGroups?.length }));
console.log(`[live-search-browser-debug] ${JSON.stringify({ size, term, browserState,
requests: requests.slice(firstRequest).map(entry => ({ kind: entry.kind, query: entry.query, status: entry.status })) })}`);
throw error;
}
await page.waitForTimeout(75);
const settledAt = performance.now();
const current = requests.slice(firstRequest);
await Promise.all(responseTasks);
const indexState = searchIndexSnapshot(dir);
searches.push({ term, inputToStableDomMs: Number((settledAt - startedAt).toFixed(2)),
channels: await page.evaluate(() => window.app.channelList.channels.length),
domChannelGroups: await page.locator('#channel-list .channel-group').count(),
network: current.map(entry => ({ endpoint: entry.kind, method: entry.method,
query: entry.query, status: entry.status, responseBytes: entry.responseBytes,
durationMs: entry.durationMs })),
ftsSchemaVersion: indexState.schemaVersion,
ftsRows: { playlist: indexState.playlistRows, categories: indexState.categoryRows },
schemaUnchanged: indexState.schemaVersion === beforeIndexes.schemaVersion
&& JSON.stringify(indexState.definitions) === JSON.stringify(beforeIndexes.definitions)
&& indexState.playlistRows === beforeIndexes.playlistRows
&& indexState.categoryRows === beforeIndexes.categoryRows });
}
await Promise.all(responseTasks);
const afterIndexes = searchIndexSnapshot(dir);
const report = { rows: size, blankContext: blankContext.cookies === 0 && blankContext.localStorage === 0,
homeReadyMs: Number(homeReadyMs.toFixed(2)), liveReadyMs: Number(liveReadyMs.toFixed(2)),
homeNetwork: homeNetwork.map(entry => ({ endpoint: entry.kind, method: entry.method,
query: entry.query, status: entry.status, responseBytes: entry.responseBytes,
durationMs: entry.durationMs })),
initialLiveNetwork: initialLiveNetwork.map(entry => ({ endpoint: entry.kind, method: entry.method,
query: entry.query, status: entry.status, responseBytes: entry.responseBytes,
durationMs: entry.durationMs })),
searches, ftsUnchangedAcrossSearches: JSON.stringify(beforeIndexes) === JSON.stringify(afterIndexes) };
console.log(`[live-search-browser] ${JSON.stringify(report)}`);
assert.equal(report.blankContext, true, 'the browser must start without cookies or localStorage');
assert.ok(searches.every(search => search.schemaUnchanged), 'search requests must not recreate or backfill FTS indexes');
assert.equal(report.ftsUnchangedAcrossSearches, true);
await context.close();
return report;
}
function measureService(dir, userId, term) {
const db = new Database(path.join(dir, 'content.db'));
ensureSearch(db);
let sqlMs = 0;
const prepare = db.prepare.bind(db);
db.prepare = sql => {
const statement = prepare(sql);
if (!sql.includes('WITH live_sources AS')) return statement;
return { all: (...args) => {
const startedAt = performance.now();
try { return statement.all(...args); }
finally { sqlMs += performance.now() - startedAt; }
} };
};
const allSources = [{ id: 1, type: 'm3u', enabled: true }];
const options = parseQuery({ q: term, limit: '100' }, userId);
const startedAt = performance.now();
const result = getLiveCatalog(db, allSources, options);
const serviceMs = performance.now() - startedAt;
db.close();
return { sqlMs, serviceMs, returned: result.items.length, hasMore: result.page.hasMore };
}
test('live catalog repeated searches record first-use FTS backfill and per-query timing at 1.2k–100k rows', {
skip: process.env.NODECAST_LIVE_SEARCH_DIAGNOSTIC === '1' ? false : 'Opt-in diagnostic benchmark',
timeout: 300000
}, async t => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'nodecast-live-search-perf-'));
fs.writeFileSync(path.join(dir, 'db.json'), JSON.stringify({
sources: [
{ id: 1, type: 'm3u', name: 'Fixture', url: 'http://127.0.0.1:1/unused.m3u', enabled: true },
{ id: 2, type: 'epg', name: 'Fixture EPG', url: 'http://epg.fixture.invalid/guide.xml', enabled: true }
],
hiddenItems: [], favorites: [], users: [], nextId: 3
}));
let activeChild;
t.after(async () => {
if (activeChild) await stopServer(activeChild);
fs.rmSync(dir, { recursive: true, force: true });
});
let app = await launchServer(t, dir);
activeChild = app.child;
const setup = await fetch(`${app.base}/api/auth/setup`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: 'SearchPerf', password: 'password123' })
});
assert.equal(setup.status, 201);
const setupBody = await setup.json();
const cookie = setup.headers.getSetCookie().find(value => value.startsWith('nodecast_auth_')).split(';')[0];
const initialized = await apiRequest(app.base, cookie);
assert.equal(initialized.status, 200);
const reports = [];
let previousSize = 0;
for (const size of SCALE) {
seedDelta(dir, previousSize, size);
previousSize = size;
for (const [round, term] of [...TERMS, ...TERMS].entries()) {
const response = await apiRequest(app.base, cookie, { q: term });
assert.equal(response.status, 200);
assert.equal(response.body.items.length, 100);
assert.equal(response.body.page.hasMore, true);
const sql = measureService(dir, setupBody.user.id, term);
const expectedMatches = Math.floor((size - 1 - TERMS.indexOf(term)) / TERMS.length) + 1;
const report = { rows: size, phase: round < TERMS.length ? 'first distinct pass' : 'repeat pass',
term, expectedMatches, returned: response.body.items.length, hasMore: response.body.page.hasMore,
bytes: response.bytes, httpMs: Number(response.totalMs.toFixed(2)),
sqlMs: Number(sql.sqlMs.toFixed(2)), serviceMs: Number(sql.serviceMs.toFixed(2)) };
reports.push(report);
console.log(`[live-search-diagnostic] ${JSON.stringify(report)}`);
}
if (process.env.NODECAST_LIVE_SEARCH_BROWSER === '1' && (size === 1200 || size === 100000)) {
const chromium = require('playwright').chromium;
const browser = await chromium.launch({ headless: true,
executablePath: process.env.NODECAST_BROWSER_EXECUTABLE || undefined });
try {
const browserReport = await runBrowserJourney(browser, app.base, dir, size);
assert.ok(browserReport.searches.length === BROWSER_TERMS.length);
} finally { await browser.close(); }
}
}
// Simulate a pre-FTS database on process restart. sqlite.js creates and backfills
// both trigram indexes on the first catalog request after the server is ready.
await stopServer(activeChild);
activeChild = null;
removeSearchIndexes(dir);
app = await launchServer(t, dir);
activeChild = app.child;
const login = await fetch(`${app.base}/api/auth/login`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: 'SearchPerf', password: 'password123' })
});
assert.equal(login.status, 200);
const loginCookie = login.headers.getSetCookie().find(value => value.startsWith('nodecast_auth_')).split(';')[0];
const backfillStarted = performance.now();
const firstAfterRestart = await apiRequest(app.base, loginCookie);
const backfillMs = performance.now() - backfillStarted;
assert.equal(firstAfterRestart.status, 200);
const indexDb = new Database(path.join(dir, 'content.db'), { readonly: true });
const indexes = indexDb.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'live_catalog_%_fts'`).all();
const indexedPlaylistRows = indexDb.prepare('SELECT COUNT(*) AS n FROM live_catalog_playlist_items_fts').get().n;
indexDb.close();
assert.equal(indexes.length, 2);
assert.equal(indexedPlaylistRows, previousSize);
console.log(`[live-search-diagnostic] ${JSON.stringify({ rows: previousSize, phase: 'first catalog request after dropping FTS/restart',
httpMs: Number(firstAfterRestart.totalMs.toFixed(2)), backfillRequestMs: Number(backfillMs.toFixed(2)),
bytes: firstAfterRestart.bytes, indexes: indexes.length, indexedPlaylistRows })}`);
const slow = reports.filter(report => report.httpMs >= SEARCH_LIMIT_MS);
assert.deepEqual(slow, [], `search requests reached the observed 10s failure window: ${JSON.stringify(slow)}`);
});

View File

@ -0,0 +1,175 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { performance } = require('node:perf_hooks');
const Database = require('better-sqlite3');
const { parseQuery, getLiveCatalog, ensureSearch } = require('../server/services/liveCatalog');
const RUN = process.env.NODECAST_LIVE_SEARCH_SHAPE_REPRO === '1';
const EXPECTED_LIVE = 38617;
const EXPECTED_PLAYLIST = 66877;
const EXPECTED_CATEGORIES = 1099;
const INDEXED_LIMIT_MS = 1000;
function createFixtureDatabase() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'nodecast-live-search-shape-'));
const db = new Database(path.join(dir, 'content.db'));
db.pragma('journal_mode = OFF');
db.exec(`
CREATE TABLE categories (
id TEXT PRIMARY KEY, source_id INTEGER NOT NULL, category_id TEXT NOT NULL,
type TEXT NOT NULL, name TEXT NOT NULL, is_hidden INTEGER DEFAULT 0
);
CREATE INDEX idx_categories_source_type ON categories(source_id, type);
CREATE TABLE playlist_items (
id TEXT PRIMARY KEY, source_id INTEGER NOT NULL, item_id TEXT NOT NULL,
type TEXT NOT NULL, name TEXT NOT NULL, category_id TEXT, stream_url TEXT,
is_hidden INTEGER DEFAULT 0, data JSON
);
CREATE INDEX idx_items_source_type ON playlist_items(source_id, type);
CREATE INDEX idx_items_category ON playlist_items(source_id, category_id);
CREATE INDEX idx_live_catalog_order ON playlist_items(name COLLATE NOCASE, source_id, id) WHERE type='live';
CREATE TABLE favorites (
id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL, source_id INTEGER NOT NULL,
item_id TEXT NOT NULL, item_type TEXT NOT NULL
);
`);
const insertCategory = db.prepare(`INSERT INTO categories (id, source_id, category_id, type, name, is_hidden)
VALUES (?, 1, ?, 'live', ?, 0)`);
const insertItem = db.prepare(`INSERT INTO playlist_items
(id, source_id, item_id, type, name, category_id, stream_url, is_hidden, data)
VALUES (?, 1, ?, ?, ?, ?, 'http://fixture.invalid/stream', 0, ?)`);
const typicalData = JSON.stringify({ padding: 'x'.repeat(370) });
const largestData = JSON.stringify({ padding: 'x'.repeat(17870) });
db.transaction(() => {
for (let i = 0; i < EXPECTED_CATEGORIES; i++) {
const categoryId = `group-${i}`;
insertCategory.run(`1:${categoryId}`, categoryId,
i < 5 ? `News Group ${i}` : `Bouquet ${i}`);
}
for (let i = 0; i < EXPECTED_PLAYLIST; i++) {
const isLive = i < EXPECTED_LIVE;
const offset = isLive ? i : i - EXPECTED_LIVE;
const itemType = isLive ? 'live' : 'movie';
const itemId = `${itemType}-${offset}`;
const hasNews = isLive ? i < 549 : offset < 89;
const name = hasNews ? `News ${isLive ? 'Channel' : 'Movie'} ${offset}`
: `${isLive ? 'Channel' : 'Movie'} Catalog ${offset}`;
const categoryId = `group-${offset % EXPECTED_CATEGORIES}`;
insertItem.run(`1:${itemId}`, itemId, itemType, name, categoryId,
i === 0 ? largestData : typicalData);
}
})();
return { dir, db };
}
function summarizePlan(planRows) {
const tables = {};
const indexes = [];
let tempBtreeForOrder = false;
let categoryCompositeLookup = false;
for (const row of planRows) {
const detail = String(row.detail || '').toUpperCase();
if (detail.includes('USE TEMP B-TREE FOR ORDER BY')) tempBtreeForOrder = true;
const match = detail.match(/(?:SCAN|SEARCH) ([A-Z_]+)/);
if (!match) continue;
const alias = match[1].toLowerCase();
const indexMatch = detail.match(/USING (?:COVERING )?INDEX ([A-Z0-9_]+)/);
if (indexMatch) indexes.push(indexMatch[1].toLowerCase());
if (/SEARCH C USING INDEX IDX_CATEGORIES_SOURCE_TYPE_CATEGORY \(SOURCE_ID=\? AND TYPE=\? AND CATEGORY_ID=\?\)/.test(detail)) {
categoryCompositeLookup = true;
}
tables[alias] ||= { scan: 0, search: 0, indexLookup: 0, virtualLookup: 0 };
if (detail.startsWith('SCAN')) tables[alias].scan++;
else tables[alias].search++;
if (detail.includes('USING INDEX')) tables[alias].indexLookup++;
if (detail.includes('VIRTUAL TABLE INDEX')) tables[alias].virtualLookup++;
}
return { steps: planRows.length, tables, indexes, categoryCompositeLookup, tempBtreeForOrder };
}
function timedCatalog(db, q) {
const options = parseQuery({ q, limit: '200' }, 1);
let querySql;
let queryParams;
let sqlMs = 0;
const prepare = db.prepare.bind(db);
db.prepare = sql => {
const statement = prepare(sql);
if (!sql.includes('WITH live_sources AS')) return statement;
return { all: (...args) => {
querySql = sql;
queryParams = args[0];
const startedAt = performance.now();
try { return statement.all(...args); }
finally { sqlMs = performance.now() - startedAt; }
} };
};
const startedAt = performance.now();
const result = getLiveCatalog(db, [{ id: 1, type: 'm3u', enabled: true }], options);
const serviceMs = performance.now() - startedAt;
const plan = summarizePlan(prepare(`EXPLAIN QUERY PLAN ${querySql}`).all(queryParams));
return { serviceMs: +serviceMs.toFixed(1), sqlMs: +sqlMs.toFixed(1),
returned: result.items.length, hasMore: result.page.hasMore, plan };
}
test('38k live rows and 1,099 groups stay fast with a composite category lookup index', {
skip: RUN ? false : 'Opt-in local-shape performance reproduction',
timeout: 180000
}, t => {
const { dir, db } = createFixtureDatabase();
t.after(() => {
db.close();
fs.rmSync(dir, { recursive: true, force: true });
});
ensureSearch(db);
const fixture = {
liveRows: db.prepare("SELECT COUNT(*) AS n FROM playlist_items WHERE type='live'").get().n,
playlistRows: db.prepare('SELECT COUNT(*) AS n FROM playlist_items').get().n,
categoryRows: db.prepare('SELECT COUNT(*) AS n FROM categories').get().n,
ftsPlaylistRows: db.prepare('SELECT COUNT(*) AS n FROM live_catalog_playlist_items_fts').get().n,
newsFtsCandidates: db.prepare('SELECT COUNT(*) AS n FROM live_catalog_playlist_items_fts WHERE name LIKE ?').get('%news%').n,
liveNewsCandidates: db.prepare(`SELECT COUNT(*) AS n FROM playlist_items p WHERE p.type='live'
AND p.rowid IN (SELECT rowid FROM live_catalog_playlist_items_fts WHERE name LIKE ?)`)
.get('%news%').n,
liveJson: db.prepare(`SELECT COUNT(*) AS n, SUM(length(CAST(data AS BLOB))) AS bytes,
AVG(length(CAST(data AS BLOB))) AS avgBytes, MAX(length(CAST(data AS BLOB))) AS maxBytes
FROM playlist_items WHERE type='live'`).get()
};
assert.deepEqual([fixture.liveRows, fixture.playlistRows, fixture.categoryRows, fixture.ftsPlaylistRows,
fixture.newsFtsCandidates, fixture.liveNewsCandidates],
[EXPECTED_LIVE, EXPECTED_PLAYLIST, EXPECTED_CATEGORIES, EXPECTED_PLAYLIST, 638, 549]);
const ftsStarted = performance.now();
const ftsCandidates = db.prepare('SELECT COUNT(*) AS n FROM live_catalog_playlist_items_fts WHERE name LIKE ?')
.get('%news%').n;
const ftsMs = performance.now() - ftsStarted;
const baseline = { noQuery: timedCatalog(db, ''), news: timedCatalog(db, 'news') };
for (const [query, measurement] of Object.entries(baseline)) {
assert.equal(measurement.plan.categoryCompositeLookup, false,
`baseline EXPLAIN QUERY PLAN for ${query} should not use the composite lookup index`);
}
// Diagnostic-only control: add an index to the throwaway fixture, never the local DB.
db.exec('CREATE INDEX idx_categories_source_type_category ON categories(source_id, type, category_id)');
const withLookupIndex = { noQuery: timedCatalog(db, ''), news: timedCatalog(db, 'news') };
for (const [query, measurement] of Object.entries(withLookupIndex)) {
assert.equal(measurement.plan.categoryCompositeLookup, true,
`EXPLAIN QUERY PLAN for ${query} must use idx_categories_source_type_category on source_id, type, category_id`);
}
console.log(`[live-search-shape-repro] ${JSON.stringify({
fixture: { ...fixture, liveJson: { rows: fixture.liveJson.n, bytes: fixture.liveJson.bytes,
avgBytes: +fixture.liveJson.avgBytes.toFixed(1), maxBytes: fixture.liveJson.maxBytes },
categoriesNameCandidates: db.prepare('SELECT COUNT(*) AS n FROM live_catalog_categories_fts WHERE name LIKE ?').get('%news%').n },
ftsCandidateMs: +ftsMs.toFixed(2), baseline, withLookupIndex
})}`);
for (const [query, measurement] of Object.entries(withLookupIndex)) {
assert.ok(measurement.serviceMs < INDEXED_LIMIT_MS,
`indexed catalogue route for ${query} took ${measurement.serviceMs}ms (limit ${INDEXED_LIMIT_MS}ms)`);
}
});