fix(stalker): normalize catalog series flags

This commit is contained in:
4gray committed 2026-07-30 08:28:49 +02:00
1 parent ec2a8137f9
commit 3081f3e5ad
5 files changed
+118 -49

No files matched your search

@@ -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 () => {
@@ -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'
@@ -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))
);
});
@@ -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<string, unknown> & {
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();
@@ -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 };