mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 18:36: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
-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