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

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

No files matched your search

@@ -33,7 +33,7 @@
class="item-count item-count--loading"
aria-hidden="true"
></div>
} @else {
} @else if (hasItemCount(item)) {
<div class="item-count app-count-badge">
{{ getItemCount(item) }}
</div>
@@ -32,6 +32,12 @@ export class WorkspaceContextCategoryViewComponent {
readonly itemCounts = input<Map<number, number>>(new Map());
readonly showCounts = input(false);
readonly countDisplayMode = input<'loading' | 'ready'>('ready');
/**
* When true, items without an entry in `itemCounts` render no badge at
* all instead of "0" — used for Stalker censored (adult) genres whose
* real channel count is unknown to the full-list cache.
*/
readonly omitMissingCounts = input(false);
readonly interactionEnabled = input(true);
readonly statusText = input('');
@@ -93,6 +99,15 @@ export class WorkspaceContextCategoryViewComponent {
return this.itemCounts().get(itemId) ?? 0;
}
hasItemCount(item: WorkspaceCategoryViewItem): boolean {
if (!this.omitMissingCounts()) {
return true;
}
// NaN (from the "*" all-category id) is a valid Map key here.
return this.itemCounts().has(Number(item.id ?? item.category_id));
}
onCategoryClick(item: WorkspaceCategoryViewItem): void {
if (!this.interactionEnabled()) {
return;
@@ -166,6 +166,10 @@
<app-workspace-context-category-view
[items]="filteredStalkerCategories()"
[selectedCategoryId]="$any(stalkerSelectedCategoryId())"
[itemCounts]="stalkerCategoryItemCounts()"
[showCounts]="stalkerShowCounts()"
[countDisplayMode]="stalkerCountDisplayMode()"
[omitMissingCounts]="true"
(categoryClicked)="onStalkerCategoryClicked($any($event))"
/>
}
@@ -94,6 +94,9 @@ describe('WorkspaceContextPanelComponent', () => {
selectedCategoryId: signal<string | null>(null),
isCategoryResourceLoading: signal(false),
isCategoryResourceFailed: signal(false),
itvFullListActive: signal(false),
itvFullListLoading: signal(false),
itvCategoryItemCounts: signal<Map<number, number>>(new Map()),
setSelectedCategory: jest.fn(),
setPage: jest.fn(),
clearSelectedItem: jest.fn(),
@@ -398,6 +401,88 @@ describe('WorkspaceContextPanelComponent', () => {
]);
});
it('shows per-genre count badges on stalker Live TV categories when the full list is cached', () => {
fixture.componentRef.setInput('context', {
provider: 'stalker',
playlistId: 'stalker-1',
});
fixture.componentRef.setInput('section', 'itv');
stalkerStore.getCategoryResource.set([
{ category_id: '*', category_name: 'All' },
{ category_id: '1', category_name: 'Documentary' },
{ category_id: '2', category_name: 'Sports' },
]);
stalkerStore.itvFullListActive.set(true);
stalkerStore.itvCategoryItemCounts.set(
new Map<number, number>([
[1, 190],
[2, 38],
[Number.NaN, 228],
])
);
fixture.detectChanges();
const counts = Array.from(
fixture.nativeElement.querySelectorAll('.category-item .item-count')
).map((el) => (el as HTMLElement).textContent?.trim());
// "All" (NaN key → total), Documentary, Sports.
expect(counts).toEqual(['228', '190', '38']);
});
it('omits the badge for censored stalker genres missing from the count map', () => {
fixture.componentRef.setInput('context', {
provider: 'stalker',
playlistId: 'stalker-1',
});
fixture.componentRef.setInput('section', 'itv');
stalkerStore.getCategoryResource.set([
{ category_id: '1', category_name: 'Documentary' },
{
category_id: '19',
category_name: 'For adults',
censored: true,
} as never,
]);
stalkerStore.itvFullListActive.set(true);
// Censored genre 19 has no entry — its real count is unknown.
stalkerStore.itvCategoryItemCounts.set(
new Map<number, number>([[1, 190]])
);
fixture.detectChanges();
const categoryButtons = Array.from(
fixture.nativeElement.querySelectorAll('.category-item')
) as HTMLElement[];
const documentary = categoryButtons.find((el) =>
el.textContent?.includes('Documentary')
);
const adults = categoryButtons.find((el) =>
el.textContent?.includes('For adults')
);
expect(documentary?.querySelector('.item-count')?.textContent).toContain(
'190'
);
expect(adults?.querySelector('.item-count')).toBeNull();
});
it('hides count badges for stalker categories without a cached full list (e.g. VOD)', () => {
fixture.componentRef.setInput('context', {
provider: 'stalker',
playlistId: 'stalker-1',
});
fixture.componentRef.setInput('section', 'vod');
stalkerStore.getCategoryResource.set([
{ category_id: '1', category_name: 'Action' },
]);
stalkerStore.itvFullListActive.set(false);
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector('.category-item .item-count')
).toBeNull();
});
it('preserves the active xtream live item when switching live categories', () => {
fixture.componentRef.setInput('section', 'live');
xtreamSelectedTypeContentState.set('ready');
@@ -144,6 +144,21 @@ export class WorkspaceContextPanelComponent {
this.stalkerStore.isCategoryResourceLoading;
readonly isStalkerCategoryFailed =
this.stalkerStore.isCategoryResourceFailed;
// Category count badges are only available for Stalker Live TV, where the
// full channel list is cached — VOD/series/radio still page lazily, so
// their per-category totals are unknown.
readonly stalkerCategoryItemCounts =
this.stalkerStore.itvCategoryItemCounts;
readonly stalkerShowCounts = computed(
() =>
this.isStalkerCategories() &&
this.section() === 'itv' &&
(this.stalkerStore.itvFullListActive() ||
this.stalkerStore.itvFullListLoading())
);
readonly stalkerCountDisplayMode = computed<'loading' | 'ready'>(() =>
this.stalkerStore.itvFullListActive() ? 'ready' : 'loading'
);
readonly skeletonRows = Array.from({ length: 14 }, (_, index) => index);
readonly skeletonLabelWidths = [
78, 66, 74, 59, 83, 69, 76, 62, 81, 64, 72, 67, 79, 61,
@@ -123,11 +123,15 @@ export class WorkspaceShellSearchService {
};
}
const isDegradedStalkerItv =
// Stalker live search is "loaded only" (limited to fetched pages)
// unless the full ITV channel list is cached. ITV with the full list
// active covers everything; radio always pages, so it stays degraded.
const isDegradedStalkerLive =
context?.provider === 'stalker' &&
section === 'itv' &&
appliedQuery.length > 0;
const behavior = isDegradedStalkerItv
appliedQuery.length > 0 &&
((section === 'itv' && !this.stalkerStore.itvFullListActive()) ||
section === 'radio');
const behavior = isDegradedStalkerLive
? 'degraded-loaded-only'
: route.searchMode;
@@ -151,7 +155,7 @@ export class WorkspaceShellSearchService {
stalkerCategoryName:
this.stalkerStore.getSelectedCategoryName(),
}),
statusLabel: isDegradedStalkerItv
statusLabel: isDegradedStalkerLive
? this.translateText(SEARCH_LOADED_ONLY_STATUS)
: '',
minLength: route.searchMode === 'remote-search' ? 3 : 1,
@@ -72,6 +72,7 @@ class MockXtreamStore {
class MockStalkerStore {
readonly searchPhrase = signal('');
readonly getSelectedCategoryName = signal('All Items');
readonly itvFullListActive = signal(false);
setSearchPhrase = jest.fn((term: string) => this.searchPhrase.set(term));
}
@@ -594,7 +595,17 @@ describe('WorkspaceShellFacade', () => {
);
});
it('treats stalker radio search as a remote section search', () => {
it('drops the loaded-only status once the full ITV channel list is cached', () => {
stalkerStore.itvFullListActive.set(true);
facade.currentUrl.set('/workspace/stalker/pl-1/itv?q=cnn');
searchSync.syncSearchFromRoute();
TestBed.flushEffects();
expect(facade.canUseSearch()).toBe(true);
expect(facade.searchStatusLabel()).toBe('');
});
it('marks stalker radio search as loaded-only (radio always pages)', () => {
facade.currentUrl.set('/workspace/stalker/pl-1/radio?q=jazz');
searchSync.syncSearchFromRoute();
TestBed.flushEffects();
@@ -604,7 +615,11 @@ describe('WorkspaceShellFacade', () => {
expect(facade.searchScopeLabel()).toBe(
'WORKSPACE.SHELL.RAIL_RADIO / All Items'
);
expect(facade.searchStatusLabel()).toBe('');
// Radio has no full-list cache, so its local search only covers loaded
// stations — surface the same "loaded only" hint as degraded ITV.
expect(facade.searchStatusLabel()).toBe(
'WORKSPACE.SHELL.SEARCH_STATUS_LOADED_ONLY'
);
});
it('applies q to Xtream category search on vod routes', () => {