mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
fix(stalker): align season markers and preserve episode loading (#1545)
This commit is contained in:
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 ?? ''),
|
||||
|
||||
+37
@@ -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] }],
|
||||
|
||||
+14
-22
@@ -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',
|
||||
});
|
||||
|
||||
|
||||
+31
-4
@@ -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);
|
||||
|
||||
|
||||
+10
-1
@@ -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)
|
||||
|
||||
+16
-5
@@ -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);
|
||||
|
||||
|
||||
+7
-8
@@ -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);
|
||||
|
||||
+48
-13
@@ -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],
|
||||
|
||||
+35
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user