Merge develop into main
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -7,6 +7,7 @@
|
|||||||
.env*
|
.env*
|
||||||
node_modules
|
node_modules
|
||||||
data
|
data
|
||||||
|
user_data
|
||||||
transcode-cache
|
transcode-cache
|
||||||
coverage
|
coverage
|
||||||
dist
|
dist
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
# - NVIDIA NVENC: Requires nvidia-container-toolkit on host + --gpus flag
|
# - NVIDIA NVENC: Requires nvidia-container-toolkit on host + --gpus flag
|
||||||
# - Intel QSV: Mount /dev/dri
|
# - 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 ...
|
# Run with VAAPI: docker run --device /dev/dri:/dev/dri --group-add video ...
|
||||||
|
|
||||||
FROM ubuntu:24.04
|
FROM ubuntu:24.04
|
||||||
|
|||||||
29
README.md
29
README.md
@ -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.
|
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):
|
1. Create a `docker-compose.yml` file (or copy the one from this repo):
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
nodecast-tv:
|
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
|
container_name: nodecast-tv
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000" # Host:Container
|
- "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:
|
2. Run the container:
|
||||||
```bash
|
```bash
|
||||||
docker-compose up -d
|
docker compose pull
|
||||||
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
The application will be available at `http://localhost:3000`.
|
The application will be available at `http://localhost:3000`.
|
||||||
|
|||||||
39
build-push.sh
Executable file
39
build-push.sh
Executable 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"
|
||||||
@ -1,7 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
nodecast-tv:
|
nodecast-tv:
|
||||||
image: ghcr.io/technomancer702/nodecast-tv:latest
|
image: ${NODECAST_IMAGE:-gitea.anthonybouteiller.ovh/blomios/nodecast-tv}:${NODECAST_TAG:-latest}
|
||||||
build: https://github.com/technomancer702/nodecast-tv.git#main
|
|
||||||
container_name: nodecast-tv
|
container_name: nodecast-tv
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
|
|||||||
@ -4820,3 +4820,44 @@ kbd {
|
|||||||
max-width: 70px;
|
max-width: 70px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/* Keep Live catalogue controls reachable alongside the scrolling list. */
|
||||||
|
.sidebar-header-row .search-wrapper {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-catalog-status,
|
||||||
|
.live-catalog-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;
|
||||||
|
}
|
||||||
|
|||||||
@ -127,8 +127,9 @@
|
|||||||
<div class="sidebar-header-row">
|
<div class="sidebar-header-row">
|
||||||
<div class="search-wrapper">
|
<div class="search-wrapper">
|
||||||
<input type="text" id="channel-search" placeholder="Search channels..." class="search-input">
|
<input type="text" id="channel-search" placeholder="Search channels..." class="search-input">
|
||||||
<button type="button" class="search-clear" title="Clear search">×</button>
|
<button type="button" class="search-clear" title="Clear search" aria-label="Clear search">×</button>
|
||||||
</div>
|
</div>
|
||||||
|
<button type="button" id="channel-search-submit" class="btn btn-sm">Search</button>
|
||||||
<button id="sidebar-collapse-btn" class="btn btn-sm btn-ghost sidebar-collapse-btn"
|
<button id="sidebar-collapse-btn" class="btn btn-sm btn-ghost sidebar-collapse-btn"
|
||||||
title="Collapse sidebar">
|
title="Collapse sidebar">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon">
|
||||||
|
|||||||
@ -6,9 +6,10 @@ const API = {
|
|||||||
/**
|
/**
|
||||||
* Make API request
|
* Make API request
|
||||||
*/
|
*/
|
||||||
async request(method, endpoint, data = null) {
|
async request(method, endpoint, data = null, signal = undefined) {
|
||||||
const options = {
|
const options = {
|
||||||
method,
|
method,
|
||||||
|
signal,
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
@ -40,6 +41,10 @@ const API = {
|
|||||||
throw new Error(result.error || `Server responded with ${response.status}`);
|
throw new Error(result.error || `Server responded with ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (method !== 'GET' && /^\/(sources|channels)(\/|$)/.test(endpoint)) {
|
||||||
|
API.liveCatalogRevision = (API.liveCatalogRevision || 0) + 1;
|
||||||
|
if (window.app?.channelList) window.app.channelList.guideCatalog = null;
|
||||||
|
}
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -61,6 +66,13 @@ const API = {
|
|||||||
|
|
||||||
// Channels (hidden items)
|
// Channels (hidden items)
|
||||||
channels: {
|
channels: {
|
||||||
|
liveCatalog: (params = {}, signal) => {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
Object.entries(params).forEach(([key, value]) => {
|
||||||
|
if (value !== undefined && value !== null && value !== '') query.set(key, value);
|
||||||
|
});
|
||||||
|
return API.request('GET', `/channels/live-catalog?${query}`, null, signal);
|
||||||
|
},
|
||||||
getHidden: (sourceId = null) => API.request('GET', `/channels/hidden${sourceId ? `?sourceId=${sourceId}` : ''}`),
|
getHidden: (sourceId = null) => API.request('GET', `/channels/hidden${sourceId ? `?sourceId=${sourceId}` : ''}`),
|
||||||
hide: (sourceId, itemType, itemId) => API.request('POST', '/channels/hide', { sourceId, itemType, itemId }),
|
hide: (sourceId, itemType, itemId) => API.request('POST', '/channels/hide', { sourceId, itemType, itemId }),
|
||||||
show: (sourceId, itemType, itemId) => API.request('POST', '/channels/show', { sourceId, itemType, itemId }),
|
show: (sourceId, itemType, itemId) => API.request('POST', '/channels/show', { sourceId, itemType, itemId }),
|
||||||
|
|||||||
@ -130,6 +130,7 @@ class App {
|
|||||||
|
|
||||||
// Search clear buttons (global handler for all)
|
// Search clear buttons (global handler for all)
|
||||||
document.querySelectorAll('.search-clear').forEach(btn => {
|
document.querySelectorAll('.search-clear').forEach(btn => {
|
||||||
|
if (btn.parentElement.querySelector('#channel-search')) return;
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
const wrapper = btn.closest('.search-wrapper');
|
const wrapper = btn.closest('.search-wrapper');
|
||||||
const input = wrapper?.querySelector('.search-input');
|
const input = wrapper?.querySelector('.search-input');
|
||||||
|
|||||||
@ -23,6 +23,19 @@ class ChannelList {
|
|||||||
this.sources = [];
|
this.sources = [];
|
||||||
this.isLoading = false;
|
this.isLoading = false;
|
||||||
this.renderedChannels = [];
|
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.loadCollapsedState();
|
||||||
this.init();
|
this.init();
|
||||||
@ -145,21 +158,28 @@ class ChannelList {
|
|||||||
|
|
||||||
init() {
|
init() {
|
||||||
// Search handler (debounced)
|
// Search handler (debounced)
|
||||||
let searchTimeout;
|
const searchNow = () => {
|
||||||
|
clearTimeout(this.searchTimeout);
|
||||||
|
this.loadChannels();
|
||||||
|
};
|
||||||
this.searchInput.addEventListener('input', () => {
|
this.searchInput.addEventListener('input', () => {
|
||||||
clearTimeout(searchTimeout);
|
clearTimeout(this.searchTimeout);
|
||||||
searchTimeout = setTimeout(() => {
|
this.catalogVersion++;
|
||||||
this.render();
|
this.catalogController?.abort();
|
||||||
}, 300);
|
this.catalogRetryButton.hidden = true;
|
||||||
|
this.searchTimeout = setTimeout(searchNow, 300);
|
||||||
});
|
});
|
||||||
|
this.searchInput.addEventListener('keydown', e => {
|
||||||
// Source filter handler
|
if (e.key === 'Enter') { e.preventDefault(); searchNow(); }
|
||||||
this.sourceSelect.addEventListener('change', () => this.loadChannels());
|
});
|
||||||
|
document.getElementById('channel-search-submit')?.addEventListener('click', searchNow);
|
||||||
// Show hidden toggle
|
this.searchInput.parentElement.querySelector('.search-clear')?.addEventListener('click', () => {
|
||||||
if (this.showHiddenCheckbox) {
|
this.searchInput.value = '';
|
||||||
this.showHiddenCheckbox.addEventListener('change', () => this.render());
|
searchNow();
|
||||||
}
|
this.searchInput.focus();
|
||||||
|
});
|
||||||
|
this.sourceSelect.addEventListener('change', searchNow);
|
||||||
|
this.showHiddenCheckbox?.addEventListener('change', searchNow);
|
||||||
|
|
||||||
// Context menu handlers
|
// Context menu handlers
|
||||||
document.addEventListener('click', (e) => {
|
document.addEventListener('click', (e) => {
|
||||||
@ -235,7 +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
|
* Get current program info string - cached for performance
|
||||||
@ -281,7 +300,7 @@ class ChannelList {
|
|||||||
|
|
||||||
escapeHtml(text) {
|
escapeHtml(text) {
|
||||||
if (!text) return '';
|
if (!text) return '';
|
||||||
return text
|
return String(text)
|
||||||
.replace(/&/g, "&")
|
.replace(/&/g, "&")
|
||||||
.replace(/</g, "<")
|
.replace(/</g, "<")
|
||||||
.replace(/>/g, ">")
|
.replace(/>/g, ">")
|
||||||
@ -289,62 +308,84 @@ class ChannelList {
|
|||||||
.replace(/'/g, "'");
|
.replace(/'/g, "'");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
compareBinary(left, right) {
|
||||||
|
const a = Array.from(String(left ?? ''));
|
||||||
|
const b = Array.from(String(right ?? ''));
|
||||||
|
for (let i = 0; i < Math.min(a.length, b.length); i++) {
|
||||||
|
const difference = a[i].codePointAt(0) - b[i].codePointAt(0);
|
||||||
|
if (difference) return difference;
|
||||||
|
}
|
||||||
|
return a.length - b.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
catalogSortKey(value) {
|
||||||
|
return String(value ?? '').replace(/^ +| +$/g, '').replace(/[A-Z]/g, character => character.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
compareCatalogTitles(a, b) {
|
||||||
|
return this.compareBinary(this.catalogSortKey(a), this.catalogSortKey(b)) || this.compareBinary(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
compareCatalogChannels(a, b) {
|
||||||
|
return this.compareCatalogTitles(a.name, b.name) || Number(a.sourceId) - Number(b.sourceId) ||
|
||||||
|
this.compareBinary(a.streamId ?? a.id, b.streamId ?? b.id);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render channel list
|
* Render channel list
|
||||||
*/
|
*/
|
||||||
render() {
|
render() {
|
||||||
const searchTerm = this.searchInput.value.toLowerCase();
|
const searchTerm = this.catalogQuery;
|
||||||
const showHidden = this.showHiddenCheckbox ? this.showHiddenCheckbox.checked : false;
|
const showHidden = this.showHiddenCheckbox ? this.showHiddenCheckbox.checked : false;
|
||||||
|
|
||||||
// Reset batching
|
// Reset batching
|
||||||
this.currentBatch = 0;
|
this.currentBatch = 0;
|
||||||
this.batchSize = 100; // Number of groups to render per batch (increased to handle many hidden groups)
|
this.batchSize = 100; // Number of groups to render per batch (increased to handle many hidden groups)
|
||||||
|
this.observer.disconnect();
|
||||||
this.container.innerHTML = ''; // Clear container
|
this.container.innerHTML = ''; // Clear container
|
||||||
|
|
||||||
// Filter and Group channels
|
// Filter and Group channels
|
||||||
const groupedChannels = {};
|
const groupedChannels = Object.create(null);
|
||||||
|
|
||||||
// 1. Filter
|
// 1. Filter
|
||||||
this.filteredChannels = this.channels;
|
this.filteredChannels = this.channels;
|
||||||
if (searchTerm) {
|
|
||||||
this.filteredChannels = this.channels.filter(ch =>
|
|
||||||
String(ch.name ?? "").toLowerCase().includes(searchTerm) ||
|
|
||||||
String(ch.groupTitle ?? "").toLowerCase().includes(searchTerm)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let filteredChannels = this.filteredChannels;
|
let filteredChannels = this.filteredChannels;
|
||||||
|
|
||||||
// 2. Group
|
// 2. Group
|
||||||
filteredChannels.forEach(ch => {
|
filteredChannels.forEach(ch => {
|
||||||
const groupKey = ch.groupTitle || 'Uncategorized';
|
const groupKey = ch.groupTitle ?? 'Uncategorized';
|
||||||
if (!groupedChannels[groupKey]) {
|
if (!groupedChannels[groupKey]) {
|
||||||
groupedChannels[groupKey] = [];
|
groupedChannels[groupKey] = [];
|
||||||
}
|
}
|
||||||
groupedChannels[groupKey].push(ch);
|
groupedChannels[groupKey].push(ch);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Object.values(groupedChannels).forEach(channels => channels.sort((a, b) => this.compareCatalogChannels(a, b)));
|
||||||
|
// Keep the synthetic Favorites group separate even from a provider's "Favorites" group.
|
||||||
|
this.favoriteGroupKey = '__nodecast_favorites__';
|
||||||
|
while (Object.hasOwn(groupedChannels, this.favoriteGroupKey)) this.favoriteGroupKey += '_';
|
||||||
|
|
||||||
// 3. Add Favorites
|
// 3. Add Favorites
|
||||||
const favoritedChannels = this.channels.filter(ch => this.isFavorite(ch.sourceId, ch.id));
|
const favoritedChannels = this.channels.filter(ch => this.isFavorite(ch.sourceId, ch.id));
|
||||||
if (favoritedChannels.length > 0) {
|
if (favoritedChannels.length > 0) {
|
||||||
favoritedChannels.sort((a, b) => a.name.localeCompare(b.name));
|
favoritedChannels.sort((a, b) => this.compareCatalogChannels(a, b));
|
||||||
groupedChannels['Favorites'] = favoritedChannels;
|
groupedChannels[this.favoriteGroupKey] = favoritedChannels;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Sort Groups and filter to only those with visible channels
|
// 4. Sort Groups and filter to only those with visible channels
|
||||||
const allGroups = Object.keys(groupedChannels).sort((a, b) => {
|
const allGroups = Object.keys(groupedChannels).sort((a, b) => {
|
||||||
if (a === 'Favorites') return -1;
|
if (a === this.favoriteGroupKey) return -1;
|
||||||
if (b === 'Favorites') return 1;
|
if (b === this.favoriteGroupKey) return 1;
|
||||||
return a.localeCompare(b);
|
return this.compareCatalogTitles(a, b);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Pre-filter to only include groups with visible channels (so hidden groups don't consume batch slots)
|
// Pre-filter to only include groups with visible channels (so hidden groups don't consume batch slots)
|
||||||
this.sortedGroups = allGroups.filter(groupName => {
|
this.sortedGroups = allGroups.filter(groupName => {
|
||||||
if (groupName === 'Favorites') return true;
|
if (groupName === this.favoriteGroupKey) return true;
|
||||||
const channels = groupedChannels[groupName];
|
const channels = groupedChannels[groupName];
|
||||||
// Check if any channel in this group is visible
|
// Check if any channel in this group is visible
|
||||||
return channels.some(channel => {
|
return channels.some(channel => {
|
||||||
const rawChannelId = channel.streamId || channel.id;
|
const rawChannelId = channel.streamId ?? channel.id;
|
||||||
const isHidden = this.isHidden('channel', channel.sourceId, rawChannelId);
|
const isHidden = this.isHidden('channel', channel.sourceId, rawChannelId);
|
||||||
return !isHidden || showHidden;
|
return !isHidden || showHidden;
|
||||||
});
|
});
|
||||||
@ -357,7 +398,7 @@ class ChannelList {
|
|||||||
// This prevents rendering 100K+ channel items on initial load
|
// This prevents rendering 100K+ channel items on initial load
|
||||||
if (!this._hasCollapsedState && this.sortedGroups.length > 0) {
|
if (!this._hasCollapsedState && this.sortedGroups.length > 0) {
|
||||||
this.sortedGroups.forEach(groupName => {
|
this.sortedGroups.forEach(groupName => {
|
||||||
if (groupName !== 'Favorites') {
|
if (groupName !== this.favoriteGroupKey) {
|
||||||
this.collapsedGroups.add(groupName);
|
this.collapsedGroups.add(groupName);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -369,11 +410,11 @@ class ChannelList {
|
|||||||
this.renderedChannels = [];
|
this.renderedChannels = [];
|
||||||
this.sortedGroups.forEach(groupName => {
|
this.sortedGroups.forEach(groupName => {
|
||||||
const channels = this.groupedChannels[groupName];
|
const channels = this.groupedChannels[groupName];
|
||||||
const isFavoritesGroup = groupName === 'Favorites';
|
const isFavoritesGroup = groupName === this.favoriteGroupKey;
|
||||||
|
|
||||||
const visibleChannels = channels.filter(channel => {
|
const visibleChannels = channels.filter(channel => {
|
||||||
if (isFavoritesGroup) return true;
|
if (isFavoritesGroup) return true;
|
||||||
const rawChannelId = channel.streamId || channel.id;
|
const rawChannelId = channel.streamId ?? channel.id;
|
||||||
const channelHidden = this.isHidden('channel', channel.sourceId, rawChannelId);
|
const channelHidden = this.isHidden('channel', channel.sourceId, rawChannelId);
|
||||||
return !channelHidden || this.showHidden;
|
return !channelHidden || this.showHidden;
|
||||||
});
|
});
|
||||||
@ -384,7 +425,7 @@ class ChannelList {
|
|||||||
// ensuring no side effects on the main channel object
|
// ensuring no side effects on the main channel object
|
||||||
const renderedCh = {
|
const renderedCh = {
|
||||||
...ch,
|
...ch,
|
||||||
_renderId: `rid_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
_renderId: `rid_${encodeURIComponent(groupName)}_${ch.sourceId}_${encodeURIComponent(ch.id)}`,
|
||||||
_renderGroup: groupName // Track visual group for navigation
|
_renderGroup: groupName // Track visual group for navigation
|
||||||
};
|
};
|
||||||
this.renderedChannels.push(renderedCh);
|
this.renderedChannels.push(renderedCh);
|
||||||
@ -443,18 +484,18 @@ class ChannelList {
|
|||||||
this.loader.style.opacity = '1';
|
this.loader.style.opacity = '1';
|
||||||
let html = '';
|
let html = '';
|
||||||
|
|
||||||
let renderIndex = start; // Keep track of global index for mapping to renderedChannels
|
let renderIndex = this.sortedGroups.slice(0, start).reduce((count, name) => count + this.renderedChannels.filter(c => c._renderGroup === name).length, 0); // Keep track of global index for mapping to renderedChannels
|
||||||
|
|
||||||
for (const groupName of groupsToRender) {
|
for (const groupName of groupsToRender) {
|
||||||
const channels = this.groupedChannels[groupName];
|
const channels = this.groupedChannels[groupName];
|
||||||
if (channels.length === 0) continue;
|
if (channels.length === 0) continue;
|
||||||
|
|
||||||
const isFavoritesGroup = groupName === 'Favorites';
|
const isFavoritesGroup = groupName === this.favoriteGroupKey;
|
||||||
|
|
||||||
// Pre-filter visible channels for this group
|
// Pre-filter visible channels for this group
|
||||||
const visibleChannels = channels.filter(channel => {
|
const visibleChannels = channels.filter(channel => {
|
||||||
if (isFavoritesGroup) return true;
|
if (isFavoritesGroup) return true;
|
||||||
const rawChannelId = channel.streamId || channel.id;
|
const rawChannelId = channel.streamId ?? channel.id;
|
||||||
const channelHidden = this.isHidden('channel', channel.sourceId, rawChannelId);
|
const channelHidden = this.isHidden('channel', channel.sourceId, rawChannelId);
|
||||||
return !channelHidden || this.showHidden;
|
return !channelHidden || this.showHidden;
|
||||||
});
|
});
|
||||||
@ -470,9 +511,9 @@ class ChannelList {
|
|||||||
|
|
||||||
html += `
|
html += `
|
||||||
<div class="channel-group">
|
<div class="channel-group">
|
||||||
<div class="group-header ${this.collapsedGroups.has(groupName) ? 'collapsed' : ''} ${isFavoritesGroup ? 'favorites-group' : ''}" data-group="${groupName}">
|
<div class="group-header ${this.collapsedGroups.has(groupName) ? 'collapsed' : ''} ${isFavoritesGroup ? 'favorites-group' : ''}" data-group="${this.escapeHtml(groupName)}">
|
||||||
<span class="group-toggle">${Icons.chevronDown}</span>
|
<span class="group-toggle">${Icons.chevronDown}</span>
|
||||||
<span class="group-name">${groupName}</span>
|
<span class="group-name">${this.escapeHtml(isFavoritesGroup ? 'Favorites' : groupName)}</span>
|
||||||
<span class="group-count">${visibleChannels.length}</span>
|
<span class="group-count">${visibleChannels.length}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="group-channels">
|
<div class="group-channels">
|
||||||
@ -481,6 +522,7 @@ class ChannelList {
|
|||||||
// Skip rendering channel items if group is collapsed (major performance optimization)
|
// Skip rendering channel items if group is collapsed (major performance optimization)
|
||||||
// Channels will be rendered when user expands the group
|
// Channels will be rendered when user expands the group
|
||||||
if (this.collapsedGroups.has(groupName)) {
|
if (this.collapsedGroups.has(groupName)) {
|
||||||
|
renderIndex += visibleChannels.length;
|
||||||
html += '</div></div>';
|
html += '</div></div>';
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -488,7 +530,7 @@ class ChannelList {
|
|||||||
|
|
||||||
for (const channel of visibleChannels) {
|
for (const channel of visibleChannels) {
|
||||||
// Check hidden again for styling (showHidden mode)
|
// Check hidden again for styling (showHidden mode)
|
||||||
const rawChannelId = channel.streamId || channel.id;
|
const rawChannelId = channel.streamId ?? channel.id;
|
||||||
const channelHidden = !isFavoritesGroup && this.isHidden('channel', channel.sourceId, rawChannelId);
|
const channelHidden = !isFavoritesGroup && this.isHidden('channel', channel.sourceId, rawChannelId);
|
||||||
|
|
||||||
const isActive = this.currentChannel?.id === channel.id;
|
const isActive = this.currentChannel?.id === channel.id;
|
||||||
@ -502,13 +544,13 @@ class ChannelList {
|
|||||||
|
|
||||||
html += `
|
html += `
|
||||||
<div class="channel-item ${isActive ? 'active' : ''} ${isRenderActive ? 'nav-active' : ''} ${channelHidden ? 'hidden' : ''}"
|
<div class="channel-item ${isActive ? 'active' : ''} ${isRenderActive ? 'nav-active' : ''} ${channelHidden ? 'hidden' : ''}"
|
||||||
data-channel-id="${channel.id}"
|
data-channel-id="${this.escapeHtml(String(channel.id))}"
|
||||||
data-source-id="${channel.sourceId}"
|
data-source-id="${channel.sourceId}"
|
||||||
data-source-type="${channel.sourceType}"
|
data-source-type="${channel.sourceType}"
|
||||||
data-stream-id="${channel.streamId || ''}"
|
data-stream-id="${this.escapeHtml(String(channel.streamId ?? ''))}"
|
||||||
data-url="${channel.url || ''}"
|
data-url="${channel.url || ''}"
|
||||||
data-render-id="${renderId}"
|
data-render-id="${renderId}"
|
||||||
data-render-group="${renderGroup}">
|
data-render-group="${this.escapeHtml(renderGroup)}">
|
||||||
<img class="channel-logo" src="${this.getProxiedImageUrl(channel.tvgLogo)}"
|
<img class="channel-logo" src="${this.getProxiedImageUrl(channel.tvgLogo)}"
|
||||||
alt="" onerror="this.onerror=null;this.src='/img/placeholder.png'">
|
alt="" onerror="this.onerror=null;this.src='/img/placeholder.png'">
|
||||||
<div class="channel-info">
|
<div class="channel-info">
|
||||||
@ -589,19 +631,19 @@ class ChannelList {
|
|||||||
const channels = this.groupedChannels[groupName];
|
const channels = this.groupedChannels[groupName];
|
||||||
if (!channels || channels.length === 0) return;
|
if (!channels || channels.length === 0) return;
|
||||||
|
|
||||||
const isFavoritesGroup = groupName === 'Favorites';
|
const isFavoritesGroup = groupName === this.favoriteGroupKey;
|
||||||
|
|
||||||
// Filter visible channels
|
// Filter visible channels
|
||||||
const visibleChannels = channels.filter(channel => {
|
const visibleChannels = channels.filter(channel => {
|
||||||
if (isFavoritesGroup) return true;
|
if (isFavoritesGroup) return true;
|
||||||
const rawChannelId = channel.streamId || channel.id;
|
const rawChannelId = channel.streamId ?? channel.id;
|
||||||
const channelHidden = this.isHidden('channel', channel.sourceId, rawChannelId);
|
const channelHidden = this.isHidden('channel', channel.sourceId, rawChannelId);
|
||||||
return !channelHidden || this.showHidden;
|
return !channelHidden || this.showHidden;
|
||||||
});
|
});
|
||||||
|
|
||||||
let html = '';
|
let html = '';
|
||||||
for (const channel of visibleChannels) {
|
for (const channel of visibleChannels) {
|
||||||
const rawChannelId = channel.streamId || channel.id;
|
const rawChannelId = channel.streamId ?? channel.id;
|
||||||
const channelHidden = !isFavoritesGroup && this.isHidden('channel', channel.sourceId, rawChannelId);
|
const channelHidden = !isFavoritesGroup && this.isHidden('channel', channel.sourceId, rawChannelId);
|
||||||
const isActive = this.currentChannel?.id === channel.id;
|
const isActive = this.currentChannel?.id === channel.id;
|
||||||
const isFavorite = this.isFavorite(channel.sourceId, channel.id);
|
const isFavorite = this.isFavorite(channel.sourceId, channel.id);
|
||||||
@ -615,13 +657,13 @@ class ChannelList {
|
|||||||
|
|
||||||
html += `
|
html += `
|
||||||
<div class="channel-item ${isActive ? 'active' : ''} ${channelHidden ? 'hidden' : ''}"
|
<div class="channel-item ${isActive ? 'active' : ''} ${channelHidden ? 'hidden' : ''}"
|
||||||
data-channel-id="${channel.id}"
|
data-channel-id="${this.escapeHtml(String(channel.id))}"
|
||||||
data-source-id="${channel.sourceId}"
|
data-source-id="${channel.sourceId}"
|
||||||
data-source-type="${channel.sourceType}"
|
data-source-type="${channel.sourceType}"
|
||||||
data-stream-id="${channel.streamId || ''}"
|
data-stream-id="${this.escapeHtml(String(channel.streamId ?? ''))}"
|
||||||
data-url="${channel.url || ''}"
|
data-url="${channel.url || ''}"
|
||||||
data-render-id="${renderId}"
|
data-render-id="${renderId}"
|
||||||
data-render-group="${renderGroup}">
|
data-render-group="${this.escapeHtml(renderGroup)}">
|
||||||
<img class="channel-logo" src="${this.getProxiedImageUrl(channel.tvgLogo)}"
|
<img class="channel-logo" src="${this.getProxiedImageUrl(channel.tvgLogo)}"
|
||||||
alt="" onerror="this.onerror=null;this.src='/img/placeholder.png'">
|
alt="" onerror="this.onerror=null;this.src='/img/placeholder.png'">
|
||||||
<div class="channel-info">
|
<div class="channel-info">
|
||||||
@ -698,164 +740,86 @@ class ChannelList {
|
|||||||
/**
|
/**
|
||||||
* Load channels from selected source
|
* Load channels from selected source
|
||||||
*/
|
*/
|
||||||
async loadChannels() {
|
loadChannels() {
|
||||||
if (this.isLoading) return;
|
clearTimeout(this.searchTimeout);
|
||||||
this.isLoading = true;
|
this.catalogVersion++;
|
||||||
this.currentRenderId = null; // Reset render tracking
|
this.catalogController?.abort();
|
||||||
|
this.catalogScopeVersion = this.catalogVersion;
|
||||||
const sourceValue = this.sourceSelect.value;
|
this.catalogQuery = this.searchInput.value.trim();
|
||||||
const self = this;
|
this.catalogParams = {
|
||||||
|
sourceId: this.sourceSelect.value.split(':')[1],
|
||||||
if (!sourceValue) {
|
q: this.catalogQuery,
|
||||||
// Load from all sources
|
includeHidden: this.showHiddenCheckbox?.checked || false,
|
||||||
await this.loadAllChannels();
|
full: true
|
||||||
this.isLoading = false;
|
};
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [type, id] = sourceValue.split(':');
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.container.innerHTML = '<div class="loading"></div>';
|
|
||||||
|
|
||||||
if (type === 'xtream') {
|
|
||||||
await this.loadXtreamChannels(parseInt(id));
|
|
||||||
} else if (type === 'm3u') {
|
|
||||||
await this.loadM3uChannels(parseInt(id));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load hidden items and favorites
|
|
||||||
await Promise.all([
|
|
||||||
this.loadHiddenItems(),
|
|
||||||
this.loadFavorites()
|
|
||||||
]);
|
|
||||||
|
|
||||||
this.render();
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error loading channels:', err);
|
|
||||||
this.container.innerHTML = `<div class="empty-state"><p>Error loading channels</p><p class="hint">${err.message}</p></div>`;
|
|
||||||
} finally {
|
|
||||||
this.isLoading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load channels from all enabled sources
|
|
||||||
*/
|
|
||||||
async loadAllChannels() {
|
|
||||||
this.channels = [];
|
this.channels = [];
|
||||||
this.groups = [];
|
this.renderedChannels = [];
|
||||||
|
this.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 {
|
try {
|
||||||
this.container.innerHTML = '<div class="loading"></div>';
|
const [result] = await Promise.all([
|
||||||
|
API.channels.liveCatalog(this.catalogParams, controller.signal),
|
||||||
const xtreamSources = this.sources.filter(s => s.type === 'xtream' && s.enabled);
|
this.loadHiddenItems(), this.loadFavorites()
|
||||||
const m3uSources = this.sources.filter(s => s.type === 'm3u' && s.enabled);
|
|
||||||
console.log('[ChannelList] loadAllChannels: xtream=', xtreamSources.length, 'm3u=', m3uSources.length);
|
|
||||||
|
|
||||||
for (const source of xtreamSources) {
|
|
||||||
await this.loadXtreamChannels(source.id, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const source of m3uSources) {
|
|
||||||
await this.loadM3uChannels(source.id, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
await Promise.all([
|
|
||||||
this.loadHiddenItems(),
|
|
||||||
this.loadFavorites()
|
|
||||||
]);
|
]);
|
||||||
|
if (version !== this.catalogVersion) return false;
|
||||||
|
this.channels = result.items;
|
||||||
this.render();
|
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) {
|
} 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
|
* Load hidden items
|
||||||
*/
|
*/
|
||||||
@ -907,7 +871,7 @@ class ChannelList {
|
|||||||
const wasFavorite = this.visibleFavorites.has(key);
|
const wasFavorite = this.visibleFavorites.has(key);
|
||||||
|
|
||||||
// Find all buttons for this channel in the DOM (it may appear in multiple groups)
|
// Find all buttons for this channel in the DOM (it may appear in multiple groups)
|
||||||
const btns = document.querySelectorAll(`.channel-item[data-channel-id="${channelId}"][data-source-id="${sourceId}"] .favorite-btn`);
|
const btns = document.querySelectorAll(`.channel-item[data-channel-id="${CSS.escape(String(channelId))}"][data-source-id="${CSS.escape(String(sourceId))}"] .favorite-btn`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Optimistic update
|
// Optimistic update
|
||||||
@ -975,11 +939,11 @@ class ChannelList {
|
|||||||
*/
|
*/
|
||||||
updateFavoritesGroup(channel, isAdded) {
|
updateFavoritesGroup(channel, isAdded) {
|
||||||
// 1. Update Data
|
// 1. Update Data
|
||||||
if (!this.groupedChannels['Favorites']) {
|
if (!this.groupedChannels[this.favoriteGroupKey]) {
|
||||||
this.groupedChannels['Favorites'] = [];
|
this.groupedChannels[this.favoriteGroupKey] = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const favArray = this.groupedChannels['Favorites'];
|
const favArray = this.groupedChannels[this.favoriteGroupKey];
|
||||||
const existingIdx = favArray.findIndex(c => c.id === channel.id && c.sourceId === channel.sourceId);
|
const existingIdx = favArray.findIndex(c => c.id === channel.id && c.sourceId === channel.sourceId);
|
||||||
|
|
||||||
if (isAdded) {
|
if (isAdded) {
|
||||||
@ -988,8 +952,10 @@ class ChannelList {
|
|||||||
if (existingIdx !== -1) favArray.splice(existingIdx, 1);
|
if (existingIdx !== -1) favArray.splice(existingIdx, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
favArray.sort((a, b) => this.compareCatalogChannels(a, b));
|
||||||
|
|
||||||
// 2. Update DOM
|
// 2. Update DOM
|
||||||
const groupHeader = this.listContainer.querySelector('.group-header[data-group="Favorites"]');
|
const groupHeader = this.listContainer?.querySelector('.group-header.favorites-group');
|
||||||
|
|
||||||
if (!groupHeader) {
|
if (!groupHeader) {
|
||||||
// If group doesn't exist and we're adding, we ideally should create it
|
// If group doesn't exist and we're adding, we ideally should create it
|
||||||
@ -1006,13 +972,16 @@ class ChannelList {
|
|||||||
|
|
||||||
if (isAdded) {
|
if (isAdded) {
|
||||||
// Check if already in DOM (to avoid dupes)
|
// Check if already in DOM (to avoid dupes)
|
||||||
const existingEl = groupChannels.querySelector(`.channel-item[data-channel-id="${channel.id}"][data-source-id="${channel.sourceId}"]`);
|
const existingEl = groupChannels.querySelector(`.channel-item[data-channel-id="${CSS.escape(String(channel.id))}"][data-source-id="${CSS.escape(String(channel.sourceId))}"]`);
|
||||||
if (!existingEl) {
|
if (!existingEl) {
|
||||||
const newEl = this.createChannelElement(channel);
|
const newEl = this.createChannelElement(channel);
|
||||||
groupChannels.appendChild(newEl);
|
const next = favArray[favArray.indexOf(channel) + 1];
|
||||||
|
const nextElement = next && [...groupChannels.children].find(element =>
|
||||||
|
element.dataset.channelId === String(next.id) && element.dataset.sourceId === String(next.sourceId));
|
||||||
|
groupChannels.insertBefore(newEl, nextElement || null);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const existingEl = groupChannels.querySelector(`.channel-item[data-channel-id="${channel.id}"][data-source-id="${channel.sourceId}"]`);
|
const existingEl = groupChannels.querySelector(`.channel-item[data-channel-id="${CSS.escape(String(channel.id))}"][data-source-id="${CSS.escape(String(channel.sourceId))}"]`);
|
||||||
if (existingEl) {
|
if (existingEl) {
|
||||||
existingEl.remove();
|
existingEl.remove();
|
||||||
}
|
}
|
||||||
@ -1041,7 +1010,7 @@ class ChannelList {
|
|||||||
div.dataset.channelId = channel.id;
|
div.dataset.channelId = channel.id;
|
||||||
div.dataset.sourceId = channel.sourceId;
|
div.dataset.sourceId = channel.sourceId;
|
||||||
div.dataset.sourceType = channel.sourceType;
|
div.dataset.sourceType = channel.sourceType;
|
||||||
div.dataset.streamId = channel.streamId || '';
|
div.dataset.streamId = channel.streamId ?? '';
|
||||||
div.dataset.url = channel.url || '';
|
div.dataset.url = channel.url || '';
|
||||||
|
|
||||||
div.innerHTML = `
|
div.innerHTML = `
|
||||||
@ -1079,9 +1048,10 @@ class ChannelList {
|
|||||||
* Select and play a channel
|
* Select and play a channel
|
||||||
*/
|
*/
|
||||||
async selectChannel(dataset) {
|
async selectChannel(dataset) {
|
||||||
const channel = this.channels.find(c => c.id === dataset.channelId);
|
const channel = [...this.channels, ...(this.favoriteCatalog || []), ...(this.guideCatalog || [])].find(c => String(c.id) === String(dataset.channelId) && (dataset.sourceId === undefined || String(c.sourceId) === String(dataset.sourceId)));
|
||||||
if (!channel) return;
|
if (!channel) return;
|
||||||
|
|
||||||
|
const previousChannel = this.currentChannel;
|
||||||
this.currentChannel = channel;
|
this.currentChannel = channel;
|
||||||
this.currentRenderId = dataset.renderId; // Track which visual instance is active
|
this.currentRenderId = dataset.renderId; // Track which visual instance is active
|
||||||
this.currentRenderGroup = dataset.renderGroup; // Track which group the selection came from
|
this.currentRenderGroup = dataset.renderGroup; // Track which group the selection came from
|
||||||
@ -1111,7 +1081,8 @@ class ChannelList {
|
|||||||
|
|
||||||
// Fallback checks if still not found
|
// Fallback checks if still not found
|
||||||
if (!activeItem) {
|
if (!activeItem) {
|
||||||
activeItem = this.container.querySelector(`[data-channel-id="${channel.id}"]`);
|
activeItem = [...this.container.querySelectorAll('.channel-item')].find(item =>
|
||||||
|
item.dataset.channelId === String(channel.id) && item.dataset.sourceId === String(channel.sourceId));
|
||||||
// If we fell back to channel ID, update currentRenderId to match what we found
|
// If we fell back to channel ID, update currentRenderId to match what we found
|
||||||
if (activeItem && activeItem.dataset.renderId) {
|
if (activeItem && activeItem.dataset.renderId) {
|
||||||
this.currentRenderId = activeItem.dataset.renderId;
|
this.currentRenderId = activeItem.dataset.renderId;
|
||||||
@ -1171,9 +1142,13 @@ class ChannelList {
|
|||||||
streamUrl = channel.url;
|
streamUrl = channel.url;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (dataset.catalogVersion !== undefined && dataset.catalogVersion !== this.catalogVersion) {
|
||||||
|
if (this.currentChannel === channel) this.currentChannel = previousChannel;
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Play channel
|
// Play channel
|
||||||
if (window.app?.player) {
|
if (window.app?.player) {
|
||||||
window.app.player.play(channel, streamUrl);
|
await window.app.player.play(channel, streamUrl);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1331,7 +1306,7 @@ class ChannelList {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update DOM (All instances)
|
// Update DOM (All instances)
|
||||||
const btns = document.querySelectorAll(`.channel-item[data-channel-id="${channelId}"][data-source-id="${sourceId}"] .favorite-btn`);
|
const btns = document.querySelectorAll(`.channel-item[data-channel-id="${CSS.escape(String(channelId))}"][data-source-id="${CSS.escape(String(sourceId))}"] .favorite-btn`);
|
||||||
|
|
||||||
btns.forEach(btn => {
|
btns.forEach(btn => {
|
||||||
if (isFavorite) {
|
if (isFavorite) {
|
||||||
@ -1355,88 +1330,48 @@ class ChannelList {
|
|||||||
/**
|
/**
|
||||||
* Select next channel in the current list
|
* Select next channel in the current list
|
||||||
*/
|
*/
|
||||||
selectNextChannel() {
|
selectNextChannel() { return window.app?.player?.channelDown(); }
|
||||||
if (!this.currentChannel || !this.renderedChannels || this.renderedChannels.length === 0) return;
|
|
||||||
|
|
||||||
let currentIndex = -1;
|
selectPrevChannel() { return window.app?.player?.channelUp(); }
|
||||||
|
|
||||||
// Try to find by render ID first (strict visual order)
|
queueZap(direction) {
|
||||||
if (this.currentRenderId) {
|
const version = this.catalogVersion;
|
||||||
currentIndex = this.renderedChannels.findIndex(c => c._renderId === this.currentRenderId);
|
const run = async () => {
|
||||||
}
|
if (version !== this.catalogVersion || version !== this.catalogScopeVersion) return;
|
||||||
|
if (this.catalogRequest?.version === version) {
|
||||||
// Fallback: Find matching channel ID, prioritizing same render group
|
if (!await this.catalogRequest.promise) return;
|
||||||
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 (version !== this.catalogVersion || this.isLoading) return;
|
||||||
if (currentIndex === -1) {
|
const current = window.app?.player?.currentChannel || this.currentChannel;
|
||||||
currentIndex = this.renderedChannels.findIndex(c =>
|
const selection = this.currentChannel;
|
||||||
c.id === this.currentChannel.id && c.sourceId === this.currentChannel.sourceId
|
const streamId = current?.streamId ?? current?.id;
|
||||||
);
|
if (current?.sourceId == null || streamId == null) return;
|
||||||
|
const matches = channel => String(channel.sourceId) === String(current.sourceId) &&
|
||||||
|
String(channel.streamId ?? channel.id) === String(streamId);
|
||||||
|
const isCurrent = () => version === this.catalogVersion && this.currentChannel === selection &&
|
||||||
|
(window.app?.player?.currentChannel || this.currentChannel) === current;
|
||||||
|
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 (!isCurrent() || index === -1 || !channels.length) return;
|
||||||
|
const neighbor = direction < 0
|
||||||
if (currentIndex === -1) return;
|
? channels[index === 0 ? channels.length - 1 : index - 1]
|
||||||
|
: channels[index >= channels.length - 1 ? 0 : index + 1];
|
||||||
const nextIndex = (currentIndex + 1) % this.renderedChannels.length;
|
try {
|
||||||
const nextChannel = this.renderedChannels[nextIndex];
|
await this.selectChannel({ channelId: neighbor.id, sourceId: neighbor.sourceId, catalogVersion: version });
|
||||||
|
} catch (err) {
|
||||||
this.selectChannel({
|
if (this.currentChannel === neighbor) this.currentChannel = current;
|
||||||
channelId: nextChannel.id,
|
throw err;
|
||||||
sourceId: nextChannel.sourceId,
|
}
|
||||||
sourceType: nextChannel.sourceType,
|
};
|
||||||
streamId: nextChannel.streamId,
|
this.zapQueue = (this.zapQueue || Promise.resolve()).then(run).catch(err => {
|
||||||
url: nextChannel.url,
|
console.error('Error changing channel:', err);
|
||||||
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
|
|
||||||
});
|
});
|
||||||
|
return this.zapQueue;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -1454,13 +1389,11 @@ class ChannelList {
|
|||||||
* Get list of visible (non-hidden) channels in display order
|
* Get list of visible (non-hidden) channels in display order
|
||||||
*/
|
*/
|
||||||
getVisibleChannels() {
|
getVisibleChannels() {
|
||||||
const showHidden = this.showHiddenCheckbox?.checked ?? false;
|
// Use the same groups and child order as render(), without the synthetic duplicates.
|
||||||
return this.channels.filter(ch => {
|
return (this.sortedGroups || []).filter(group => group !== this.favoriteGroupKey).flatMap(group =>
|
||||||
if (showHidden) return true;
|
(this.groupedChannels[group] || []).filter(channel =>
|
||||||
const channelHidden = this.isHidden('channel', ch.sourceId, ch.id);
|
this.showHidden || !this.isHidden('channel', channel.sourceId, channel.streamId ?? channel.id)));
|
||||||
const groupHidden = this.isHidden('group', ch.sourceId, ch.groupTitle);
|
|
||||||
return !channelHidden && !groupHidden;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -348,7 +348,7 @@ class EpgGuide {
|
|||||||
// Get channels and filter out hidden ones (always enforce hidden in EPG)
|
// Get channels and filter out hidden ones (always enforce hidden in EPG)
|
||||||
// Note: We only check individual channel visibility, not group visibility
|
// Note: We only check individual channel visibility, not group visibility
|
||||||
// A group is implicitly visible if it has any visible children
|
// A group is implicitly visible if it has any visible children
|
||||||
const playableChannels = (channelList.channels || []).filter(ch => {
|
const playableChannels = (channelList.guideCatalog || channelList.channels || []).filter(ch => {
|
||||||
// Use streamId (raw ID) for hidden check since that's what SourceManager stores
|
// Use streamId (raw ID) for hidden check since that's what SourceManager stores
|
||||||
const rawChannelId = ch.streamId || ch.id;
|
const rawChannelId = ch.streamId || ch.id;
|
||||||
const isChannelHidden = channelList.isHidden('channel', ch.sourceId, rawChannelId);
|
const isChannelHidden = channelList.isHidden('channel', ch.sourceId, rawChannelId);
|
||||||
@ -915,7 +915,7 @@ class EpgGuide {
|
|||||||
async playChannel(channelName) {
|
async playChannel(channelName) {
|
||||||
// Find channel in channel list and play
|
// Find channel in channel list and play
|
||||||
if (window.app?.channelList) {
|
if (window.app?.channelList) {
|
||||||
const channel = window.app.channelList.channels.find(c =>
|
const channel = (window.app.channelList.guideCatalog || window.app.channelList.channels).find(c =>
|
||||||
c.name === channelName || c.tvgName === channelName
|
c.name === channelName || c.tvgName === channelName
|
||||||
);
|
);
|
||||||
if (channel) {
|
if (channel) {
|
||||||
|
|||||||
@ -1259,32 +1259,12 @@ class VideoPlayer {
|
|||||||
* Go to previous channel
|
* Go to previous channel
|
||||||
*/
|
*/
|
||||||
channelUp() {
|
channelUp() {
|
||||||
if (!window.app?.channelList) return;
|
return window.app?.channelList?.queueZap(-1);
|
||||||
const channels = window.app.channelList.getVisibleChannels();
|
|
||||||
if (channels.length === 0) return;
|
|
||||||
|
|
||||||
const currentIdx = this.currentChannel
|
|
||||||
? channels.findIndex(c => c.id === this.currentChannel.id)
|
|
||||||
: -1;
|
|
||||||
|
|
||||||
const prevIdx = currentIdx <= 0 ? channels.length - 1 : currentIdx - 1;
|
|
||||||
window.app.channelList.selectChannel({ channelId: channels[prevIdx].id });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Go to the next channel, waiting for a catalogue page when needed. */
|
||||||
* Go to next channel
|
|
||||||
*/
|
|
||||||
channelDown() {
|
channelDown() {
|
||||||
if (!window.app?.channelList) return;
|
return window.app?.channelList?.queueZap(1);
|
||||||
const channels = window.app.channelList.getVisibleChannels();
|
|
||||||
if (channels.length === 0) return;
|
|
||||||
|
|
||||||
const currentIdx = this.currentChannel
|
|
||||||
? channels.findIndex(c => c.id === this.currentChannel.id)
|
|
||||||
: -1;
|
|
||||||
|
|
||||||
const nextIdx = currentIdx >= channels.length - 1 ? 0 : currentIdx + 1;
|
|
||||||
window.app.channelList.selectChannel({ channelId: channels[nextIdx].id });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async zapBeforeStreaming(channel, streamUrl, signal) {
|
async zapBeforeStreaming(channel, streamUrl, signal) {
|
||||||
|
|||||||
@ -11,28 +11,71 @@ class GuidePage {
|
|||||||
// EPG guide will lazy load when shown
|
// EPG guide will lazy load when shown
|
||||||
}
|
}
|
||||||
|
|
||||||
async show() {
|
async loadCatalog() {
|
||||||
// Ensure channel data is loaded before rendering EPG
|
|
||||||
// This fixes a race condition where navigating directly to the Guide page
|
|
||||||
// before visiting Live TV would result in an empty EPG.
|
|
||||||
const channelList = this.app.channelList;
|
const channelList = this.app.channelList;
|
||||||
if (!channelList.channels || channelList.channels.length === 0) {
|
const revision = API.liveCatalogRevision || 0;
|
||||||
await channelList.loadSources();
|
if (channelList.guideCatalog && channelList.guideCatalogRevision === revision) return;
|
||||||
await channelList.loadChannels();
|
if (this.catalogLoad?.revision === revision) return this.catalogLoad.promise;
|
||||||
|
const promise = (async () => {
|
||||||
|
const channels = [];
|
||||||
|
let cursor;
|
||||||
|
do {
|
||||||
|
// Guide ignores group visibility; EpgGuide filters individually hidden channels.
|
||||||
|
const result = await API.channels.liveCatalog({ limit: 500, cursor, includeHidden: true });
|
||||||
|
if (revision !== (API.liveCatalogRevision || 0)) throw new Error('Catalogue changed');
|
||||||
|
channels.push(...result.items);
|
||||||
|
const next = result.page.hasMore ? result.page.nextCursor : null;
|
||||||
|
if (result.page.hasMore && (!next || next === cursor)) throw new Error('Invalid catalogue cursor');
|
||||||
|
cursor = next;
|
||||||
|
} while (cursor);
|
||||||
|
await channelList.loadHiddenItems();
|
||||||
|
if (revision !== (API.liveCatalogRevision || 0)) throw new Error('Catalogue changed');
|
||||||
|
channelList.guideCatalog = channels;
|
||||||
|
channelList.guideCatalogRevision = revision;
|
||||||
|
})();
|
||||||
|
this.catalogLoad = { revision, promise };
|
||||||
|
try { await promise; }
|
||||||
|
finally {
|
||||||
|
if (this.catalogLoad?.promise === promise) this.catalogLoad = null;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Only load EPG data if not already loaded
|
async show() {
|
||||||
if (!this.app.epgGuide.programmes || this.app.epgGuide.programmes.length === 0) {
|
const visit = this.visit = (this.visit || 0) + 1;
|
||||||
await this.app.epgGuide.loadEpg();
|
const container = this.app.epgGuide.container;
|
||||||
} else {
|
const channelList = this.app.channelList;
|
||||||
// Just re-render with existing data (updates time position)
|
const needsCatalog = !channelList.guideCatalog || channelList.guideCatalogRevision !== (API.liveCatalogRevision || 0);
|
||||||
this.app.epgGuide.render();
|
if (needsCatalog) {
|
||||||
|
container.textContent = 'Loading…';
|
||||||
|
container.setAttribute('aria-busy', 'true');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await this.loadCatalog();
|
||||||
|
if (visit !== this.visit) return;
|
||||||
|
if (!this.app.epgGuide.programmes?.length) {
|
||||||
|
await this.app.epgGuide.loadEpg();
|
||||||
|
} else {
|
||||||
|
this.app.epgGuide.render();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (visit !== this.visit) return;
|
||||||
|
container.textContent = 'Error loading channels';
|
||||||
|
const retry = document.createElement('button');
|
||||||
|
retry.type = 'button';
|
||||||
|
retry.className = 'btn btn-sm';
|
||||||
|
retry.textContent = 'Retry';
|
||||||
|
retry.addEventListener('click', () => this.show());
|
||||||
|
container.appendChild(retry);
|
||||||
|
} finally {
|
||||||
|
if (visit === this.visit) container.setAttribute('aria-busy', 'false');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
hide() {
|
hide() {
|
||||||
// Page is hidden
|
this.visit = (this.visit || 0) + 1;
|
||||||
|
this.app.epgGuide.container.setAttribute('aria-busy', 'false');
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
window.GuidePage = GuidePage;
|
window.GuidePage = GuidePage;
|
||||||
|
|||||||
@ -195,34 +195,13 @@ class HomePage {
|
|||||||
const section = document.getElementById('favorite-channels-section');
|
const section = document.getElementById('favorite-channels-section');
|
||||||
if (!list || !section) return;
|
if (!list || !section) return;
|
||||||
|
|
||||||
|
const version = this.favoriteCatalogVersion = (this.favoriteCatalogVersion || 0) + 1;
|
||||||
try {
|
try {
|
||||||
// Fetch favorite channels for current user
|
|
||||||
const favorites = await window.API.request('GET', '/favorites?itemType=channel');
|
|
||||||
|
|
||||||
if (!favorites || favorites.length === 0) {
|
|
||||||
list.innerHTML = '<div class="empty-state hint">Add channels to favorites from Live TV</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure channel list is loaded to resolve channel details
|
|
||||||
const channelList = this.app.channelList;
|
const channelList = this.app.channelList;
|
||||||
if (!channelList.channels || channelList.channels.length === 0) {
|
const result = await API.channels.liveCatalog({ favoriteOnly: true, limit: 200 });
|
||||||
await channelList.loadSources();
|
if (version !== this.favoriteCatalogVersion) return;
|
||||||
await channelList.loadChannels();
|
const channels = result.items;
|
||||||
}
|
channelList.favoriteCatalog = channels;
|
||||||
|
|
||||||
// Match favorites to channel data
|
|
||||||
const channels = [];
|
|
||||||
for (const fav of favorites) {
|
|
||||||
// Find channel in loaded channel list
|
|
||||||
const channel = channelList.channels.find(ch =>
|
|
||||||
String(ch.sourceId) === String(fav.source_id) &&
|
|
||||||
(String(ch.id) === String(fav.item_id) || String(ch.streamId) === String(fav.item_id))
|
|
||||||
);
|
|
||||||
if (channel) {
|
|
||||||
channels.push({ ...channel, favoriteId: fav.id });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (channels.length === 0) {
|
if (channels.length === 0) {
|
||||||
list.innerHTML = '<div class="empty-state hint">Add channels to favorites from Live TV</div>';
|
list.innerHTML = '<div class="empty-state hint">Add channels to favorites from Live TV</div>';
|
||||||
@ -234,6 +213,7 @@ class HomePage {
|
|||||||
|
|
||||||
// Attach click handlers
|
// Attach click handlers
|
||||||
list.querySelectorAll('.channel-tile').forEach(tile => {
|
list.querySelectorAll('.channel-tile').forEach(tile => {
|
||||||
|
tile.addEventListener('keydown', event => this.activateFavoriteTile(event, tile.dataset.channelId, tile.dataset.sourceId));
|
||||||
tile.addEventListener('click', () => {
|
tile.addEventListener('click', () => {
|
||||||
const channelId = tile.dataset.channelId;
|
const channelId = tile.dataset.channelId;
|
||||||
const sourceId = tile.dataset.sourceId;
|
const sourceId = tile.dataset.sourceId;
|
||||||
@ -241,6 +221,48 @@ class HomePage {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let nextCursor = result.page.nextCursor;
|
||||||
|
if (result.page.hasMore) {
|
||||||
|
const more = document.createElement('button');
|
||||||
|
more.type = 'button';
|
||||||
|
more.className = 'channel-tile channel-tile-action';
|
||||||
|
more.textContent = 'Load more';
|
||||||
|
list.appendChild(more);
|
||||||
|
more.addEventListener('click', async event => {
|
||||||
|
const keyboard = event.detail === 0;
|
||||||
|
const scrollLeft = list.scrollLeft;
|
||||||
|
more.disabled = true;
|
||||||
|
more.textContent = 'Loading…';
|
||||||
|
let firstNewTile;
|
||||||
|
try {
|
||||||
|
const page = await API.channels.liveCatalog({ favoriteOnly: true, limit: 200, cursor: nextCursor });
|
||||||
|
if (version !== this.favoriteCatalogVersion) return;
|
||||||
|
channelList.favoriteCatalog.push(...page.items);
|
||||||
|
for (const channel of page.items) {
|
||||||
|
const wrapper = document.createElement('div');
|
||||||
|
wrapper.innerHTML = this.createChannelTile(channel);
|
||||||
|
const tile = wrapper.firstElementChild;
|
||||||
|
tile.addEventListener('click', () => this.playChannel(channel.id, channel.sourceId));
|
||||||
|
tile.addEventListener('keydown', event => this.activateFavoriteTile(event, channel.id, channel.sourceId));
|
||||||
|
firstNewTile ||= tile;
|
||||||
|
list.insertBefore(tile, more);
|
||||||
|
}
|
||||||
|
nextCursor = page.page.nextCursor;
|
||||||
|
if (keyboard && firstNewTile) {
|
||||||
|
firstNewTile.focus({ preventScroll: true });
|
||||||
|
firstNewTile.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
||||||
|
} else {
|
||||||
|
list.scrollLeft = scrollLeft;
|
||||||
|
}
|
||||||
|
more.hidden = !page.page.hasMore;
|
||||||
|
more.textContent = 'Load more';
|
||||||
|
this.updateScrollArrows();
|
||||||
|
} catch (err) {
|
||||||
|
more.textContent = 'Retry';
|
||||||
|
} finally { more.disabled = false; }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Update scroll arrows after content renders
|
// Update scroll arrows after content renders
|
||||||
this.updateScrollArrows();
|
this.updateScrollArrows();
|
||||||
|
|
||||||
@ -254,9 +276,11 @@ class HomePage {
|
|||||||
const logo = channel.tvgLogo || '/img/placeholder.png';
|
const logo = channel.tvgLogo || '/img/placeholder.png';
|
||||||
const logoUrl = logo.startsWith('http') ? `/api/proxy/image?url=${encodeURIComponent(logo)}` : logo;
|
const logoUrl = logo.startsWith('http') ? `/api/proxy/image?url=${encodeURIComponent(logo)}` : logo;
|
||||||
const name = channel.name || 'Unknown';
|
const name = channel.name || 'Unknown';
|
||||||
|
const channelId = this.app.channelList.escapeHtml(String(channel.id));
|
||||||
|
const sourceId = this.app.channelList.escapeHtml(String(channel.sourceId));
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="channel-tile" data-channel-id="${channel.id}" data-source-id="${channel.sourceId}">
|
<div class="channel-tile" role="button" tabindex="0" data-channel-id="${channelId}" data-source-id="${sourceId}">
|
||||||
<div class="tile-logo">
|
<div class="tile-logo">
|
||||||
<img src="${logoUrl}" alt="${name}" loading="lazy" onerror="this.onerror=null;this.src='/img/placeholder.png'">
|
<img src="${logoUrl}" alt="${name}" loading="lazy" onerror="this.onerror=null;this.src='/img/placeholder.png'">
|
||||||
</div>
|
</div>
|
||||||
@ -265,29 +289,21 @@ class HomePage {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
playChannel(channelId, sourceId) {
|
activateFavoriteTile(event, channelId, sourceId) {
|
||||||
// Navigate to Live TV and select the channel
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
this.app.navigateTo('live');
|
event.preventDefault();
|
||||||
|
this.playChannel(channelId, sourceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Small delay to ensure page is ready
|
playChannel(channelId, sourceId) {
|
||||||
setTimeout(() => {
|
const channelList = this.app.channelList;
|
||||||
const channelList = this.app.channelList;
|
const channel = channelList.favoriteCatalog?.find(ch =>
|
||||||
if (channelList) {
|
String(ch.id) === String(channelId) && String(ch.sourceId) === String(sourceId));
|
||||||
// Find and select the channel
|
if (!channel) return;
|
||||||
const channel = channelList.channels.find(ch =>
|
this.app.navigateTo('live');
|
||||||
String(ch.id) === String(channelId) && String(ch.sourceId) === String(sourceId)
|
// selectChannel resolves Home DTOs independently of the active Live filters.
|
||||||
);
|
channelList.selectChannel({ channelId: channel.id, sourceId: channel.sourceId });
|
||||||
if (channel) {
|
|
||||||
channelList.selectChannel({
|
|
||||||
channelId: channel.id,
|
|
||||||
sourceId: channel.sourceId,
|
|
||||||
sourceType: channel.sourceType,
|
|
||||||
streamId: channel.streamId || '',
|
|
||||||
url: channel.url || ''
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
renderHistory(items) {
|
renderHistory(items) {
|
||||||
|
|||||||
@ -42,6 +42,8 @@ function initSchema() {
|
|||||||
data JSON -- Extra provider data
|
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 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)
|
// 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_source_type ON playlist_items(source_id, type);
|
||||||
CREATE INDEX IF NOT EXISTS idx_items_category ON playlist_items(source_id, category_id);
|
CREATE INDEX IF NOT EXISTS idx_items_category ON playlist_items(source_id, category_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_live_catalog_order
|
||||||
|
ON playlist_items(name COLLATE NOCASE, source_id, id) WHERE type = 'live';
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// EPG Programs
|
// EPG Programs
|
||||||
@ -152,6 +156,7 @@ function initSchema() {
|
|||||||
// Column already exists, ignore
|
// Column already exists, ignore
|
||||||
}
|
}
|
||||||
|
|
||||||
|
require('../services/liveCatalog').ensureSearch(db);
|
||||||
console.log('[SQLite] Schema initialized');
|
console.log('[SQLite] Schema initialized');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,20 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const { getDb } = require('../db/sqlite');
|
const { getDb } = require('../db/sqlite');
|
||||||
|
const { sources } = require('../db');
|
||||||
|
const { parseQuery, getLiveCatalog } = require('../services/liveCatalog');
|
||||||
|
|
||||||
|
router.get('/live-catalog', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const options = parseQuery(req.query, req.user.id);
|
||||||
|
const result = getLiveCatalog(getDb(), await sources.getAll(), options);
|
||||||
|
res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
if (error.status === 400) return res.status(400).json({ error: error.message });
|
||||||
|
console.error('Error getting live catalog:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to get live catalog' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Helper to map API item types to DB types and tables
|
// Helper to map API item types to DB types and tables
|
||||||
function mapItemType(apiType) {
|
function mapItemType(apiType) {
|
||||||
|
|||||||
141
server/services/liveCatalog.js
Normal file
141
server/services/liveCatalog.js
Normal 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
29
start.sh
Executable 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
|
||||||
741
test/live-catalog-initial-load.test.js
Normal file
741
test/live-catalog-initial-load.test.js
Normal 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();
|
||||||
|
});
|
||||||
360
test/live-catalog-search-performance.test.js
Normal file
360
test/live-catalog-search-performance.test.js
Normal 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)}`);
|
||||||
|
});
|
||||||
175
test/live-catalog-search-shape-repro.test.js
Normal file
175
test/live-catalog-search-shape-repro.test.js
Normal 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)`);
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user