feat(stalker): full Live TV channel list for complete search, count badges, and all-channels grid (#1209)

Load the complete ITV channel list once per portal (Ministra get_all_channels with a paged crawl fallback) and use it for: full local search across every channel, per-genre category count badges, an all-channels paginated grid on Live TV entry, and eager bulk EPG so row previews appear without playing a channel. Censored (adult) genres absent from get_all_channels fall back to the legacy paged flow and show no badge. Includes mock-server support, unit + E2E coverage, and architecture docs.
This commit is contained in:
4gray authored and GitHub committed 2026-07-23 23:31:49 +02:00
1 parent e53adcbeaf
commit b3e130aa65
56 files changed
+3044 -63

No files matched your search

+4 -1
View File
@@ -5,10 +5,13 @@ interface ProviderTargetPayload {
}
const BACKEND_ORIGIN = 'http://localhost:3000';
const APP_ORIGIN = process.env['BASE_URL']
? new URL(process.env['BASE_URL']).origin
: 'http://localhost:4200';
const CORS_HEADERS = {
'access-control-allow-headers': 'content-type',
'access-control-allow-methods': 'POST, OPTIONS',
'access-control-allow-origin': 'http://localhost:4200',
'access-control-allow-origin': APP_ORIGIN,
} as const;
export async function interceptProviderTargetRegistration(
+174
View File
@@ -35,6 +35,9 @@ const DEFAULT_MAC = '00:1A:79:00:00:01';
/** Minimal scenario MAC — 2 categories, 5 items (edge case testing) */
const MINIMAL_MAC = '00:1A:79:00:00:03';
/** Legacy pagination MAC — portal without get_all_channels support */
const LEGACY_PAGINATION_MAC = '00:1A:79:00:00:06';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
@@ -333,6 +336,177 @@ test('@stalker PWA skips bulk EPG across channel switches', async ({
expect(shortEpgRequests).toHaveLength(0);
});
test('@stalker ITV full channel list loads via get_all_channels and search covers it', async ({
page,
}) => {
await addStalkerPortal(page);
const allChannelsRequests: string[] = [];
page.on('request', (request) => {
if (request.url().includes('action=get_all_channels')) {
allChannelsRequests.push(request.url());
}
});
await page.getByRole('link', { name: /live|itv/i }).click();
await page.waitForURL(/stalker.*itv/);
const categories = page.locator('.category-item');
await expect(categories.nth(1)).toBeVisible({ timeout: 10_000 });
// BEFORE any category click (Xtream parity): entering the Live TV section
// preloads the full list, so the main area shows the paginated
// all-channels grid and the categories already carry count badges.
const allItemsGrid = page.locator('app-stalker-itv-all-items');
await expect(allItemsGrid.locator('mat-card').first()).toBeVisible({
timeout: 20_000,
});
await expect(
allItemsGrid.locator('.mat-mdc-paginator-range-label')
).toContainText('of 320');
await expect(categories.nth(0).locator('.item-count')).toHaveText('320', {
timeout: 10_000,
});
await expect(categories.nth(1).locator('.item-count')).toHaveText('40');
await categories.nth(1).click();
const channels = page.locator('[data-test-id="channel-item"]');
await expect(channels.first()).toBeVisible({ timeout: 20_000 });
// Regression for "search only finds the first 14 loaded items": once the
// full list is cached, the whole category (40 channels) is available
// without scrolling through 14-item pages.
await expect(page.locator('.category-subtitle')).toHaveText('40 items', {
timeout: 20_000,
});
await expect.poll(() => allChannelsRequests.length).toBeGreaterThan(0);
// Regression: switching to another category once the full list is cached
// must serve that category from the cache, not get stuck on an empty
// skeleton. (The reset-on-category-change effect used to clobber the
// synchronously served list.)
await categories.nth(2).click();
await expect(page.locator('.category-subtitle')).toHaveText('40 items', {
timeout: 20_000,
});
await expect(channels.first()).toBeVisible({ timeout: 10_000 });
// No further get_all_channels request — it's served from the session cache.
const requestsAfterFirstCategory = allChannelsRequests.length;
await categories.nth(3).click();
await expect(channels.first()).toBeVisible({ timeout: 10_000 });
expect(allChannelsRequests.length).toBe(requestsAfterFirstCategory);
// Back to the first category for the search assertions below.
await categories.nth(1).click();
await expect(page.locator('.category-subtitle')).toHaveText('40 items', {
timeout: 20_000,
});
// Search a channel from deep in the list (beyond the first 14 items).
const deepChannelName = (
await channels.nth(30).locator('.channel-name').textContent()
)?.trim();
expect(deepChannelName).toBeTruthy();
const searchInput = page.locator('input[type="search"]');
await searchInput.fill(deepChannelName as string);
await searchInput.press('Enter');
await expect(
page
.locator('[data-test-id="channel-item"] .channel-name')
.filter({ hasText: deepChannelName as string })
.first()
).toBeVisible({ timeout: 10_000 });
// The "loaded only" degraded-search hint must be gone in full-list mode.
await expect(page.locator('.search-chip--status')).toHaveCount(0);
});
test('@stalker ITV censored category pages from the portal and hides its badge', async ({
page,
}) => {
await addStalkerPortal(page);
const adultListRequests: string[] = [];
page.on('request', (request) => {
const url = request.url();
if (
url.includes('action=get_ordered_list') &&
url.includes('type=itv') &&
url.includes('genre=1099')
) {
adultListRequests.push(url);
}
});
await page.getByRole('link', { name: /live|itv/i }).click();
await page.waitForURL(/stalker.*itv/);
const categories = page.locator('.category-item');
// Wait until the full list is cached (a regular category shows its badge).
await expect(categories.nth(1).locator('.item-count')).toHaveText('40', {
timeout: 20_000,
});
// The censored genre is excluded from get_all_channels, so its real count
// is unknown — no badge instead of a misleading "0".
const adultCategory = page.locator('.category-item', {
hasText: 'For adults',
});
await expect(adultCategory).toBeVisible();
await expect(adultCategory.locator('.item-count')).toHaveCount(0);
// Clicking it falls back to the legacy paged flow and still shows channels.
await adultCategory.click();
const channels = page.locator('[data-test-id="channel-item"]');
await expect(channels.first()).toBeVisible({ timeout: 20_000 });
await expect.poll(() => adultListRequests.length).toBeGreaterThan(0);
});
test('@stalker ITV falls back to page crawling on portals without get_all_channels', async ({
page,
}) => {
await addStalkerPortal(page, {
name: 'Legacy Stalker Portal',
mac: LEGACY_PAGINATION_MAC,
});
const allChannelsRequests: string[] = [];
const crawlRequests: string[] = [];
page.on('request', (request) => {
const url = request.url();
if (url.includes('action=get_all_channels')) {
allChannelsRequests.push(url);
}
// The full-list crawl pages through ALL genres (genre=*).
if (
url.includes('action=get_ordered_list') &&
url.includes('type=itv') &&
(url.includes('genre=*') || url.includes('genre=%2A'))
) {
crawlRequests.push(url);
}
});
await page.getByRole('link', { name: /live|itv/i }).click();
await page.waitForURL(/stalker.*itv/);
const categories = page.locator('.category-item');
await expect(categories.nth(1)).toBeVisible({ timeout: 10_000 });
await categories.nth(1).click();
const channels = page.locator('[data-test-id="channel-item"]');
await expect(channels.first()).toBeVisible({ timeout: 20_000 });
// The crawl collects all 6 × 40 channels; the selected category then
// shows its full 40 items without manual lazy-load scrolling.
await expect(page.locator('.category-subtitle')).toHaveText('40 items', {
timeout: 30_000,
});
await expect.poll(() => allChannelsRequests.length).toBeGreaterThan(0);
expect(crawlRequests.length).toBeGreaterThan(1);
});
test('@stalker mock server reset clears cached state', async ({ request }) => {
// Generate data for default MAC
const before = await request.get(