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:
@ -4826,7 +4826,7 @@ kbd {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.live-catalog-status,
|
.live-catalog-status,
|
||||||
.live-catalog-more {
|
.live-catalog-retry {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
margin: var(--space-sm);
|
margin: var(--space-sm);
|
||||||
}
|
}
|
||||||
@ -4836,7 +4836,7 @@ kbd {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.channel-sidebar.collapsed .live-catalog-status,
|
.channel-sidebar.collapsed .live-catalog-status,
|
||||||
.channel-sidebar.collapsed .live-catalog-more {
|
.channel-sidebar.collapsed .live-catalog-retry {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -4857,3 +4857,7 @@ kbd {
|
|||||||
.channel-tile-action[hidden] {
|
.channel-tile-action[hidden] {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.live-catalog-retry[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|||||||
@ -24,19 +24,18 @@ class ChannelList {
|
|||||||
this.isLoading = false;
|
this.isLoading = false;
|
||||||
this.renderedChannels = [];
|
this.renderedChannels = [];
|
||||||
this.catalogVersion = 0;
|
this.catalogVersion = 0;
|
||||||
this.hasMore = false;
|
|
||||||
this.catalogQuery = '';
|
this.catalogQuery = '';
|
||||||
this.catalogStatus = document.createElement('div');
|
this.catalogStatus = document.createElement('div');
|
||||||
this.catalogStatus.setAttribute('role', 'status');
|
this.catalogStatus.setAttribute('role', 'status');
|
||||||
this.catalogStatus.className = 'live-catalog-status';
|
this.catalogStatus.className = 'live-catalog-status';
|
||||||
this.container.after(this.catalogStatus);
|
this.container.after(this.catalogStatus);
|
||||||
this.loadMoreButton = document.createElement('button');
|
this.catalogRetryButton = document.createElement('button');
|
||||||
this.loadMoreButton.type = 'button';
|
this.catalogRetryButton.type = 'button';
|
||||||
this.loadMoreButton.className = 'btn btn-sm live-catalog-more';
|
this.catalogRetryButton.className = 'btn btn-sm live-catalog-retry';
|
||||||
this.loadMoreButton.textContent = 'Load more';
|
this.catalogRetryButton.textContent = 'Retry';
|
||||||
this.loadMoreButton.hidden = true;
|
this.catalogRetryButton.hidden = true;
|
||||||
this.catalogStatus.after(this.loadMoreButton);
|
this.catalogStatus.after(this.catalogRetryButton);
|
||||||
this.loadMoreButton.addEventListener('click', () => this.loadCatalogPage(this.channels.length > 0));
|
this.catalogRetryButton.addEventListener('click', () => this.loadChannels());
|
||||||
|
|
||||||
this.loadCollapsedState();
|
this.loadCollapsedState();
|
||||||
this.init();
|
this.init();
|
||||||
@ -167,7 +166,7 @@ class ChannelList {
|
|||||||
clearTimeout(this.searchTimeout);
|
clearTimeout(this.searchTimeout);
|
||||||
this.catalogVersion++;
|
this.catalogVersion++;
|
||||||
this.catalogController?.abort();
|
this.catalogController?.abort();
|
||||||
this.loadMoreButton.hidden = true;
|
this.catalogRetryButton.hidden = true;
|
||||||
this.searchTimeout = setTimeout(searchNow, 300);
|
this.searchTimeout = setTimeout(searchNow, 300);
|
||||||
});
|
});
|
||||||
this.searchInput.addEventListener('keydown', e => {
|
this.searchInput.addEventListener('keydown', e => {
|
||||||
@ -741,7 +740,7 @@ class ChannelList {
|
|||||||
/**
|
/**
|
||||||
* Load channels from selected source
|
* Load channels from selected source
|
||||||
*/
|
*/
|
||||||
async loadChannels() {
|
loadChannels() {
|
||||||
clearTimeout(this.searchTimeout);
|
clearTimeout(this.searchTimeout);
|
||||||
this.catalogVersion++;
|
this.catalogVersion++;
|
||||||
this.catalogController?.abort();
|
this.catalogController?.abort();
|
||||||
@ -751,84 +750,72 @@ class ChannelList {
|
|||||||
sourceId: this.sourceSelect.value.split(':')[1],
|
sourceId: this.sourceSelect.value.split(':')[1],
|
||||||
q: this.catalogQuery,
|
q: this.catalogQuery,
|
||||||
includeHidden: this.showHiddenCheckbox?.checked || false,
|
includeHidden: this.showHiddenCheckbox?.checked || false,
|
||||||
limit: 200
|
full: true
|
||||||
};
|
};
|
||||||
this.channels = [];
|
this.channels = [];
|
||||||
this.renderedChannels = [];
|
this.renderedChannels = [];
|
||||||
this.nextCursor = null;
|
this.sortedGroups = [];
|
||||||
this.hasMore = false;
|
this.groupedChannels = Object.create(null);
|
||||||
this.currentRenderId = 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 version = this.catalogVersion;
|
||||||
const promise = this.fetchCatalogPage(append);
|
const promise = this.fetchCatalog();
|
||||||
this.catalogPagePromise = { version, promise };
|
this.catalogRequest = { version, promise };
|
||||||
promise.finally(() => {
|
promise.finally(() => {
|
||||||
if (this.catalogPagePromise?.promise === promise) this.catalogPagePromise = null;
|
if (this.catalogRequest?.promise === promise) this.catalogRequest = null;
|
||||||
});
|
});
|
||||||
return promise;
|
return promise;
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchCatalogPage(append) {
|
async loadAllChannels() { return this.loadChannels(); }
|
||||||
if (append && (this.isLoading || !this.hasMore)) return false;
|
|
||||||
|
async fetchCatalog() {
|
||||||
const version = this.catalogVersion;
|
const version = this.catalogVersion;
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
this.catalogController = controller;
|
this.catalogController = controller;
|
||||||
this.isLoading = true;
|
this.isLoading = true;
|
||||||
this.catalogStatus.textContent = this.catalogQuery ? 'Searching…' : 'Loading…';
|
this.catalogStatus.textContent = this.catalogQuery ? 'Searching…' : 'Loading…';
|
||||||
this.container.setAttribute('aria-busy', 'true');
|
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 active = document.activeElement;
|
||||||
const item = active?.closest('.channel-item');
|
const item = active?.closest('.channel-item');
|
||||||
const groupName = item?.closest('.channel-group')?.querySelector('.group-header')?.dataset.group;
|
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();
|
this.render();
|
||||||
if (item) {
|
if (item && document.activeElement === document.body) {
|
||||||
const replacement = [...this.container.querySelectorAll('.channel-item')].find(el =>
|
const replacement = [...this.container.querySelectorAll('.channel-item')].find(el =>
|
||||||
el.dataset.channelId === item.dataset.channelId && el.dataset.sourceId === item.dataset.sourceId &&
|
el.dataset.channelId === item.dataset.channelId && el.dataset.sourceId === item.dataset.sourceId &&
|
||||||
el.closest('.channel-group')?.querySelector('.group-header')?.dataset.group === groupName);
|
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 = '';
|
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;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (version !== this.catalogVersion || err.name === 'AbortError') return false;
|
if (version !== this.catalogVersion || err.name === 'AbortError') return false;
|
||||||
this.catalogStatus.textContent = 'Error loading channels';
|
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;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
if (version === this.catalogVersion) {
|
if (version === this.catalogVersion) {
|
||||||
this.isLoading = false;
|
this.isLoading = false;
|
||||||
this.container.setAttribute('aria-busy', '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 version = this.catalogVersion;
|
||||||
const run = async () => {
|
const run = async () => {
|
||||||
if (version !== this.catalogVersion || version !== this.catalogScopeVersion) return;
|
if (version !== this.catalogVersion || version !== this.catalogScopeVersion) return;
|
||||||
// A page already requested by Load more belongs to this same scope.
|
if (this.catalogRequest?.version === version) {
|
||||||
if (this.catalogPagePromise?.version === version) {
|
if (!await this.catalogRequest.promise) return;
|
||||||
if (!await this.catalogPagePromise.promise) return;
|
|
||||||
}
|
}
|
||||||
if (version !== this.catalogVersion || this.isLoading) return;
|
if (version !== this.catalogVersion || this.isLoading) return;
|
||||||
const current = window.app?.player?.currentChannel || this.currentChannel;
|
const current = window.app?.player?.currentChannel || this.currentChannel;
|
||||||
@ -1364,30 +1350,13 @@ class ChannelList {
|
|||||||
String(channel.streamId ?? channel.id) === String(streamId);
|
String(channel.streamId ?? channel.id) === String(streamId);
|
||||||
const isCurrent = () => version === this.catalogVersion && this.currentChannel === selection &&
|
const isCurrent = () => version === this.catalogVersion && this.currentChannel === selection &&
|
||||||
(window.app?.player?.currentChannel || this.currentChannel) === current;
|
(window.app?.player?.currentChannel || this.currentChannel) === current;
|
||||||
let channels = this.getVisibleChannels();
|
const channels = this.getVisibleChannels();
|
||||||
let index = channels.findIndex(matches);
|
const 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);
|
|
||||||
}
|
|
||||||
if (!isCurrent()) return;
|
if (!isCurrent()) return;
|
||||||
if (index === -1) {
|
if (index === -1) {
|
||||||
this.catalogStatus.textContent = 'Current channel is outside the Live filters. Select a channel in the list to change channels.';
|
this.catalogStatus.textContent = 'Current channel is outside the Live filters. Select a channel in the list to change channels.';
|
||||||
return;
|
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;
|
if (!isCurrent() || index === -1 || !channels.length) return;
|
||||||
const neighbor = direction < 0
|
const neighbor = direction < 0
|
||||||
? channels[index === 0 ? channels.length - 1 : index - 1]
|
? channels[index === 0 ? channels.length - 1 : index - 1]
|
||||||
|
|||||||
@ -16,7 +16,7 @@ function badRequest(message) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function parseQuery(query, userId) {
|
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}`);
|
if (query[key] !== undefined && typeof query[key] !== 'string') badRequest(`Invalid ${key}`);
|
||||||
}
|
}
|
||||||
const sourceId = query.sourceId === undefined ? null : Number(query.sourceId);
|
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);
|
const requestedLimit = query.limit === undefined ? 100 : Number(query.limit);
|
||||||
if (!Number.isSafeInteger(requestedLimit) || requestedLimit < 1) badRequest('Invalid 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 || '',
|
const filters = { sourceId, categoryId: query.categoryId ?? null, q: query.q || '',
|
||||||
includeHidden: boolean('includeHidden'), favoriteOnly: boolean('favoriteOnly'), userId };
|
includeHidden: boolean('includeHidden'), favoriteOnly: boolean('favoriteOnly'), userId };
|
||||||
const fingerprint = createHash('sha256').update(JSON.stringify({ ...filters, order: ORDER_VERSION })).digest('hex');
|
const fingerprint = createHash('sha256').update(JSON.stringify({ ...filters, order: ORDER_VERSION })).digest('hex');
|
||||||
@ -42,7 +46,7 @@ function parseQuery(query, userId) {
|
|||||||
after = cursor.k;
|
after = cursor.k;
|
||||||
} catch { badRequest('Invalid cursor'); }
|
} 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
|
// 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)`);
|
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.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 (
|
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 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,
|
) 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
|
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'
|
AND f.item_id = p.item_id AND f.item_type = 'channel'
|
||||||
WHERE ${where.join(' AND ')}
|
WHERE ${where.join(' AND ')}
|
||||||
ORDER BY ${CATALOG_ORDER} LIMIT @limit`).all(params);
|
ORDER BY ${CATALOG_ORDER}${options.full ? '' : ' LIMIT @limit'}`).all(params);
|
||||||
const hasMore = rows.length > options.limit;
|
const hasMore = !options.full && rows.length > options.limit;
|
||||||
if (hasMore) rows.pop();
|
if (hasMore) rows.pop();
|
||||||
const items = rows.map(row => {
|
const items = rows.map(row => {
|
||||||
const data = JSON.parse(row.data || '{}');
|
const data = JSON.parse(row.data || '{}');
|
||||||
|
|||||||
@ -77,6 +77,16 @@ async function requestAllPages(base, cookie, query = {}) {
|
|||||||
return items;
|
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 => {
|
test('application startup creates the composite category lookup index', async t => {
|
||||||
const app = await startApp(t);
|
const app = await startApp(t);
|
||||||
const response = await request(app.base, app.cookie);
|
const response = await request(app.base, app.cookie);
|
||||||
@ -142,6 +152,16 @@ function seedCatalog(dir, userId) {
|
|||||||
db.close();
|
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) {
|
function seedIdentityAndOrderCases(dir) {
|
||||||
const db = new Database(path.join(dir, 'content.db'));
|
const db = new Database(path.join(dir, 'content.db'));
|
||||||
const insertCategory = db.prepare(`
|
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}`);
|
const identities = all.map(item => `${item.sourceId}:${item.streamId}`);
|
||||||
assert.equal(all.length, 1209);
|
assert.equal(all.length, 1209);
|
||||||
assert.equal(new Set(identities).size, all.length, 'cursor pages must not repeat channels');
|
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));
|
const completeCatalogBytes = Buffer.byteLength(JSON.stringify(all));
|
||||||
assert.ok(first.bytes < completeCatalogBytes,
|
assert.ok(first.bytes < completeCatalogBytes,
|
||||||
`first page ${first.bytes} bytes should be smaller than complete catalog ${completeCatalogBytes} bytes`);
|
`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' });
|
const beyondFirstPage = await request(fixture.base, fixture.cookie, { q: 'Needle Far Away' });
|
||||||
assert.equal(beyondFirstPage.status, 200);
|
assert.equal(beyondFirstPage.status, 200);
|
||||||
assert.deepEqual(beyondFirstPage.body.items.map(item => item.streamId), ['needle']);
|
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' });
|
const groupSearch = await requestAllPages(fixture.base, fixture.cookie, { q: 'Alpha Group' });
|
||||||
assert.equal(groupSearch.length, 600);
|
assert.equal(groupSearch.length, 600);
|
||||||
assert.ok(groupSearch.every(item => item.groupTitle === 'Alpha Group'));
|
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' });
|
const sourceFiltered = await request(fixture.base, fixture.cookie, { sourceId: '2' });
|
||||||
assert.equal(sourceFiltered.status, 200);
|
assert.equal(sourceFiltered.status, 200);
|
||||||
assert.deepEqual(sourceFiltered.body.items.map(item => item.sourceId), [2, 2, 2, 2]);
|
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' });
|
const categoryFiltered = await requestAllPages(fixture.base, fixture.cookie, { sourceId: '1', categoryId: 'beta' });
|
||||||
assert.equal(categoryFiltered.length, 598);
|
assert.equal(categoryFiltered.length, 598);
|
||||||
assert.ok(categoryFiltered.every(item => item.groupId === 'beta'));
|
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' });
|
const includeHidden = await requestAllPages(fixture.base, fixture.cookie, { sourceId: '1', includeHidden: 'true' });
|
||||||
assert.equal(includeHidden.length, 1204);
|
assert.equal(includeHidden.length, 1204);
|
||||||
assert.equal(includeHidden.filter(item => item.isHidden).length, 2);
|
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' });
|
const hiddenGroup = await request(fixture.base, fixture.cookie, { sourceId: '1', categoryId: 'hidden' });
|
||||||
assert.equal(hiddenGroup.body.items.length, 0);
|
assert.equal(hiddenGroup.body.items.length, 0);
|
||||||
const hiddenGroupIncluded = await request(fixture.base, fixture.cookie,
|
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' });
|
const favoriteOnly = await request(fixture.base, fixture.cookie, { favoriteOnly: 'true' });
|
||||||
assert.equal(favoriteOnly.status, 200);
|
assert.equal(favoriteOnly.status, 200);
|
||||||
assert.deepEqual(favoriteOnly.body.items.map(item => item.streamId), ['needle']);
|
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,
|
const otherUserFavorite = await request(fixture.base, fixture.cookie,
|
||||||
{ sourceId: '1', favoriteOnly: 'true', q: 'Channel 0000' });
|
{ sourceId: '1', favoriteOnly: 'true', q: 'Channel 0000' });
|
||||||
assert.equal(otherUserFavorite.body.items.length, 0);
|
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://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.route('https://fonts.**', route => route.abort());
|
||||||
await page.goto(`${fixture.base}/#live`);
|
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);
|
&& !window.app.channelList.isLoading);
|
||||||
await page.locator('#channel-search').fill('Shared Collision Channel');
|
await page.locator('#channel-search').fill('Shared Collision Channel');
|
||||||
await page.waitForFunction(() => window.app?.channelList?.channels?.length === 2
|
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();
|
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 { channelList, played } = clientPageFixture();
|
||||||
const pages = [
|
const channels = [0, 1, 2].map(id => ({
|
||||||
[0, 1].map(id => ({ id: `ch${id}`, sourceId: 1, sourceType: 'm3u', url: `http://streams.test/ch${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(channels);
|
||||||
channelList.setVisibleTestChannels([...pages[0]]);
|
channelList.currentChannel = channels[2];
|
||||||
channelList.currentChannel = pages[0][1];
|
|
||||||
channelList.contextPageLoads = [];
|
channelList.contextPageLoads = [];
|
||||||
channelList.hasMore = true;
|
channelList.loadCatalogPage = async () => { channelList.contextPageLoads.push('unexpected'); };
|
||||||
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;
|
|
||||||
};
|
|
||||||
|
|
||||||
await channelList.queueZap(1);
|
await channelList.queueZap(1);
|
||||||
|
|
||||||
assert.deepEqual(channelList.contextPageLoads, ['page-2']);
|
assert.deepEqual(channelList.contextPageLoads, []);
|
||||||
assert.equal(played.length, 1);
|
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');
|
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 { channelList, played } = clientPageFixture();
|
||||||
const pages = [
|
const current = { id: 'current', sourceId: 1, sourceType: 'm3u', url: 'http://streams.test/current' };
|
||||||
[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 next = { id: 'next', sourceId: 1, sourceType: 'm3u', url: 'http://streams.test/next' };
|
const next = { id: 'next', sourceId: 1, sourceType: 'm3u', url: 'http://streams.test/next' };
|
||||||
playbackFixture.channelList.setVisibleTestChannels([playbackCurrent]);
|
channelList.setVisibleTestChannels([current, next]);
|
||||||
playbackFixture.channelList.currentChannel = playbackCurrent;
|
channelList.currentChannel = current;
|
||||||
playbackFixture.channelList.hasMore = true;
|
channelList.contextPageLoads = [];
|
||||||
playbackFixture.channelList.nextCursor = 'page-2';
|
channelList.loadCatalogPage = async () => { channelList.contextPageLoads.push('unexpected'); };
|
||||||
playbackFixture.channelList.loadCatalogPage = async () => {
|
channelList.selectChannel = async () => {
|
||||||
playbackFixture.channelList.channels.push(next);
|
|
||||||
playbackFixture.channelList.hasMore = false;
|
|
||||||
playbackFixture.channelList.nextCursor = null;
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
playbackFixture.channelList.selectChannel = async () => {
|
|
||||||
playbackFixture.channelList.currentChannel = next;
|
|
||||||
throw new Error('simulated playback failure');
|
throw new Error('simulated playback failure');
|
||||||
};
|
};
|
||||||
|
|
||||||
await playbackFixture.channelList.queueZap(1);
|
await channelList.queueZap(1);
|
||||||
|
|
||||||
assert.equal(playbackFixture.channelList.currentChannel, playbackCurrent);
|
assert.deepEqual(channelList.contextPageLoads, []);
|
||||||
assert.deepEqual(playbackFixture.played, []);
|
assert.equal(channelList.currentChannel, current);
|
||||||
|
assert.deepEqual(played, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Home favorite can launch a channel absent from the loaded Live page', () => {
|
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);
|
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 => {
|
{ skip: process.env.NODECAST_BROWSER_TESTS === '1' ? false : 'Browser tests enabled in CI', timeout: 60000 }, async t => {
|
||||||
const { chromium } = require('playwright');
|
const { chromium } = require('playwright');
|
||||||
const fixture = await startApp(t);
|
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://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.route('https://fonts.**', route => route.abort());
|
||||||
await page.goto(`${fixture.base}/#live`);
|
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);
|
&& !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('#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 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.locator('#channel-search').fill('Needle Far Away');
|
||||||
await page.waitForTimeout(100);
|
await page.waitForTimeout(100);
|
||||||
assert.equal(catalogRequests.length, initialRequestCount, 'debounce should defer the request');
|
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('q'), 'Needle Far Away');
|
||||||
|
assert.equal(catalogRequests.at(-1).get('full'), 'true');
|
||||||
assert.equal(await page.evaluate(() => window.app.channelList.channels.length), 1);
|
assert.equal(await page.evaluate(() => window.app.channelList.channels.length), 1);
|
||||||
|
|
||||||
await page.locator('#channel-search').fill('Alpha Group');
|
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 page.locator('#channel-search').press('Enter');
|
||||||
await enterRequest;
|
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'));
|
&& window.app.channelList.channels.every(channel => channel.groupTitle === 'Alpha Group'));
|
||||||
assert.ok(catalogRequests.length > enterCount);
|
assert.ok(catalogRequests.length > enterCount);
|
||||||
assert.equal(catalogRequests.at(-1).get('q'), 'Alpha Group');
|
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');
|
await page.locator('#channel-search').fill('Other Group');
|
||||||
const buttonRequest = page.waitForRequest(req => {
|
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
|
await page.waitForFunction(() => window.app?.channelList?.channels?.length === 3
|
||||||
&& window.app.channelList.channels.every(channel => channel.groupTitle === 'Other Group'));
|
&& 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('q'), 'Other Group');
|
||||||
|
assert.equal(catalogRequests.at(-1).get('full'), 'true');
|
||||||
|
|
||||||
await page.locator('#channel-search').fill('');
|
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);
|
&& !window.app.channelList.isLoading);
|
||||||
const beforeMore = catalogRequests.length;
|
assert.equal(catalogRequests.at(-1).get('full'), 'true');
|
||||||
await page.locator('.live-catalog-more').click();
|
assert.equal(catalogRequests.at(-1).get('cursor'), null);
|
||||||
await page.waitForFunction(() => window.app?.channelList?.channels?.length === 400
|
assert.equal(await page.locator('.live-catalog-more').count(), 0);
|
||||||
&& !window.app.channelList.isLoading);
|
|
||||||
assert.ok(catalogRequests.length > beforeMore);
|
|
||||||
assert.ok(catalogRequests.at(-1).get('cursor'));
|
|
||||||
|
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
window.__selectedChannel = null;
|
window.__selectedChannel = null;
|
||||||
@ -640,6 +634,49 @@ test('Live search debounce, Enter, Search button, Load more, and channel selecti
|
|||||||
await context.close();
|
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',
|
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 => {
|
{ skip: process.env.NODECAST_BROWSER_TESTS === '1' ? false : 'Browser tests enabled in CI', timeout: 60000 }, async t => {
|
||||||
const { chromium } = require('playwright');
|
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
|
await page.waitForFunction(() => window.app?.channelList?.guideCatalog?.length >= 1200
|
||||||
&& window.app?.epgGuide?.allMatchedChannels?.length > 0);
|
&& 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 guideState = await page.evaluate(() => {
|
||||||
const guide = window.app.epgGuide;
|
const guide = window.app.epgGuide;
|
||||||
const ids = guide.allMatchedChannels.map(match => match.sourceChannel.streamId || match.sourceChannel.id);
|
const ids = guide.allMatchedChannels.map(match => match.sourceChannel.streamId || match.sourceChannel.id);
|
||||||
|
|||||||
Reference in New Issue
Block a user