fix(stalker): preserve is_series episode metadata (#1218)

This commit is contained in:
4gray authored and GitHub committed 2026-07-21 07:51:37 +02:00
1 parent 48ff4b9cc5
commit d308749e2c
14 files changed
+1065 -41

No files matched your search

@@ -78,6 +78,44 @@ describe('stalker-series.adapters', () => {
expect(firstEpisode.id).not.toBe(mapped['1'][1].id);
});
it('derives missing VOD-series season numbers from natural season order', () => {
const mapped = mapVodSeriesEpisodes([
{
id: 'season-2',
video_id: 'v1',
name: 'Season 2',
season_number: '',
episodes: [
{
id: 'episode-2',
series_number: 1,
name: 'Second season pilot',
},
],
isLoading: false,
isExpanded: false,
},
{
id: 'season-1',
video_id: 'v1',
name: 'Season 1',
season_number: '',
episodes: [
{
id: 'episode-1',
series_number: 1,
name: 'Pilot',
},
],
isLoading: false,
isExpanded: false,
},
]);
expect(mapped['Season 1'][0].season).toBe(1);
expect(mapped['Season 2'][0].season).toBe(2);
});
it('maps embedded series payload into regular season episodes', () => {
const regularSeasons = mapRegularSeriesSeasons(
{
@@ -7,6 +7,11 @@ import {
} from './models';
import { isStalkerSeriesFlag } from './stalker-vod.utils';
const naturalSeasonCollator = new Intl.Collator(undefined, {
numeric: true,
sensitivity: 'base',
});
export interface VodSeriesSeasonVm {
id: string;
video_id: string;
@@ -154,7 +159,7 @@ export function mapVodSeriesEpisodes(
seasons.forEach((season) => {
const seasonKey = season.season_number || season.name || season.id;
const seasonNum = toEpisodeNumber(seasonKey) || 1;
const seasonNum = getVodSeriesSeasonNumber(season, seasons);
mapped[seasonKey] = (season.episodes ?? []).map((episode) => {
const episodeNum =
@@ -230,3 +235,24 @@ export function mapRegularSeriesEpisodes(
export function getVodSeriesSeasonKey(season: VodSeriesSeasonVm): string {
return season.season_number || season.name || season.id;
}
export function getVodSeriesSeasonNumber(
season: VodSeriesSeasonVm,
seasons: ReadonlyArray<VodSeriesSeasonVm>
): number {
const parsedSeasonNumber = Number(season.season_number);
if (Number.isInteger(parsedSeasonNumber) && parsedSeasonNumber > 0) {
return parsedSeasonNumber;
}
const orderedSeasons = [...seasons].sort((seasonA, seasonB) =>
naturalSeasonCollator.compare(
getVodSeriesSeasonKey(seasonA),
getVodSeriesSeasonKey(seasonB)
)
);
const seasonIndex = orderedSeasons.findIndex(
(candidate) => candidate.id === season.id
);
return seasonIndex >= 0 ? seasonIndex + 1 : 1;
}
@@ -6,6 +6,7 @@ import {
} from '@iptvnator/portal/shared/util';
import {
getVodSeriesSeasonKey,
getVodSeriesSeasonNumber,
type VodSeriesSeasonVm,
} from '@iptvnator/portal/stalker/data-access';
import type {
@@ -15,6 +16,7 @@ import type {
export interface StalkerQuickStartButton {
labelKey: string;
labelParams?: Record<string, number>;
episodeLabel: string | null;
icon: string;
disabled: boolean;
@@ -72,6 +74,7 @@ export function getStalkerSeriesQuickStartButton(
return {
labelKey: action.labelKey,
labelParams: action.labelParams,
episodeLabel: action.episodeLabel,
icon: action.icon,
disabled: action.disabled,
@@ -158,16 +161,8 @@ function getLazyVodSeriesEpisodeLabel(
season: VodSeriesSeasonVm,
seasons: ReadonlyArray<VodSeriesSeasonVm>
): string {
const seasonIndex = seasons.findIndex((item) => item.id === season.id);
const parsedSeasonNumber = Number(season.season_number);
const seasonNumber =
Number.isInteger(parsedSeasonNumber) && parsedSeasonNumber > 0
? parsedSeasonNumber
: getFallbackSeasonNumber(seasonIndex);
return formatSeriesEpisodeCode(seasonNumber, 1);
}
function getFallbackSeasonNumber(seasonIndex: number): number {
return seasonIndex >= 0 ? seasonIndex + 1 : 1;
return formatSeriesEpisodeCode(
getVodSeriesSeasonNumber(season, seasons),
1
);
}
@@ -104,7 +104,10 @@
</span>
<span class="play-btn__copy">
<span class="play-btn__label">
{{ action.labelKey | translate }}
{{
action.labelKey
| translate: action.labelParams
}}
</span>
@if (action.episodeLabel) {
<span class="play-btn__meta">
@@ -241,7 +241,17 @@ describe('StalkerSeriesViewComponent', () => {
StubSeasonContainerComponent,
MockPipe(
TranslatePipe,
(value: string | null | undefined) => value ?? ''
(
value: string | null | undefined,
params?: Record<string, number>
) => {
if (value === 'XTREAM.PLAY_EPISODE') {
return `Play episode ${
params?.['episode'] ?? '{{episode}}'
}`;
}
return value ?? '';
}
),
],
},
@@ -290,6 +300,68 @@ describe('StalkerSeriesViewComponent', () => {
);
});
it('interpolates the episode number for a recently started VOD is_series episode', async () => {
selectedContentType.set('vod');
selectedItem.set({
id: '50001',
is_series: '1',
info: {
name: 'VOD Flagged Series',
description: 'Lazy seasons',
movie_image: 'vod-series.jpg',
},
});
serialSeasonsResource.set([]);
vodSeriesSeasonsResource.set([]);
fixture.detectChanges();
await fixture.whenStable();
fixture.componentInstance.vodSeriesSeasons.set([
{
id: 'season-1',
video_id: '50001',
season_number: '1',
name: 'Season 1',
episodes: [
{
id: 'episode-1',
series_number: 1,
name: 'Pilot',
},
],
isLoading: false,
isExpanded: false,
},
]);
const episode = fixture.componentInstance.mappedSeasons()['1'][0];
fixture.componentInstance.episodePlaybackPositions.set(
new Map([
[
Number(episode.id),
{
contentXtreamId: Number(episode.id),
contentType: 'episode',
seriesXtreamId: 50001,
positionSeconds: 5,
durationSeconds: 100,
},
],
])
);
fixture.detectChanges();
const button: HTMLButtonElement | null =
fixture.nativeElement.querySelector(
'[data-testid="series-quick-start"]'
);
expect(
fixture.componentInstance.quickStartAction()?.labelParams
).toEqual({ episode: 1 });
expect(button?.textContent).toContain('Play episode 1');
expect(button?.textContent).not.toContain('{{episode}}');
});
it('loads the first VOD-series season and starts its first episode from quick start', async () => {
selectedContentType.set('vod');
selectedItem.set({
@@ -350,6 +422,15 @@ describe('StalkerSeriesViewComponent', () => {
expect.any(Number),
undefined
);
expect(openResolvedPlayback).toHaveBeenCalledWith(
expect.objectContaining({
contentInfo: expect.objectContaining({
seasonNumber: 1,
episodeNumber: 1,
}),
}),
true
);
});
it('loads an earlier unloaded VOD-series season before showing completed', async () => {
@@ -541,6 +622,14 @@ describe('StalkerSeriesViewComponent', () => {
By.directive(StubPortalInlinePlayerComponent)
).componentInstance as StubPortalInlinePlayerComponent;
expect(inlinePlayer.playback()).toEqual(
expect.objectContaining({
contentInfo: expect.objectContaining({
seasonNumber: 1,
episodeNumber: 1,
}),
})
);
expect(inlinePlayer.episodeMetadata()).toEqual({
label: 'S01E01',
title: 'Pilot',
@@ -687,15 +687,37 @@ export class StalkerSeriesViewComponent implements OnDestroy {
episodeId,
startTime
);
const episodeState =
episodeId === undefined
? null
: resolveSeriesPlaybackEpisodeState({
episodesBySeason: this.mappedSeasons(),
currentEpisodeId: episodeId,
fallbackEpisodeNumber: episodeNum,
});
const resolvedPlayback =
episodeState && playback.contentInfo?.contentType === 'episode'
? {
...playback,
contentInfo: {
...playback.contentInfo,
seasonNumber: episodeState.seasonNumber,
episodeNumber: episodeState.episodeNumber,
},
}
: playback;
this.lastSaveTime = 0;
if (this.portalPlayer.isEmbeddedPlayer()) {
this.inlinePlayback.set(playback);
this.inlinePlayback.set(resolvedPlayback);
return;
}
this.closeInlinePlayer();
void this.portalPlayer.openResolvedPlayback(playback, true);
void this.portalPlayer.openResolvedPlayback(
resolvedPlayback,
true
);
} catch (error) {
this.logger.error('Failed to start inline series playback', error);
const errorMessage =
@@ -1003,6 +1003,63 @@ describe('DashboardDataService', () => {
});
});
it('resolves episode metadata for a Stalker VOD is_series recent item', async () => {
playlistsSignal.set([
...createDefaultPlaylists(),
{
_id: 'stalker-series',
title: 'Ministra Portal',
count: 1,
importDate: '2026-01-01T00:00:00.000Z',
autoRefresh: false,
macAddress: '00:11:22:33:44:55',
recentlyViewed: [
{
id: '50001',
title: 'VOD Flagged Series',
category_id: 'vod',
is_series: '1',
added_at: '2026-07-20T12:00:00.000Z',
},
],
},
]);
playbackPositionsMock.getAllPlaybackPositions.mockImplementation(
async (playlistId: string) =>
playlistId === 'stalker-series'
? [
{
playlistId,
contentXtreamId: 5000101,
contentType: 'episode',
seriesXtreamId: 50001,
seasonNumber: 1,
episodeNumber: 1,
positionSeconds: 120,
durationSeconds: 1800,
},
]
: []
);
await service.reloadPlaybackPositions();
const item = service
.globalRecentItems()
.find((recent) => recent.playlist_id === 'stalker-series');
if (!item) {
throw new Error('expected the Stalker is_series recent item');
}
expect(item.type).toBe('series');
expect(service.getPlaybackPositionForItem(item)).toEqual(
expect.objectContaining({
seasonNumber: 1,
episodeNumber: 1,
})
);
});
it('keeps legacy episode-keyed recents detail-only when the position row lacks the parent series id', async () => {
dbServiceMock.getGlobalRecentlyViewed.mockResolvedValue([
{