diff --git a/public/css/main.css b/public/css/main.css index 16ade88..a4095c6 100644 --- a/public/css/main.css +++ b/public/css/main.css @@ -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; +} diff --git a/public/js/components/ChannelList.js b/public/js/components/ChannelList.js index 2ad1eaf..3a177b6 100644 --- a/public/js/components/ChannelList.js +++ b/public/js/components/ChannelList.js @@ -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(); + 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, cursor: append ? this.nextCursor : undefined }, controller.signal), - ...(append ? [] : [this.loadHiddenItems(), this.loadFavorites()]) + API.channels.liveCatalog(this.catalogParams, controller.signal), + this.loadHiddenItems(), this.loadFavorites() ]); if (version !== this.catalogVersion) return false; - if (result.page.hasMore && (!result.page.nextCursor || (append && result.page.nextCursor === this.nextCursor))) { - throw new Error('Invalid catalogue cursor'); - } - this.channels = append ? this.channels.concat(result.items) : result.items; - this.nextCursor = result.page.nextCursor; - this.hasMore = result.page.hasMore; - const scrollTop = this.container.scrollTop; - const active = document.activeElement; - const item = active?.closest('.channel-item'); - const groupName = item?.closest('.channel-group')?.querySelector('.group-header')?.dataset.group; + this.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] diff --git a/server/services/liveCatalog.js b/server/services/liveCatalog.js index 591bfae..b1ba11b 100644 --- a/server/services/liveCatalog.js +++ b/server/services/liveCatalog.js @@ -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 || '{}'); diff --git a/test/live-catalog-initial-load.test.js b/test/live-catalog-initial-load.test.js index a0d6c27..c36a45b 100644 --- a/test/live-catalog-initial-load.test.js +++ b/test/live-catalog-initial-load.test.js @@ -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);