feat(live): remove pagination from Live catalog (#51)
Keep server-side search and cursor pagination for Home and Guide while allowing Live to load the complete filtered catalogue. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -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);
|
||||
|
||||
Reference in New Issue
Block a user