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

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

No files matched your search

@@ -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<void> {
await expect(
page.locator('app-workspace-shell-header .search-chip--status')
).toHaveCount(0);
}
async function startPortalDebugCapture(page: Page): Promise<void> {
await page.evaluate(() => {
const electronApi = window.electron as typeof window.electron & {
+9 -13
View File
@@ -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<string, string | number>;
};
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();
+2
View File
@@ -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 |
| `<any other MAC>` | **auto** | MAC bytes used as seed → deterministic unique dataset |
## Configuration
@@ -65,6 +66,7 @@ All endpoints are served at `GET /portal.php?action=<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`) |
@@ -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);
@@ -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,
},
});
}
@@ -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 });
@@ -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) ?? [];
@@ -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;
@@ -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<string, ScenarioConfig> = {
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,
},
};
/**
+4 -1
View File
@@ -5,10 +5,13 @@ interface ProviderTargetPayload {
}
const BACKEND_ORIGIN = 'http://localhost:3000';
const APP_ORIGIN = process.env['BASE_URL']
? new URL(process.env['BASE_URL']).origin
: 'http://localhost:4200';
const CORS_HEADERS = {
'access-control-allow-headers': 'content-type',
'access-control-allow-methods': 'POST, OPTIONS',
'access-control-allow-origin': 'http://localhost:4200',
'access-control-allow-origin': APP_ORIGIN,
} as const;
export async function interceptProviderTargetRegistration(
+174
View File
@@ -35,6 +35,9 @@ const DEFAULT_MAC = '00:1A:79:00:00:01';
/** Minimal scenario MAC — 2 categories, 5 items (edge case testing) */
const MINIMAL_MAC = '00:1A:79:00:00:03';
/** Legacy pagination MAC — portal without get_all_channels support */
const LEGACY_PAGINATION_MAC = '00:1A:79:00:00:06';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
@@ -333,6 +336,177 @@ test('@stalker PWA skips bulk EPG across channel switches', async ({
expect(shortEpgRequests).toHaveLength(0);
});
test('@stalker ITV full channel list loads via get_all_channels and search covers it', async ({
page,
}) => {
await addStalkerPortal(page);
const allChannelsRequests: string[] = [];
page.on('request', (request) => {
if (request.url().includes('action=get_all_channels')) {
allChannelsRequests.push(request.url());
}
});
await page.getByRole('link', { name: /live|itv/i }).click();
await page.waitForURL(/stalker.*itv/);
const categories = page.locator('.category-item');
await expect(categories.nth(1)).toBeVisible({ timeout: 10_000 });
// BEFORE any category click (Xtream parity): entering the Live TV section
// preloads the full list, so the main area shows the paginated
// all-channels grid and the categories already carry count badges.
const allItemsGrid = page.locator('app-stalker-itv-all-items');
await expect(allItemsGrid.locator('mat-card').first()).toBeVisible({
timeout: 20_000,
});
await expect(
allItemsGrid.locator('.mat-mdc-paginator-range-label')
).toContainText('of 320');
await expect(categories.nth(0).locator('.item-count')).toHaveText('320', {
timeout: 10_000,
});
await expect(categories.nth(1).locator('.item-count')).toHaveText('40');
await categories.nth(1).click();
const channels = page.locator('[data-test-id="channel-item"]');
await expect(channels.first()).toBeVisible({ timeout: 20_000 });
// Regression for "search only finds the first 14 loaded items": once the
// full list is cached, the whole category (40 channels) is available
// without scrolling through 14-item pages.
await expect(page.locator('.category-subtitle')).toHaveText('40 items', {
timeout: 20_000,
});
await expect.poll(() => allChannelsRequests.length).toBeGreaterThan(0);
// Regression: switching to another category once the full list is cached
// must serve that category from the cache, not get stuck on an empty
// skeleton. (The reset-on-category-change effect used to clobber the
// synchronously served list.)
await categories.nth(2).click();
await expect(page.locator('.category-subtitle')).toHaveText('40 items', {
timeout: 20_000,
});
await expect(channels.first()).toBeVisible({ timeout: 10_000 });
// No further get_all_channels request — it's served from the session cache.
const requestsAfterFirstCategory = allChannelsRequests.length;
await categories.nth(3).click();
await expect(channels.first()).toBeVisible({ timeout: 10_000 });
expect(allChannelsRequests.length).toBe(requestsAfterFirstCategory);
// Back to the first category for the search assertions below.
await categories.nth(1).click();
await expect(page.locator('.category-subtitle')).toHaveText('40 items', {
timeout: 20_000,
});
// Search a channel from deep in the list (beyond the first 14 items).
const deepChannelName = (
await channels.nth(30).locator('.channel-name').textContent()
)?.trim();
expect(deepChannelName).toBeTruthy();
const searchInput = page.locator('input[type="search"]');
await searchInput.fill(deepChannelName as string);
await searchInput.press('Enter');
await expect(
page
.locator('[data-test-id="channel-item"] .channel-name')
.filter({ hasText: deepChannelName as string })
.first()
).toBeVisible({ timeout: 10_000 });
// The "loaded only" degraded-search hint must be gone in full-list mode.
await expect(page.locator('.search-chip--status')).toHaveCount(0);
});
test('@stalker ITV censored category pages from the portal and hides its badge', async ({
page,
}) => {
await addStalkerPortal(page);
const adultListRequests: string[] = [];
page.on('request', (request) => {
const url = request.url();
if (
url.includes('action=get_ordered_list') &&
url.includes('type=itv') &&
url.includes('genre=1099')
) {
adultListRequests.push(url);
}
});
await page.getByRole('link', { name: /live|itv/i }).click();
await page.waitForURL(/stalker.*itv/);
const categories = page.locator('.category-item');
// Wait until the full list is cached (a regular category shows its badge).
await expect(categories.nth(1).locator('.item-count')).toHaveText('40', {
timeout: 20_000,
});
// The censored genre is excluded from get_all_channels, so its real count
// is unknown — no badge instead of a misleading "0".
const adultCategory = page.locator('.category-item', {
hasText: 'For adults',
});
await expect(adultCategory).toBeVisible();
await expect(adultCategory.locator('.item-count')).toHaveCount(0);
// Clicking it falls back to the legacy paged flow and still shows channels.
await adultCategory.click();
const channels = page.locator('[data-test-id="channel-item"]');
await expect(channels.first()).toBeVisible({ timeout: 20_000 });
await expect.poll(() => adultListRequests.length).toBeGreaterThan(0);
});
test('@stalker ITV falls back to page crawling on portals without get_all_channels', async ({
page,
}) => {
await addStalkerPortal(page, {
name: 'Legacy Stalker Portal',
mac: LEGACY_PAGINATION_MAC,
});
const allChannelsRequests: string[] = [];
const crawlRequests: string[] = [];
page.on('request', (request) => {
const url = request.url();
if (url.includes('action=get_all_channels')) {
allChannelsRequests.push(url);
}
// The full-list crawl pages through ALL genres (genre=*).
if (
url.includes('action=get_ordered_list') &&
url.includes('type=itv') &&
(url.includes('genre=*') || url.includes('genre=%2A'))
) {
crawlRequests.push(url);
}
});
await page.getByRole('link', { name: /live|itv/i }).click();
await page.waitForURL(/stalker.*itv/);
const categories = page.locator('.category-item');
await expect(categories.nth(1)).toBeVisible({ timeout: 10_000 });
await categories.nth(1).click();
const channels = page.locator('[data-test-id="channel-item"]');
await expect(channels.first()).toBeVisible({ timeout: 20_000 });
// The crawl collects all 6 × 40 channels; the selected category then
// shows its full 40 items without manual lazy-load scrolling.
await expect(page.locator('.category-subtitle')).toHaveText('40 items', {
timeout: 30_000,
});
await expect.poll(() => allChannelsRequests.length).toBeGreaterThan(0);
expect(crawlRequests.length).toBeGreaterThan(1);
});
test('@stalker mock server reset clears cached state', async ({ request }) => {
// Generate data for default MAC
const before = await request.get(
+5
View File
@@ -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": "كل الراديو"
},
+5
View File
@@ -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"
},
+5
View File
@@ -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": "Усе радыё"
},
+5
View File
@@ -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"
},
+5
View File
@@ -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": "Όλα τα ραδιόφωνα"
},
+5
View File
@@ -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"
},
+5
View File
@@ -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"
},
+5
View File
@@ -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"
},
+5
View File
@@ -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"
},
+5
View File
@@ -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": "すべてのラジオ"
},
+5
View File
@@ -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": "모든 라디오"
},
+5
View File
@@ -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"
},
+5
View File
@@ -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"
},
+5
View File
@@ -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"
},
+5
View File
@@ -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": "Все радио"
},
+5
View File
@@ -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"
},
+5
View File
@@ -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": "所有广播"
},
+5
View File
@@ -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": "所有廣播"
},
+13 -3
View File
@@ -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`.
+106 -1
View File
@@ -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:
@@ -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';
@@ -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;
}
@@ -10,4 +10,6 @@ export interface StalkerItvChannel extends StalkerVodSource {
name?: string;
o_name?: string;
logo?: string;
tv_genre_id?: string | number;
number?: string | number;
}
@@ -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<string, string | number>;
interface RequestHandlers {
allChannels?: () => Promise<unknown> | unknown;
page?: (page: number) => Promise<unknown> | 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<void> {
for (let index = 0; index < times; index += 1) {
await Promise.resolve();
}
}
describe('StalkerItvCacheService', () => {
let service: StalkerItvCacheService;
let sendIpcEvent: jest.Mock<Promise<unknown>, 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);
});
});
@@ -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<ReadonlySet<string>>(new Set());
private readonly loadingKeys = signal<ReadonlySet<string>>(new Set());
private readonly progressByKey = signal<
Readonly<Record<string, StalkerItvLoadProgress>>
>({});
/**
* 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<string, WritableSignal<number>>();
private readonly channelsByKey = new Map<string, StalkerItvChannel[]>();
private readonly unsupportedKeys = new Set<string>();
private readonly inflight = new Map<string, Promise<void>>();
/** Wall-clock (ms) until which a portal's crawl error suppresses retries. */
private readonly errorCooldownUntil = new Map<string, number>();
/**
* 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<void> {
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<void> {
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<number> {
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<void> {
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<typeof signal<ReadonlySet<string>>>,
key: string,
present: boolean
): void {
target.update((current) => {
const next = new Set(current);
if (present) {
next.add(key);
} else {
next.delete(key);
}
return next;
});
}
}
@@ -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<StalkerItvLoadOutcome> {
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<StalkerItvLoadOutcome> {
try {
const response =
await executeStalkerRequest<StalkerChannelListResponse>(
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<StalkerItvLoadOutcome> {
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<string>();
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<string>
): 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<StalkerChannelListResponse | null> {
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<StalkerChannelListResponse | null> {
try {
return await executeStalkerRequest<StalkerChannelListResponse>(
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<string>();
for (const item of items) {
const channel = toStalkerItvChannel(
toStalkerContentItem(
item as Parameters<typeof toStalkerContentItem>[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;
}
@@ -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<typeof TestContentStore>;
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<typeof TestContentStore>;
let dataService: {
sendIpcEvent: jest.Mock<Promise<unknown>, unknown[]>;
};
let itvCache: ReturnType<typeof createItvCacheMock>;
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<unknown>();
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);
});
});
@@ -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<string, string | number> =
@@ -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<number, number>();
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<void> {
await itvCache.refresh(storeContext.currentPlaylist());
},
setCategories(
type: StalkerContentType,
categories: StalkerCategoryItem[]
@@ -622,6 +794,7 @@ export function withStalkerContent() {
setRadioChannels(channels: StalkerItvChannel[]) {
patchState(store, { radioChannels: channels });
},
}))
};
})
);
}
@@ -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([]);
});
});
@@ -61,6 +61,24 @@ export function toStalkerContentItem(
};
}
/**
* Local category filter for fully cached ITV channel lists.
* Mirrors the portal-side `genre=<id>` 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,
@@ -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;
}
@@ -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<StalkerItvAllItemsComponent>;
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();
});
});
@@ -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: `
<div class="category-content-header">
<div class="category-meta">
<h2 class="category-title">
{{ 'PORTALS.ALL_CATEGORIES' | translate }}
</h2>
@if (loading()) {
<span class="all-items-progress" role="status">
<mat-spinner diameter="14" />
@if (progress(); as loadProgress) {
<span class="all-items-progress__count">
{{ loadProgress.loaded }}/{{
loadProgress.total
}}
</span>
}
</span>
} @else if (filteredChannels().length > 0) {
<span class="category-subtitle"
>{{ filteredChannels().length }}
{{
(filteredChannels().length === 1
? 'PORTALS.ITEM'
: 'PORTALS.ITEMS'
) | translate
}}</span
>
}
</div>
@if (!loading() && filteredChannels().length > 0) {
<mat-paginator
[pageIndex]="pageIndex()"
[length]="filteredChannels().length"
[pageSize]="pageSize()"
[pageSizeOptions]="pageSizeOptions"
(page)="onPageChange($event)"
aria-label="Select page"
/>
}
</div>
<app-grid-list
class="all-items-grid app-scrollbar"
[isLoading]="loading()"
[items]="pagedGridItems()"
[limit]="pageSize()"
[showPaginator]="false"
[searchTerm]="searchTerm()"
[variant]="'logo'"
[type]="'live'"
(itemClicked)="onItemClicked($event)"
/>
`,
styleUrl: './stalker-itv-all-items.component.scss',
})
export class StalkerItvAllItemsComponent {
readonly channels = input<StalkerItvChannel[]>([]);
readonly loading = input(false);
readonly progress = input<StalkerItvLoadProgress | null>(null);
readonly searchTerm = input('');
readonly channelActivated = output<StalkerItvChannel>();
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);
}
}
@@ -15,15 +15,30 @@
<h2 class="category-title">
{{ selectedCategoryTitle() }}
</h2>
@if (visibleChannels().length > 0) {
@if (totalChannelCount() > 0) {
<span class="category-subtitle"
>{{ visibleChannels().length }}
>{{ totalChannelCount() }}
{{
visibleChannels().length === 1 ? 'item' : 'items'
(totalChannelCount() === 1
? 'PORTALS.ITEM'
: 'PORTALS.ITEMS'
) | translate
}}</span
>
}
</div>
@if (isFullListMode()) {
<button
mat-icon-button
type="button"
[disabled]="isFullListLoading()"
[attr.aria-label]="'PORTALS.REFRESH_CHANNEL_LIST' | translate"
[matTooltip]="'PORTALS.REFRESH_CHANNEL_LIST' | translate"
(click)="refreshChannels()"
>
<mat-icon>refresh</mat-icon>
</button>
}
<button
mat-icon-button
type="button"
@@ -36,6 +51,20 @@
</button>
</div>
@if (isFullListLoading()) {
<div class="full-list-progress" role="status">
<mat-spinner diameter="14" />
<span class="full-list-progress__label">
{{ 'PORTALS.LOADING_ALL_CHANNELS' | translate }}
</span>
@if (fullListProgress(); as progress) {
<span class="full-list-progress__count">
{{ progress.loaded }}/{{ progress.total }}
</span>
}
</div>
}
<div #scrollContainer class="channels-list">
@if (isInitialChannelsLoading()) {
<app-channel-list-skeleton
@@ -50,6 +79,13 @@
[message]="'CHANNELS.NO_SEARCH_RESULTS' | translate"
/>
</div>
} @else if (isCategoryEmpty()) {
<div class="empty-search-state">
<app-portal-empty-state
icon="tv_off"
[message]="'PORTALS.NO_CHANNELS_IN_CATEGORY' | translate"
/>
</div>
} @else {
@for (item of visibleChannels(); track item.id ?? $index) {
<app-channel-list-item
@@ -108,11 +144,23 @@
<mat-icon>chevron_right</mat-icon>
</button>
}
@if (!stalkerStore.selectedCategoryId()) {
<app-portal-empty-state
icon="live_tv"
[message]="'PORTALS.SELECT_CATEGORY' | translate"
/>
@if (!stalkerStore.selectedItem() && !stalkerStore.selectedCategoryId()) {
@if (showItvAllItems()) {
<!-- Xtream parity: all channels of the portal, paginated, before
any category is picked. Clicking a card starts playback. -->
<app-stalker-itv-all-items
[channels]="itvFullChannelList()"
[loading]="isFullListLoading()"
[progress]="fullListProgress()"
[searchTerm]="searchTerm()"
(channelActivated)="playChannel($event)"
/>
} @else {
<app-portal-empty-state
icon="live_tv"
[message]="'PORTALS.SELECT_CATEGORY' | translate"
/>
}
} @else if (stalkerStore.selectedItem()) {
@if (streamUrl() && (isEmbeddedPlayer || isRadioMode())) {
<div class="video-player">
@@ -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;
@@ -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<string | null>('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<typeof defaultItvChannels>
>([]);
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<void> {
for (let i = 0; i < 5; i += 1) {
fixture.detectChanges();
await fixture.whenStable();
await new Promise<void>((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 () => {
@@ -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<string, StalkerItvChannel>();
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) {
@@ -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',
@@ -33,7 +33,7 @@
class="item-count item-count--loading"
aria-hidden="true"
></div>
} @else {
} @else if (hasItemCount(item)) {
<div class="item-count app-count-badge">
{{ getItemCount(item) }}
</div>
@@ -32,6 +32,12 @@ export class WorkspaceContextCategoryViewComponent {
readonly itemCounts = input<Map<number, number>>(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;
@@ -166,6 +166,10 @@
<app-workspace-context-category-view
[items]="filteredStalkerCategories()"
[selectedCategoryId]="$any(stalkerSelectedCategoryId())"
[itemCounts]="stalkerCategoryItemCounts()"
[showCounts]="stalkerShowCounts()"
[countDisplayMode]="stalkerCountDisplayMode()"
[omitMissingCounts]="true"
(categoryClicked)="onStalkerCategoryClicked($any($event))"
/>
}
@@ -94,6 +94,9 @@ describe('WorkspaceContextPanelComponent', () => {
selectedCategoryId: signal<string | null>(null),
isCategoryResourceLoading: signal(false),
isCategoryResourceFailed: signal(false),
itvFullListActive: signal(false),
itvFullListLoading: signal(false),
itvCategoryItemCounts: signal<Map<number, number>>(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<number, number>([
[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<number, number>([[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');
@@ -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,
@@ -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,
@@ -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', () => {