Merge feature/remove-live-catalog-pagination into develop

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-10-22 23:51:05 +02:00
4 changed files with 197 additions and 176 deletions

View File

@ -4826,7 +4826,7 @@ kbd {
}
.live-catalog-status,
.live-catalog-more {
.live-catalog-retry {
flex-shrink: 0;
margin: var(--space-sm);
}
@ -4836,7 +4836,7 @@ kbd {
}
.channel-sidebar.collapsed .live-catalog-status,
.channel-sidebar.collapsed .live-catalog-more {
.channel-sidebar.collapsed .live-catalog-retry {
display: none;
}
@ -4857,3 +4857,7 @@ kbd {
.channel-tile-action[hidden] {
display: none;
}
.live-catalog-retry[hidden] {
display: none;
}

View File

@ -24,19 +24,18 @@ class ChannelList {
this.isLoading = false;
this.renderedChannels = [];
this.catalogVersion = 0;
this.hasMore = false;
this.catalogQuery = '';
this.catalogStatus = document.createElement('div');
this.catalogStatus.setAttribute('role', 'status');
this.catalogStatus.className = 'live-catalog-status';
this.container.after(this.catalogStatus);
this.loadMoreButton = document.createElement('button');
this.loadMoreButton.type = 'button';
this.loadMoreButton.className = 'btn btn-sm live-catalog-more';
this.loadMoreButton.textContent = 'Load more';
this.loadMoreButton.hidden = true;
this.catalogStatus.after(this.loadMoreButton);
this.loadMoreButton.addEventListener('click', () => this.loadCatalogPage(this.channels.length > 0));
this.catalogRetryButton = document.createElement('button');
this.catalogRetryButton.type = 'button';
this.catalogRetryButton.className = 'btn btn-sm live-catalog-retry';
this.catalogRetryButton.textContent = 'Retry';
this.catalogRetryButton.hidden = true;
this.catalogStatus.after(this.catalogRetryButton);
this.catalogRetryButton.addEventListener('click', () => this.loadChannels());
this.loadCollapsedState();
this.init();
@ -167,7 +166,7 @@ class ChannelList {
clearTimeout(this.searchTimeout);
this.catalogVersion++;
this.catalogController?.abort();
this.loadMoreButton.hidden = true;
this.catalogRetryButton.hidden = true;
this.searchTimeout = setTimeout(searchNow, 300);
});
this.searchInput.addEventListener('keydown', e => {
@ -741,7 +740,7 @@ class ChannelList {
/**
* Load channels from selected source
*/
async loadChannels() {
loadChannels() {
clearTimeout(this.searchTimeout);
this.catalogVersion++;
this.catalogController?.abort();
@ -751,84 +750,72 @@ class ChannelList {
sourceId: this.sourceSelect.value.split(':')[1],
q: this.catalogQuery,
includeHidden: this.showHiddenCheckbox?.checked || false,
limit: 200
full: true
};
this.channels = [];
this.renderedChannels = [];
this.nextCursor = null;
this.hasMore = false;
this.sortedGroups = [];
this.groupedChannels = Object.create(null);
this.currentRenderId = null;
return this.loadCatalogPage(false);
}
async loadAllChannels() { return this.loadChannels(); }
loadCatalogPage(append) {
if (append && this.catalogPagePromise?.version === this.catalogVersion) {
return this.catalogPagePromise.promise;
}
const version = this.catalogVersion;
const promise = this.fetchCatalogPage(append);
this.catalogPagePromise = { version, promise };
const promise = this.fetchCatalog();
this.catalogRequest = { version, promise };
promise.finally(() => {
if (this.catalogPagePromise?.promise === promise) this.catalogPagePromise = null;
if (this.catalogRequest?.promise === promise) this.catalogRequest = null;
});
return promise;
}
async fetchCatalogPage(append) {
if (append && (this.isLoading || !this.hasMore)) return false;
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');
this.loadMoreButton.disabled = true;
if (!append) this.container.replaceChildren();
try {
const [result] = await Promise.all([
API.channels.liveCatalog({ ...this.catalogParams, cursor: append ? this.nextCursor : undefined }, controller.signal),
...(append ? [] : [this.loadHiddenItems(), this.loadFavorites()])
]);
if (version !== this.catalogVersion) return false;
if (result.page.hasMore && (!result.page.nextCursor || (append && result.page.nextCursor === this.nextCursor))) {
throw new Error('Invalid catalogue cursor');
}
this.channels = append ? this.channels.concat(result.items) : result.items;
this.nextCursor = result.page.nextCursor;
this.hasMore = result.page.hasMore;
const scrollTop = this.container.scrollTop;
const active = document.activeElement;
const item = active?.closest('.channel-item');
const groupName = item?.closest('.channel-group')?.querySelector('.group-header')?.dataset.group;
const scrollTop = this.container.scrollTop;
const retryHadFocus = active === this.catalogRetryButton;
this.catalogRetryButton.hidden = true;
this.container.replaceChildren();
try {
const [result] = await Promise.all([
API.channels.liveCatalog(this.catalogParams, controller.signal),
this.loadHiddenItems(), this.loadFavorites()
]);
if (version !== this.catalogVersion) return false;
this.channels = result.items;
this.render();
if (item) {
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);
(active.classList.contains('favorite-btn') ? replacement?.querySelector('.favorite-btn') : replacement)?.focus({ preventScroll: true });
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.container.scrollTop = append ? scrollTop : 0;
this.catalogStatus.textContent = '';
if (!this.hasMore && document.activeElement === this.loadMoreButton) {
this.catalogStatus.tabIndex = -1;
this.catalogStatus.textContent = 'All channels loaded';
this.catalogStatus.focus({ preventScroll: true });
}
this.loadMoreButton.textContent = 'Load more';
return true;
} catch (err) {
if (version !== this.catalogVersion || err.name === 'AbortError') return false;
this.catalogStatus.textContent = 'Error loading channels';
this.loadMoreButton.textContent = 'Retry';
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');
this.loadMoreButton.disabled = false;
this.loadMoreButton.hidden = !this.hasMore && this.loadMoreButton.textContent !== 'Retry';
}
}
}
@ -1351,9 +1338,8 @@ class ChannelList {
const version = this.catalogVersion;
const run = async () => {
if (version !== this.catalogVersion || version !== this.catalogScopeVersion) return;
// A page already requested by Load more belongs to this same scope.
if (this.catalogPagePromise?.version === version) {
if (!await this.catalogPagePromise.promise) return;
if (this.catalogRequest?.version === version) {
if (!await this.catalogRequest.promise) return;
}
if (version !== this.catalogVersion || this.isLoading) return;
const current = window.app?.player?.currentChannel || this.currentChannel;
@ -1364,30 +1350,13 @@ class ChannelList {
String(channel.streamId ?? channel.id) === String(streamId);
const isCurrent = () => version === this.catalogVersion && this.currentChannel === selection &&
(window.app?.player?.currentChannel || this.currentChannel) === current;
let channels = this.getVisibleChannels();
let index = channels.findIndex(matches);
// Home can start playback outside the loaded Live prefix. Resolve its
// source/stream identity in this exact scope before choosing any neighbour.
while (index === -1 && this.hasMore) {
if (!await this.loadCatalogPage(true) || !isCurrent()) return;
channels = this.getVisibleChannels();
index = channels.findIndex(matches);
}
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;
}
// Keep every fetched page: the previous neighbour is in the retained prefix.
// The API exposes a forward cursor only; wrapping backwards needs the true end.
while (this.hasMore && (direction < 0 ? index <= 0 : index >= channels.length - 1)) {
const cursor = this.nextCursor;
if (!await this.loadCatalogPage(true)) return;
if (!isCurrent()) return;
channels = this.getVisibleChannels();
index = channels.findIndex(matches);
if (index === -1 || (this.hasMore && this.nextCursor === cursor)) return;
}
if (!isCurrent() || index === -1 || !channels.length) return;
const neighbor = direction < 0
? channels[index === 0 ? channels.length - 1 : index - 1]

View File

@ -16,7 +16,7 @@ function badRequest(message) {
}
function parseQuery(query, userId) {
for (const key of ['sourceId', 'categoryId', 'q', 'includeHidden', 'favoriteOnly', 'limit', 'cursor']) {
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);
@ -28,6 +28,10 @@ function parseQuery(query, userId) {
};
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');
@ -42,7 +46,7 @@ function parseQuery(query, userId) {
after = cursor.k;
} catch { badRequest('Invalid cursor'); }
}
return { ...filters, limit: Math.min(requestedLimit, 200), fingerprint, after };
return { ...filters, full, limit: Math.min(requestedLimit, 200), fingerprint, after };
}
// Optional acceleration: LIKE remains the source of truth and also handles short
@ -102,7 +106,7 @@ function getLiveCatalog(db, allSources, options) {
where.push(`(${CATALOG_ORDER}) > (@groupKey, @groupTitle, @channelKey, @name, @source, @item)`);
[params.groupKey, params.groupTitle, params.channelKey, params.name, params.source, params.item] = options.after;
}
params.limit = options.limit + 1;
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,
@ -114,8 +118,8 @@ function getLiveCatalog(db, allSources, options) {
LEFT JOIN favorites f ON f.user_id = @user AND f.source_id = p.source_id
AND f.item_id = p.item_id AND f.item_type = 'channel'
WHERE ${where.join(' AND ')}
ORDER BY ${CATALOG_ORDER} LIMIT @limit`).all(params);
const hasMore = rows.length > options.limit;
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 || '{}');

View File

@ -77,6 +77,16 @@ async function requestAllPages(base, cookie, query = {}) {
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);
@ -142,6 +152,16 @@ function seedCatalog(dir, userId) {
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(`
@ -237,6 +257,8 @@ test('live catalog pages bound the initial response and preserve filters across
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`);
@ -251,22 +273,32 @@ test('live catalog pages bound the initial response and preserve filters across
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,
@ -277,6 +309,8 @@ test('live catalog pages bound the initial response and preserve filters across
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);
@ -358,7 +392,7 @@ test('the same provider stream ID in two sources keeps distinct identities and s
await page.route('https://cdn.jsdelivr.net/npm/hls.js@1.5.7/dist/hls.min.js', route => route.abort());
await page.route('https://fonts.**', route => route.abort());
await page.goto(`${fixture.base}/#live`);
await page.waitForFunction(() => window.app?.channelList?.channels?.length === 200
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
@ -433,100 +467,49 @@ test('the same provider stream ID in two sources keeps distinct identities and s
await context.close();
});
test('zapping forward from the last channel in page one fetches and plays page two first', async () => {
test('zapping at either end of the full Live list wraps without requesting another page', async () => {
const { channelList, played } = clientPageFixture();
const pages = [
[0, 1].map(id => ({ id: `ch${id}`, sourceId: 1, sourceType: 'm3u', url: `http://streams.test/ch${id}` })),
[2, 3].map(id => ({ id: `ch${id}`, sourceId: 1, sourceType: 'm3u', url: `http://streams.test/ch${id}` }))
];
channelList.setVisibleTestChannels([...pages[0]]);
channelList.currentChannel = pages[0][1];
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.hasMore = true;
channelList.nextCursor = 'page-2';
channelList.loadCatalogPage = async append => {
assert.equal(append, true);
channelList.contextPageLoads.push(channelList.nextCursor);
channelList.channels.push(...pages[1]);
channelList.hasMore = false;
channelList.nextCursor = null;
return true;
};
channelList.loadCatalogPage = async () => { channelList.contextPageLoads.push('unexpected'); };
await channelList.queueZap(1);
assert.deepEqual(channelList.contextPageLoads, ['page-2']);
assert.deepEqual(channelList.contextPageLoads, []);
assert.equal(played.length, 1);
assert.equal(played[0].channel.id, 'ch2');
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('zapping previous from page one loads through the true end before wrapping', async () => {
test('failed zap playback keeps the current channel selected without fetching a page', async () => {
const { channelList, played } = clientPageFixture();
const pages = [
[0, 1], [2, 3], [4]
].map(page => page.map(id => ({
id: `ch${id}`, sourceId: 1, sourceType: 'm3u', url: `http://streams.test/ch${id}`
})));
channelList.setVisibleTestChannels([...pages[0]]);
channelList.currentChannel = pages[0][0];
channelList.contextPageLoads = [];
channelList.hasMore = true;
channelList.nextCursor = 'page-2';
channelList.loadCatalogPage = async append => {
assert.equal(append, true);
channelList.contextPageLoads.push(channelList.nextCursor);
const nextPage = channelList.contextPageLoads.length;
channelList.channels.push(...pages[nextPage]);
channelList.hasMore = nextPage < pages.length - 1;
channelList.nextCursor = channelList.hasMore ? `page-${nextPage + 2}` : null;
return true;
};
await channelList.queueZap(-1);
assert.deepEqual(channelList.contextPageLoads, ['page-2', 'page-3']);
assert.equal(played.length, 1);
assert.equal(played[0].channel.id, 'ch4');
assert.equal(channelList.currentChannel.id, 'ch4');
});
test('failed cross-page fetch or playback keeps the current channel selected', async () => {
const { channelList, played } = clientPageFixture();
const current = { id: 'last-on-page', sourceId: 1, sourceType: 'm3u', url: 'http://streams.test/current' };
channelList.setVisibleTestChannels([current]);
channelList.currentChannel = current;
channelList.hasMore = true;
channelList.nextCursor = 'page-2';
channelList.loadCatalogPage = async () => false;
await channelList.queueZap(1);
assert.equal(channelList.currentChannel, current);
assert.deepEqual(played, []);
const playbackFixture = clientPageFixture();
const playbackCurrent = { ...current, id: 'before-playback-failure' };
const 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' };
playbackFixture.channelList.setVisibleTestChannels([playbackCurrent]);
playbackFixture.channelList.currentChannel = playbackCurrent;
playbackFixture.channelList.hasMore = true;
playbackFixture.channelList.nextCursor = 'page-2';
playbackFixture.channelList.loadCatalogPage = async () => {
playbackFixture.channelList.channels.push(next);
playbackFixture.channelList.hasMore = false;
playbackFixture.channelList.nextCursor = null;
return true;
};
playbackFixture.channelList.selectChannel = async () => {
playbackFixture.channelList.currentChannel = next;
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 playbackFixture.channelList.queueZap(1);
await channelList.queueZap(1);
assert.equal(playbackFixture.channelList.currentChannel, playbackCurrent);
assert.deepEqual(playbackFixture.played, []);
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', () => {
@ -546,7 +529,7 @@ test('Home favorite can launch a channel absent from the loaded Live page', () =
assert.equal(played[0].streamUrl, favorite.url);
});
test('Live search debounce, Enter, Search button, Load more, and channel selection work in Chromium',
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);
@ -572,18 +555,28 @@ test('Live search debounce, Enter, Search button, Load more, and channel selecti
await page.route('https://cdn.jsdelivr.net/npm/hls.js@1.5.7/dist/hls.min.js', route => route.abort());
await page.route('https://fonts.**', route => route.abort());
await page.goto(`${fixture.base}/#live`);
await page.waitForFunction(() => window.app?.channelList?.channels?.length === 200
await page.waitForFunction(() => window.app?.channelList?.channels?.length === 1209
&& !window.app.channelList.isLoading);
assert.equal(catalogRequests[0].get('limit'), '200');
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').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 page.waitForFunction(() => window.app?.channelList?.channels?.some(channel => channel.streamId === 'needle'));
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');
@ -595,10 +588,13 @@ test('Live search debounce, Enter, Search button, Load more, and channel selecti
});
await page.locator('#channel-search').press('Enter');
await enterRequest;
await page.waitForFunction(() => window.app?.channelList?.channels?.length > 0
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 => {
@ -610,16 +606,14 @@ test('Live search debounce, Enter, Search button, Load more, and channel selecti
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 === 200
await page.waitForFunction(() => window.app?.channelList?.channels?.length === 1209
&& !window.app.channelList.isLoading);
const beforeMore = catalogRequests.length;
await page.locator('.live-catalog-more').click();
await page.waitForFunction(() => window.app?.channelList?.channels?.length === 400
&& !window.app.channelList.isLoading);
assert.ok(catalogRequests.length > beforeMore);
assert.ok(catalogRequests.at(-1).get('cursor'));
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;
@ -640,6 +634,49 @@ test('Live search debounce, Enter, Search button, Load more, and channel selecti
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');
@ -673,7 +710,14 @@ test('Guide includes visible channels in hidden groups and excludes individually
await page.waitForFunction(() => window.app?.channelList?.guideCatalog?.length >= 1200
&& window.app?.epgGuide?.allMatchedChannels?.length > 0);
assert.ok(catalogRequests.some(params => params.get('includeHidden') === 'true'));
const 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);