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
@@ -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