fix(stalker): align season markers and preserve episode loading (#1545)

This commit is contained in:
4gray authored and GitHub committed 2026-09-05 22:22:46 +02:00
1 parent 812ec69fd4
commit 249cd38a66
16 files changed
+546 -74

No files matched your search

@@ -16,6 +16,100 @@ type EpisodeWithMetadata = {
};
describe('stalker-series.adapters', () => {
it.each([
'The Gentlemen s02',
'The Gentlemen season2',
'The Gentlemen (s02)',
'The Gentlemen (season 2)',
'Олдскул (2 сезон)',
'Олдскул (сезон 2)',
'Олдскул s02',
'Олдскул season2',
])(
'uses the title season for lazy VOD %s without changing tracking IDs',
(title) => {
const provider = [
{
id: 's1',
video_id: 'v1',
season_number: '1',
name: 'Season 1',
},
];
const load = (
seasons: ReturnType<typeof mapVodSeriesSeasonsToVm>
) =>
seasons.map((season) => ({
...season,
episodes: [
{
id: 'provider-episode-1',
series_number: 1,
name: 'Episode 1',
},
],
}));
const original = mapVodSeriesEpisodes(
load(mapVodSeriesSeasonsToVm(provider)),
{ parentSeriesId: 100 }
);
const correctedSeasons = mapVodSeriesSeasonsToVm(provider, title);
expect(correctedSeasons[0].season_number).toBe('2');
expect(correctedSeasons[0].episodes).toEqual([]);
expect(correctedSeasons[0].id).toBe('s1');
expect(correctedSeasons[0].video_id).toBe('v1');
const corrected = mapVodSeriesEpisodes(load(correctedSeasons), {
parentSeriesId: 100,
});
expect(Object.keys(corrected)).toEqual(['2']);
expect(corrected['2'][0]).toEqual(
expect.objectContaining({
id: original['1'][0].id,
season: 2,
providerSeasonNumber: 1,
legacyTrackingId: (original['1'][0] as EpisodeWithMetadata)
.legacyTrackingId,
originalId: 'provider-episode-1',
})
);
}
);
it('keeps multi-season VOD numbering despite a parent title marker', () => {
const provider = [1, 2].map((number) => ({
id: `s${number}`,
video_id: 'v1',
season_number: String(number),
}));
expect(
mapVodSeriesSeasonsToVm(provider, 'Show s02').map(
(season) => season.season_number
)
).toEqual(['1', '2']);
});
it('corrects embedded and single regular season coordinates without changing episode IDs', () => {
const seasons = [
{
id: 's1',
name: 'Episodes',
cmd: '/media/file_100.mpg',
series: [1, 2],
},
];
const original = mapRegularSeriesEpisodes(seasons);
const corrected = mapRegularSeriesEpisodes(
seasons,
undefined,
'Show (season 2)'
);
expect(Object.keys(corrected)).toEqual(['2']);
expect(corrected['2'][0].season).toBe(2);
expect(corrected['2'].map((episode) => episode.id)).toEqual(
original['1'].map((episode) => episode.id)
);
});
it('detects vod-series flags from heterogeneous payloads', () => {
expect(isVodSeriesItem({ is_series: true })).toBe(true);
expect(isVodSeriesItem({ is_series: 1 })).toBe(true);
@@ -1,4 +1,7 @@
import { XtreamSerieEpisode } from '@iptvnator/shared/interfaces';
import {
XtreamSerieEpisode,
resolveEnrichmentSeasonNumber,
} from '@iptvnator/shared/interfaces';
import {
StalkerSeason,
StalkerVodSeriesEpisode,
@@ -17,6 +20,9 @@ export interface VodSeriesSeasonVm {
video_id: string;
name: string;
season_number: string;
/** Original provider coordinates, retained when the title corrects a slice. */
providerSeasonKey?: string;
providerSeasonNumber?: number;
episodes: StalkerVodSeriesEpisode[];
isLoading: boolean;
isExpanded: boolean;
@@ -43,6 +49,8 @@ export interface MapVodSeriesEpisodesOptions {
export interface StalkerMappedEpisode extends XtreamSerieEpisode {
legacyTrackingId?: number;
/** Allows pre-correction legacy progress to match the same episode. */
providerSeasonNumber?: number;
originalId?: string;
originalCmd?: string;
}
@@ -119,9 +127,10 @@ export function isVodSeriesItem(
}
export function mapVodSeriesSeasonsToVm(
seasons: StalkerVodSeriesSeason[] | undefined
seasons: StalkerVodSeriesSeason[] | undefined,
rawTitle?: string | null
): VodSeriesSeasonVm[] {
return (seasons ?? []).map((season) => ({
const mapped = (seasons ?? []).map((season) => ({
id: String(season.id),
video_id: String(season.video_id),
name: toNonEmptyString(
@@ -133,6 +142,23 @@ export function mapVodSeriesSeasonsToVm(
isLoading: false,
isExpanded: false,
}));
if (mapped.length !== 1) return mapped;
return mapped.map((season) => {
const providerSeasonNumber = getVodSeriesSeasonNumber(season, mapped);
const seasonNumber = resolveEnrichmentSeasonNumber({
rawTitle,
providerSeasonNumber,
providerSeasonCount: mapped.length,
});
return seasonNumber === providerSeasonNumber
? season
: {
...season,
season_number: String(seasonNumber),
providerSeasonKey: getVodSeriesSeasonKey(season),
providerSeasonNumber,
};
});
}
export function mapRegularSeriesSeasons(
@@ -188,6 +214,7 @@ export function mapVodSeriesEpisodes(
seasons.forEach((season) => {
const seasonKey = season.season_number || season.name || season.id;
const trackingSeasonKey = season.providerSeasonKey ?? seasonKey;
const seasonNum = getVodSeriesSeasonNumber(season, seasons);
mapped[seasonKey] = (season.episodes ?? []).map((episode) => {
@@ -197,12 +224,12 @@ export function mapVodSeriesEpisodes(
const providerEpisodeId = String(episode.id ?? '');
const legacyTrackingId = generateLegacyVodEpisodeId(
episodeNum,
seasonKey
trackingSeasonKey
);
const trackingId = generateVodEpisodeId({
parentSeriesId: options.parentSeriesId,
providerEpisodeId,
seasonKey,
seasonKey: trackingSeasonKey,
episodeNum,
});
@@ -223,6 +250,9 @@ export function mapVodSeriesEpisodes(
}
),
legacyTrackingId,
...(season.providerSeasonNumber !== undefined
? { providerSeasonNumber: season.providerSeasonNumber }
: {}),
originalId: providerEpisodeId,
} as StalkerMappedEpisode;
});
@@ -233,12 +263,19 @@ export function mapVodSeriesEpisodes(
export function mapRegularSeriesEpisodes(
seasons: ReadonlyArray<StalkerSeriesSeasonVm>,
fallbackPoster?: string
fallbackPoster?: string,
rawTitle?: string | null
): Record<string, XtreamSerieEpisode[]> {
const mapped: Record<string, XtreamSerieEpisode[]> = {};
seasons.forEach((season, index) => {
const seasonKey = String(index + 1);
const seasonKey = String(
resolveEnrichmentSeasonNumber({
rawTitle,
providerSeasonNumber: index + 1,
providerSeasonCount: seasons.length,
})
);
mapped[seasonKey] = (season.series ?? []).map((episodeNum) => {
const trackingId = generateRegularEpisodeId(
String(season.cmd ?? ''),
@@ -114,6 +114,43 @@ describe('withStalkerSeries serialSeasonsResource gating', () => {
void store.isVodSeriesSeasonsLoading();
});
it('keeps VOD seasons loaded when TMDB patches the selected item metadata', async () => {
dataService.sendIpcEvent.mockResolvedValue({
js: {
data: [
{
id: 's1',
video_id: '11',
is_season: true,
season_number: '1',
},
],
},
});
store.setSelectedContentType('vod');
store.setSelectedItem({ id: '11', name: 'Show s02', is_series: '1' });
await waitForCondition(
() => store.getVodSeriesSeasonsResource()?.length === 1
);
const originalSeasons = store.getVodSeriesSeasonsResource();
const item = store.selectedItem();
if (!item) throw new Error('Expected the selected VOD series');
patchState(store, {
selectedItem: { ...item, info: { ...item.info, tmdb_id: 777 } },
});
await flushResources();
await flushResources();
expect(dataService.sendIpcEvent).toHaveBeenCalledTimes(1);
expect(store.getVodSeriesSeasonsResource()).toBe(originalSeasons);
store.setSelectedItem({ id: '12', name: 'Show s03', is_series: '1' });
await waitForCondition(
() => dataService.sendIpcEvent.mock.calls.length === 2
);
expect(dataService.sendIpcEvent.mock.calls[1][1]).toMatchObject({
params: { movie_id: '12' },
});
});
it('fetches seasons for a regular series selection', async () => {
dataService.sendIpcEvent.mockResolvedValue({
js: [{ id: '42:1', name: 'Season 1', series: [1, 2] }],
@@ -100,6 +100,16 @@ export function withStalkerSeries() {
) => {
const storeContext = store as typeof store &
StalkerSeriesStoreContext;
// Enrichment patches selectedItem in place. Only provider
// identity/mode changes should reload seasons and reset episodes.
const vodSeriesMovieId = computed(() => {
const item = storeContext.selectedItem();
return storeContext.selectedContentType() === 'vod' &&
isStalkerSeriesFlag(item?.is_series) &&
item?.id != null
? String(item.id)
: null;
});
const requestDeps = {
dataService,
stalkerSession,
@@ -147,32 +157,14 @@ export function withStalkerSeries() {
vodSeriesSeasonsResource: resource({
params: () => ({
currentPlaylist: storeContext.currentPlaylist(),
selectedItem: storeContext.selectedItem(),
selectedContentType:
storeContext.selectedContentType(),
movieId: vodSeriesMovieId(),
}),
loader: async ({
params,
}): Promise<StalkerVodSeriesSeason[]> => {
const { currentPlaylist, selectedItem } = params;
const { currentPlaylist, movieId } = params;
logger.debug(
'vodSeriesSeasonsResource loader called',
{
item: selectedItem,
isSeries: selectedItem?.is_series,
currentPlaylist,
}
);
if (
!currentPlaylist ||
params.selectedContentType !== 'vod' ||
!selectedItem ||
selectedItem.id === undefined ||
selectedItem.id === null ||
!isStalkerSeriesFlag(selectedItem.is_series)
) {
if (!currentPlaylist || movieId === null) {
logger.debug(
'vodSeriesSeasonsResource skipped - conditions not met'
);
@@ -184,7 +176,7 @@ export function withStalkerSeries() {
>(requestDeps, currentPlaylist, {
action: StalkerPortalActions.GetOrderedList,
type: 'vod',
movie_id: selectedItem.id,
movie_id: movieId,
p: '1',
});
@@ -75,6 +75,36 @@ function reconcile(
}
describe('stalker series position compatibility', () => {
it('retains pre-correction legacy progress only for the original provider season', () => {
const episode = {
...createEpisode({
legacyTrackingId: LEGACY_TRACKING_ID,
seasonNumber: 2,
}),
providerSeasonNumber: 1,
};
const legacyPosition = createPosition({
contentXtreamId: LEGACY_TRACKING_ID,
});
const result = reconcile([episode], [legacyPosition]);
expect(result.positionsByTrackingId.get(SCOPED_TRACKING_ID)).toEqual({
...legacyPosition,
contentXtreamId: SCOPED_TRACKING_ID,
seasonNumber: 2,
});
expect(result.legacyPositionByTrackingId.get(SCOPED_TRACKING_ID)).toBe(
legacyPosition
);
expect(
reconcile([episode], [{ ...legacyPosition, seasonNumber: 3 }])
.positionsByTrackingId.size
).toBe(0);
expect(
reconcile([episode], [{ ...legacyPosition, episodeNumber: 3 }])
.positionsByTrackingId.size
).toBe(0);
});
it('prefers an exact scoped row while retaining compatible legacy cleanup metadata', () => {
const episode = createEpisode({
legacyTrackingId: LEGACY_TRACKING_ID,
@@ -557,10 +587,7 @@ describe('stalker series position compatibility', () => {
legacyPosition,
})
).rejects.toBe(clearError);
expect(order).toEqual([
LEGACY_TRACKING_ID,
SCOPED_TRACKING_ID,
]);
expect(order).toEqual([LEGACY_TRACKING_ID, SCOPED_TRACKING_ID]);
expect(rows.has(LEGACY_TRACKING_ID)).toBe(false);
expect(rows.get(SCOPED_TRACKING_ID)).toBe(exactPosition);
@@ -34,8 +34,17 @@ function isCompatibleLegacyPosition(
position: PlaybackPositionData,
episode: XtreamSerieEpisode
): boolean {
const providerSeason = (episode as StalkerMappedEpisode)
.providerSeasonNumber;
const matchesSeason =
matchesMappedCoordinate(
position.seasonNumber,
Number(episode.season)
) ||
(providerSeason !== undefined &&
matchesMappedCoordinate(position.seasonNumber, providerSeason));
return (
matchesMappedCoordinate(position.seasonNumber, Number(episode.season)) &&
matchesSeason &&
matchesMappedCoordinate(
position.episodeNumber,
Number(episode.episode_num)
@@ -33,11 +33,22 @@ describe('StalkerSeriesTmdbSeasonsService', () => {
service = TestBed.inject(StalkerSeriesTmdbSeasonsService);
});
it('fetches the title-marked season for a renumbered single-season slice', async () => {
await service.fetchSeason(82856, '1', episodesOfSeason(1), {
rawTitle: 'Мандалорец (2 сезон)',
seasonCount: 1,
});
it.each([
'The Gentlemen s02',
'The Gentlemen season2',
'The Gentlemen (s02)',
'The Gentlemen (season 2)',
'Мандалорец (2 сезон)',
'Мандалорец (сезон 2)',
'Мандалорец s02',
'Мандалорец season2',
])(
'fetches the title-marked season for the single-season slice %s',
async (rawTitle) => {
await service.fetchSeason(82856, '1', episodesOfSeason(1), {
rawTitle,
seasonCount: 1,
});
expect(getSeason).toHaveBeenCalledWith(82856, 2);
@@ -191,7 +191,6 @@ describe('StalkerSeriesViewComponent', () => {
fetchVodSeriesEpisodes,
resolveVodPlayback,
fetchLinkToPlay,
clearSelectedItem: jest.fn(),
},
},
{
@@ -226,7 +225,6 @@ describe('StalkerSeriesViewComponent', () => {
useValue: {
isEnabled: () => true,
getSeason: tmdbGetSeason,
getSeasonEpisodes: jest.fn().mockResolvedValue(null),
},
},
{
@@ -1217,7 +1215,7 @@ describe('StalkerSeriesViewComponent', () => {
} as never);
await stabilize();
fixture.componentInstance.onSeasonSelected('1');
fixture.componentInstance.onSeasonSelected('2');
await stabilize();
// Season resource still loading — fetching now would pass a zero
@@ -1268,10 +1266,9 @@ describe('StalkerSeriesViewComponent', () => {
// context while the resource reloads.
expect(tmdbGetSeason).not.toHaveBeenCalled();
// Once the new item's own seasons land, the fetch runs WITHOUT a
// new seasonSelected emission — the season container deduplicates
// emissions when both items share the same season-key set, so the
// retained key must stay usable.
// Once the new item's own seasons land, the fetch runs without a
// stale-season fetch. The container selects the corrected key once
// the new season map is rendered.
serialSeasonsResource.set([
{
id: 'season-1',
@@ -1281,6 +1278,7 @@ describe('StalkerSeriesViewComponent', () => {
},
]);
isSerialSeasonsLoading.set(false);
fixture.componentInstance.onSeasonSelected('2');
await stabilize();
expect(tmdbGetSeason).toHaveBeenCalledWith(888, 2);
});
@@ -1307,6 +1305,7 @@ describe('StalkerSeriesViewComponent', () => {
expect(tmdbGetSeason).not.toHaveBeenCalled();
isSerialSeasonsLoading.set(false);
fixture.componentInstance.onSeasonSelected('3');
await stabilize();
expect(tmdbGetSeason).toHaveBeenCalledWith(999, 3);
});
@@ -1325,7 +1324,7 @@ describe('StalkerSeriesViewComponent', () => {
} as never);
await stabilize();
fixture.componentInstance.onSeasonSelected('1');
fixture.componentInstance.onSeasonSelected('2');
await stabilize();
expect(tmdbGetSeason).toHaveBeenCalledWith(777, 2);
@@ -388,7 +388,9 @@ export class StalkerSeriesViewComponent implements OnDestroy {
if (this.isVodSeries()) {
// Get seasons from the resource
const seasons = this.stalkerStore.getVodSeriesSeasonsResource();
this.vodSeriesSeasons.set(mapVodSeriesSeasonsToVm(seasons));
this.vodSeriesSeasons.set(
mapVodSeriesSeasonsToVm(seasons, this.seriesSeasonTitle())
);
} else {
this.vodSeriesSeasons.set([]);
}
@@ -523,6 +525,13 @@ export class StalkerSeriesViewComponent implements OnDestroy {
youtubeEmbedUrl(this.displayItem()?.info?.tmdb_trailer)
);
private readonly seriesSeasonTitle = computed(() =>
pickSeasonMarkedTitle(
this.displayItem()?.info?.name,
this.displayItem()?.info?.o_name
)
);
readonly seriesMode = computed(() =>
this.isVodSeries()
? STALKER_SERIES_DOWNLOAD_MODES.LazyVod
@@ -575,7 +584,8 @@ export class StalkerSeriesViewComponent implements OnDestroy {
})
: mapRegularSeriesEpisodes(
this.regularSeasons(),
displayItem?.info?.movie_image
displayItem?.info?.movie_image,
this.seriesSeasonTitle()
);
// Overlay lazily fetched TMDB episode data (real names,
@@ -723,7 +733,7 @@ export class StalkerSeriesViewComponent implements OnDestroy {
private readonly vodSeasonEpisodeLoads = new Map<
string,
Promise<boolean>
{ season: VodSeriesSeasonVm; promise: Promise<boolean> }
>();
/**
@@ -737,15 +747,32 @@ export class StalkerSeriesViewComponent implements OnDestroy {
*/
/** Resolves true when the portal answered, false when the request failed. */
loadEpisodesForSeason(season: VodSeriesSeasonVm): Promise<boolean> {
const key = `${season.video_id}:${season.id}`;
const key = JSON.stringify([
this.seriesPlaybackOwnerKey(),
season.video_id,
season.id,
getVodSeriesSeasonKey(season),
]);
const inFlight = this.vodSeasonEpisodeLoads.get(key);
if (inFlight) {
return inFlight;
if (inFlight && this.vodSeriesSeasons().includes(inFlight.season)) {
return inFlight.promise;
}
const load = this.fetchEpisodesForSeason(season).finally(() => {
this.vodSeasonEpisodeLoads.delete(key);
if (this.vodSeasonEpisodeLoads.get(key)?.promise === load) {
this.vodSeasonEpisodeLoads.delete(key);
}
});
this.vodSeasonEpisodeLoads.set(key, load);
const loadingSeason = this.vodSeriesSeasons().find(
(candidate) =>
candidate.id === season.id &&
candidate.video_id === season.video_id
);
if (loadingSeason) {
this.vodSeasonEpisodeLoads.set(key, {
season: loadingSeason,
promise: load,
});
}
return load;
}
@@ -754,11 +781,17 @@ export class StalkerSeriesViewComponent implements OnDestroy {
): Promise<boolean> {
// Set loading state in local signal
const seasons = this.vodSeriesSeasons();
const index = seasons.findIndex((s) => s.id === season.id);
const index = seasons.findIndex(
(s) =>
s.id === season.id &&
s.video_id === season.video_id &&
getVodSeriesSeasonKey(s) === getVodSeriesSeasonKey(season)
);
if (index === -1) return false;
const updatedSeasons = [...seasons];
updatedSeasons[index] = { ...updatedSeasons[index], isLoading: true };
const loadingSeason = { ...updatedSeasons[index], isLoading: true };
updatedSeasons[index] = loadingSeason;
this.vodSeriesSeasons.set(updatedSeasons);
try {
@@ -769,7 +802,9 @@ export class StalkerSeriesViewComponent implements OnDestroy {
// Update with loaded episodes
const newSeasons = [...this.vodSeriesSeasons()];
const newIndex = newSeasons.findIndex((s) => s.id === season.id);
// Only the exact loading VM owns this response. A navigation or
// refresh can reuse provider ids while replacing the season list.
const newIndex = newSeasons.indexOf(loadingSeason);
if (newIndex !== -1) {
newSeasons[newIndex] = {
...newSeasons[newIndex],
@@ -782,11 +817,11 @@ export class StalkerSeriesViewComponent implements OnDestroy {
};
this.vodSeriesSeasons.set(newSeasons);
}
return true;
return newIndex !== -1;
} catch (error) {
this.logger.error('Failed to load episodes', error);
const newSeasons = [...this.vodSeriesSeasons()];
const newIndex = newSeasons.findIndex((s) => s.id === season.id);
const newIndex = newSeasons.indexOf(loadingSeason);
if (newIndex !== -1) {
newSeasons[newIndex] = {
...newSeasons[newIndex],
@@ -321,6 +321,41 @@ describe('StalkerSeriesViewComponent season watched toggle', () => {
jest.restoreAllMocks();
});
it('ignores an old episode response after navigating to another season slice', async () => {
await startWithTwoLoadedEpisodes();
const store = TestBed.inject(StalkerStore);
let finishOld!: (episodes: VodSeriesSeasonVm['episodes']) => void;
jest.spyOn(store, 'fetchVodSeriesEpisodes').mockImplementationOnce(
() =>
new Promise((resolve) => {
finishOld = resolve;
})
);
const oldLoad = fixture.componentInstance.loadEpisodesForSeason(
fixture.componentInstance.vodSeriesSeasons()[0]
);
selectedItem.set({
...createVodItem(SERIES_B_ID),
info: { name: 'Series B s02' },
});
vodSeriesSeasonsResource.set([createSeason(SERIES_B_ID)]);
await settle();
expect(fixture.componentInstance.quickStartAction()?.episodeLabel).toBe(
'S02E01'
);
finishOld([createProviderEpisode('old-season-episode')]);
await oldLoad;
await settle();
expect(fixture.componentInstance.mappedSeasons()['2']).toEqual([]);
await fixture.componentInstance.loadEpisodesForSeason(
fixture.componentInstance.vodSeriesSeasons()[0]
);
await settle();
expect(fixture.componentInstance.mappedSeasons()['2'][0].season).toBe(
2
);
});
it('marks a season watched sequentially, counting failed legacy cleanup as watched', async () => {
const [firstId, secondId] = await startWithTwoLoadedEpisodes();
const loadsBefore = getSeriesPlaybackPositions.mock.calls.length;