mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
feat(stalker): full Live TV channel list for complete search, count badges, and all-channels grid (#1209)
Load the complete ITV channel list once per portal (Ministra get_all_channels with a paged crawl fallback) and use it for: full local search across every channel, per-genre category count badges, an all-channels paginated grid on Live TV entry, and eager bulk EPG so row previews appear without playing a channel. Censored (adult) genres absent from get_all_channels fall back to the legacy paged flow and show no badge. Includes mock-server support, unit + E2E coverage, and architecture docs.
This commit is contained in:
1 parent
e53adcbeaf
commit
b3e130aa65
56 files changed
+3044
-63
No files matched your search
@@ -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 & {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,10 +5,13 @@ interface ProviderTargetPayload {
|
||||
}
|
||||
|
||||
const BACKEND_ORIGIN = 'http://localhost:3000';
|
||||
const APP_ORIGIN = process.env['BASE_URL']
|
||||
? new URL(process.env['BASE_URL']).origin
|
||||
: 'http://localhost:4200';
|
||||
const CORS_HEADERS = {
|
||||
'access-control-allow-headers': 'content-type',
|
||||
'access-control-allow-methods': 'POST, OPTIONS',
|
||||
'access-control-allow-origin': 'http://localhost:4200',
|
||||
'access-control-allow-origin': APP_ORIGIN,
|
||||
} as const;
|
||||
|
||||
export async function interceptProviderTargetRegistration(
|
||||
|
||||
@@ -35,6 +35,9 @@ const DEFAULT_MAC = '00:1A:79:00:00:01';
|
||||
/** Minimal scenario MAC — 2 categories, 5 items (edge case testing) */
|
||||
const MINIMAL_MAC = '00:1A:79:00:00:03';
|
||||
|
||||
/** Legacy pagination MAC — portal without get_all_channels support */
|
||||
const LEGACY_PAGINATION_MAC = '00:1A:79:00:00:06';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -333,6 +336,177 @@ test('@stalker PWA skips bulk EPG across channel switches', async ({
|
||||
expect(shortEpgRequests).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('@stalker ITV full channel list loads via get_all_channels and search covers it', async ({
|
||||
page,
|
||||
}) => {
|
||||
await addStalkerPortal(page);
|
||||
|
||||
const allChannelsRequests: string[] = [];
|
||||
page.on('request', (request) => {
|
||||
if (request.url().includes('action=get_all_channels')) {
|
||||
allChannelsRequests.push(request.url());
|
||||
}
|
||||
});
|
||||
await page.getByRole('link', { name: /live|itv/i }).click();
|
||||
await page.waitForURL(/stalker.*itv/);
|
||||
|
||||
const categories = page.locator('.category-item');
|
||||
await expect(categories.nth(1)).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// BEFORE any category click (Xtream parity): entering the Live TV section
|
||||
// preloads the full list, so the main area shows the paginated
|
||||
// all-channels grid and the categories already carry count badges.
|
||||
const allItemsGrid = page.locator('app-stalker-itv-all-items');
|
||||
await expect(allItemsGrid.locator('mat-card').first()).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
await expect(
|
||||
allItemsGrid.locator('.mat-mdc-paginator-range-label')
|
||||
).toContainText('of 320');
|
||||
await expect(categories.nth(0).locator('.item-count')).toHaveText('320', {
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(categories.nth(1).locator('.item-count')).toHaveText('40');
|
||||
|
||||
await categories.nth(1).click();
|
||||
|
||||
const channels = page.locator('[data-test-id="channel-item"]');
|
||||
await expect(channels.first()).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// Regression for "search only finds the first 14 loaded items": once the
|
||||
// full list is cached, the whole category (40 channels) is available
|
||||
// without scrolling through 14-item pages.
|
||||
await expect(page.locator('.category-subtitle')).toHaveText('40 items', {
|
||||
timeout: 20_000,
|
||||
});
|
||||
await expect.poll(() => allChannelsRequests.length).toBeGreaterThan(0);
|
||||
|
||||
// Regression: switching to another category once the full list is cached
|
||||
// must serve that category from the cache, not get stuck on an empty
|
||||
// skeleton. (The reset-on-category-change effect used to clobber the
|
||||
// synchronously served list.)
|
||||
await categories.nth(2).click();
|
||||
await expect(page.locator('.category-subtitle')).toHaveText('40 items', {
|
||||
timeout: 20_000,
|
||||
});
|
||||
await expect(channels.first()).toBeVisible({ timeout: 10_000 });
|
||||
// No further get_all_channels request — it's served from the session cache.
|
||||
const requestsAfterFirstCategory = allChannelsRequests.length;
|
||||
await categories.nth(3).click();
|
||||
await expect(channels.first()).toBeVisible({ timeout: 10_000 });
|
||||
expect(allChannelsRequests.length).toBe(requestsAfterFirstCategory);
|
||||
|
||||
// Back to the first category for the search assertions below.
|
||||
await categories.nth(1).click();
|
||||
await expect(page.locator('.category-subtitle')).toHaveText('40 items', {
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// Search a channel from deep in the list (beyond the first 14 items).
|
||||
const deepChannelName = (
|
||||
await channels.nth(30).locator('.channel-name').textContent()
|
||||
)?.trim();
|
||||
expect(deepChannelName).toBeTruthy();
|
||||
|
||||
const searchInput = page.locator('input[type="search"]');
|
||||
await searchInput.fill(deepChannelName as string);
|
||||
await searchInput.press('Enter');
|
||||
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-test-id="channel-item"] .channel-name')
|
||||
.filter({ hasText: deepChannelName as string })
|
||||
.first()
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
// The "loaded only" degraded-search hint must be gone in full-list mode.
|
||||
await expect(page.locator('.search-chip--status')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('@stalker ITV censored category pages from the portal and hides its badge', async ({
|
||||
page,
|
||||
}) => {
|
||||
await addStalkerPortal(page);
|
||||
|
||||
const adultListRequests: string[] = [];
|
||||
page.on('request', (request) => {
|
||||
const url = request.url();
|
||||
if (
|
||||
url.includes('action=get_ordered_list') &&
|
||||
url.includes('type=itv') &&
|
||||
url.includes('genre=1099')
|
||||
) {
|
||||
adultListRequests.push(url);
|
||||
}
|
||||
});
|
||||
|
||||
await page.getByRole('link', { name: /live|itv/i }).click();
|
||||
await page.waitForURL(/stalker.*itv/);
|
||||
|
||||
const categories = page.locator('.category-item');
|
||||
// Wait until the full list is cached (a regular category shows its badge).
|
||||
await expect(categories.nth(1).locator('.item-count')).toHaveText('40', {
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// The censored genre is excluded from get_all_channels, so its real count
|
||||
// is unknown — no badge instead of a misleading "0".
|
||||
const adultCategory = page.locator('.category-item', {
|
||||
hasText: 'For adults',
|
||||
});
|
||||
await expect(adultCategory).toBeVisible();
|
||||
await expect(adultCategory.locator('.item-count')).toHaveCount(0);
|
||||
|
||||
// Clicking it falls back to the legacy paged flow and still shows channels.
|
||||
await adultCategory.click();
|
||||
const channels = page.locator('[data-test-id="channel-item"]');
|
||||
await expect(channels.first()).toBeVisible({ timeout: 20_000 });
|
||||
await expect.poll(() => adultListRequests.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('@stalker ITV falls back to page crawling on portals without get_all_channels', async ({
|
||||
page,
|
||||
}) => {
|
||||
await addStalkerPortal(page, {
|
||||
name: 'Legacy Stalker Portal',
|
||||
mac: LEGACY_PAGINATION_MAC,
|
||||
});
|
||||
|
||||
const allChannelsRequests: string[] = [];
|
||||
const crawlRequests: string[] = [];
|
||||
page.on('request', (request) => {
|
||||
const url = request.url();
|
||||
if (url.includes('action=get_all_channels')) {
|
||||
allChannelsRequests.push(url);
|
||||
}
|
||||
// The full-list crawl pages through ALL genres (genre=*).
|
||||
if (
|
||||
url.includes('action=get_ordered_list') &&
|
||||
url.includes('type=itv') &&
|
||||
(url.includes('genre=*') || url.includes('genre=%2A'))
|
||||
) {
|
||||
crawlRequests.push(url);
|
||||
}
|
||||
});
|
||||
|
||||
await page.getByRole('link', { name: /live|itv/i }).click();
|
||||
await page.waitForURL(/stalker.*itv/);
|
||||
|
||||
const categories = page.locator('.category-item');
|
||||
await expect(categories.nth(1)).toBeVisible({ timeout: 10_000 });
|
||||
await categories.nth(1).click();
|
||||
|
||||
const channels = page.locator('[data-test-id="channel-item"]');
|
||||
await expect(channels.first()).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// The crawl collects all 6 × 40 channels; the selected category then
|
||||
// shows its full 40 items without manual lazy-load scrolling.
|
||||
await expect(page.locator('.category-subtitle')).toHaveText('40 items', {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect.poll(() => allChannelsRequests.length).toBeGreaterThan(0);
|
||||
expect(crawlRequests.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
test('@stalker mock server reset clears cached state', async ({ request }) => {
|
||||
// Generate data for default MAC
|
||||
const before = await request.get(
|
||||
|
||||
@@ -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": "كل الراديو"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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": "Усе радыё"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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": "Όλα τα ραδιόφωνα"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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": "すべてのラジオ"
|
||||
},
|
||||
|
||||
@@ -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": "모든 라디오"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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": "Все радио"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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": "所有广播"
|
||||
},
|
||||
|
||||
@@ -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": "所有廣播"
|
||||
},
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+293
@@ -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);
|
||||
});
|
||||
});
|
||||
+177
-4
@@ -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,
|
||||
|
||||
+72
@@ -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;
|
||||
}
|
||||
+123
@@ -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();
|
||||
});
|
||||
});
|
||||
+146
@@ -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);
|
||||
}
|
||||
}
|
||||
+56
-8
@@ -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">
|
||||
|
||||
+30
@@ -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;
|
||||
|
||||
+356
-10
@@ -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 () => {
|
||||
|
||||
+188
-12
@@ -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',
|
||||
|
||||
+1
-1
@@ -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>
|
||||
|
||||
+15
@@ -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;
|
||||
|
||||
+4
@@ -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))"
|
||||
/>
|
||||
}
|
||||
|
||||
+85
@@ -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');
|
||||
|
||||
+15
@@ -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,
|
||||
|
||||
+9
-5
@@ -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,
|
||||
|
||||
+17
-2
@@ -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', () => {
|
||||
|
||||
Reference in new issue
Block a user