diff --git a/apps/electron-backend-e2e/src/electron-test-fixtures.ts b/apps/electron-backend-e2e/src/electron-test-fixtures.ts index a7c2f6c66..8b4198ab4 100644 --- a/apps/electron-backend-e2e/src/electron-test-fixtures.ts +++ b/apps/electron-backend-e2e/src/electron-test-fixtures.ts @@ -1596,6 +1596,15 @@ export async function expectWorkspaceSearchStatus( ).toHaveText(expected); } +/** The degraded-search hint chip must be absent (e.g. complete local search). */ +export async function expectNoWorkspaceSearchStatus( + page: Page +): Promise { + await expect( + page.locator('app-workspace-shell-header .search-chip--status') + ).toHaveCount(0); +} + async function startPortalDebugCapture(page: Page): Promise { await page.evaluate(() => { const electronApi = window.electron as typeof window.electron & { diff --git a/apps/electron-backend-e2e/src/search.e2e.ts b/apps/electron-backend-e2e/src/search.e2e.ts index 9be5dd110..dc96e4b45 100644 --- a/apps/electron-backend-e2e/src/search.e2e.ts +++ b/apps/electron-backend-e2e/src/search.e2e.ts @@ -6,8 +6,8 @@ import { closeElectronApp, defaultStalkerMacAddress, expect, + expectNoWorkspaceSearchStatus, expectWorkspaceSearchScope, - expectWorkspaceSearchStatus, fillWorkspaceSearch, goToDashboard, importM3uPlaylistFromNativeDialog, @@ -987,7 +987,7 @@ test.describe('Electron Workspace Search', () => { } }); - test('@search @stalker shows loaded-only scope and filters channels on ITV routes', async ({ + test('@search @stalker searches the complete channel list on ITV routes', async ({ dataDir, request, }) => { @@ -1030,26 +1030,22 @@ test.describe('Electron Workspace Search', () => { app.mainWindow, `Live TV / ${sample.categoryName}` ); - await expectWorkspaceSearchStatus( - app.mainWindow, - 'Loaded channels only' - ); + // Entering Live TV preloads the COMPLETE channel list (Ministra + // get_all_channels), so search runs locally over every channel: + // no portal-side search request, and no "Loaded channels only" + // degraded hint. await waitForPortalDebugEvent(app.mainWindow, { provider: 'stalker', - operation: 'get_ordered_list', + operation: 'get_all_channels', predicate: (event) => { const requestPayload = event.request as { params?: Record; }; - return ( - requestPayload.params?.['type'] === 'itv' && - String(requestPayload.params?.['category']) === - sample.categoryId && - requestPayload.params?.['search'] === itvQuery - ); + return requestPayload.params?.['type'] === 'itv'; }, }); + await expectNoWorkspaceSearchStatus(app.mainWindow); await expect( channelItemByTitle(app.mainWindow, sample.targetTitle).first() ).toBeVisible(); diff --git a/apps/stalker-mock-server/README.md b/apps/stalker-mock-server/README.md index 68a71617e..fbcda2e2e 100644 --- a/apps/stalker-mock-server/README.md +++ b/apps/stalker-mock-server/README.md @@ -38,6 +38,7 @@ Then in IPTVnator, add a new Stalker portal: | `00:1A:79:00:00:03` | **minimal** | 2 categories, 5 items — edge case testing (empty states, single items) | | `00:1A:79:00:00:04` | **is-series** | 60% of VOD items have `is_series=1` — tests the Ministra lazy-season flow | | `00:1A:79:00:00:05` | **embedded-series** | 50% of VOD items have embedded `series[]` arrays — tests the embedded series flow | +| `00:1A:79:00:00:06` | **legacy-pagination** | No `get_all_channels` support — tests the paginated `get_ordered_list` crawl fallback for the full ITV channel list | | `` | **auto** | MAC bytes used as seed → deterministic unique dataset | ## Configuration @@ -65,6 +66,7 @@ All endpoints are served at `GET /portal.php?action=&...` matching the r | `get_categories` | Category list filtered by `type` (itv/vod/series) | | `get_genres` | Genre list (mirrors categories) | | `get_ordered_list` | Paginated content list; if `movie_id` is present → returns seasons | +| `get_all_channels` | Complete ITV channel list in one response (`type=itv` only); excludes censored (adult) genres; disabled in the `legacy-pagination` scenario | | `create_link` | Returns a real public HLS stream URL for playback | | `favorites` | Add / remove / get favorites (in-memory, resets on restart) | | `get_short_epg` | Current-and-upcoming EPG window for a channel (`ch_id`, `size`) | diff --git a/apps/stalker-mock-server/src/app/data-generator.ts b/apps/stalker-mock-server/src/app/data-generator.ts index 0f0cb9c68..9e7e1af44 100644 --- a/apps/stalker-mock-server/src/app/data-generator.ts +++ b/apps/stalker-mock-server/src/app/data-generator.ts @@ -9,6 +9,8 @@ export interface RawCategory { id: string; title: string; alias: string; + /** Ministra adult-genre flag ('1' = censored). */ + censored?: string; } export interface RawChannel { @@ -171,6 +173,16 @@ export function generatePortalData(config: ScenarioConfig): GeneratedPortalData // ------ ITV categories + channels ------ data.itvCategories = generateCategories('itv', config.categoryCount.itv); + // Real Ministra portals mark adult genres as censored and EXCLUDE their + // channels from get_all_channels / the "*" listing; the channels are only + // served by paging the specific genre. Mirror that with one extra + // censored category so clients can exercise the fallback path. + data.itvCategories.push({ + id: '1099', + title: 'For adults', + alias: 'for_adults', + censored: '1', + }); let channelIndex = 0; for (const cat of data.itvCategories) { const channels = generateChannels(cat.id, config.itemsPerCategory, channelIndex); diff --git a/apps/stalker-mock-server/src/app/handlers/get-all-channels.handler.ts b/apps/stalker-mock-server/src/app/handlers/get-all-channels.handler.ts new file mode 100644 index 000000000..7d37396fb --- /dev/null +++ b/apps/stalker-mock-server/src/app/handlers/get-all-channels.handler.ts @@ -0,0 +1,46 @@ +import { Request, Response } from 'express'; +import { getPortalData } from '../data-store.js'; +import { getScenario } from '../scenarios.js'; +import { extractMac } from './get-categories.handler.js'; + +/** + * Stalker/Ministra get_all_channels — returns the COMPLETE ITV channel list + * in a single response (no pagination). This is what STB clients use to build + * their local channel list. + * + * Only type=itv is supported, matching the reference Ministra middleware. + * Scenarios with supportsGetAllChannels=false mimic legacy portals and answer + * with an error payload so clients fall back to paginated get_ordered_list. + */ +export function handleGetAllChannels(req: Request, res: Response): void { + const mac = extractMac(req); + const type = (req.query['type'] as string) ?? 'itv'; + const scenario = getScenario(mac); + + if (scenario.supportsGetAllChannels === false || type !== 'itv') { + res.json({ js: { error: 'Unknown action: get_all_channels' } }); + return; + } + + const data = getPortalData(mac); + // Like real Ministra portals: censored (adult) genres are excluded from + // the bulk channel list — clients must page those genres explicitly. + const censoredIds = new Set( + data.itvCategories + .filter((cat) => cat.censored === '1') + .map((cat) => cat.id) + ); + const allChannels = [...data.channels.entries()] + .filter(([categoryId]) => !censoredIds.has(categoryId)) + .flatMap(([, channels]) => channels); + + res.json({ + js: { + data: allChannels, + total_items: allChannels.length, + max_page_items: allChannels.length, + cur_page: 0, + selected_item: 0, + }, + }); +} diff --git a/apps/stalker-mock-server/src/app/handlers/get-genres.handler.ts b/apps/stalker-mock-server/src/app/handlers/get-genres.handler.ts index a31002aa0..bc9e30a9a 100644 --- a/apps/stalker-mock-server/src/app/handlers/get-genres.handler.ts +++ b/apps/stalker-mock-server/src/app/handlers/get-genres.handler.ts @@ -26,7 +26,7 @@ export function handleGetGenres(req: Request, res: Response): void { id: cat.id, title: cat.title, alias: cat.alias, - censored: '0', + censored: cat.censored ?? '0', })); res.json({ js: genres }); diff --git a/apps/stalker-mock-server/src/app/handlers/get-ordered-list.handler.ts b/apps/stalker-mock-server/src/app/handlers/get-ordered-list.handler.ts index 569ead57d..0a60127bd 100644 --- a/apps/stalker-mock-server/src/app/handlers/get-ordered-list.handler.ts +++ b/apps/stalker-mock-server/src/app/handlers/get-ordered-list.handler.ts @@ -34,8 +34,17 @@ export function handleGetOrderedList(req: Request, res: Response): void { if (type === 'itv') { if (categoryId === '*') { - for (const items of data.channels.values()) { - allItems.push(...items); + // The "*" listing excludes censored (adult) genres, matching + // real portals; their channels are only served by genre id. + const censoredIds = new Set( + data.itvCategories + .filter((cat) => cat.censored === '1') + .map((cat) => cat.id) + ); + for (const [catId, items] of data.channels.entries()) { + if (!censoredIds.has(catId)) { + allItems.push(...items); + } } } else { allItems = data.channels.get(categoryId) ?? []; diff --git a/apps/stalker-mock-server/src/app/routes/dispatch.ts b/apps/stalker-mock-server/src/app/routes/dispatch.ts index 507ad1bf8..4d8aa2420 100644 --- a/apps/stalker-mock-server/src/app/routes/dispatch.ts +++ b/apps/stalker-mock-server/src/app/routes/dispatch.ts @@ -1,6 +1,7 @@ import { Request, Response } from 'express'; import { handleHandshake } from '../handlers/handshake.handler.js'; import { handleDoAuth } from '../handlers/do-auth.handler.js'; +import { handleGetAllChannels } from '../handlers/get-all-channels.handler.js'; import { handleGetCategories } from '../handlers/get-categories.handler.js'; import { handleGetOrderedList } from '../handlers/get-ordered-list.handler.js'; import { handleGetSeasons } from '../handlers/get-seasons.handler.js'; @@ -39,6 +40,9 @@ export default function dispatchPortalAction(req: Request, res: Response): void handleGetOrderedList(req, res); } break; + case 'get_all_channels': + handleGetAllChannels(req, res); + break; case 'create_link': handleCreateLink(req, res); break; diff --git a/apps/stalker-mock-server/src/app/scenarios.ts b/apps/stalker-mock-server/src/app/scenarios.ts index 6657990ae..74c271dd6 100644 --- a/apps/stalker-mock-server/src/app/scenarios.ts +++ b/apps/stalker-mock-server/src/app/scenarios.ts @@ -15,6 +15,12 @@ export interface ScenarioConfig { isSeriesFraction: number; /** Fraction of VOD items that have embedded series[] array */ embeddedSeriesFraction: number; + /** + * Whether the portal implements the ITV `get_all_channels` action. + * Defaults to true; set false to force clients onto paginated + * `get_ordered_list` crawling (legacy portals). + */ + supportsGetAllChannels?: boolean; } /** @@ -88,6 +94,19 @@ export const SCENARIOS: Record = { isSeriesFraction: 0, embeddedSeriesFraction: 0.5, // 50% of VOD items have embedded series[] }, + '00:1a:79:00:00:06': { + name: 'legacy-pagination', + description: + 'Portal without get_all_channels — clients must crawl get_ordered_list pages', + seed: 6006, + categoryCount: { itv: 6, radio: 4, vod: 4, series: 4 }, + itemsPerCategory: 40, + seasonsPerSeries: 2, + episodesPerSeason: 5, + isSeriesFraction: 0, + embeddedSeriesFraction: 0, + supportsGetAllChannels: false, + }, }; /** diff --git a/apps/web-e2e/src/provider-target-route.ts b/apps/web-e2e/src/provider-target-route.ts index 50ebd11ef..ccc0cf9ab 100644 --- a/apps/web-e2e/src/provider-target-route.ts +++ b/apps/web-e2e/src/provider-target-route.ts @@ -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( diff --git a/apps/web-e2e/src/stalker.e2e.ts b/apps/web-e2e/src/stalker.e2e.ts index f7c7c56b3..f381d33cf 100644 --- a/apps/web-e2e/src/stalker.e2e.ts +++ b/apps/web-e2e/src/stalker.e2e.ts @@ -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( diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index bf4ec546f..833bd2075 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -968,6 +968,11 @@ "CONTENT_NOT_AVAILABLE": "هذا المحتوى غير متاح على هذا الخادم", "PLAYBACK_ERROR": "فشل الحصول على رابط التشغيل", "LOADING_SEASONS": "جارٍ تحميل المواسم...", + "LOADING_ALL_CHANNELS": "جارٍ تحميل قائمة القنوات الكاملة…", + "REFRESH_CHANNEL_LIST": "تحديث قائمة القنوات", + "NO_CHANNELS_IN_CATEGORY": "لا توجد قنوات في هذه الفئة", + "ITEM": "عنصر", + "ITEMS": "عناصر", "NO_EPISODES": "لا توجد حلقات متاحة", "ALL_RADIO": "كل الراديو" }, diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 34d2bc61a..27188b4e8 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -968,6 +968,11 @@ "CONTENT_NOT_AVAILABLE": "هاد المحتوى ما متوفرش على هاد السيرفر", "PLAYBACK_ERROR": "فشل جلب رابط التشغيل", "LOADING_SEASONS": "جاري تشريج المواسم...", + "LOADING_ALL_CHANNELS": "كيتم تحميل لائحة القنوات كاملة…", + "REFRESH_CHANNEL_LIST": "تحديث لائحة القنوات", + "NO_CHANNELS_IN_CATEGORY": "ماكاينش قنوات فهاد الصنف", + "ITEM": "عنصر", + "ITEMS": "عناصر", "NO_EPISODES": "ما كاينش حلقات متوفرة", "ALL_RADIO": "All radio" }, diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 307fe897c..ac6cd8081 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -968,6 +968,11 @@ "CONTENT_NOT_AVAILABLE": "Гэты кантэнт недаступны на дадзеным серверы", "PLAYBACK_ERROR": "Не ўдалося атрымаць URL для прайгравання", "LOADING_SEASONS": "Загрузка сезонаў...", + "LOADING_ALL_CHANNELS": "Загрузка поўнага спісу каналаў…", + "REFRESH_CHANNEL_LIST": "Абнавіць спіс каналаў", + "NO_CHANNELS_IN_CATEGORY": "У гэтай катэгорыі няма каналаў", + "ITEM": "элемент", + "ITEMS": "элементаў", "NO_EPISODES": "Няма даступных эпізодаў", "ALL_RADIO": "Усе радыё" }, diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index e530ef81d..b9a3c2d9f 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -968,6 +968,11 @@ "CONTENT_NOT_AVAILABLE": "Dieser Inhalt ist auf diesem Server nicht verfügbar", "PLAYBACK_ERROR": "Wiedergabe-URL konnte nicht abgerufen werden", "LOADING_SEASONS": "Staffeln werden geladen …", + "LOADING_ALL_CHANNELS": "Vollständige Senderliste wird geladen…", + "REFRESH_CHANNEL_LIST": "Senderliste aktualisieren", + "NO_CHANNELS_IN_CATEGORY": "Keine Sender in dieser Kategorie", + "ITEM": "Element", + "ITEMS": "Elemente", "NO_EPISODES": "Keine Episoden verfügbar", "ALL_RADIO": "Alle Radiosender" }, diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 24ad44f67..335cc47bb 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -968,6 +968,11 @@ "CONTENT_NOT_AVAILABLE": "Αυτό το περιεχόμενο δεν είναι διαθέσιμο σε αυτόν τον διακομιστή", "PLAYBACK_ERROR": "Αποτυχία λήψης διεύθυνσης URL αναπαραγωγής", "LOADING_SEASONS": "Φόρτωση σεζόν...", + "LOADING_ALL_CHANNELS": "Φόρτωση πλήρους λίστας καναλιών…", + "REFRESH_CHANNEL_LIST": "Ανανέωση λίστας καναλιών", + "NO_CHANNELS_IN_CATEGORY": "Δεν υπάρχουν κανάλια σε αυτήν την κατηγορία", + "ITEM": "στοιχείο", + "ITEMS": "στοιχεία", "NO_EPISODES": "Δεν υπάρχουν διαθέσιμα επεισόδια", "ALL_RADIO": "Όλα τα ραδιόφωνα" }, diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index d0e7651a2..92d396baa 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -968,6 +968,11 @@ "CONTENT_NOT_AVAILABLE": "This content is not available on this server", "PLAYBACK_ERROR": "Failed to get playback URL", "LOADING_SEASONS": "Loading seasons...", + "LOADING_ALL_CHANNELS": "Loading full channel list…", + "REFRESH_CHANNEL_LIST": "Refresh channel list", + "NO_CHANNELS_IN_CATEGORY": "No channels in this category", + "ITEM": "item", + "ITEMS": "items", "NO_EPISODES": "No episodes available", "ALL_RADIO": "All radio" }, diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 752453717..c7cef836c 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -968,6 +968,11 @@ "CONTENT_NOT_AVAILABLE": "Este contenido no está disponible en este servidor", "PLAYBACK_ERROR": "No se pudo obtener la URL de reproducción", "LOADING_SEASONS": "Cargando temporadas…", + "LOADING_ALL_CHANNELS": "Cargando la lista completa de canales…", + "REFRESH_CHANNEL_LIST": "Actualizar lista de canales", + "NO_CHANNELS_IN_CATEGORY": "No hay canales en esta categoría", + "ITEM": "elemento", + "ITEMS": "elementos", "NO_EPISODES": "No hay episodios disponibles", "ALL_RADIO": "Todas las radios" }, diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index ae7d75e39..79e523fff 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -968,6 +968,11 @@ "CONTENT_NOT_AVAILABLE": "Ce contenu n'est pas disponible sur ce serveur", "PLAYBACK_ERROR": "Échec de l'obtention de l'URL de lecture", "LOADING_SEASONS": "Chargement des saisons…", + "LOADING_ALL_CHANNELS": "Chargement de la liste complète des chaînes…", + "REFRESH_CHANNEL_LIST": "Actualiser la liste des chaînes", + "NO_CHANNELS_IN_CATEGORY": "Aucune chaîne dans cette catégorie", + "ITEM": "élément", + "ITEMS": "éléments", "NO_EPISODES": "Aucun épisode disponible", "ALL_RADIO": "Toutes les radios" }, diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 4b970e7e0..0421f61d8 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -968,6 +968,11 @@ "CONTENT_NOT_AVAILABLE": "Questo contenuto non è disponibile su questo server", "PLAYBACK_ERROR": "Impossibile ottenere l'URL di riproduzione", "LOADING_SEASONS": "Caricamento stagioni...", + "LOADING_ALL_CHANNELS": "Caricamento dell'elenco completo dei canali…", + "REFRESH_CHANNEL_LIST": "Aggiorna elenco canali", + "NO_CHANNELS_IN_CATEGORY": "Nessun canale in questa categoria", + "ITEM": "elemento", + "ITEMS": "elementi", "NO_EPISODES": "Nessun episodio disponibile", "ALL_RADIO": "Tutte le radio" }, diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 2d108d99c..73ea0138e 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -968,6 +968,11 @@ "CONTENT_NOT_AVAILABLE": "このコンテンツはこのサーバーでは利用できません", "PLAYBACK_ERROR": "再生URLの取得に失敗しました", "LOADING_SEASONS": "シーズンを読み込み中...", + "LOADING_ALL_CHANNELS": "全チャンネルリストを読み込み中…", + "REFRESH_CHANNEL_LIST": "チャンネルリストを更新", + "NO_CHANNELS_IN_CATEGORY": "このカテゴリーにチャンネルがありません", + "ITEM": "件", + "ITEMS": "件", "NO_EPISODES": "利用可能なエピソードはありません", "ALL_RADIO": "すべてのラジオ" }, diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 25b7226bd..e43ddc6b3 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -968,6 +968,11 @@ "CONTENT_NOT_AVAILABLE": "이 서버에서는 이 콘텐츠를 사용할 수 없습니다", "PLAYBACK_ERROR": "재생 URL을 가져오지 못했습니다", "LOADING_SEASONS": "시즌을 불러오는 중...", + "LOADING_ALL_CHANNELS": "전체 채널 목록 불러오는 중…", + "REFRESH_CHANNEL_LIST": "채널 목록 새로고침", + "NO_CHANNELS_IN_CATEGORY": "이 카테고리에 채널이 없습니다", + "ITEM": "개", + "ITEMS": "개", "NO_EPISODES": "사용 가능한 에피소드가 없습니다", "ALL_RADIO": "모든 라디오" }, diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index fff5dfbee..6d05aab79 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -968,6 +968,11 @@ "CONTENT_NOT_AVAILABLE": "Deze inhoud is niet beschikbaar op deze server", "PLAYBACK_ERROR": "Ophalen van afspeel-URL mislukt", "LOADING_SEASONS": "Seizoenen laden...", + "LOADING_ALL_CHANNELS": "Volledige zenderlijst wordt geladen…", + "REFRESH_CHANNEL_LIST": "Zenderlijst vernieuwen", + "NO_CHANNELS_IN_CATEGORY": "Geen zenders in deze categorie", + "ITEM": "item", + "ITEMS": "items", "NO_EPISODES": "Geen afleveringen beschikbaar", "ALL_RADIO": "Alle radio" }, diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index d853d98cb..14758b632 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -968,6 +968,11 @@ "CONTENT_NOT_AVAILABLE": "Ta treść jest niedostępna na tym serwerze", "PLAYBACK_ERROR": "Nie udało się uzyskać URL odtwarzania", "LOADING_SEASONS": "Ładowanie sezonów...", + "LOADING_ALL_CHANNELS": "Ładowanie pełnej listy kanałów…", + "REFRESH_CHANNEL_LIST": "Odśwież listę kanałów", + "NO_CHANNELS_IN_CATEGORY": "Brak kanałów w tej kategorii", + "ITEM": "pozycja", + "ITEMS": "pozycji", "NO_EPISODES": "Brak dostępnych odcinków", "ALL_RADIO": "Wszystkie radia" }, diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index c0cd4ade0..89f58e2ec 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -968,6 +968,11 @@ "CONTENT_NOT_AVAILABLE": "Este conteúdo não está disponível neste servidor", "PLAYBACK_ERROR": "Falha ao obter URL de reprodução", "LOADING_SEASONS": "Carregando temporadas...", + "LOADING_ALL_CHANNELS": "A carregar a lista completa de canais…", + "REFRESH_CHANNEL_LIST": "Atualizar lista de canais", + "NO_CHANNELS_IN_CATEGORY": "Nenhum canal nesta categoria", + "ITEM": "item", + "ITEMS": "itens", "NO_EPISODES": "Nenhum episódio disponível", "ALL_RADIO": "Todas as rádios" }, diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 414e25772..f0c007b9d 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -968,6 +968,11 @@ "CONTENT_NOT_AVAILABLE": "Этот контент недоступен на этом сервере", "PLAYBACK_ERROR": "Не удалось получить URL для воспроизведения", "LOADING_SEASONS": "Загрузка сезонов…", + "LOADING_ALL_CHANNELS": "Загрузка полного списка каналов…", + "REFRESH_CHANNEL_LIST": "Обновить список каналов", + "NO_CHANNELS_IN_CATEGORY": "В этой категории нет каналов", + "ITEM": "элемент", + "ITEMS": "элементов", "NO_EPISODES": "Нет доступных эпизодов", "ALL_RADIO": "Все радио" }, diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 24c48b1c3..d89dbad45 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -968,6 +968,11 @@ "CONTENT_NOT_AVAILABLE": "Bu içerik bu sunucuda mevcut değil", "PLAYBACK_ERROR": "Oynatma URL'si alınamadı", "LOADING_SEASONS": "Sezonlar yükleniyor...", + "LOADING_ALL_CHANNELS": "Tüm kanal listesi yükleniyor…", + "REFRESH_CHANNEL_LIST": "Kanal listesini yenile", + "NO_CHANNELS_IN_CATEGORY": "Bu kategoride kanal yok", + "ITEM": "öğe", + "ITEMS": "öğe", "NO_EPISODES": "Bölüm mevcut değil", "ALL_RADIO": "Tüm radyolar" }, diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index db2db90ae..17ccb0b36 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -968,6 +968,11 @@ "CONTENT_NOT_AVAILABLE": "此服务器上没有此内容", "PLAYBACK_ERROR": "获取播放 URL 失败", "LOADING_SEASONS": "正在加载季…", + "LOADING_ALL_CHANNELS": "正在加载完整频道列表…", + "REFRESH_CHANNEL_LIST": "刷新频道列表", + "NO_CHANNELS_IN_CATEGORY": "此分类中没有频道", + "ITEM": "项", + "ITEMS": "项", "NO_EPISODES": "没有可用的剧集", "ALL_RADIO": "所有广播" }, diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index ed43744f8..87d93aa43 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -968,6 +968,11 @@ "CONTENT_NOT_AVAILABLE": "此伺服器上未提供此內容", "PLAYBACK_ERROR": "無法取得播放網址", "LOADING_SEASONS": "正在載入季數...", + "LOADING_ALL_CHANNELS": "正在載入完整頻道清單…", + "REFRESH_CHANNEL_LIST": "重新整理頻道清單", + "NO_CHANNELS_IN_CATEGORY": "此分類中沒有頻道", + "ITEM": "項", + "ITEMS": "項", "NO_EPISODES": "沒有可用的集數", "ALL_RADIO": "所有廣播" }, diff --git a/docs/architecture/stalker-epg.md b/docs/architecture/stalker-epg.md index 20367b344..437f251be 100644 --- a/docs/architecture/stalker-epg.md +++ b/docs/architecture/stalker-epg.md @@ -15,9 +15,19 @@ Stalker now uses two EPG paths with different purposes: - The active channel EPG panel uses `get_epg_info` as a bulk endpoint, fetches a 7-day window once per playlist session, caches programs by channel id, and renders the selected channel through the shared `app-epg-timeline` component. -- Channel rows no longer send preview EPG requests during initial category load. - They stay empty until bulk EPG has been fetched once, then derive their - current program and progress bar from the cached bulk map. +- Channel rows never send per-row EPG requests. The bulk EPG load is triggered + **eagerly when a category's channels first render** (a constructor effect in + `StalkerLiveStreamLayoutComponent` calls `ensureBulkItvEpg(168)` once ITV + channels are present) — not only after the first channel is played — so the + row "now playing" previews and the EPG panel populate immediately. Rows derive + their current program and progress bar from the cached bulk map. + - Effect ordering matters: the eager-EPG effect is registered **after** the + playlist-change effect that calls `clearBulkItvEpgCache()`. On a portal + switch the cache is cleared first and then refilled; if the order is + reversed the clear clobbers the just-loaded bulk EPG on initial render. + - `ensureBulkItvEpg` de-duplicates (via `isLoadingBulkItvEpg` / + `bulkItvEpgLoaded` + matching playlist/period), so the eager trigger and the + play-time `loadEpgForChannel` path never double-fetch. - If a portal does not return usable bulk data for the selected channel, the active panel falls back to `get_short_epg`. diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md index 931a54610..c37c44d59 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -76,7 +76,8 @@ Important store responsibilities: - Selected content/category/item state - Category and paginated content resources -- ITV channel list + pagination +- ITV channel list + pagination (full-list session cache when the portal + supports it, legacy 14-per-page lazy loading otherwise) - Radio category/station list + pagination - Regular series seasons resource - VOD-series (`is_series=1`) seasons + episodes resources @@ -142,6 +143,9 @@ The Stalker live route and radio route intentionally share - `itv` uses `type=itv&action=get_ordered_list`, stores results in `itvChannels`, resolves playback through `resolveItvPlayback(...)`, and keeps the EPG panel visible. +- ITV additionally loads the COMPLETE channel list once per portal session (see + "Full ITV channel list cache" below), so category views and search are not + limited to the lazily loaded 14-item pages. - `radio` uses `type=radio&action=get_ordered_list`, stores results in `radioChannels`, resolves playback through `resolveRadioPlayback(...)`, and renders `AudioPlayerComponent` instead of a video player. @@ -157,6 +161,107 @@ The Stalker live route and radio route intentionally share falls back to a synthetic `PORTALS.ALL_RADIO` category with `category_id: '*'` so the station list can still be loaded. +## Full ITV Channel List Cache + +Stalker portals paginate `get_ordered_list` with a server-side page size +(typically 14 items), so lazy loading alone can never power a complete local +search — this used to limit ITV search to whatever pages the user had scrolled +through. `StalkerItvCacheService` +(`libs/portal/stalker/data-access/src/lib/stalker-itv-cache.service.ts`) fixes +this with a per-portal, in-memory session cache of the complete live channel +list: + +- Load strategy: first try the Ministra `get_all_channels` action (`type=itv`, + returns ALL channels in one response — the same call STB clients use); if + the portal does not implement it, crawl `get_ordered_list` pages + (`category=*`, `genre=*`, concurrency 4, one retry per page, early stop on + an empty page **or a page that adds no new channel ids** — some portals + ignore `p` and repeat — 30k-channel hard cap) with progress reporting. The + assembled list is de-duplicated by channel id (both strategies) so it never + collides with the template's `track item.id`. The loading strategy itself is + a stateless helper (`stalker-itv-channel-loader.ts`); the service owns state. +- Outcomes: a well-formed but unusable response marks the portal + `unsupported` for the session (legacy paged flow stays in charge); a + transient failure (network, or a page that failed both attempts) is retried + later but throttled by a per-portal cooldown (`ERROR_COOLDOWN_MS`, 30s) so a + deterministically-failing page can't trigger an unbounded re-crawl loop. +- Per-portal reactivity: the "cache ready / refreshed" trigger is a + **per-portal** version signal (`versionFor(playlist)`), not one global + counter, and the content resource reads it **only for ITV**. This is + load-bearing: a global counter re-fired the resource for whatever was on + screen (radio, another portal), and the legacy paged branch appends at + `pageIndex > 1`, so an unrelated load completing duplicated the visible page + (colliding `track item.id` → NG0955). The `isCurrentRequest` guard is scoped + the same way. +- Integration: the `getContentResource` loader in + `with-stalker-content.feature.ts` serves ITV categories from the cache when + ready (local `tv_genre_id` filtering via `filterItvChannelsByGenre`, + `hasMoreChannels=false`), and otherwise runs the legacy paged fetch while + `ensureLoaded()` fills the cache in the background; the resource re-fires + via the `cacheVersion` signal once the full list arrives. +- UI: `StalkerLiveStreamLayoutComponent` windows the rendered list + (100-item chunks extended by the existing scroll handler) so multi-thousand + channel lists do not blow up the DOM; the header count and search cover the + whole category; a refresh button re-loads the list in place; a progress line + shows crawl status. +- Loading state contract (important — regressions here strand the sidebar on a + skeleton): in full-list mode the content loader serves the filtered list + **synchronously** from the cache. The category-change reset effect therefore + must NOT `setItvChannels([])` while `itvFullListActive()` is true — it runs + after the store resource and would clobber the freshly served list, leaving + every category after the first stuck on a skeleton. The initial-loading + skeleton (`isInitialChannelsLoading`) must key off an actual in-flight load + (`itvFullListLoading()` or `isPaginatedContentLoading()`), not merely an empty + channel list; an empty result once loading has settled is an empty category + and renders `PORTALS.NO_CHANNELS_IN_CATEGORY`, not a spinner. +- Search: with the cache active, the header search spans the ENTIRE portal + (all genres) — filtering the store's `itvFullChannelList`, not just the + selected category — so searching "CNN" while a "Sports" genre is selected + still finds it; clearing the term returns to the selected category. The + workspace shell drops the `degraded-loaded-only` / "loaded only" status for + Stalker ITV once `itvFullListActive`; radio (no full-list cache) always keeps + the loaded-only hint (`workspace-shell-search.service.ts`). +- Windowed selection: remote channel-up/down and numeric select operate over + the full filtered category, so the render window (`renderLimit`) grows to + include a selection beyond it (`ensureChannelWithinRenderWindow`) instead of + drifting off-screen. +- Category count badges: the context panel shows per-genre channel counts on + Stalker **Live TV** categories (like Xtream/M3U), fed by the store computed + `itvCategoryItemCounts` (the full list grouped by numeric `tv_genre_id`; the + `'*'` "All" row's total is stored under the `NaN` key that + `Number('*')` produces). Badges are ITV-only — VOD/series/radio still page + lazily so their per-category totals are unknown — and show a loading shimmer + while the full list is still loading (`workspace-context-panel` → + `stalkerShowCounts` / `stalkerCountDisplayMode`). +- Censored (adult) genres: portals typically EXCLUDE these channels from + `get_all_channels` (sometimes without even flagging the genre `censored` in + `get_genres`), so the cache legitimately has zero channels for them. The + content loader therefore serves a genre from the cache only when the + genre-filtered result is non-empty; otherwise it falls back to the legacy + paged `get_ordered_list` fetch, which still returns those channels. The + store computed `itvSelectedCategoryFromCache` is the single source of truth + for this mode — the live layout keys windowing/infinite-scroll/`loadMore` + and the category-change reset off it, NOT off `itvFullListActive`. Count + badges: genres with no cached channels get NO map entry and the category + view omits their badge (`omitMissingCounts`) instead of showing a + misleading "0". The mock server ships a censored `For adults` ITV category + (id 1099) to exercise this path. +- Eager preload + all-channels view (Xtream parity): entering the Live TV + section immediately starts the full-list load (`preloadItvChannels()`, fired + from an effect in `StalkerLiveStreamLayoutComponent` — not from the first + category click), so the count badges and the all-channels view are available + right away. Before a category is selected, the main area shows + `StalkerItvAllItemsComponent` — a paginated card grid of every channel in + the portal (client-side pagination only; it must never touch the store's + legacy `page` state, which would re-fire portal requests). Clicking a card + runs the same `playChannel` flow as the sidebar. Portals without a usable + full list keep the "select a category" placeholder. +- Scope: ITV only. VOD/series keep server-side search; radio keeps legacy + paging (station lists are small). +- The stalker-mock-server implements `get_all_channels` and provides the + `legacy-pagination` scenario MAC (`00:1A:79:00:00:06`) to exercise the + crawl fallback. + ## VOD/Series Modes Stalker has multiple real-world data shapes. The current implementation supports all three: diff --git a/libs/portal/stalker/data-access/src/index.ts b/libs/portal/stalker/data-access/src/index.ts index b56ac9050..9ea6e474c 100644 --- a/libs/portal/stalker/data-access/src/index.ts +++ b/libs/portal/stalker/data-access/src/index.ts @@ -1,6 +1,7 @@ export * from './lib/models'; export * from './lib/stores'; export * from './lib/stalker-content-types'; +export * from './lib/stalker-itv-cache.service'; export * from './lib/stalker-live-playback.utils'; export * from './lib/stalker-series.adapters'; export * from './lib/stalker-session.service'; diff --git a/libs/portal/stalker/data-access/src/lib/models/stalker-category-item.interface.ts b/libs/portal/stalker/data-access/src/lib/models/stalker-category-item.interface.ts index 5f7678593..98103990f 100644 --- a/libs/portal/stalker/data-access/src/lib/models/stalker-category-item.interface.ts +++ b/libs/portal/stalker/data-access/src/lib/models/stalker-category-item.interface.ts @@ -1,5 +1,11 @@ export interface StalkerCategoryItem { category_id: string; category_name: string; + /** + * Ministra "censored" genre flag (adult categories). Portals typically + * exclude these channels from `get_all_channels`, so censored categories + * are served via the legacy paged flow and get no count badge. + */ + censored?: boolean; [key: string]: unknown; } diff --git a/libs/portal/stalker/data-access/src/lib/models/stalker-itv-channel.interface.ts b/libs/portal/stalker/data-access/src/lib/models/stalker-itv-channel.interface.ts index 9fae5e652..18fc90dd7 100644 --- a/libs/portal/stalker/data-access/src/lib/models/stalker-itv-channel.interface.ts +++ b/libs/portal/stalker/data-access/src/lib/models/stalker-itv-channel.interface.ts @@ -10,4 +10,6 @@ export interface StalkerItvChannel extends StalkerVodSource { name?: string; o_name?: string; logo?: string; + tv_genre_id?: string | number; + number?: string | number; } diff --git a/libs/portal/stalker/data-access/src/lib/stalker-itv-cache.service.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-itv-cache.service.spec.ts new file mode 100644 index 000000000..ef647483a --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stalker-itv-cache.service.spec.ts @@ -0,0 +1,391 @@ +import { TestBed } from '@angular/core/testing'; +import { DataService } from '@iptvnator/services'; +import { PlaylistMeta } from '@iptvnator/shared/interfaces'; +import { StalkerItvCacheService } from './stalker-itv-cache.service'; +import { StalkerSessionService } from './stalker-session.service'; + +jest.mock('@iptvnator/portal/shared/util', () => ({ + createLogger: () => ({ + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }), +})); + +const PLAYLIST = { + _id: 'playlist-1', + title: 'Demo Stalker', + count: 0, + autoRefresh: false, + importDate: '2026-04-14T00:00:00.000Z', + portalUrl: 'http://demo.example/stalker_portal/server/load.php', + macAddress: '00:1A:79:00:00:01', + isFullStalkerPortal: false, +} as PlaylistMeta; + +type StalkerParams = Record; + +interface RequestHandlers { + allChannels?: () => Promise | unknown; + page?: (page: number) => Promise | unknown; +} + +function channel(id: string, name: string, genreId: string) { + return { + id, + name, + cmd: `ffrt http://stream.example/${id}`, + tv_genre_id: genreId, + }; +} + +function pageOf(items: unknown[], totalItems: number, pageSize = 14) { + return { + js: { + data: items, + total_items: totalItems, + max_page_items: pageSize, + }, + }; +} + +const UNSUPPORTED_ACTION = { js: { error: 'Unknown action: get_all_channels' } }; + +async function flushMicrotasks(times = 5): Promise { + for (let index = 0; index < times; index += 1) { + await Promise.resolve(); + } +} + +describe('StalkerItvCacheService', () => { + let service: StalkerItvCacheService; + let sendIpcEvent: jest.Mock, unknown[]>; + + function mockRequests(handlers: RequestHandlers): void { + sendIpcEvent.mockImplementation( + async (_event: unknown, payload: unknown) => { + const params = (payload as { params: StalkerParams }).params; + if (params['action'] === 'get_all_channels') { + if (!handlers.allChannels) { + throw new Error('unexpected get_all_channels'); + } + return handlers.allChannels(); + } + if (params['action'] === 'get_ordered_list') { + if (!handlers.page) { + throw new Error('unexpected get_ordered_list'); + } + return handlers.page(Number(params['p'])); + } + throw new Error(`unexpected action ${params['action']}`); + } + ); + } + + function callsFor(action: string): number { + return sendIpcEvent.mock.calls.filter( + (call) => + (call[1] as { params: StalkerParams }).params['action'] === + action + ).length; + } + + beforeEach(() => { + sendIpcEvent = jest.fn(); + + TestBed.configureTestingModule({ + providers: [ + { provide: DataService, useValue: { sendIpcEvent } }, + { + provide: StalkerSessionService, + useValue: { makeAuthenticatedRequest: jest.fn() }, + }, + ], + }); + + service = TestBed.inject(StalkerItvCacheService); + }); + + it('loads the full list via get_all_channels in a single request', async () => { + mockRequests({ + allChannels: () => + pageOf( + [ + channel('1', 'News One', '5'), + // stream_id fallback used when id is missing + { + stream_id: '2', + name: 'Sports HD', + cmd: 'ffrt http://stream.example/2', + tv_genre_id: '9', + }, + ], + 2 + ), + }); + + await service.ensureLoaded(PLAYLIST); + + expect(service.isReady(PLAYLIST)).toBe(true); + expect(service.versionFor(PLAYLIST)).toBe(1); + expect(sendIpcEvent).toHaveBeenCalledTimes(1); + + const channels = service.getChannels(PLAYLIST); + expect(channels?.map((item) => item.id)).toEqual(['1', '2']); + expect(channels?.every((item) => typeof item.cmd === 'string')).toBe( + true + ); + }); + + it('falls back to crawling get_ordered_list pages when get_all_channels is unsupported', async () => { + mockRequests({ + allChannels: () => UNSUPPORTED_ACTION, + page: (page) => + pageOf( + Array.from({ length: page === 2 ? 14 : 14 }, (_, index) => + channel( + `${page}-${index}`, + `Channel ${page}-${index}`, + '5' + ) + ), + 28 + ), + }); + + await service.ensureLoaded(PLAYLIST); + + expect(service.isReady(PLAYLIST)).toBe(true); + expect(service.getChannels(PLAYLIST)).toHaveLength(28); + expect(callsFor('get_all_channels')).toBe(1); + expect(callsFor('get_ordered_list')).toBe(2); + }); + + it('reports crawl progress while loading and clears it afterwards', async () => { + let resolveSecondPage!: (value: unknown) => void; + mockRequests({ + allChannels: () => UNSUPPORTED_ACTION, + page: (page) => { + if (page === 1) { + return pageOf( + Array.from({ length: 14 }, (_, index) => + channel(`1-${index}`, `Channel ${index}`, '5') + ), + 28 + ); + } + return new Promise((resolve) => { + resolveSecondPage = resolve; + }); + }, + }); + + const load = service.ensureLoaded(PLAYLIST); + await flushMicrotasks(); + + expect(service.isLoading(PLAYLIST)).toBe(true); + expect(service.progressOf(PLAYLIST)).toEqual({ loaded: 14, total: 28 }); + + resolveSecondPage( + pageOf( + Array.from({ length: 14 }, (_, index) => + channel(`2-${index}`, `Channel ${index}`, '5') + ), + 28 + ) + ); + await load; + + expect(service.isLoading(PLAYLIST)).toBe(false); + expect(service.progressOf(PLAYLIST)).toBeNull(); + expect(service.getChannels(PLAYLIST)).toHaveLength(28); + }); + + it('stops the crawl early when the portal serves an empty page', async () => { + mockRequests({ + allChannels: () => UNSUPPORTED_ACTION, + page: (page) => + page === 1 + ? pageOf( + Array.from({ length: 14 }, (_, index) => + channel(`1-${index}`, `Channel ${index}`, '5') + ), + // Portal claims more items than it can serve. + 280 + ) + : pageOf([], 280), + }); + + await service.ensureLoaded(PLAYLIST); + + expect(service.isReady(PLAYLIST)).toBe(true); + expect(service.getChannels(PLAYLIST)).toHaveLength(14); + }); + + it('memoizes unsupported portals and keeps the legacy flow in charge', async () => { + mockRequests({ + allChannels: () => UNSUPPORTED_ACTION, + page: () => pageOf([], 0), + }); + + await service.ensureLoaded(PLAYLIST); + const callsAfterFirstAttempt = sendIpcEvent.mock.calls.length; + + await service.ensureLoaded(PLAYLIST); + + expect(service.isReady(PLAYLIST)).toBe(false); + expect(sendIpcEvent.mock.calls.length).toBe(callsAfterFirstAttempt); + }); + + it('throttles retries after a transient failure, then retries once the cooldown elapses', async () => { + const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(1_000_000); + try { + mockRequests({ + allChannels: () => Promise.reject(new Error('network down')), + page: () => Promise.reject(new Error('network down')), + }); + + await service.ensureLoaded(PLAYLIST); + expect(service.isReady(PLAYLIST)).toBe(false); + const callsAfterError = sendIpcEvent.mock.calls.length; + + // Portal is reachable again, but within the cooldown window the + // whole portal must NOT be re-crawled (prevents a hammering loop + // when a page fails deterministically). + mockRequests({ + allChannels: () => pageOf([channel('1', 'News One', '5')], 1), + }); + await service.ensureLoaded(PLAYLIST); + expect(sendIpcEvent.mock.calls.length).toBe(callsAfterError); + expect(service.isReady(PLAYLIST)).toBe(false); + + // After the cooldown elapses the next visit retries. + nowSpy.mockReturnValue(1_000_000 + 31_000); + await service.ensureLoaded(PLAYLIST); + expect(service.isReady(PLAYLIST)).toBe(true); + expect(service.getChannels(PLAYLIST)).toHaveLength(1); + } finally { + nowSpy.mockRestore(); + } + }); + + it('a refresh bypasses the error cooldown', async () => { + const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(2_000_000); + try { + mockRequests({ + allChannels: () => Promise.reject(new Error('network down')), + page: () => Promise.reject(new Error('network down')), + }); + await service.ensureLoaded(PLAYLIST); + expect(service.isReady(PLAYLIST)).toBe(false); + + mockRequests({ + allChannels: () => pageOf([channel('1', 'News One', '5')], 1), + }); + // Same instant — cooldown still active — but an explicit refresh + // (e.g. the refresh button) must retry immediately. + await service.refresh(PLAYLIST); + expect(service.isReady(PLAYLIST)).toBe(true); + } finally { + nowSpy.mockRestore(); + } + }); + + it('deduplicates channels returned across crawl pages (portal ignoring the page param)', async () => { + const samePage = () => + pageOf( + [ + channel('1', 'News One', '5'), + channel('2', 'Sports HD', '9'), + ], + // Portal claims many items but returns the same two regardless + // of `p`. + 280 + ); + mockRequests({ + allChannels: () => UNSUPPORTED_ACTION, + page: () => samePage(), + }); + + await service.ensureLoaded(PLAYLIST); + + expect(service.isReady(PLAYLIST)).toBe(true); + expect(service.getChannels(PLAYLIST)).toHaveLength(2); + }); + + it('deduplicates channels returned by get_all_channels', async () => { + mockRequests({ + allChannels: () => + pageOf( + [ + channel('1', 'News One', '5'), + channel('1', 'News One dup', '5'), + channel('2', 'Sports HD', '9'), + ], + 3 + ), + }); + + await service.ensureLoaded(PLAYLIST); + + expect( + service.getChannels(PLAYLIST)?.map((c) => c.id) + ).toEqual(['1', '2']); + }); + + it('deduplicates concurrent load requests', async () => { + let resolveAllChannels!: (value: unknown) => void; + mockRequests({ + allChannels: () => + new Promise((resolve) => { + resolveAllChannels = resolve; + }), + }); + + const first = service.ensureLoaded(PLAYLIST); + const second = service.ensureLoaded(PLAYLIST); + await flushMicrotasks(); + + resolveAllChannels(pageOf([channel('1', 'News One', '5')], 1)); + await Promise.all([first, second]); + + expect(callsFor('get_all_channels')).toBe(1); + expect(service.isReady(PLAYLIST)).toBe(true); + }); + + it('keeps serving the previous list while a refresh is in flight', async () => { + mockRequests({ + allChannels: () => pageOf([channel('1', 'News One', '5')], 1), + }); + await service.ensureLoaded(PLAYLIST); + + let resolveRefresh!: (value: unknown) => void; + mockRequests({ + allChannels: () => + new Promise((resolve) => { + resolveRefresh = resolve; + }), + }); + + const refresh = service.refresh(PLAYLIST); + await flushMicrotasks(); + + expect(service.isReady(PLAYLIST)).toBe(true); + expect(service.getChannels(PLAYLIST)?.[0]?.name).toBe('News One'); + + resolveRefresh( + pageOf( + [ + channel('1', 'News One', '5'), + channel('2', 'Fresh Channel', '9'), + ], + 2 + ) + ); + await refresh; + + expect(service.getChannels(PLAYLIST)).toHaveLength(2); + expect(service.versionFor(PLAYLIST)).toBe(2); + }); +}); diff --git a/libs/portal/stalker/data-access/src/lib/stalker-itv-cache.service.ts b/libs/portal/stalker/data-access/src/lib/stalker-itv-cache.service.ts new file mode 100644 index 000000000..f16bcbee5 --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stalker-itv-cache.service.ts @@ -0,0 +1,223 @@ +import { Injectable, WritableSignal, inject, signal } from '@angular/core'; +import { createLogger } from '@iptvnator/portal/shared/util'; +import { DataService } from '@iptvnator/services'; +import { PlaylistMeta } from '@iptvnator/shared/interfaces'; +import { StalkerItvChannel } from './models'; +import { + StalkerItvLoadProgress, + loadFullItvChannelList, +} from './stalker-itv-channel-loader'; +import { StalkerSessionService } from './stalker-session.service'; +import { StalkerRequestDeps } from './stores/utils'; + +export type { StalkerItvLoadProgress }; + +/** + * After a transient crawl failure, don't immediately re-crawl the whole portal + * on the next resource fire — a deterministically-failing page would otherwise + * trigger an unbounded re-crawl loop that hammers the portal. + */ +const ERROR_COOLDOWN_MS = 30_000; + +/** + * Session cache holding the complete ITV channel list per Stalker portal. + * + * Stalker portals paginate `get_ordered_list` with a server-side page size + * (typically 14), so incremental loading can never power a complete local + * search. This service loads the full list once per portal: it first tries the + * Ministra `get_all_channels` action (all channels in a single response) and + * falls back to crawling `get_ordered_list` pages. Portals where both + * strategies fail are marked unsupported and keep the legacy paged behavior. + */ +@Injectable({ providedIn: 'root' }) +export class StalkerItvCacheService { + private readonly logger = createLogger('StalkerItvCache'); + private readonly requestDeps: StalkerRequestDeps = { + dataService: inject(DataService), + stalkerSession: inject(StalkerSessionService), + }; + + /** Portal keys whose full channel list is loaded. */ + private readonly readyKeys = signal>(new Set()); + private readonly loadingKeys = signal>(new Set()); + private readonly progressByKey = signal< + Readonly> + >({}); + /** + * Per-portal version counters. Kept as independent signals (not one shared + * counter) so a resource that reads `versionFor(portal)` only re-fires when + * THAT portal's list becomes ready or is refreshed — a different portal's + * background load completing must never re-fire an unrelated resource. + */ + private readonly versionSignals = new Map>(); + + private readonly channelsByKey = new Map(); + private readonly unsupportedKeys = new Set(); + private readonly inflight = new Map>(); + /** Wall-clock (ms) until which a portal's crawl error suppresses retries. */ + private readonly errorCooldownUntil = new Map(); + + /** + * Reactive, per-portal readiness token. Changes only when this portal's + * full channel list becomes ready or is refreshed. Resources that serve + * from the cache read this to re-fire at the right moment without being + * disturbed by other portals' loads. + */ + versionFor(playlist: PlaylistMeta | undefined): number { + const key = this.keyFor(playlist); + return key === null ? 0 : this.versionSignalFor(key)(); + } + + isReady(playlist: PlaylistMeta | undefined): boolean { + const key = this.keyFor(playlist); + return key !== null && this.readyKeys().has(key); + } + + isLoading(playlist: PlaylistMeta | undefined): boolean { + const key = this.keyFor(playlist); + return key !== null && this.loadingKeys().has(key); + } + + progressOf( + playlist: PlaylistMeta | undefined + ): StalkerItvLoadProgress | null { + const key = this.keyFor(playlist); + return key !== null ? (this.progressByKey()[key] ?? null) : null; + } + + /** + * Full channel list for the portal, or null while not (yet) loaded. + * Deliberately NON-reactive: it reads only the plain `channelsByKey` map + * (an entry exists iff a load succeeded), so computeds that call it don't + * pick up the shared `readyKeys` signal and re-evaluate when an UNRELATED + * portal's list becomes ready. Reactive consumers establish their own + * per-portal dependency via {@link versionFor}; boolean readiness for UI + * stays on the reactive {@link isReady}. + */ + getChannels(playlist: PlaylistMeta | undefined): StalkerItvChannel[] | null { + const key = this.keyFor(playlist); + return key === null ? null : (this.channelsByKey.get(key) ?? null); + } + + /** + * Loads the full channel list once per portal. No-op when the list is + * already loaded, currently loading, or the portal is unsupported. + */ + async ensureLoaded(playlist: PlaylistMeta | undefined): Promise { + const key = this.keyFor(playlist); + if ( + key === null || + !playlist || + this.readyKeys().has(key) || + this.unsupportedKeys.has(key) || + this.isInErrorCooldown(key) + ) { + return; + } + + await (this.inflight.get(key) ?? this.runLoad(key, playlist)); + } + + /** Reloads the list while keeping the current cache served in the meantime. */ + async refresh(playlist: PlaylistMeta | undefined): Promise { + const key = this.keyFor(playlist); + if (key === null || !playlist) { + return; + } + + const pending = this.inflight.get(key); + if (pending) { + return pending; + } + + this.unsupportedKeys.delete(key); + this.errorCooldownUntil.delete(key); + await this.runLoad(key, playlist); + } + + private keyFor(playlist: PlaylistMeta | undefined): string | null { + return playlist?._id ?? playlist?.portalUrl ?? null; + } + + private versionSignalFor(key: string): WritableSignal { + let versionSignal = this.versionSignals.get(key); + if (!versionSignal) { + versionSignal = signal(0); + this.versionSignals.set(key, versionSignal); + } + return versionSignal; + } + + private isInErrorCooldown(key: string): boolean { + const until = this.errorCooldownUntil.get(key); + if (until === undefined) { + return false; + } + if (Date.now() >= until) { + this.errorCooldownUntil.delete(key); + return false; + } + return true; + } + + private runLoad(key: string, playlist: PlaylistMeta): Promise { + const task = (async () => { + this.patchKeySet(this.loadingKeys, key, true); + try { + const outcome = await loadFullItvChannelList( + this.requestDeps, + playlist, + (loaded, total) => this.reportProgress(key, loaded, total), + this.logger + ); + if (Array.isArray(outcome)) { + this.channelsByKey.set(key, outcome); + this.errorCooldownUntil.delete(key); + this.patchKeySet(this.readyKeys, key, true); + this.versionSignalFor(key).update((version) => version + 1); + } else if (outcome === 'error') { + this.errorCooldownUntil.set( + key, + Date.now() + ERROR_COOLDOWN_MS + ); + } else if (!this.readyKeys().has(key)) { + this.unsupportedKeys.add(key); + } + } finally { + this.inflight.delete(key); + this.patchKeySet(this.loadingKeys, key, false); + this.progressByKey.update((progress) => { + const next = { ...progress }; + delete next[key]; + return next; + }); + } + })(); + + this.inflight.set(key, task); + return task; + } + + private reportProgress(key: string, loaded: number, total: number): void { + this.progressByKey.update((progress) => ({ + ...progress, + [key]: { loaded, total }, + })); + } + + private patchKeySet( + target: ReturnType>>, + key: string, + present: boolean + ): void { + target.update((current) => { + const next = new Set(current); + if (present) { + next.add(key); + } else { + next.delete(key); + } + return next; + }); + } +} diff --git a/libs/portal/stalker/data-access/src/lib/stalker-itv-channel-loader.ts b/libs/portal/stalker/data-access/src/lib/stalker-itv-channel-loader.ts new file mode 100644 index 000000000..b90efc694 --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stalker-itv-channel-loader.ts @@ -0,0 +1,283 @@ +import { + PlaylistMeta, + StalkerPortalActions, +} from '@iptvnator/shared/interfaces'; +import { StalkerItvChannel } from './models'; +import { + StalkerRequestDeps, + executeStalkerRequest, + toStalkerContentItem, + toStalkerItvChannel, +} from './stores/utils'; + +export interface StalkerItvLoadProgress { + loaded: number; + total: number; +} + +/** + * 'unsupported' — the portal answered but cannot serve a full list; the caller + * should keep the legacy paged flow. 'error' — a transient failure (network, + * timeout, a page that failed both attempts) that is worth retrying later. + */ +export type StalkerItvLoadOutcome = + | StalkerItvChannel[] + | 'unsupported' + | 'error'; + +export interface StalkerItvLoadLogger { + info(...args: unknown[]): void; + warn(...args: unknown[]): void; +} + +interface StalkerChannelListResponse { + js?: { + data?: unknown; + total_items?: number | string; + max_page_items?: number | string; + }; +} + +const FALLBACK_PAGE_SIZE = 14; +const CRAWL_CONCURRENCY = 4; +/** Hard cap so a misbehaving portal cannot make the crawl run forever. */ +const MAX_CHANNELS = 30_000; + +function toPositiveInt(value: unknown): number | null { + const parsed = Number.parseInt(String(value ?? ''), 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; +} + +/** + * Loads the complete ITV channel list for a portal. First tries the Ministra + * `get_all_channels` action (all channels in one response — the same call STB + * clients use); if that isn't usable, crawls `get_ordered_list` pages. Stateless + * — the {@link StalkerItvCacheService} owns caching, progress state, and retry + * throttling. + */ +export async function loadFullItvChannelList( + deps: StalkerRequestDeps, + playlist: PlaylistMeta, + onProgress: (loaded: number, total: number) => void, + logger: StalkerItvLoadLogger +): Promise { + const viaAllChannels = await tryGetAllChannels(deps, playlist, logger); + if (Array.isArray(viaAllChannels)) { + return viaAllChannels; + } + + const crawled = await crawlOrderedPages(deps, playlist, onProgress, logger); + if (Array.isArray(crawled)) { + return crawled; + } + + return viaAllChannels === 'error' || crawled === 'error' + ? 'error' + : 'unsupported'; +} + +async function tryGetAllChannels( + deps: StalkerRequestDeps, + playlist: PlaylistMeta, + logger: StalkerItvLoadLogger +): Promise { + try { + const response = + await executeStalkerRequest( + deps, + playlist, + { + action: StalkerPortalActions.GetAllChannels, + type: 'itv', + } + ); + const data = response?.js?.data; + if (!Array.isArray(data) || data.length === 0) { + logger.info( + 'get_all_channels not usable, falling back to page crawl' + ); + return 'unsupported'; + } + + return mapChannels(data, playlist); + } catch (error) { + logger.warn('get_all_channels failed', error); + return 'error'; + } +} + +async function crawlOrderedPages( + deps: StalkerRequestDeps, + playlist: PlaylistMeta, + onProgress: (loaded: number, total: number) => void, + logger: StalkerItvLoadLogger +): Promise { + const firstResponse = await fetchOrderedPageWithRetry(deps, playlist, 1, logger); + if (firstResponse === null) { + return 'error'; + } + + const firstPage = Array.isArray(firstResponse.js?.data) + ? firstResponse.js.data + : null; + if (firstPage === null || firstPage.length === 0) { + return 'unsupported'; + } + + const totalItems = Math.min( + toPositiveInt(firstResponse?.js?.total_items) ?? firstPage.length, + MAX_CHANNELS + ); + const pageSize = + toPositiveInt(firstResponse?.js?.max_page_items) ?? + (firstPage.length || FALLBACK_PAGE_SIZE); + const totalPages = Math.max(1, Math.ceil(totalItems / pageSize)); + + const items: unknown[] = []; + const seen = new Set(); + collectUnique(firstPage, items, seen); + onProgress(items.length, totalItems); + + for ( + let page = 2; + page <= totalPages && items.length < totalItems; + page += CRAWL_CONCURRENCY + ) { + const batch: number[] = []; + for ( + let next = page; + next < page + CRAWL_CONCURRENCY && next <= totalPages; + next++ + ) { + batch.push(next); + } + + const results = await Promise.all( + batch.map((pageNumber) => + fetchOrderedPageWithRetry(deps, playlist, pageNumber, logger) + ) + ); + + let reachedEnd = false; + for (const pageResponse of results) { + const pageItems = Array.isArray(pageResponse?.js?.data) + ? pageResponse.js.data + : null; + if (pageItems === null) { + logger.warn('Aborting ITV page crawl after failures'); + return 'error'; + } + const added = collectUnique(pageItems, items, seen); + if (added === 0) { + // Empty page, or a portal that ignores `p` and keeps returning + // already-seen channels — treat the collected list as complete + // instead of duplicating rows forever. + reachedEnd = true; + break; + } + } + + onProgress(Math.min(items.length, totalItems), totalItems); + if (reachedEnd) { + break; + } + } + + return mapChannels(items, playlist); +} + +/** + * Appends only channels whose id hasn't been seen yet. Returns how many new + * channels were added so the crawl can stop when a page contributes nothing new. + */ +function collectUnique( + rawItems: unknown[], + target: unknown[], + seen: Set +): number { + let added = 0; + for (const item of rawItems) { + const id = rawChannelId(item); + if (id === null || seen.has(id)) { + continue; + } + seen.add(id); + target.push(item); + added += 1; + } + return added; +} + +function rawChannelId(item: unknown): string | null { + if (!item || typeof item !== 'object') { + return null; + } + const source = item as { id?: unknown; stream_id?: unknown }; + const raw = source.id ?? source.stream_id; + return raw === undefined || raw === null || raw === '' ? null : String(raw); +} + +async function fetchOrderedPageWithRetry( + deps: StalkerRequestDeps, + playlist: PlaylistMeta, + page: number, + logger: StalkerItvLoadLogger +): Promise { + const firstAttempt = await fetchOrderedPage(deps, playlist, page, logger); + if (firstAttempt !== null) { + return firstAttempt; + } + return fetchOrderedPage(deps, playlist, page, logger); +} + +async function fetchOrderedPage( + deps: StalkerRequestDeps, + playlist: PlaylistMeta, + page: number, + logger: StalkerItvLoadLogger +): Promise { + try { + return await executeStalkerRequest( + deps, + playlist, + { + action: StalkerPortalActions.GetOrderedList, + type: 'itv', + category: '*', + genre: '*', + sortby: 'number', + p: page, + } + ); + } catch (error) { + logger.warn(`Failed to fetch ITV page ${page}`, error); + return null; + } +} + +function mapChannels( + items: unknown[], + playlist: PlaylistMeta +): StalkerItvChannel[] { + const mapped: StalkerItvChannel[] = []; + const seen = new Set(); + for (const item of items) { + const channel = toStalkerItvChannel( + toStalkerContentItem( + item as Parameters[0], + playlist.portalUrl ?? '' + ) + ); + // Duplicate channel ids collide with the template's `track item.id` + // (get_all_channels can repeat, and some crawl paths overlap). + const id = String(channel.id ?? ''); + if (id !== '' && seen.has(id)) { + continue; + } + if (id !== '') { + seen.add(id); + } + mapped.push(channel); + } + return mapped; +} diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.spec.ts index 39af80336..00ab9e8da 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.spec.ts @@ -1,8 +1,11 @@ +import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { patchState, signalStore, withMethods, withState } from '@ngrx/signals'; import { TranslateService } from '@ngx-translate/core'; import { DataService } from '@iptvnator/services'; import { PlaylistMeta, StalkerPortalActions } from '@iptvnator/shared/interfaces'; +import { StalkerItvChannel } from '../../models'; +import { StalkerItvCacheService } from '../../stalker-itv-cache.service'; import { StalkerSessionService } from '../../stalker-session.service'; import { withStalkerContent } from './with-stalker-content.feature'; @@ -101,6 +104,29 @@ async function waitForCondition( throw new Error('Timed out waiting for resource activity'); } +/** + * Controllable stand-in for StalkerItvCacheService so the legacy paged flow + * can be tested in isolation and the cache-served flow deterministically. + */ +function createItvCacheMock(initialChannels: StalkerItvChannel[] | null = null) { + const version = signal(0); + let channels = initialChannels; + + return { + versionFor: jest.fn(() => version()), + getChannels: jest.fn(() => channels), + ensureLoaded: jest.fn().mockResolvedValue(undefined), + refresh: jest.fn().mockResolvedValue(undefined), + isReady: jest.fn(() => channels !== null), + isLoading: jest.fn(() => false), + progressOf: jest.fn(() => null), + setChannels(next: StalkerItvChannel[] | null) { + channels = next; + version.update((value) => value + 1); + }, + }; +} + describe('withStalkerContent failure states', () => { let store: InstanceType; let dataService: { @@ -116,6 +142,10 @@ describe('withStalkerContent failure states', () => { providers: [ TestContentStore, { provide: DataService, useValue: dataService }, + { + provide: StalkerItvCacheService, + useValue: createItvCacheMock(), + }, { provide: StalkerSessionService, useValue: { @@ -461,3 +491,266 @@ describe('withStalkerContent failure states', () => { expect(store.getPaginatedContent()[0]?.name).toBe('Page 1 restored'); }); }); + +describe('withStalkerContent full ITV channel list cache', () => { + const CACHED_CHANNELS: StalkerItvChannel[] = [ + { id: '1', cmd: 'ffrt http://x/1', name: 'News One', tv_genre_id: '5' }, + { id: '2', cmd: 'ffrt http://x/2', name: 'Sports HD', tv_genre_id: '9' }, + { id: '3', cmd: 'ffrt http://x/3', name: 'News Two', tv_genre_id: '5' }, + ]; + + let store: InstanceType; + let dataService: { + sendIpcEvent: jest.Mock, unknown[]>; + }; + let itvCache: ReturnType; + + function setup(initialChannels: StalkerItvChannel[] | null) { + dataService = { + sendIpcEvent: jest.fn(), + }; + itvCache = createItvCacheMock(initialChannels); + + TestBed.configureTestingModule({ + providers: [ + TestContentStore, + { provide: DataService, useValue: dataService }, + { provide: StalkerItvCacheService, useValue: itvCache }, + { + provide: StalkerSessionService, + useValue: { + makeAuthenticatedRequest: jest.fn(), + }, + }, + { + provide: TranslateService, + useValue: { + instant: jest.fn((key: string) => key), + }, + }, + ], + }); + + store = TestBed.inject(TestContentStore); + } + + function enterItvCategory(categoryId: string) { + store.setSelectedContentType('itv'); + store.setCategories('itv', [ + { category_id: '5', category_name: 'News' }, + { category_id: '9', category_name: 'Sports' }, + ]); + store.setSelectedCategory(categoryId); + store.setCurrentPlaylist(PLAYLIST); + void store.isPaginatedContentLoading(); + } + + it('serves the whole category from the cache without portal requests', async () => { + setup(CACHED_CHANNELS); + enterItvCategory('5'); + + await waitForCondition(() => store.itvChannels().length === 2); + + expect(store.itvChannels().map((channel) => channel.name)).toEqual([ + 'News One', + 'News Two', + ]); + expect(store.totalCount()).toBe(2); + expect(store.hasMoreChannels()).toBe(false); + expect(dataService.sendIpcEvent).not.toHaveBeenCalled(); + }); + + it('serves all channels for the "*" category so search can cover everything', async () => { + setup(CACHED_CHANNELS); + enterItvCategory('*'); + + await waitForCondition(() => store.itvChannels().length === 3); + + // Regression for the "search only finds the first 14 loaded items" + // bug: the full list is available at once, no paging required. + expect(store.itvChannels()).toHaveLength(3); + expect(store.hasMoreChannels()).toBe(false); + expect(dataService.sendIpcEvent).not.toHaveBeenCalled(); + }); + + it('falls back to the legacy paged fetch for a genre absent from the cache (censored/adult)', async () => { + setup(CACHED_CHANNELS); + dataService.sendIpcEvent.mockResolvedValue({ + js: { + data: [ + { + id: 'adult-1', + name: 'Adult One', + cmd: 'ffrt http://x/adult-1', + }, + ], + total_items: 40, + }, + }); + + // Genre '19' has no channels in the cached full list. + store.setSelectedContentType('itv'); + store.setCategories('itv', [ + { category_id: '5', category_name: 'News' }, + { category_id: '19', category_name: 'For adults', censored: true }, + ]); + store.setSelectedCategory('19'); + store.setCurrentPlaylist(PLAYLIST); + void store.isPaginatedContentLoading(); + + await waitForCondition(() => store.itvChannels().length === 1); + + expect(dataService.sendIpcEvent).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + params: expect.objectContaining({ + action: StalkerPortalActions.GetOrderedList, + type: 'itv', + genre: '19', + p: 1, + }), + }) + ); + expect(store.itvChannels()[0]?.name).toBe('Adult One'); + // Portal pagination stays in charge for this genre. + expect(store.hasMoreChannels()).toBe(true); + }); + + it('reports whether the selected category is served from the cache', () => { + setup(CACHED_CHANNELS); + enterItvCategory('5'); + expect(store.itvSelectedCategoryFromCache()).toBe(true); + + store.setSelectedCategory('*'); + expect(store.itvSelectedCategoryFromCache()).toBe(true); + + // Genre with zero cached channels — e.g. a censored/adult category. + store.setSelectedCategory('19'); + expect(store.itvSelectedCategoryFromCache()).toBe(false); + }); + + it('omits genres without cached channels from the count map (adult genres)', () => { + setup(CACHED_CHANNELS); + store.setSelectedContentType('itv'); + store.setCategories('itv', [ + { category_id: '*', category_name: 'All' }, + { category_id: '5', category_name: 'News' }, + { category_id: '19', category_name: 'For adults', censored: true }, + ]); + store.setCurrentPlaylist(PLAYLIST); + + const counts = store.itvCategoryItemCounts(); + expect(counts.get(5)).toBe(2); + // Adult genres are excluded from get_all_channels — regardless of any + // `censored` flag — so their count is unknown: no entry, no badge. + expect(counts.has(19)).toBe(false); + }); + + it('preloadItvChannels kicks off the cache load for the current playlist', () => { + setup(null); + store.setCurrentPlaylist(PLAYLIST); + + store.preloadItvChannels(); + + expect(itvCache.ensureLoaded).toHaveBeenCalledWith(PLAYLIST); + }); + + it('exposes per-genre channel counts for category badges (with the total under the "All" key)', () => { + setup(CACHED_CHANNELS); + enterItvCategory('*'); + + const counts = store.itvCategoryItemCounts(); + // Two channels in genre 5, one in genre 9. + expect(counts.get(5)).toBe(2); + expect(counts.get(9)).toBe(1); + // "All" category is category_id '*' → Number('*') is NaN; the grand + // total lives under the NaN key so the "All" row shows every channel. + expect(counts.get(Number.NaN)).toBe(3); + }); + + it('has an empty count map when no full list is cached', () => { + setup(null); + enterItvCategory('*'); + + const counts = store.itvCategoryItemCounts(); + // No genre entries at all — counts are unknown without the cache. + expect(counts.get(5)).toBeUndefined(); + expect(counts.get(Number.NaN)).toBe(0); + }); + + it('drops a stale legacy page response that resolves after the cache took over', async () => { + setup(null); + const legacyResponse = createDeferred(); + dataService.sendIpcEvent.mockReturnValue(legacyResponse.promise); + + enterItvCategory('5'); + + await waitForCondition( + () => dataService.sendIpcEvent.mock.calls.length > 0 + ); + + // The background full-list load finishes while the legacy page-1 + // request is still in flight; the resource re-fires and serves the + // complete category from the cache. + itvCache.setChannels(CACHED_CHANNELS); + await waitForCondition(() => store.itvChannels().length === 2); + + // The late legacy response must not overwrite the full list with the + // first 14-item page again. + legacyResponse.resolve({ + js: { + data: [ + { + id: 'legacy-1', + name: 'Legacy page one', + cmd: 'ffrt http://x/legacy', + }, + ], + total_items: 28, + }, + }); + await flushResources(); + + expect(store.itvChannels().map((channel) => channel.name)).toEqual([ + 'News One', + 'News Two', + ]); + expect(store.hasMoreChannels()).toBe(false); + }); + + it('kicks off a background full-list load and swaps it in once ready', async () => { + setup(null); + dataService.sendIpcEvent.mockResolvedValue({ + js: { + data: [ + { + id: 'legacy-1', + name: 'Legacy page one', + cmd: 'ffrt http://x/legacy', + }, + ], + total_items: 28, + }, + }); + + enterItvCategory('5'); + + await waitForCondition(() => store.itvChannels().length === 1); + + // Legacy paged flow stays in charge while the cache is cold. + expect(store.itvChannels()[0]?.name).toBe('Legacy page one'); + expect(store.hasMoreChannels()).toBe(true); + expect(itvCache.ensureLoaded).toHaveBeenCalledWith(PLAYLIST); + + // Full list finished loading in the background. + itvCache.setChannels(CACHED_CHANNELS); + + await waitForCondition(() => store.itvChannels().length === 2); + + expect(store.itvChannels().map((channel) => channel.name)).toEqual([ + 'News One', + 'News Two', + ]); + expect(store.hasMoreChannels()).toBe(false); + }); +}); diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts index d4b3bffe4..567152ec3 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts @@ -17,6 +17,7 @@ import { StalkerVodSource, } from '../../models'; import { StalkerContentTypes } from '../../stalker-content-types'; +import { StalkerItvCacheService } from '../../stalker-itv-cache.service'; import { StalkerSessionService } from '../../stalker-session.service'; import { ResourceState, @@ -26,6 +27,7 @@ import { } from '../stalker-store.contracts'; import { executeStalkerRequest, + filterItvChannelsByGenre, toStalkerContentItem, toStalkerItvChannel, } from '../utils'; @@ -64,6 +66,7 @@ const initialContentState: StalkerContentState = { interface StalkerCategoryResponseItem { id?: string | number; title?: string; + censored?: string | number; } interface StalkerCategoryResponse { @@ -191,7 +194,8 @@ export function withStalkerContent() { store, dataService = inject(DataService), stalkerSession = inject(StalkerSessionService), - translateService = inject(TranslateService) + translateService = inject(TranslateService), + itvCache = inject(StalkerItvCacheService) ) => { const storeContext = store as typeof store & StalkerContentResourceStoreContract; @@ -269,6 +273,9 @@ export function withStalkerContent() { (item): StalkerCategoryItem => ({ category_name: item.title ?? '', category_id: String(item.id), + censored: + item.censored === 1 || + item.censored === '1', }) ); const categories = prependAllCategory( @@ -325,6 +332,17 @@ export function withStalkerContent() { search: storeContext.searchPhrase(), pageIndex: storeContext.page() + 1, currentPlaylist: storeContext.currentPlaylist(), + // Re-fires the loader once THIS portal's full ITV + // channel list finishes loading or is refreshed. + // Read only for ITV and scoped per-portal so a + // different portal's (or a radio/vod) load never + // re-fires and re-appends this resource's page. + itvCacheVersion: + storeContext.selectedContentType() === 'itv' + ? itvCache.versionFor( + storeContext.currentPlaylist() + ) + : 0, availableCategoryCount: getCategoriesByType( store, storeContext.selectedContentType() @@ -376,6 +394,45 @@ export function withStalkerContent() { } const categoryParam = params.category || '*'; + + if (params.contentType === 'itv') { + const cachedChannels = + itvCache.getChannels(playlist); + const channels = + cachedChannels !== null + ? filterItvChannelsByGenre( + cachedChannels, + categoryParam + ) + : null; + // Serve from the cache only when it actually + // has channels for this genre. Censored (adult) + // genres are typically EXCLUDED from + // get_all_channels by the portal, so an empty + // filter result falls through to the legacy + // paged fetch, which still returns them. + if ( + channels !== null && + (categoryParam === '*' || + channels.length > 0) + ) { + patchState(store, { + totalCount: channels.length, + paginatedContent: channels, + itvChannels: channels, + hasMoreChannels: false, + contentError: null, + }); + return channels; + } + + // Full-list load runs in the background; the + // resource re-fires via `itvCacheVersion` once + // the cache is ready. Until then the legacy + // paged flow below serves the first pages. + void itvCache.ensureLoaded(playlist); + } + const paramsPlaylistKey = params.currentPlaylist?._id ?? params.currentPlaylist?.portalUrl ?? @@ -397,7 +454,18 @@ export function withStalkerContent() { storeContext.searchPhrase() && params.pageIndex === storeContext.page() + 1 && - paramsPlaylistKey === currentPlaylistKey + paramsPlaylistKey === currentPlaylistKey && + // A legacy paged response must not overwrite + // the full cached list that a re-fired + // loader served in the meantime. Scoped + // per-portal and to ITV so another portal's + // load never invalidates this response. + params.itvCacheVersion === + (params.contentType === 'itv' + ? itvCache.versionFor( + currentPlaylist + ) + : 0) ); }; const queryParams: Record = @@ -535,8 +603,94 @@ export function withStalkerContent() { withComputed((store) => { const storeContext = store as typeof store & StalkerContentResourceStoreContract; + const itvCache = inject(StalkerItvCacheService); + + /** + * The whole portal's ITV channel list (all categories) when + * cached. `versionFor` establishes the reactive dependency so this + * recomputes when the list becomes ready or is refreshed. + */ + const itvFullChannelList = computed(() => { + const playlist = storeContext.currentPlaylist(); + itvCache.versionFor(playlist); + return itvCache.getChannels(playlist) ?? []; + }); + + /** + * Per-genre channel counts for ITV category badges, keyed by + * numeric `tv_genre_id` (mirrors the Xtream count map). Only + * populated when the full list is cached. The "All" pseudo + * category has id `'*'` → `Number('*')` is NaN, and a Map keys + * NaN by SameValueZero, so the grand total under `NaN` makes the + * "All" row show every channel. Genres with NO cached channels + * get no entry at all: adult genres are excluded from + * `get_all_channels` (with or without a `censored` flag from + * `get_genres`), so their real count is unknown and the badge + * is omitted rather than showing a misleading "0". + */ + const itvCategoryItemCounts = computed(() => { + const counts = new Map(); + const channels = itvFullChannelList(); + for (const channel of channels) { + const genreId = Number(channel.tv_genre_id); + if (!Number.isNaN(genreId)) { + counts.set(genreId, (counts.get(genreId) ?? 0) + 1); + } + } + counts.set(Number.NaN, channels.length); + return counts; + }); return { + /** True when the complete ITV channel list is cached, so local search covers all channels. */ + itvFullListActive: computed(() => + itvCache.isReady(storeContext.currentPlaylist()) + ), + itvFullListLoading: computed(() => + itvCache.isLoading(storeContext.currentPlaylist()) + ), + itvFullListProgress: computed(() => + itvCache.progressOf(storeContext.currentPlaylist()) + ), + /** Exposes the whole portal's ITV channel list so search can span every channel. */ + itvFullChannelList, + /** + * True when the currently selected ITV category can be served + * from the full-list cache. Censored (adult) genres are usually + * excluded from `get_all_channels`, so a genre with zero cached + * channels stays on the legacy paged flow. O(1): reuses the + * memoized per-genre count map instead of re-scanning the list + * on every category click. + */ + itvSelectedCategoryFromCache: computed(() => { + const playlist = storeContext.currentPlaylist(); + if (!itvCache.isReady(playlist)) { + return false; + } + + const categoryId = storeContext.selectedCategoryId(); + if (!categoryId) { + return false; + } + if (categoryId === '*') { + return true; + } + + const genreId = Number(categoryId); + if (Number.isNaN(genreId)) { + // Non-numeric genre id would collide with the NaN + // "All" total key — fall back to the direct scan. + return ( + filterItvChannelsByGenre( + itvFullChannelList(), + categoryId + ).length > 0 + ); + } + + return (itvCategoryItemCounts().get(genreId) ?? 0) > 0; + }), + itvCategoryItemCounts, getTotalPages: computed(() => Math.ceil(store.totalCount() / storeContext.limit()) ), @@ -600,7 +754,25 @@ export function withStalkerContent() { isCategoryResourceFailed: computed(() => store.categoryError()), }; }), - withMethods((store) => ({ + withMethods((store) => { + const storeContext = store as typeof store & + StalkerContentResourceStoreContract; + const itvCache = inject(StalkerItvCacheService); + + return { + /** + * Kicks off the full ITV channel list load as soon as the Live TV + * section is entered (instead of waiting for the first category + * click), so the all-channels view and category count badges are + * available immediately. Safe to call repeatedly — the cache + * de-duplicates in-flight loads and memoizes unsupported portals. + */ + preloadItvChannels(): void { + void itvCache.ensureLoaded(storeContext.currentPlaylist()); + }, + async refreshItvChannels(): Promise { + await itvCache.refresh(storeContext.currentPlaylist()); + }, setCategories( type: StalkerContentType, categories: StalkerCategoryItem[] @@ -622,6 +794,7 @@ export function withStalkerContent() { setRadioChannels(channels: StalkerItvChannel[]) { patchState(store, { radioChannels: channels }); }, - })) + }; + }) ); } diff --git a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-content-mappers.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-content-mappers.spec.ts new file mode 100644 index 000000000..3533e68ec --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-content-mappers.spec.ts @@ -0,0 +1,32 @@ +import { StalkerItvChannel } from '../../models'; +import { filterItvChannelsByGenre } from './stalker-content-mappers'; + +describe('filterItvChannelsByGenre', () => { + const CHANNELS: StalkerItvChannel[] = [ + { id: '1', cmd: 'ffrt http://x/1', name: 'News One', tv_genre_id: '5' }, + { id: '2', cmd: 'ffrt http://x/2', name: 'Sports HD', tv_genre_id: 9 }, + { id: '3', cmd: 'ffrt http://x/3', name: 'No Genre' }, + ]; + + it('returns all channels for the "*" category', () => { + expect(filterItvChannelsByGenre(CHANNELS, '*')).toHaveLength(3); + }); + + it('returns all channels when no category is selected', () => { + expect(filterItvChannelsByGenre(CHANNELS, null)).toHaveLength(3); + expect(filterItvChannelsByGenre(CHANNELS, undefined)).toHaveLength(3); + }); + + it('matches numeric and string genre ids', () => { + expect( + filterItvChannelsByGenre(CHANNELS, '5').map((item) => item.id) + ).toEqual(['1']); + expect( + filterItvChannelsByGenre(CHANNELS, '9').map((item) => item.id) + ).toEqual(['2']); + }); + + it('hides channels without a genre from specific categories', () => { + expect(filterItvChannelsByGenre(CHANNELS, '404')).toEqual([]); + }); +}); diff --git a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-content-mappers.ts b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-content-mappers.ts index 937ce5920..359dcd5d1 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-content-mappers.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-content-mappers.ts @@ -61,6 +61,24 @@ export function toStalkerContentItem( }; } +/** + * Local category filter for fully cached ITV channel lists. + * Mirrors the portal-side `genre=` filter of `get_ordered_list`. + */ +export function filterItvChannelsByGenre( + channels: StalkerItvChannel[], + categoryId: string | null | undefined +): StalkerItvChannel[] { + if (!categoryId || categoryId === '*') { + return channels; + } + + const target = String(categoryId); + return channels.filter( + (channel) => String(channel.tv_genre_id ?? '') === target + ); +} + export function toStalkerItvChannel(item: StalkerContentItem): StalkerItvChannel { return { ...item, diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-itv-all-items.component.scss b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-itv-all-items.component.scss new file mode 100644 index 000000000..14fc6ebc1 --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-itv-all-items.component.scss @@ -0,0 +1,72 @@ +@use '../../../../../../ui/styles/panel-header' as panel; + +:host { + align-self: stretch; + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + overflow: hidden; + width: 100%; +} + +.category-content-header { + @include panel.standard-panel-header($sticky: true); +} + +.category-meta { + @include panel.standard-panel-meta(8px); +} + +.category-title { + @include panel.standard-panel-title(0.94rem); + color: var(--mat-sys-on-surface); +} + +.category-subtitle { + @include panel.standard-panel-subtitle( + $font-size: 0.72rem, + $padding: 2px 8px, + $color: var(--mat-sys-on-surface-variant), + $background: var(--mat-sys-surface-container) + ); +} + +.all-items-progress { + align-items: center; + color: var(--mat-sys-on-surface-variant); + display: inline-flex; + font-size: 0.72rem; + gap: 6px; + + &__count { + font-variant-numeric: tabular-nums; + opacity: 0.85; + } +} + +// Let the paginator inherit the panel header's tinted background instead of +// painting its own (mismatched) Material surface color. +mat-paginator { + background: transparent !important; + flex-shrink: 0; + --mat-paginator-container-size: 40px; + --mat-paginator-enabled-icon-color: var( + --app-body-color, + var(--mat-sys-on-surface-variant) + ); + --mat-paginator-disabled-icon-color: rgba(255, 255, 255, 0.18); +} + +app-grid-list.all-items-grid { + --cover-grid-min-width: var(--live-channel-grid-min-width, 148px); + + display: block; + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 16px 20px 20px; + scrollbar-width: thin; + scrollbar-color: color-mix(in srgb, var(--app-muted-color) 55%, transparent) + transparent; +} diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-itv-all-items.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-itv-all-items.component.spec.ts new file mode 100644 index 000000000..fa54f9531 --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-itv-all-items.component.spec.ts @@ -0,0 +1,123 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { StalkerItvChannel } from '@iptvnator/portal/stalker/data-access'; +import { StalkerItvAllItemsComponent } from './stalker-itv-all-items.component'; + +function buildChannels(count: number): StalkerItvChannel[] { + return Array.from({ length: count }, (_, index) => ({ + id: `ch-${index}`, + cmd: `ffrt4://itv/${index}`, + name: index === count - 1 ? 'Needle TV' : `Channel ${index}`, + o_name: index === count - 1 ? 'Needle TV' : `Channel ${index}`, + logo: `logo-${index}.png`, + is_series: null, + })); +} + +describe('StalkerItvAllItemsComponent', () => { + let fixture: ComponentFixture; + let component: StalkerItvAllItemsComponent; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + StalkerItvAllItemsComponent, + TranslateModule.forRoot(), + ], + }).compileComponents(); + + fixture = TestBed.createComponent(StalkerItvAllItemsComponent); + component = fixture.componentInstance; + }); + + it('renders the first client-side page of channels with a paginator', () => { + fixture.componentRef.setInput('channels', buildChannels(60)); + fixture.detectChanges(); + + expect(component.pagedGridItems()).toHaveLength(25); + expect(fixture.nativeElement.querySelectorAll('mat-card')).toHaveLength( + 25 + ); + expect(fixture.nativeElement.querySelector('mat-paginator')).toBeTruthy(); + expect( + fixture.nativeElement + .querySelector('.category-subtitle') + ?.textContent?.trim() + ).toContain('60'); + }); + + it('slices the next page on paginator change without touching the source', () => { + fixture.componentRef.setInput('channels', buildChannels(60)); + fixture.detectChanges(); + + component.onPageChange({ + pageIndex: 2, + pageSize: 25, + length: 60, + } as never); + fixture.detectChanges(); + + // Third page holds the remaining 10 channels. + expect(component.pagedGridItems()).toHaveLength(10); + expect(component.pagedGridItems()[0]['id']).toBe('ch-50'); + }); + + it('filters by the search term across ALL channels and resets to page one', () => { + fixture.componentRef.setInput('channels', buildChannels(60)); + fixture.detectChanges(); + component.onPageChange({ + pageIndex: 1, + pageSize: 25, + length: 60, + } as never); + + fixture.componentRef.setInput('searchTerm', 'needle'); + fixture.detectChanges(); + + expect(component.pageIndex()).toBe(0); + expect( + component.pagedGridItems().map((item) => item['name']) + ).toEqual(['Needle TV']); + }); + + it('maps the stalker logo to stream_icon and drops null is_series for the grid', () => { + fixture.componentRef.setInput('channels', buildChannels(1)); + fixture.detectChanges(); + + const [item] = component.pagedGridItems(); + expect(item['stream_icon']).toBe('logo-0.png'); + expect('is_series' in item).toBe(false); + }); + + it('emits channelActivated when a card is clicked', () => { + const activated = jest.fn(); + fixture.componentRef.setInput('channels', buildChannels(3)); + fixture.componentInstance.channelActivated.subscribe(activated); + fixture.detectChanges(); + + ( + fixture.nativeElement.querySelector('mat-card') as HTMLElement + ).click(); + + expect(activated).toHaveBeenCalledWith( + expect.objectContaining({ id: 'ch-0' }) + ); + }); + + it('shows skeletons and load progress while loading', () => { + fixture.componentRef.setInput('channels', []); + fixture.componentRef.setInput('loading', true); + fixture.componentRef.setInput('progress', { loaded: 140, total: 400 }); + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector('.grid-skeleton-card') + ).toBeTruthy(); + expect( + fixture.nativeElement + .querySelector('.all-items-progress') + ?.textContent?.replace(/\s+/g, '') + ).toContain('140/400'); + expect(fixture.nativeElement.querySelector('mat-paginator')).toBeNull(); + }); +}); diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-itv-all-items.component.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-itv-all-items.component.ts new file mode 100644 index 000000000..fc596bc1a --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-itv-all-items.component.ts @@ -0,0 +1,146 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + input, + linkedSignal, + output, + signal, +} from '@angular/core'; +import { MatPaginatorModule, PageEvent } from '@angular/material/paginator'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { TranslatePipe } from '@ngx-translate/core'; +import { GridListComponent } from '@iptvnator/portal/shared/ui'; +import { + StalkerItvChannel, + StalkerItvLoadProgress, +} from '@iptvnator/portal/stalker/data-access'; + +/** + * "All channels" grid shown in the Live TV main area before a category is + * selected — mirrors the Xtream live "All Items" view. Fed by the full ITV + * channel list cache; pagination is purely client-side so it never touches the + * store's legacy page state (which would re-fire portal requests). + */ +@Component({ + selector: 'app-stalker-itv-all-items', + imports: [ + GridListComponent, + MatPaginatorModule, + MatProgressSpinnerModule, + TranslatePipe, + ], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+
+

+ {{ 'PORTALS.ALL_CATEGORIES' | translate }} +

+ @if (loading()) { + + + @if (progress(); as loadProgress) { + + {{ loadProgress.loaded }}/{{ + loadProgress.total + }} + + } + + } @else if (filteredChannels().length > 0) { + {{ filteredChannels().length }} + {{ + (filteredChannels().length === 1 + ? 'PORTALS.ITEM' + : 'PORTALS.ITEMS' + ) | translate + }} + } +
+ @if (!loading() && filteredChannels().length > 0) { + + } +
+ + `, + styleUrl: './stalker-itv-all-items.component.scss', +}) +export class StalkerItvAllItemsComponent { + readonly channels = input([]); + readonly loading = input(false); + readonly progress = input(null); + readonly searchTerm = input(''); + + readonly channelActivated = output(); + + readonly pageSizeOptions = [10, 25, 50, 100]; + readonly pageSize = signal(25); + /** Resets to the first page whenever the source list or search changes. */ + readonly pageIndex = linkedSignal({ + source: () => ({ + term: this.searchTerm(), + channelCount: this.channels().length, + }), + computation: () => 0, + }); + + readonly filteredChannels = computed(() => { + const term = this.searchTerm().trim().toLowerCase(); + const channels = this.channels(); + if (!term) { + return channels; + } + + return channels.filter((channel) => + `${channel.o_name ?? ''} ${channel.name ?? ''}` + .toLowerCase() + .includes(term) + ); + }); + + /** The current page, mapped so GridListComponent can resolve the logo. */ + readonly pagedGridItems = computed(() => { + const start = this.pageIndex() * this.pageSize(); + return this.filteredChannels() + .slice(start, start + this.pageSize()) + .map((channel) => { + // GridListItem forbids null is_series; Stalker payloads may + // carry it — drop the nullish form (same as toPlayableChannel). + const { is_series, ...rest } = channel; + return { + ...rest, + ...(is_series == null ? {} : { is_series }), + stream_icon: channel.logo, + }; + }); + }); + + onPageChange(event: PageEvent): void { + this.pageSize.set(event.pageSize); + this.pageIndex.set(event.pageIndex); + } + + onItemClicked(item: unknown): void { + this.channelActivated.emit(item as StalkerItvChannel); + } +} diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html index a187c2db6..f9780a282 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html @@ -15,15 +15,30 @@

{{ selectedCategoryTitle() }}

- @if (visibleChannels().length > 0) { + @if (totalChannelCount() > 0) { {{ visibleChannels().length }} + >{{ totalChannelCount() }} {{ - visibleChannels().length === 1 ? 'item' : 'items' + (totalChannelCount() === 1 + ? 'PORTALS.ITEM' + : 'PORTALS.ITEMS' + ) | translate }} } + @if (isFullListMode()) { + + } } - @if (!stalkerStore.selectedCategoryId()) { - + @if (!stalkerStore.selectedItem() && !stalkerStore.selectedCategoryId()) { + @if (showItvAllItems()) { + + + } @else { + + } } @else if (stalkerStore.selectedItem()) { @if (streamUrl() && (isEmbeddedPlayer || isRadioMode())) {
diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.scss b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.scss index 57ccc3f1c..27fa099ec 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.scss +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.scss @@ -36,6 +36,36 @@ ); } +.full-list-progress { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + font-size: 0.72rem; + line-height: 1.2; + color: var(--mat-sys-on-surface-variant); + background: var(--mat-sys-surface-container); + border-bottom: 1px solid var(--app-separator); + + mat-spinner { + flex: 0 0 auto; + } + + &__label { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__count { + flex: 0 0 auto; + font-variant-numeric: tabular-nums; + opacity: 0.85; + } +} + .channels-list { flex: 1; overflow-y: auto; diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts index dac8cf39c..79a12e7a9 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts @@ -1,4 +1,11 @@ -import { Component, Directive, input, output, signal } from '@angular/core'; +import { + Component, + Directive, + EventEmitter, + input, + output, + signal, +} from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { MatSnackBar } from '@angular/material/snack-bar'; @@ -138,7 +145,7 @@ describe('StalkerLiveStreamLayoutComponent', () => { }); const selectedCategoryId = signal('1001'); const searchPhrase = signal(''); - const itvChannels = signal([ + const defaultItvChannels = () => [ { id: '10001', cmd: 'ffrt4://itv/10001', @@ -153,7 +160,8 @@ describe('StalkerLiveStreamLayoutComponent', () => { o_name: 'Beta TV', logo: 'beta.png', }, - ]); + ]; + const itvChannels = signal(defaultItvChannels()); const radioChannels = signal([ { id: 'radio-1', @@ -186,6 +194,17 @@ describe('StalkerLiveStreamLayoutComponent', () => { const isLoadingBulkItvEpg = signal(false); const hasMoreChannels = signal(false); const page = signal(0); + const itvFullListActive = signal(false); + const itvFullListLoading = signal(false); + const itvFullListProgress = signal<{ + loaded: number; + total: number; + } | null>(null); + const itvFullChannelList = signal< + ReturnType + >([]); + const itvSelectedCategoryFromCache = signal(false); + const isPaginatedContentLoading = signal(false); const stalkerStore = { getSelectedCategoryName: signal('News'), @@ -193,6 +212,14 @@ describe('StalkerLiveStreamLayoutComponent', () => { radioChannels, searchPhrase, hasMoreChannels, + itvFullListActive, + itvFullListLoading, + itvFullListProgress, + itvFullChannelList, + itvSelectedCategoryFromCache, + isPaginatedContentLoading, + preloadItvChannels: jest.fn(), + refreshItvChannels: jest.fn().mockResolvedValue(undefined), selectedItvId, currentPlaylist: playlist, selectedItvEpgPrograms, @@ -260,6 +287,7 @@ describe('StalkerLiveStreamLayoutComponent', () => { resolveItvPlayback = stalkerStore.resolveItvPlayback; resolveRadioPlayback = stalkerStore.resolveRadioPlayback; + itvChannels.set(defaultItvChannels()); selectedCategoryId.set('1001'); searchPhrase.set(''); stalkerStore.selectedContentType.set('itv'); @@ -273,6 +301,13 @@ describe('StalkerLiveStreamLayoutComponent', () => { isLoadingBulkItvEpg.set(false); hasMoreChannels.set(false); page.set(0); + itvFullListActive.set(false); + itvFullListLoading.set(false); + itvFullListProgress.set(null); + itvFullChannelList.set([]); + itvSelectedCategoryFromCache.set(false); + isPaginatedContentLoading.set(false); + stalkerStore.preloadItvChannels.mockClear(); localStorage.removeItem(LIVE_EPG_PANEL_STATE_STORAGE_KEY); settingsStore.openStreamOnDoubleClick.set(false); @@ -343,6 +378,13 @@ describe('StalkerLiveStreamLayoutComponent', () => { provide: TranslateService, useValue: { instant: jest.fn((value: string) => value), + // The real TranslatePipe (used by child components + // that aren't stubbed) needs these members too. + get: jest.fn((value: string) => of(value)), + stream: jest.fn((value: string) => of(value)), + onTranslationChange: new EventEmitter(), + onLangChange: new EventEmitter(), + onDefaultLangChange: new EventEmitter(), }, }, { @@ -645,6 +687,282 @@ describe('StalkerLiveStreamLayoutComponent', () => { expect(stalkerStore.setItvChannels).not.toHaveBeenCalled(); }); + it('windows the rendered list in full-list mode while search covers everything', () => { + const initialChannels = itvChannels(); + try { + const full = Array.from({ length: 250 }, (_, index) => ({ + id: `ch-${index}`, + cmd: `ffrt4://itv/${index}`, + name: index === 249 ? 'Needle TV' : `Channel ${index}`, + o_name: index === 249 ? 'Needle TV' : `Channel ${index}`, + logo: '', + })); + itvFullListActive.set(true); + itvSelectedCategoryFromCache.set(true); + itvChannels.set(full); + itvFullChannelList.set(full); + fixture.detectChanges(); + + // Only the first render window hits the DOM… + expect(component.visibleChannels()).toHaveLength(100); + // …but the header count reflects the whole list. + expect(component.totalChannelCount()).toBe(250); + expect(component.hasMoreItems()).toBe(true); + + stalkerStore.setPage.mockClear(); + component.loadMore(); + + // Scrolling extends the window from memory without portal paging. + expect(component.visibleChannels()).toHaveLength(200); + expect(stalkerStore.setPage).not.toHaveBeenCalled(); + + // Regression: search matches channels far beyond the first page. + searchPhrase.set('needle'); + fixture.detectChanges(); + expect( + component.visibleChannels().map((channel) => channel.name) + ).toEqual(['Needle TV']); + } finally { + itvChannels.set(initialChannels); + } + }); + + it('searches the whole portal (all categories) in full-list mode, not just the current category', () => { + // The current category (itvChannels) has only 'Alpha TV'/'Beta TV', but + // the portal's full list contains a News channel in another genre. + itvFullListActive.set(true); + itvFullChannelList.set([ + ...defaultItvChannels(), + { + id: '55', + cmd: 'ffrt4://itv/55', + name: 'CNN International', + o_name: 'CNN International', + logo: '', + }, + ]); + searchPhrase.set('cnn'); + fixture.detectChanges(); + + expect( + component.filteredChannels().map((channel) => channel.name) + ).toEqual(['CNN International']); + }); + + it('includes paged censored-category channels in full-list search results', () => { + // The adult category's channels come from the legacy paged flow and + // are intentionally absent from the full-list cache — searching for a + // currently visible channel must still find it (merged source). + itvFullListActive.set(true); + itvSelectedCategoryFromCache.set(false); + itvChannels.set([ + { + id: 'adult-1', + cmd: 'ffrt4://itv/adult-1', + name: 'Erox HD', + o_name: 'Erox HD', + logo: '', + }, + ]); + itvFullChannelList.set(defaultItvChannels()); + searchPhrase.set('erox'); + fixture.detectChanges(); + + expect( + component.filteredChannels().map((channel) => channel.name) + ).toEqual(['Erox HD']); + + // And the cached portal-wide channels remain searchable too. + searchPhrase.set('alpha'); + fixture.detectChanges(); + expect( + component.filteredChannels().map((channel) => channel.name) + ).toEqual(['Alpha TV']); + }); + + it('grows the render window to include a channel selected beyond it (remote/numeric nav)', async () => { + const full = Array.from({ length: 250 }, (_, index) => ({ + id: `ch-${index}`, + cmd: `ffrt4://itv/${index}`, + name: `Channel ${index}`, + o_name: `Channel ${index}`, + logo: '', + })); + itvFullListActive.set(true); + itvSelectedCategoryFromCache.set(true); + itvChannels.set(full); + itvFullChannelList.set(full); + fixture.detectChanges(); + + expect(component.visibleChannels()).toHaveLength(100); + + // Selecting channel index 150 (outside the 100-item window) must grow + // the window so it is rendered and can be highlighted/scrolled to. + await component.playChannel(full[150], false); + fixture.detectChanges(); + + expect(component.visibleChannels().length).toBeGreaterThan(150); + expect( + component + .visibleChannels() + .some((channel) => channel.id === 'ch-150') + ).toBe(true); + }); + + it('does not clear cached channels when switching category in full-list mode', async () => { + // Regression: the category-change reset effect used to setItvChannels([]) + // unconditionally, clobbering the list the content loader had just + // served synchronously from the full-list cache — leaving the sidebar + // stuck on an infinite skeleton for every category after the first. + itvFullListActive.set(true); + itvSelectedCategoryFromCache.set(true); + fixture.detectChanges(); + await fixture.whenStable(); + + stalkerStore.setItvChannels.mockClear(); + selectedCategoryId.set('42'); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(stalkerStore.setItvChannels).not.toHaveBeenCalled(); + }); + + it('still clears channels on category change when the full list is not active', async () => { + itvFullListActive.set(false); + fixture.detectChanges(); + await fixture.whenStable(); + + stalkerStore.setItvChannels.mockClear(); + selectedCategoryId.set('99'); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(stalkerStore.setItvChannels).toHaveBeenCalledWith([]); + }); + + it('shows an empty state (not a skeleton) for a loaded but empty category', () => { + // Full list is loaded, nothing is loading, and the selected category + // filters down to zero channels — this must read as "empty", not "stuck". + itvFullListActive.set(true); + itvFullListLoading.set(false); + isPaginatedContentLoading.set(false); + itvChannels.set([]); + searchPhrase.set(''); + fixture.detectChanges(); + + expect(component.isInitialChannelsLoading()).toBe(false); + expect(component.isCategoryEmpty()).toBe(true); + expect( + fixture.nativeElement.querySelector('app-channel-list-skeleton') + ).toBeNull(); + expect( + fixture.nativeElement.querySelector( + '.empty-search-state app-portal-empty-state' + ) + ).toBeTruthy(); + }); + + it('shows the skeleton only while a full-list load is genuinely in flight', () => { + itvChannels.set([]); + searchPhrase.set(''); + itvFullListActive.set(false); + isPaginatedContentLoading.set(false); + + itvFullListLoading.set(true); + fixture.detectChanges(); + expect(component.isInitialChannelsLoading()).toBe(true); + + itvFullListLoading.set(false); + fixture.detectChanges(); + expect(component.isInitialChannelsLoading()).toBe(false); + }); + + it('shows the skeleton while the legacy paged fetch is loading', () => { + itvChannels.set([]); + searchPhrase.set(''); + itvFullListActive.set(false); + itvFullListLoading.set(false); + + isPaginatedContentLoading.set(true); + fixture.detectChanges(); + expect(component.isInitialChannelsLoading()).toBe(true); + }); + + it('keeps portal pagination for a censored category absent from the cache', async () => { + // Full list IS active, but the selected (adult) genre has no cached + // channels — infinite scroll must request the next portal page instead + // of only growing the client-side render window. + itvFullListActive.set(true); + itvSelectedCategoryFromCache.set(false); + hasMoreChannels.set(true); + fixture.detectChanges(); + await fixture.whenStable(); + + page.set(0); + stalkerStore.setPage.mockClear(); + component.loadMore(); + + expect(stalkerStore.setPage).toHaveBeenCalledWith(1); + }); + + it('preloads the full channel list as soon as the Live TV section is entered', () => { + // No category selected — the preload must not wait for a category click. + selectedCategoryId.set(null); + fixture.detectChanges(); + + expect(stalkerStore.preloadItvChannels).toHaveBeenCalled(); + }); + + it('shows the all-channels grid instead of the placeholder when the full list is available', () => { + selectedCategoryId.set(null); + selectedItem.set(null); + itvFullListActive.set(true); + itvFullChannelList.set(defaultItvChannels()); + fixture.detectChanges(); + + const grid = fixture.nativeElement.querySelector( + 'app-stalker-itv-all-items' + ); + expect(grid).toBeTruthy(); + expect(grid.querySelectorAll('mat-card')).toHaveLength(2); + expect( + fixture.nativeElement.querySelector('app-portal-empty-state') + ).toBeNull(); + }); + + it('keeps the select-a-category placeholder on portals without a usable full list', () => { + selectedCategoryId.set(null); + selectedItem.set(null); + itvFullListActive.set(false); + itvFullListLoading.set(false); + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector('app-stalker-itv-all-items') + ).toBeNull(); + expect( + fixture.nativeElement.querySelector('app-portal-empty-state') + ).toBeTruthy(); + }); + + it('shows the refresh button in full-list mode and delegates to the store', () => { + itvFullListActive.set(true); + fixture.detectChanges(); + + const buttons = Array.from( + fixture.nativeElement.querySelectorAll( + '.category-content-header button' + ) + ) as HTMLButtonElement[]; + const refreshButton = buttons.find((button) => + button.textContent?.includes('refresh') + ); + + expect(refreshButton).toBeTruthy(); + refreshButton?.click(); + expect(stalkerStore.refreshItvChannels).toHaveBeenCalled(); + }); + it('persists the channels sidebar width under a dedicated storage key', () => { // The shell context panel (category sidebar) is visible at the same // time as this sidebar and persists its width under the shared @@ -659,12 +977,38 @@ describe('StalkerLiveStreamLayoutComponent', () => { ); }); - it('keeps row previews empty before bulk epg is loaded', async () => { + async function settleEagerEpg(): Promise { + for (let i = 0; i < 5; i += 1) { + fixture.detectChanges(); + await fixture.whenStable(); + await new Promise((resolve) => setTimeout(resolve)); + } + fixture.detectChanges(); + } + + it('loads bulk EPG and shows row previews on category entry, before any playback', async () => { + await settleEagerEpg(); + + // The list EPG previews appear as soon as the category's channels are + // shown — the user no longer has to play a channel first. + expect(ensureBulkItvEpg).toHaveBeenCalledWith(168); + expect(component.epgPreviewPrograms.get('10001')?.title).toBe( + 'Current Show' + ); + expect(component.epgPreviewPrograms.get('10002')?.title).toBe( + 'Next Channel Show' + ); + // The per-channel fallback is reserved for the playing channel. + expect(fetchChannelEpg).not.toHaveBeenCalled(); + }); + + it('does not load bulk EPG for radio (no EPG data)', async () => { + stalkerStore.selectedContentType.set('radio'); + selectedCategoryId.set('radio-all'); + selectedItem.set(null); fixture.detectChanges(); await fixture.whenStable(); - expect(component.epgPreviewPrograms.size).toBe(0); - expect(fetchChannelEpg).not.toHaveBeenCalled(); expect(ensureBulkItvEpg).not.toHaveBeenCalled(); }); @@ -684,16 +1028,18 @@ describe('StalkerLiveStreamLayoutComponent', () => { expect(fetchChannelEpg).not.toHaveBeenCalled(); }); - it('ensures bulk EPG only once across channel switches for the same playlist', async () => { - fixture.detectChanges(); + it('does not re-fetch bulk EPG when switching channels once it is loaded', async () => { + await settleEagerEpg(); + // Bulk EPG has loaded (eagerly, on entry). + ensureBulkItvEpg.mockClear(); await component.playChannel(itvChannels()[0]); await fixture.whenStable(); await component.playChannel(itvChannels()[1]); await fixture.whenStable(); - expect(ensureBulkItvEpg).toHaveBeenCalledTimes(1); - expect(ensureBulkItvEpg).toHaveBeenCalledWith(168); + // Playing/switching channels reuses the cached bulk EPG. + expect(ensureBulkItvEpg).not.toHaveBeenCalled(); }); it('renders inline audio playback and no EPG for radio stations', async () => { diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts index 02e33442d..3ccbe97a2 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts @@ -8,6 +8,7 @@ import { effect, ElementRef, inject, + linkedSignal, OnDestroy, signal, untracked, @@ -73,12 +74,16 @@ import { StalkerStore, normalizeStalkerEntityId, } from '@iptvnator/portal/stalker/data-access'; +import { StalkerItvAllItemsComponent } from './stalker-itv-all-items.component'; type StalkerPlayableChannel = StalkerPortalItem & { cmd?: string; has_files?: unknown; }; +/** Channels rendered per "page" when the full list is served from the cache. */ +const FULL_LIST_RENDER_CHUNK = 100; + @Component({ selector: 'app-stalker-live-stream-layout', templateUrl: './stalker-live-stream-layout.component.html', @@ -97,6 +102,7 @@ type StalkerPlayableChannel = StalkerPortalItem & { NgTemplateOutlet, PortalEmptyStateComponent, ResizableDirective, + StalkerItvAllItemsComponent, TranslatePipe, WebPlayerViewComponent, ], @@ -130,27 +136,115 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { readonly searchTerm = computed(() => this.stalkerStore.searchPhrase().trim().toLowerCase() ); - readonly visibleChannels = computed(() => { - const channels = this.channels(); + /** Full-list mode: the complete channel list is cached, so search covers everything. */ + readonly isFullListMode = computed( + () => !this.isRadioMode() && this.stalkerStore.itvFullListActive() + ); + /** + * True when the CURRENT category is actually served from the cache. + * Censored (adult) genres are excluded from `get_all_channels` on most + * portals, so they stay on the legacy paged flow (portal pagination, + * infinite scroll) even while the full-list cache is active. + */ + readonly isCategoryFromCache = computed( + () => + !this.isRadioMode() && + this.stalkerStore.itvSelectedCategoryFromCache() + ); + /** + * Channels matching the search phrase. Without a term, the current + * category. With a term in full-list mode, the WHOLE portal's channel list + * (every category) so search behaves like "search all channels" — merged + * with the currently loaded channels, because a censored (adult) category + * is paged from the portal and its channels are intentionally absent from + * the full-list cache. Otherwise the loaded channels of the current + * category. + */ + readonly filteredChannels = computed(() => { const term = this.searchTerm(); - if (!term) { - return channels; + return this.channels(); } - return channels.filter((item) => + let source = this.channels(); + if (this.isFullListMode()) { + const merged = new Map(); + for (const channel of source) { + merged.set(normalizeStalkerEntityId(channel.id), channel); + } + for (const channel of this.stalkerStore.itvFullChannelList()) { + const id = normalizeStalkerEntityId(channel.id); + if (!merged.has(id)) { + merged.set(id, channel); + } + } + source = [...merged.values()]; + } + + return source.filter((item) => `${item.o_name ?? ''} ${item.name ?? ''}` .toLowerCase() .includes(term) ); }); - readonly hasMoreItems = this.stalkerStore.hasMoreChannels; + readonly isFullListLoading = computed( + () => !this.isRadioMode() && this.stalkerStore.itvFullListLoading() + ); + readonly fullListProgress = this.stalkerStore.itvFullListProgress; + readonly itvFullChannelList = this.stalkerStore.itvFullChannelList; + /** + * All-channels grid in the main area when no category is selected yet + * (Xtream "All Items" parity). Falls back to the "select a category" + * placeholder on portals without a usable full list. + */ + readonly showItvAllItems = computed( + () => + !this.isRadioMode() && + !this.stalkerStore.selectedCategoryId() && + (this.stalkerStore.itvFullListActive() || + this.stalkerStore.itvFullListLoading()) + ); + /** Windowed render limit keeps the DOM bounded for multi-thousand channel lists. */ + private readonly renderLimit = linkedSignal({ + source: () => ({ + term: this.searchTerm(), + category: this.stalkerStore.selectedCategoryId(), + contentType: this.stalkerStore.selectedContentType(), + }), + computation: () => FULL_LIST_RENDER_CHUNK, + }); + readonly visibleChannels = computed(() => + this.isCategoryFromCache() + ? this.filteredChannels().slice(0, this.renderLimit()) + : this.filteredChannels() + ); + readonly totalChannelCount = computed(() => this.filteredChannels().length); + readonly hasMoreItems = computed(() => + this.isCategoryFromCache() + ? this.visibleChannels().length < this.filteredChannels().length + : this.stalkerStore.hasMoreChannels() + ); readonly isLoadingMore = signal(false); + /** + * Skeleton shows only while a load is genuinely in flight. An empty result + * once loading has settled is an empty category, not a stuck spinner — the + * full-list cache can legitimately filter a genre down to zero channels. + */ readonly isInitialChannelsLoading = computed( () => !!this.stalkerStore.selectedCategoryId() && this.channels().length === 0 && - !this.searchTerm() + !this.searchTerm() && + (this.isFullListLoading() || + this.stalkerStore.isPaginatedContentLoading()) + ); + /** Category is loaded but has no channels (and the user isn't searching). */ + readonly isCategoryEmpty = computed( + () => + !!this.stalkerStore.selectedCategoryId() && + !this.searchTerm() && + this.channels().length === 0 && + !this.isInitialChannelsLoading() ); readonly selectedChannelId = this.stalkerStore.selectedItvId; @@ -291,14 +385,33 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { }); } - // Reset channels/page on category change + // Start the full ITV channel list load as soon as the Live TV section + // is entered (not on the first category click), so the all-channels + // grid and the category count badges are available immediately. + effect(() => { + const contentType = this.stalkerStore.selectedContentType(); + const playlist = this.stalkerStore.currentPlaylist(); + if (contentType === 'itv' && playlist) { + untracked(() => this.stalkerStore.preloadItvChannels()); + } + }); + + // Reset channels/page on category change. When the new category is + // served from the cache, the content loader re-serves the filtered + // list synchronously, so clearing here would just clobber it (the + // reset effect runs after the store resource) and leave the list stuck + // empty. Categories on the legacy paged flow — cold cache AND censored + // genres missing from the cache — still clear to avoid flashing the + // previous category's channels during the async fetch. effect(() => { const contentType = this.stalkerStore.selectedContentType(); this.stalkerStore.selectedCategoryId(); untracked(() => { if (contentType === 'radio') { this.stalkerStore.setRadioChannels([]); - } else { + } else if ( + !this.stalkerStore.itvSelectedCategoryFromCache() + ) { this.stalkerStore.setItvChannels([]); } this.stalkerStore.setPage(0); @@ -350,6 +463,28 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { this.stalkerStore.clearBulkItvEpgCache(); }); + // Load the bulk ITV EPG as soon as a category's channels are available + // — not only after the first channel is played — so the per-channel + // "now playing" previews in the list and the EPG panel populate + // immediately. Registered AFTER the playlist-change effect above so a + // portal switch clears the stale cache first and this then refills it; + // ensureBulkItvEpg de-duplicates and reuses the cache, so this is safe + // to fire on every channel-list change. + effect(() => { + const hasChannels = this.itvChannels().length > 0; + const playlistId = this.stalkerStore.currentPlaylist()?._id; + if ( + this.isRadioMode() || + !this.supportsEpg || + !hasChannels || + !playlistId + ) { + return; + } + + untracked(() => void this.stalkerStore.ensureBulkItvEpg(168)); + }); + // Setup scroll listener when container becomes available effect(() => { const container = this.scrollContainer(); @@ -366,7 +501,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { const selectedItem = this.stalkerStore.selectedItem(); const selectedType = this.stalkerStore.selectedContentType(); - const channels = this.visibleChannels(); + const channels = this.filteredChannels(); if (selectedType !== 'itv' || !selectedItem?.id) { remoteControl.updateRemoteControlStatus({ @@ -436,6 +571,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { const requestId = ++this.playbackRequestId; const channelId = normalizeStalkerEntityId(item.id); this.stalkerStore.setSelectedItem(item); + this.ensureChannelWithinRenderWindow(channelId); try { const isRadioMode = this.isRadioMode(); @@ -525,13 +661,53 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { } } + /** + * In full-list mode the rendered list is windowed to `renderLimit`. When a + * channel beyond that window is selected (remote channel-up/down, numeric + * select), grow the window so the selection is actually in the DOM and can + * be highlighted/scrolled to instead of drifting off-window. + */ + private ensureChannelWithinRenderWindow(channelId: string): void { + if (!this.isCategoryFromCache()) { + return; + } + + const index = this.filteredChannels().findIndex( + (item) => normalizeStalkerEntityId(item.id) === channelId + ); + if (index < 0 || index < this.renderLimit()) { + return; + } + + const needed = + Math.ceil((index + 1) / FULL_LIST_RENDER_CHUNK) * + FULL_LIST_RENDER_CHUNK; + this.renderLimit.set(Math.max(this.renderLimit(), needed)); + } + loadMore() { + if (this.isCategoryFromCache()) { + // Extends the render window over the in-memory list — no request. + if (this.hasMoreItems()) { + this.renderLimit.update( + (limit) => limit + FULL_LIST_RENDER_CHUNK + ); + } + return; + } + + // Legacy portal pagination — also used for censored (adult) genres + // that are absent from the full-list cache. if (this.isLoadingMore() || !this.hasMoreItems()) return; this.isLoadingMore.set(true); const nextPage = this.stalkerStore.page() + 1; this.stalkerStore.setPage(nextPage); } + refreshChannels(): void { + void this.stalkerStore.refreshItvChannels(); + } + onLiveEpgPanelCollapsedChange(collapsed: boolean): void { const state: LiveEpgPanelState = collapsed ? 'collapsed' : 'expanded'; this.liveEpgPanelState.set(state); @@ -927,7 +1103,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { return; } - const channels = this.visibleChannels(); + const channels = this.filteredChannels(); const nextItem = getAdjacentChannelItem( channels, activeItem.id, @@ -955,7 +1131,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { } const channel = getChannelItemByNumber( - this.visibleChannels(), + this.filteredChannels(), command.number ); if (!channel) { diff --git a/libs/shared/interfaces/src/lib/stalker-portal-actions.enum.ts b/libs/shared/interfaces/src/lib/stalker-portal-actions.enum.ts index 73d5a2f60..ef582acf4 100644 --- a/libs/shared/interfaces/src/lib/stalker-portal-actions.enum.ts +++ b/libs/shared/interfaces/src/lib/stalker-portal-actions.enum.ts @@ -3,6 +3,7 @@ export enum StalkerPortalActions { GetGenres = 'get_genres', CreateLink = 'create_link', GetOrderedList = 'get_ordered_list', + GetAllChannels = 'get_all_channels', Favorites = 'favorites', Handshake = 'handshake', DoAuth = 'do_auth', diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/components/workspace-context-category-view.component.html b/libs/workspace/shell/feature/src/lib/workspace-context-panel/components/workspace-context-category-view.component.html index 64a70da92..7dde143f2 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/components/workspace-context-category-view.component.html +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/components/workspace-context-category-view.component.html @@ -33,7 +33,7 @@ class="item-count item-count--loading" aria-hidden="true" >
- } @else { + } @else if (hasItemCount(item)) {
{{ getItemCount(item) }}
diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/components/workspace-context-category-view.component.ts b/libs/workspace/shell/feature/src/lib/workspace-context-panel/components/workspace-context-category-view.component.ts index 61fe42a58..676e399d0 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/components/workspace-context-category-view.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/components/workspace-context-category-view.component.ts @@ -32,6 +32,12 @@ export class WorkspaceContextCategoryViewComponent { readonly itemCounts = input>(new Map()); readonly showCounts = input(false); readonly countDisplayMode = input<'loading' | 'ready'>('ready'); + /** + * When true, items without an entry in `itemCounts` render no badge at + * all instead of "0" — used for Stalker censored (adult) genres whose + * real channel count is unknown to the full-list cache. + */ + readonly omitMissingCounts = input(false); readonly interactionEnabled = input(true); readonly statusText = input(''); @@ -93,6 +99,15 @@ export class WorkspaceContextCategoryViewComponent { return this.itemCounts().get(itemId) ?? 0; } + hasItemCount(item: WorkspaceCategoryViewItem): boolean { + if (!this.omitMissingCounts()) { + return true; + } + + // NaN (from the "*" all-category id) is a valid Map key here. + return this.itemCounts().has(Number(item.id ?? item.category_id)); + } + onCategoryClick(item: WorkspaceCategoryViewItem): void { if (!this.interactionEnabled()) { return; diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.html b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.html index c812c82d5..39eefd3e7 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.html +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.html @@ -166,6 +166,10 @@ } diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.spec.ts index 86bde639c..0aa9e3457 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.spec.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.spec.ts @@ -94,6 +94,9 @@ describe('WorkspaceContextPanelComponent', () => { selectedCategoryId: signal(null), isCategoryResourceLoading: signal(false), isCategoryResourceFailed: signal(false), + itvFullListActive: signal(false), + itvFullListLoading: signal(false), + itvCategoryItemCounts: signal>(new Map()), setSelectedCategory: jest.fn(), setPage: jest.fn(), clearSelectedItem: jest.fn(), @@ -398,6 +401,88 @@ describe('WorkspaceContextPanelComponent', () => { ]); }); + it('shows per-genre count badges on stalker Live TV categories when the full list is cached', () => { + fixture.componentRef.setInput('context', { + provider: 'stalker', + playlistId: 'stalker-1', + }); + fixture.componentRef.setInput('section', 'itv'); + stalkerStore.getCategoryResource.set([ + { category_id: '*', category_name: 'All' }, + { category_id: '1', category_name: 'Documentary' }, + { category_id: '2', category_name: 'Sports' }, + ]); + stalkerStore.itvFullListActive.set(true); + stalkerStore.itvCategoryItemCounts.set( + new Map([ + [1, 190], + [2, 38], + [Number.NaN, 228], + ]) + ); + fixture.detectChanges(); + + const counts = Array.from( + fixture.nativeElement.querySelectorAll('.category-item .item-count') + ).map((el) => (el as HTMLElement).textContent?.trim()); + // "All" (NaN key → total), Documentary, Sports. + expect(counts).toEqual(['228', '190', '38']); + }); + + it('omits the badge for censored stalker genres missing from the count map', () => { + fixture.componentRef.setInput('context', { + provider: 'stalker', + playlistId: 'stalker-1', + }); + fixture.componentRef.setInput('section', 'itv'); + stalkerStore.getCategoryResource.set([ + { category_id: '1', category_name: 'Documentary' }, + { + category_id: '19', + category_name: 'For adults', + censored: true, + } as never, + ]); + stalkerStore.itvFullListActive.set(true); + // Censored genre 19 has no entry — its real count is unknown. + stalkerStore.itvCategoryItemCounts.set( + new Map([[1, 190]]) + ); + fixture.detectChanges(); + + const categoryButtons = Array.from( + fixture.nativeElement.querySelectorAll('.category-item') + ) as HTMLElement[]; + const documentary = categoryButtons.find((el) => + el.textContent?.includes('Documentary') + ); + const adults = categoryButtons.find((el) => + el.textContent?.includes('For adults') + ); + + expect(documentary?.querySelector('.item-count')?.textContent).toContain( + '190' + ); + expect(adults?.querySelector('.item-count')).toBeNull(); + }); + + it('hides count badges for stalker categories without a cached full list (e.g. VOD)', () => { + fixture.componentRef.setInput('context', { + provider: 'stalker', + playlistId: 'stalker-1', + }); + fixture.componentRef.setInput('section', 'vod'); + stalkerStore.getCategoryResource.set([ + { category_id: '1', category_name: 'Action' }, + ]); + stalkerStore.itvFullListActive.set(false); + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector('.category-item .item-count') + ).toBeNull(); + }); + it('preserves the active xtream live item when switching live categories', () => { fixture.componentRef.setInput('section', 'live'); xtreamSelectedTypeContentState.set('ready'); diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts index 0ff997510..5ac3d3035 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts @@ -144,6 +144,21 @@ export class WorkspaceContextPanelComponent { this.stalkerStore.isCategoryResourceLoading; readonly isStalkerCategoryFailed = this.stalkerStore.isCategoryResourceFailed; + // Category count badges are only available for Stalker Live TV, where the + // full channel list is cached — VOD/series/radio still page lazily, so + // their per-category totals are unknown. + readonly stalkerCategoryItemCounts = + this.stalkerStore.itvCategoryItemCounts; + readonly stalkerShowCounts = computed( + () => + this.isStalkerCategories() && + this.section() === 'itv' && + (this.stalkerStore.itvFullListActive() || + this.stalkerStore.itvFullListLoading()) + ); + readonly stalkerCountDisplayMode = computed<'loading' | 'ready'>(() => + this.stalkerStore.itvFullListActive() ? 'ready' : 'loading' + ); readonly skeletonRows = Array.from({ length: 14 }, (_, index) => index); readonly skeletonLabelWidths = [ 78, 66, 74, 59, 83, 69, 76, 62, 81, 64, 72, 67, 79, 61, diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-search.service.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-search.service.ts index a45a1ed3c..5d374dc15 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-search.service.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-search.service.ts @@ -123,11 +123,15 @@ export class WorkspaceShellSearchService { }; } - const isDegradedStalkerItv = + // Stalker live search is "loaded only" (limited to fetched pages) + // unless the full ITV channel list is cached. ITV with the full list + // active covers everything; radio always pages, so it stays degraded. + const isDegradedStalkerLive = context?.provider === 'stalker' && - section === 'itv' && - appliedQuery.length > 0; - const behavior = isDegradedStalkerItv + appliedQuery.length > 0 && + ((section === 'itv' && !this.stalkerStore.itvFullListActive()) || + section === 'radio'); + const behavior = isDegradedStalkerLive ? 'degraded-loaded-only' : route.searchMode; @@ -151,7 +155,7 @@ export class WorkspaceShellSearchService { stalkerCategoryName: this.stalkerStore.getSelectedCategoryName(), }), - statusLabel: isDegradedStalkerItv + statusLabel: isDegradedStalkerLive ? this.translateText(SEARCH_LOADED_ONLY_STATUS) : '', minLength: route.searchMode === 'remote-search' ? 3 : 1, diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts index 32aad8089..06585d642 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts @@ -72,6 +72,7 @@ class MockXtreamStore { class MockStalkerStore { readonly searchPhrase = signal(''); readonly getSelectedCategoryName = signal('All Items'); + readonly itvFullListActive = signal(false); setSearchPhrase = jest.fn((term: string) => this.searchPhrase.set(term)); } @@ -594,7 +595,17 @@ describe('WorkspaceShellFacade', () => { ); }); - it('treats stalker radio search as a remote section search', () => { + it('drops the loaded-only status once the full ITV channel list is cached', () => { + stalkerStore.itvFullListActive.set(true); + facade.currentUrl.set('/workspace/stalker/pl-1/itv?q=cnn'); + searchSync.syncSearchFromRoute(); + TestBed.flushEffects(); + + expect(facade.canUseSearch()).toBe(true); + expect(facade.searchStatusLabel()).toBe(''); + }); + + it('marks stalker radio search as loaded-only (radio always pages)', () => { facade.currentUrl.set('/workspace/stalker/pl-1/radio?q=jazz'); searchSync.syncSearchFromRoute(); TestBed.flushEffects(); @@ -604,7 +615,11 @@ describe('WorkspaceShellFacade', () => { expect(facade.searchScopeLabel()).toBe( 'WORKSPACE.SHELL.RAIL_RADIO / All Items' ); - expect(facade.searchStatusLabel()).toBe(''); + // Radio has no full-list cache, so its local search only covers loaded + // stations — surface the same "loaded only" hint as degraded ITV. + expect(facade.searchStatusLabel()).toBe( + 'WORKSPACE.SHELL.SEARCH_STATUS_LOADED_ONLY' + ); }); it('applies q to Xtream category search on vod routes', () => {