mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 18:36:15 -08:00
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:
1 parent
e53adcbeaf
commit
b3e130aa65
56 files changed
+3044
-63
No files matched your search
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user