From 3081f3e5ade715dcc64baa07535865a04e56937d Mon Sep 17 00:00:00 2001 From: 4gray Date: Thu, 30 Jul 2026 08:28:49 +0200 Subject: [PATCH] fix(stalker): normalize catalog series flags --- .../with-stalker-series.feature.spec.ts | 42 +++++-- .../features/with-stalker-series.feature.ts | 3 +- .../stalker-catalog-detail.component.ts | 4 +- .../stalker-catalog-facade.service.spec.ts | 111 +++++++++++++----- .../src/lib/stalker-catalog-facade.service.ts | 7 +- 5 files changed, 118 insertions(+), 49 deletions(-) diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-series.feature.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-series.feature.spec.ts index 976d7b50d..b7ef4a5da 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-series.feature.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-series.feature.spec.ts @@ -1,7 +1,10 @@ import { TestBed } from '@angular/core/testing'; import { patchState, signalStore, withMethods, withState } from '@ngrx/signals'; import { DataService, TmdbEnrichmentService } from '@iptvnator/services'; -import { PlaylistMeta, StalkerPortalActions } from '@iptvnator/shared/interfaces'; +import { + PlaylistMeta, + StalkerPortalActions, +} from '@iptvnator/shared/interfaces'; import { StalkerSessionService } from '../../stalker-session.service'; import { withStalkerSelection } from './with-stalker-selection.feature'; import { withStalkerSeries } from './with-stalker-series.feature'; @@ -187,22 +190,41 @@ describe('withStalkerSeries serialSeasonsResource gating', () => { }); }); - it('does not fire a series request for a Ministra VOD-series item', async () => { + it.each([true, 1, '1'] as const)( + 'does not fire a series request for a Ministra VOD-series item with is_series=%p', + async (isSeries) => { + store.setSelectedContentType('vod'); + store.setSelectedItem({ + id: '11', + name: 'VOD series', + is_series: isSeries, + }); + + // The legit vod-series season request (type=vod) may fire; the + // wasted regular-series request (type=series) must not. + await waitForCondition( + () => dataService.sendIpcEvent.mock.calls.length > 0 + ); + await flushResources(); + + expect(seriesRequestCalls(dataService.sendIpcEvent)).toHaveLength( + 0 + ); + } + ); + + it('does not fetch VOD-series seasons for unsupported truthy is_series', async () => { store.setSelectedContentType('vod'); store.setSelectedItem({ id: '11', - name: 'VOD series', - is_series: '1', + name: 'Plain VOD', + is_series: 'true', }); - // The legit vod-series season request (type=vod) may fire; the - // wasted regular-series request (type=series) must not. - await waitForCondition( - () => dataService.sendIpcEvent.mock.calls.length > 0 - ); + await flushResources(); await flushResources(); - expect(seriesRequestCalls(dataService.sendIpcEvent)).toHaveLength(0); + expect(dataService.sendIpcEvent).not.toHaveBeenCalled(); }); it('does not refetch seasons when a VOD item is selected after a series', async () => { diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-series.feature.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-series.feature.ts index d545fdc4f..79ecf9256 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-series.feature.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-series.feature.ts @@ -17,6 +17,7 @@ import { } from '../../models'; import { StalkerContentTypes } from '../../stalker-content-types'; import { StalkerSessionService } from '../../stalker-session.service'; +import { isStalkerSeriesFlag } from '../../stalker-vod.utils'; import { StalkerSeriesFeatureStoreContract } from '../stalker-store.contracts'; import { executeStalkerRequest, @@ -155,7 +156,7 @@ export function withStalkerSeries() { !selectedItem || selectedItem.id === undefined || selectedItem.id === null || - !selectedItem.is_series + !isStalkerSeriesFlag(selectedItem.is_series) ) { logger.debug( 'vodSeriesSeasonsResource skipped - conditions not met' diff --git a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts index 4edc8b0f4..a04627bf4 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts @@ -20,6 +20,7 @@ import { import { createPortalFavoritesResource, createRefreshTrigger, + isStalkerSeriesFlag, isSelectedStalkerVodFavorite, StalkerSelectedVodItem, toggleStalkerVodFavorite, @@ -92,8 +93,7 @@ export class StalkerCatalogDetailComponent implements OnDestroy { return Boolean( item && (this.contentType() === 'series' || - item.is_series === true || - String(item.is_series) === '1') + isStalkerSeriesFlag(item.is_series)) ); }); diff --git a/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.spec.ts index 49d710289..8bb750b82 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.spec.ts @@ -18,8 +18,7 @@ describe('StalkerCatalogFacadeService', () => { }; const unsubscribe = jest.fn(); let playbackUpdateHandler: - | ((data: PlaybackPositionData) => void) - | undefined; + ((data: PlaybackPositionData) => void) | undefined; let playbackPositionBridge: { onPlaybackPositionUpdate: jest.Mock< (() => void) | undefined, @@ -49,6 +48,10 @@ describe('StalkerCatalogFacadeService', () => { [string, number, 'vod' | 'episode'] >; }; + let stalkerStoreMock: Record & { + setSearchPhrase: jest.Mock; + setSelectedItem: jest.Mock; + }; beforeEach(() => { playbackUpdateHandler = undefined; @@ -68,38 +71,37 @@ describe('StalkerCatalogFacadeService', () => { } ), }; + stalkerStoreMock = { + selectedContentType: signal<'vod' | 'series' | 'itv'>('vod'), + limit: signal(14), + page: signal(0), + getSelectedCategory: signal(null), + getPaginatedContent: signal([]), + selectedItem: signal(null), + getTotalPages: signal(0), + isPaginatedContentLoading: signal(false), + currentPlaylist: signal(playlist), + getSelectedCategoryName: jest.fn(() => null), + setSelectedCategory: jest.fn(), + clearSelectedItem: jest.fn(), + setSearchPhrase: jest.fn(), + setPage: jest.fn(), + setLimit: jest.fn(), + setSelectedItem: jest.fn(), + createLinkToPlayVod: jest.fn(), + addToFavorites: jest.fn(), + removeFromFavorites: jest.fn(), + fetchMovieFileId: jest.fn(), + fetchLinkToPlay: jest.fn(), + resolveVodPlayback: jest.fn(), + }; TestBed.configureTestingModule({ providers: [ StalkerCatalogFacadeService, { provide: StalkerStore, - useValue: { - selectedContentType: signal<'vod' | 'series' | 'itv'>( - 'vod' - ), - limit: signal(14), - page: signal(0), - getSelectedCategory: signal(null), - getPaginatedContent: signal([]), - selectedItem: signal(null), - getTotalPages: signal(0), - isPaginatedContentLoading: signal(false), - currentPlaylist: signal(playlist), - getSelectedCategoryName: jest.fn(() => null), - setSelectedCategory: jest.fn(), - clearSelectedItem: jest.fn(), - setSearchPhrase: jest.fn(), - setPage: jest.fn(), - setLimit: jest.fn(), - setSelectedItem: jest.fn(), - createLinkToPlayVod: jest.fn(), - addToFavorites: jest.fn(), - removeFromFavorites: jest.fn(), - fetchMovieFileId: jest.fn(), - fetchLinkToPlay: jest.fn(), - resolveVodPlayback: jest.fn(), - }, + useValue: stalkerStoreMock, }, { provide: PORTAL_PLAYBACK_POSITIONS, @@ -115,15 +117,60 @@ describe('StalkerCatalogFacadeService', () => { it('delegates category search query updates to the Stalker store', () => { const service = TestBed.inject(StalkerCatalogFacadeService); - const store = TestBed.inject(StalkerStore) as unknown as { - setSearchPhrase: jest.Mock; - }; service.setSearchQuery('matrix'); - expect(store.setSearchPhrase).toHaveBeenCalledWith('matrix'); + expect(stalkerStoreMock.setSearchPhrase).toHaveBeenCalledWith('matrix'); }); + it.each([true, 1, '1'] as const)( + 'normalizes supported is_series flag %p when selecting an item', + (isSeries) => { + const service = TestBed.inject(StalkerCatalogFacadeService); + + service.selectItem({ id: '42', is_series: isSeries }); + + expect(stalkerStoreMock.setSelectedItem).toHaveBeenCalledWith( + expect.objectContaining({ + id: '42', + is_series: true, + }) + ); + } + ); + + it.each([true, 1, '1'] as const)( + 'returns empty series progress for supported is_series flag %p', + (isSeries) => { + const service = TestBed.inject(StalkerCatalogFacadeService); + + expect( + service.getItemProgress({ id: '42', is_series: isSeries }) + ).toEqual({ hasSeriesProgress: false }); + } + ); + + it.each([false, 0] as const)( + 'keeps non-series flag %p on the ordinary VOD path', + (isSeries) => { + const service = TestBed.inject(StalkerCatalogFacadeService); + const item = { id: '42', is_series: isSeries }; + + service.selectItem(item); + + expect(stalkerStoreMock.setSelectedItem).toHaveBeenCalledWith( + expect.objectContaining({ + id: '42', + is_series: undefined, + }) + ); + expect(service.getItemProgress(item)).toEqual({ + progress: 0, + isWatched: false, + }); + } + ); + it('persists matching external playback updates for the current playlist', async () => { TestBed.inject(StalkerCatalogFacadeService); await Promise.resolve(); diff --git a/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.ts b/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.ts index 893374232..4a9188a27 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.ts @@ -9,6 +9,7 @@ import { } from '@angular/core'; import { buildStalkerSelectedVodItem, + isStalkerSeriesFlag, StalkerStore, StalkerVodSource, } from '@iptvnator/portal/stalker/data-access'; @@ -185,8 +186,7 @@ export class StalkerCatalogFacadeService implements StalkerPortalCatalogFacade< selectItem(item: StalkerVodSource): string[] | null { const needsSeriesFetch = - this.contentType() === 'vod' && - (item.is_series === '1' || item.is_series === 1); + this.contentType() === 'vod' && isStalkerSeriesFlag(item.is_series); this.stalkerStore.setSelectedItem( buildStalkerSelectedVodItem(item, needsSeriesFetch) @@ -209,8 +209,7 @@ export class StalkerCatalogFacadeService implements StalkerPortalCatalogFacade< ); const isSeries = this.contentType() === 'series' || - item.is_series === '1' || - item.is_series === 1; + isStalkerSeriesFlag(item.is_series); if (hasSeriesProgress) { return { hasSeriesProgress: true };