mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 14:16:16 -08:00
fix(stalker): preserve is_series episode metadata (#1218)
This commit is contained in:
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;
|
||||
}
|
||||
+7
-12
@@ -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
|
||||
);
|
||||
}
|
||||
+4
-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">
|
||||
|
||||
+90
-1
@@ -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',
|
||||
|
||||
+24
-2
@@ -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([
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user