fix(stalker): preserve progress with scoped episode IDs

This commit is contained in:
4gray committed 2026-07-30 10:17:55 +02:00
1 parent 3081f3e5ad
commit ac12272db6
6 files changed
+2569 -78

No files matched your search

@@ -10,6 +10,7 @@ import { StalkerSelectedVodItem } from './models';
type EpisodeWithMetadata = {
custom_sid?: string;
id?: string;
legacyTrackingId?: number;
originalId?: string;
originalCmd?: string;
};
@@ -68,7 +69,10 @@ describe('stalker-series.adapters', () => {
isExpanded: false,
},
],
'poster.jpg'
{
parentSeriesId: 100,
fallbackPoster: 'poster.jpg',
}
);
expect(mapped['1']).toHaveLength(2);
@@ -78,39 +82,152 @@ 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([
it('generates deterministic VOD-series episode IDs for the same parent and provider episode', () => {
const seasons = [
{
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',
id: 's1',
video_id: 'v1',
name: 'Season 1',
season_number: '',
season_number: '1',
episodes: [
{
id: 'episode-1',
id: 'provider-episode-1',
series_number: 1,
name: 'Pilot',
name: 'Episode 1',
},
],
isLoading: false,
isExpanded: false,
},
]);
];
const options = {
parentSeriesId: 100,
fallbackPoster: 'poster.jpg',
};
const firstMapping = mapVodSeriesEpisodes(seasons, options);
const secondMapping = mapVodSeriesEpisodes(seasons, options);
expect(firstMapping['1'][0].id).toBe('604391373');
expect(firstMapping['1'][0].id).toBe(secondMapping['1'][0].id);
});
it('scopes VOD-series episode IDs by parent while preserving the legacy tracking ID', () => {
const seasons = [
{
id: 's1',
video_id: 'v1',
name: 'Season 1',
season_number: '1',
episodes: [
{
id: 'provider-episode-1',
series_number: 1,
name: 'Episode 1',
},
],
isLoading: false,
isExpanded: false,
},
];
const firstSeries = mapVodSeriesEpisodes(seasons, {
parentSeriesId: 100,
fallbackPoster: 'poster.jpg',
});
const secondSeries = mapVodSeriesEpisodes(seasons, {
parentSeriesId: 200,
fallbackPoster: 'poster.jpg',
});
const firstEpisode = firstSeries['1'][0] as EpisodeWithMetadata;
const secondEpisode = secondSeries['1'][0] as EpisodeWithMetadata;
expect(firstEpisode.id).not.toBe(secondEpisode.id);
expect(firstEpisode.legacyTrackingId).toBe(624320047);
expect(firstEpisode.legacyTrackingId).toBe(
secondEpisode.legacyTrackingId
);
});
it('scopes VOD-series episode IDs by provider episode identity', () => {
const baseSeason = {
id: 's1',
video_id: 'v1',
name: 'Season 1',
season_number: '1',
isLoading: false,
isExpanded: false,
};
const firstSeries = mapVodSeriesEpisodes(
[
{
...baseSeason,
episodes: [
{
id: 'provider-episode-1',
series_number: 1,
name: 'Episode 1',
},
],
},
],
{ parentSeriesId: 100 }
);
const secondSeries = mapVodSeriesEpisodes(
[
{
...baseSeason,
episodes: [
{
id: 'provider-episode-2',
series_number: 1,
name: 'Episode 1',
},
],
},
],
{ parentSeriesId: 100 }
);
expect(firstSeries['1'][0].id).not.toBe(secondSeries['1'][0].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,
},
],
{ parentSeriesId: 100 }
);
expect(mapped['Season 1'][0].season).toBe(1);
expect(mapped['Season 2'][0].season).toBe(2);
@@ -132,6 +249,7 @@ describe('stalker-series.adapters', () => {
expect(mapped['1']).toHaveLength(2);
const firstEpisode = mapped['1'][0] as EpisodeWithMetadata;
expect(firstEpisode.custom_sid).toBe('regular-series');
expect(firstEpisode.id).toBe('91189090');
expect(firstEpisode.originalCmd).toBe('/media/file_100.mpg');
});
});
@@ -29,7 +29,13 @@ export interface StalkerSeriesSeasonVm {
series: number[];
}
export interface MapVodSeriesEpisodesOptions {
parentSeriesId: string | number;
fallbackPoster?: string;
}
export interface StalkerMappedEpisode extends XtreamSerieEpisode {
legacyTrackingId?: number;
originalId?: string;
originalCmd?: string;
}
@@ -44,15 +50,34 @@ function hashString(str: string): number {
return Math.abs(hash);
}
function generateEpisodeId(
seed: string,
function generateLegacyVodEpisodeId(
episodeNum: number,
seasonKey: string,
isVodSeries: boolean
seasonKey: string
): number {
return hashString(`vod_${seasonKey}_${episodeNum}`);
}
function generateVodEpisodeId(options: {
parentSeriesId: string | number;
providerEpisodeId: string;
seasonKey: string;
episodeNum: number;
}): number {
return hashString(
JSON.stringify([
'vod',
String(options.parentSeriesId),
options.providerEpisodeId,
options.seasonKey,
options.episodeNum,
])
);
}
function generateRegularEpisodeId(
seed: string,
episodeNum: number
): number {
if (isVodSeries) {
return hashString(`vod_${seasonKey}_${episodeNum}`);
}
return hashString(`${seed}_ep_${episodeNum}`);
}
@@ -153,7 +178,7 @@ function createBaseEpisode(
export function mapVodSeriesEpisodes(
seasons: ReadonlyArray<VodSeriesSeasonVm>,
fallbackPoster?: string
options: MapVodSeriesEpisodesOptions
): Record<string, XtreamSerieEpisode[]> {
const mapped: Record<string, XtreamSerieEpisode[]> = {};
@@ -165,12 +190,17 @@ export function mapVodSeriesEpisodes(
const episodeNum =
toEpisodeNumber(episode.series_number) ||
toEpisodeNumber(episode.episode_num);
const trackingId = generateEpisodeId(
String(episode.id ?? ''),
const providerEpisodeId = String(episode.id ?? '');
const legacyTrackingId = generateLegacyVodEpisodeId(
episodeNum,
seasonKey,
true
seasonKey
);
const trackingId = generateVodEpisodeId({
parentSeriesId: options.parentSeriesId,
providerEpisodeId,
seasonKey,
episodeNum,
});
return {
...createBaseEpisode(
@@ -181,14 +211,15 @@ export function mapVodSeriesEpisodes(
'vod-series',
seasonNum,
{
movie_image: episode.cover || fallbackPoster,
movie_image: episode.cover || options.fallbackPoster,
plot: episode.description || '',
duration: episode.duration
? `${episode.duration} min`
: '',
}
),
originalId: String(episode.id ?? ''),
legacyTrackingId,
originalId: providerEpisodeId,
} as StalkerMappedEpisode;
});
});
@@ -205,11 +236,9 @@ export function mapRegularSeriesEpisodes(
seasons.forEach((season, index) => {
const seasonKey = String(index + 1);
mapped[seasonKey] = (season.series ?? []).map((episodeNum) => {
const trackingId = generateEpisodeId(
const trackingId = generateRegularEpisodeId(
String(season.cmd ?? ''),
episodeNum,
seasonKey,
false
episodeNum
);
return {
@@ -0,0 +1,573 @@
import type {
PlaybackPositionData,
XtreamSerieEpisode,
} from '@iptvnator/shared/interfaces';
import type { StalkerMappedEpisode } from '@iptvnator/portal/stalker/data-access';
import {
clearStalkerSeriesPosition,
reconcileStalkerSeriesPositions,
saveStalkerSeriesPosition,
StalkerSeriesPositionPartialSaveError,
} from './stalker-series-position-compatibility';
const PLAYLIST_ID = 'playlist-1';
const SERIES_ID = 100;
const FOREIGN_SERIES_ID = 200;
const SCOPED_TRACKING_ID = 1001;
const LEGACY_TRACKING_ID = 501;
interface EpisodeOptions {
trackingId?: number;
legacyTrackingId?: number;
seasonNumber?: number;
episodeNumber?: number;
}
interface RejectedOwnershipCase {
name: string;
position: PlaybackPositionData;
legacyPosition?: PlaybackPositionData;
}
function createEpisode(options: EpisodeOptions = {}): StalkerMappedEpisode {
const episodeNumber = options.episodeNumber ?? 2;
return {
id: String(options.trackingId ?? SCOPED_TRACKING_ID),
episode_num: episodeNumber,
title: `Episode ${episodeNumber}`,
container_extension: 'mpg',
info: {},
custom_sid: 'vod-series',
added: '',
season: options.seasonNumber ?? 1,
direct_source: '',
legacyTrackingId: options.legacyTrackingId,
originalId: `provider-${episodeNumber}`,
};
}
function createPosition(
overrides: Partial<PlaybackPositionData> = {}
): PlaybackPositionData {
return {
contentXtreamId: SCOPED_TRACKING_ID,
contentType: 'episode',
seriesXtreamId: SERIES_ID,
seasonNumber: 1,
episodeNumber: 2,
positionSeconds: 45,
durationSeconds: 120,
playlistId: PLAYLIST_ID,
...overrides,
};
}
function reconcile(
episodes: readonly XtreamSerieEpisode[],
seriesPositions: readonly PlaybackPositionData[],
seriesXtreamId = SERIES_ID
) {
return reconcileStalkerSeriesPositions({
seriesXtreamId,
episodesBySeason: { '1': episodes },
seriesPositions,
});
}
describe('stalker series position compatibility', () => {
it('prefers an exact scoped row while retaining compatible legacy cleanup metadata', () => {
const episode = createEpisode({
legacyTrackingId: LEGACY_TRACKING_ID,
});
const exactPosition = createPosition({ positionSeconds: 90 });
const legacyPosition = createPosition({
contentXtreamId: LEGACY_TRACKING_ID,
positionSeconds: 30,
});
const result = reconcile(
[episode],
[legacyPosition, exactPosition]
);
expect(result.positionsByTrackingId.get(SCOPED_TRACKING_ID)).toBe(
exactPosition
);
expect(
result.legacyPositionByTrackingId.get(SCOPED_TRACKING_ID)
).toBe(legacyPosition);
expect(
result.positionsByTrackingId.has(LEGACY_TRACKING_ID)
).toBe(false);
});
it('aliases only the current parent legacy row under the scoped ID without mutating it', () => {
const episode = createEpisode({
legacyTrackingId: LEGACY_TRACKING_ID,
});
const currentParentLegacy = createPosition({
contentXtreamId: LEGACY_TRACKING_ID,
seasonNumber: undefined,
episodeNumber: undefined,
});
const foreignParentLegacy = createPosition({
contentXtreamId: LEGACY_TRACKING_ID,
seriesXtreamId: FOREIGN_SERIES_ID,
positionSeconds: 99,
});
const result = reconcile(
[episode],
[foreignParentLegacy, currentParentLegacy]
);
const migrated = result.positionsByTrackingId.get(SCOPED_TRACKING_ID);
expect(migrated).toEqual({
...currentParentLegacy,
contentXtreamId: SCOPED_TRACKING_ID,
seriesXtreamId: SERIES_ID,
seasonNumber: 1,
episodeNumber: 2,
});
expect(migrated).not.toBe(currentParentLegacy);
expect(currentParentLegacy.contentXtreamId).toBe(LEGACY_TRACKING_ID);
expect(
result.legacyPositionByTrackingId.get(SCOPED_TRACKING_ID)
).toBe(currentParentLegacy);
const foreignResult = reconcile(
[episode],
[foreignParentLegacy],
SERIES_ID
);
expect(foreignResult.positionsByTrackingId.size).toBe(0);
expect(foreignResult.legacyPositionByTrackingId.size).toBe(0);
});
it('matches present coordinates numerically and treats nullish coordinates as absent', () => {
const episode = createEpisode({
legacyTrackingId: LEGACY_TRACKING_ID,
});
const numericStringCoordinates = createPosition({
contentXtreamId: LEGACY_TRACKING_ID,
seasonNumber: '1' as unknown as number,
episodeNumber: '2' as unknown as number,
});
const absentCoordinates = createPosition({
contentXtreamId: LEGACY_TRACKING_ID,
seasonNumber: null as unknown as number,
episodeNumber: undefined,
});
expect(
reconcile(
[episode],
[numericStringCoordinates]
).positionsByTrackingId.has(SCOPED_TRACKING_ID)
).toBe(true);
expect(
reconcile(
[episode],
[absentCoordinates]
).positionsByTrackingId.has(SCOPED_TRACKING_ID)
).toBe(true);
});
it('rejects a legacy alias when either present coordinate conflicts', () => {
const episode = createEpisode({
legacyTrackingId: LEGACY_TRACKING_ID,
});
const wrongSeason = createPosition({
contentXtreamId: LEGACY_TRACKING_ID,
seasonNumber: 3,
});
const wrongEpisode = createPosition({
contentXtreamId: LEGACY_TRACKING_ID,
episodeNumber: 8,
});
for (const position of [wrongSeason, wrongEpisode]) {
const result = reconcile([episode], [position]);
expect(result.positionsByTrackingId.size).toBe(0);
expect(result.legacyPositionByTrackingId.size).toBe(0);
}
});
it('never aliases an episode without legacy tracking metadata', () => {
const episode = createEpisode();
const legacyPosition = createPosition({
contentXtreamId: LEGACY_TRACKING_ID,
});
const result = reconcile([episode], [legacyPosition]);
expect(result.positionsByTrackingId.size).toBe(0);
expect(result.legacyPositionByTrackingId.size).toBe(0);
});
it('awaits a scoped save before clearing the confirmed legacy row', async () => {
const order: string[] = [];
const legacyPosition = createPosition({
contentXtreamId: LEGACY_TRACKING_ID,
});
const position = reconcile(
[
createEpisode({
legacyTrackingId: LEGACY_TRACKING_ID,
}),
],
[legacyPosition]
).positionsByTrackingId.get(SCOPED_TRACKING_ID);
expect(position).toBeDefined();
if (!position) {
throw new Error('Expected a migrated scoped position');
}
const repository = {
savePlaybackPosition: jest.fn(
async (_playlistId: string, saved: PlaybackPositionData) => {
order.push(`save:${saved.contentXtreamId}`);
}
),
clearPlaybackPosition: jest.fn(
async (
_playlistId: string,
contentXtreamId: number
) => {
order.push(`clear:${contentXtreamId}`);
}
),
};
await expect(
saveStalkerSeriesPosition({
repository,
playlistId: PLAYLIST_ID,
position,
legacyPosition,
})
).resolves.toBe(true);
expect(order).toEqual([
`save:${SCOPED_TRACKING_ID}`,
`clear:${LEGACY_TRACKING_ID}`,
]);
});
it('does not clear legacy when the scoped save rejects', async () => {
const saveError = new Error('save failed');
const repository = {
savePlaybackPosition: jest.fn().mockRejectedValue(saveError),
clearPlaybackPosition: jest.fn().mockResolvedValue(undefined),
};
await expect(
saveStalkerSeriesPosition({
repository,
playlistId: PLAYLIST_ID,
position: createPosition(),
legacyPosition: createPosition({
contentXtreamId: LEGACY_TRACKING_ID,
}),
})
).rejects.toBe(saveError);
expect(repository.clearPlaybackPosition).not.toHaveBeenCalled();
});
it('reports a partial save when legacy cleanup rejects after the scoped write', async () => {
const order: string[] = [];
const clearError = new Error('clear failed');
const repository = {
savePlaybackPosition: jest.fn(
async (_playlistId: string, position: PlaybackPositionData) => {
order.push(`save:${position.contentXtreamId}`);
}
),
clearPlaybackPosition: jest.fn(
async (_playlistId: string, contentXtreamId: number) => {
order.push(`clear:${contentXtreamId}`);
throw clearError;
}
),
};
const save = saveStalkerSeriesPosition({
repository,
playlistId: PLAYLIST_ID,
position: createPosition(),
legacyPosition: createPosition({
contentXtreamId: LEGACY_TRACKING_ID,
}),
});
const rejection = await save.catch(
(error: unknown) => error
);
expect(rejection).toBeInstanceOf(
StalkerSeriesPositionPartialSaveError
);
if (
!(rejection instanceof
StalkerSeriesPositionPartialSaveError)
) {
throw new Error('Expected a partial save error');
}
expect(rejection.cause).toBe(clearError);
expect(rejection.scopedPositionSaved).toBe(true);
expect(order).toEqual([
`save:${SCOPED_TRACKING_ID}`,
`clear:${LEGACY_TRACKING_ID}`,
]);
});
const rejectedOwnershipCases: RejectedOwnershipCase[] = [
{
name: 'different parents',
position: createPosition(),
legacyPosition: createPosition({
contentXtreamId: LEGACY_TRACKING_ID,
seriesXtreamId: FOREIGN_SERIES_ID,
}),
},
{
name: 'missing scoped parent',
position: createPosition({ seriesXtreamId: undefined }),
legacyPosition: createPosition({
contentXtreamId: LEGACY_TRACKING_ID,
}),
},
{
name: 'missing legacy parent',
position: createPosition(),
legacyPosition: createPosition({
contentXtreamId: LEGACY_TRACKING_ID,
seriesXtreamId: undefined,
}),
},
{
name: 'equal scoped and legacy IDs',
position: createPosition(),
legacyPosition: createPosition(),
},
{
name: 'non-episode scoped content',
position: createPosition({ contentType: 'vod' }),
legacyPosition: createPosition({
contentXtreamId: LEGACY_TRACKING_ID,
}),
},
{
name: 'non-episode legacy content',
position: createPosition(),
legacyPosition: createPosition({
contentXtreamId: LEGACY_TRACKING_ID,
contentType: 'vod',
}),
},
{
name: 'conflicting scoped playlist',
position: createPosition({ playlistId: 'playlist-2' }),
legacyPosition: createPosition({
contentXtreamId: LEGACY_TRACKING_ID,
}),
},
{
name: 'conflicting legacy playlist',
position: createPosition(),
legacyPosition: createPosition({
contentXtreamId: LEGACY_TRACKING_ID,
playlistId: 'playlist-2',
}),
},
{
name: 'missing legacy row',
position: createPosition(),
},
];
it.each(rejectedOwnershipCases)(
'does not clean an unowned legacy row for $name',
async ({ position, legacyPosition }) => {
const saveRepository = {
savePlaybackPosition: jest.fn().mockResolvedValue(undefined),
clearPlaybackPosition: jest
.fn()
.mockResolvedValue(undefined),
};
await expect(
saveStalkerSeriesPosition({
repository: saveRepository,
playlistId: PLAYLIST_ID,
position,
legacyPosition,
})
).resolves.toBe(false);
expect(
saveRepository.savePlaybackPosition
).toHaveBeenCalledWith(PLAYLIST_ID, position);
expect(
saveRepository.clearPlaybackPosition
).not.toHaveBeenCalled();
const clearRepository = {
clearPlaybackPosition: jest
.fn()
.mockResolvedValue(undefined),
};
await expect(
clearStalkerSeriesPosition({
repository: clearRepository,
playlistId: PLAYLIST_ID,
position,
legacyPosition,
})
).resolves.toBe(false);
expect(
clearRepository.clearPlaybackPosition
).toHaveBeenCalledTimes(1);
expect(
clearRepository.clearPlaybackPosition
).toHaveBeenCalledWith(
PLAYLIST_ID,
position.contentXtreamId,
position.contentType
);
}
);
it('clears confirmed legacy before scoped state and cannot resurrect it', async () => {
const episode = createEpisode({
legacyTrackingId: LEGACY_TRACKING_ID,
});
const exactPosition = createPosition();
const legacyPosition = createPosition({
contentXtreamId: LEGACY_TRACKING_ID,
positionSeconds: 20,
});
const rows = new Map<number, PlaybackPositionData>([
[SCOPED_TRACKING_ID, exactPosition],
[LEGACY_TRACKING_ID, legacyPosition],
]);
const order: string[] = [];
const repository = {
clearPlaybackPosition: jest.fn(
async (
_playlistId: string,
contentXtreamId: number
) => {
order.push(`clear:${contentXtreamId}`);
rows.delete(contentXtreamId);
}
),
};
await expect(
clearStalkerSeriesPosition({
repository,
playlistId: PLAYLIST_ID,
position: exactPosition,
legacyPosition,
})
).resolves.toBe(true);
expect(order).toEqual([
`clear:${LEGACY_TRACKING_ID}`,
`clear:${SCOPED_TRACKING_ID}`,
]);
const refreshed = reconcile([episode], [...rows.values()]);
expect(refreshed.positionsByTrackingId.size).toBe(0);
expect(refreshed.legacyPositionByTrackingId.size).toBe(0);
});
it('leaves exact scoped progress when legacy cleanup rejects', async () => {
const episode = createEpisode({
legacyTrackingId: LEGACY_TRACKING_ID,
});
const exactPosition = createPosition();
const legacyPosition = createPosition({
contentXtreamId: LEGACY_TRACKING_ID,
});
const rows = new Map<number, PlaybackPositionData>([
[SCOPED_TRACKING_ID, exactPosition],
[LEGACY_TRACKING_ID, legacyPosition],
]);
const clearError = new Error('legacy clear failed');
const repository = {
clearPlaybackPosition: jest.fn(
async (
_playlistId: string,
contentXtreamId: number
) => {
if (contentXtreamId === LEGACY_TRACKING_ID) {
throw clearError;
}
rows.delete(contentXtreamId);
}
),
};
await expect(
clearStalkerSeriesPosition({
repository,
playlistId: PLAYLIST_ID,
position: exactPosition,
legacyPosition,
})
).rejects.toBe(clearError);
expect(rows.get(SCOPED_TRACKING_ID)).toBe(exactPosition);
expect(
reconcile([episode], [...rows.values()])
.positionsByTrackingId
.get(SCOPED_TRACKING_ID)
).toBe(exactPosition);
});
it('leaves exact scoped progress when scoped clear rejects after legacy cleanup', async () => {
const episode = createEpisode({
legacyTrackingId: LEGACY_TRACKING_ID,
});
const exactPosition = createPosition();
const legacyPosition = createPosition({
contentXtreamId: LEGACY_TRACKING_ID,
});
const rows = new Map<number, PlaybackPositionData>([
[SCOPED_TRACKING_ID, exactPosition],
[LEGACY_TRACKING_ID, legacyPosition],
]);
const order: number[] = [];
const clearError = new Error('scoped clear failed');
const repository = {
clearPlaybackPosition: jest.fn(
async (
_playlistId: string,
contentXtreamId: number
) => {
order.push(contentXtreamId);
if (contentXtreamId === SCOPED_TRACKING_ID) {
throw clearError;
}
rows.delete(contentXtreamId);
}
),
};
await expect(
clearStalkerSeriesPosition({
repository,
playlistId: PLAYLIST_ID,
position: exactPosition,
legacyPosition,
})
).rejects.toBe(clearError);
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);
const refreshed = reconcile([episode], [...rows.values()]);
expect(
refreshed.positionsByTrackingId.get(SCOPED_TRACKING_ID)
).toBe(exactPosition);
expect(refreshed.legacyPositionByTrackingId.size).toBe(0);
});
});
@@ -0,0 +1,203 @@
import type { StalkerMappedEpisode } from '@iptvnator/portal/stalker/data-access';
import type { PortalPlaybackPositions } from '@iptvnator/portal/shared/util';
import type {
PlaybackPositionData,
XtreamSerieEpisode,
} from '@iptvnator/shared/interfaces';
export interface ReconciledStalkerSeriesPositions {
positionsByTrackingId: Map<number, PlaybackPositionData>;
legacyPositionByTrackingId: Map<number, PlaybackPositionData>;
}
export class StalkerSeriesPositionPartialSaveError extends Error {
readonly cause: unknown;
readonly scopedPositionSaved = true as const;
constructor(cause: unknown) {
super(
'Scoped Stalker series position was saved, but legacy cleanup failed'
);
this.name = 'StalkerSeriesPositionPartialSaveError';
this.cause = cause;
}
}
function matchesMappedCoordinate(
value: number | undefined,
mappedValue: number
): boolean {
return value == null || Number(value) === mappedValue;
}
function isCompatibleLegacyPosition(
position: PlaybackPositionData,
episode: XtreamSerieEpisode
): boolean {
return (
matchesMappedCoordinate(position.seasonNumber, Number(episode.season)) &&
matchesMappedCoordinate(
position.episodeNumber,
Number(episode.episode_num)
)
);
}
export function reconcileStalkerSeriesPositions(options: {
seriesXtreamId: number;
episodesBySeason: Readonly<
Record<string, readonly XtreamSerieEpisode[]>
>;
seriesPositions: readonly PlaybackPositionData[];
}): ReconciledStalkerSeriesPositions {
const positionsByTrackingId = new Map<number, PlaybackPositionData>();
const legacyPositionByTrackingId = new Map<
number,
PlaybackPositionData
>();
const indexedPositions = new Map<number, PlaybackPositionData>();
for (const position of options.seriesPositions) {
if (
position.contentType === 'episode' &&
position.seriesXtreamId === options.seriesXtreamId
) {
indexedPositions.set(position.contentXtreamId, position);
}
}
for (const episodes of Object.values(options.episodesBySeason)) {
for (const episode of episodes) {
const trackingId = Number(episode.id);
if (!Number.isFinite(trackingId)) {
continue;
}
const exactPosition = indexedPositions.get(trackingId);
if (exactPosition) {
positionsByTrackingId.set(trackingId, exactPosition);
}
const legacyTrackingId = (episode as StalkerMappedEpisode)
.legacyTrackingId;
if (
legacyTrackingId == null ||
legacyTrackingId === trackingId
) {
continue;
}
const legacyPosition = indexedPositions.get(legacyTrackingId);
if (
!legacyPosition ||
!isCompatibleLegacyPosition(legacyPosition, episode)
) {
continue;
}
legacyPositionByTrackingId.set(trackingId, legacyPosition);
if (!exactPosition) {
positionsByTrackingId.set(trackingId, {
...legacyPosition,
contentXtreamId: trackingId,
seriesXtreamId: options.seriesXtreamId,
seasonNumber: Number(episode.season),
episodeNumber: Number(episode.episode_num),
});
}
}
}
return {
positionsByTrackingId,
legacyPositionByTrackingId,
};
}
function ownsLegacyPosition(
playlistId: string,
position: PlaybackPositionData,
legacyPosition: PlaybackPositionData | undefined
): legacyPosition is PlaybackPositionData {
return (
position.contentType === 'episode' &&
legacyPosition?.contentType === 'episode' &&
position.contentXtreamId !== legacyPosition.contentXtreamId &&
position.seriesXtreamId != null &&
legacyPosition.seriesXtreamId != null &&
position.seriesXtreamId === legacyPosition.seriesXtreamId &&
(!position.playlistId || position.playlistId === playlistId) &&
(!legacyPosition.playlistId ||
legacyPosition.playlistId === playlistId)
);
}
export async function saveStalkerSeriesPosition(options: {
repository: Pick<
PortalPlaybackPositions,
'savePlaybackPosition' | 'clearPlaybackPosition'
>;
playlistId: string;
position: PlaybackPositionData;
legacyPosition?: PlaybackPositionData;
}): Promise<boolean> {
await options.repository.savePlaybackPosition(
options.playlistId,
options.position
);
if (
!ownsLegacyPosition(
options.playlistId,
options.position,
options.legacyPosition
)
) {
return false;
}
try {
await options.repository.clearPlaybackPosition(
options.playlistId,
options.legacyPosition.contentXtreamId,
options.legacyPosition.contentType
);
} catch (cause) {
throw new StalkerSeriesPositionPartialSaveError(cause);
}
return true;
}
export async function clearStalkerSeriesPosition(options: {
repository: Pick<PortalPlaybackPositions, 'clearPlaybackPosition'>;
playlistId: string;
position: PlaybackPositionData;
legacyPosition?: PlaybackPositionData;
}): Promise<boolean> {
if (
ownsLegacyPosition(
options.playlistId,
options.position,
options.legacyPosition
)
) {
await options.repository.clearPlaybackPosition(
options.playlistId,
options.legacyPosition.contentXtreamId,
options.legacyPosition.contentType
);
await options.repository.clearPlaybackPosition(
options.playlistId,
options.position.contentXtreamId,
options.position.contentType
);
return true;
}
await options.repository.clearPlaybackPosition(
options.playlistId,
options.position.contentXtreamId,
options.position.contentType
);
return false;
}
@@ -76,6 +76,19 @@ import {
getStalkerSeriesQuickStartButton,
type StalkerQuickStartButton,
} from './stalker-series-quick-start';
import {
clearStalkerSeriesPosition,
reconcileStalkerSeriesPositions,
saveStalkerSeriesPosition,
StalkerSeriesPositionPartialSaveError,
} from './stalker-series-position-compatibility';
interface SeriesPositionContext {
readonly generation: number;
readonly playlistId: string;
readonly seriesXtreamId: number;
readonly mutationKey: string;
}
/**
* Component for displaying series/episodes for Stalker portal content.
@@ -120,6 +133,24 @@ export class StalkerSeriesViewComponent implements OnDestroy {
readonly episodePlaybackPositions = signal<
Map<number, PlaybackPositionData>
>(new Map());
private readonly rawSeriesPositions = signal<
readonly PlaybackPositionData[]
>([]);
private readonly legacyPositionByTrackingId = signal<
Map<number, PlaybackPositionData>
>(new Map());
private activeSeriesPositionContext: SeriesPositionContext | null = null;
private seriesPositionContextGeneration = 0;
private readonly seriesPositionMutationQueues = new Map<
string,
Promise<void>
>();
private readonly pendingSeriesPositionLoads = new Map<
SeriesPositionContext,
Set<number>
>();
private readonly seriesPositionReloadKeys = new Set<string>();
private seriesPositionsLoadGeneration = 0;
private lastSaveTime = 0;
private unsubscribePositionUpdates: (() => void) | null = null;
readonly openingEpisodeId = signal<number | null>(null);
@@ -276,24 +307,68 @@ export class StalkerSeriesViewComponent implements OnDestroy {
effect(() => {
const item = this.displayItem();
const playlist = this.stalkerStore.currentPlaylist();
if (item && playlist?._id) {
const normalizedSeriesId = this.toSeriesId(item.id);
const normalizedSeriesId = this.toSeriesId(item?.id ?? 0);
if (item && playlist?._id && normalizedSeriesId > 0) {
this.logger.debug('Loading positions for series', {
id: item.id,
seriesId: normalizedSeriesId,
isSeries: item.is_series,
});
if (!isNaN(normalizedSeriesId)) {
void this.loadSeriesPositions(
playlist._id,
normalizedSeriesId
);
}
} else {
this.rawSeriesPositions.set([]);
this.episodePlaybackPositions.set(new Map());
this.legacyPositionByTrackingId.set(new Map());
const context = this.activateSeriesPositionContext(
playlist._id,
normalizedSeriesId
);
void this.loadSeriesPositions(context);
} else {
this.activeSeriesPositionContext = null;
this.seriesPositionContextGeneration++;
this.seriesPositionsLoadGeneration++;
}
});
effect(() => {
const item = this.displayItem();
const playlistId = this.stalkerStore.currentPlaylist()?._id;
const seriesXtreamId = this.toSeriesId(item?.id ?? 0);
const rawSeriesPositions = this.rawSeriesPositions();
const episodesBySeason = this.mappedSeasons();
if (!item || !playlistId || seriesXtreamId <= 0) {
if (rawSeriesPositions.length > 0) {
this.rawSeriesPositions.set([]);
}
if (this.episodePlaybackPositions().size > 0) {
this.episodePlaybackPositions.set(new Map());
}
if (this.legacyPositionByTrackingId().size > 0) {
this.legacyPositionByTrackingId.set(new Map());
}
return;
}
const reconciled = reconcileStalkerSeriesPositions({
seriesXtreamId,
episodesBySeason,
seriesPositions: rawSeriesPositions,
});
if (
rawSeriesPositions.length === 0 &&
reconciled.positionsByTrackingId.size === 0 &&
untracked(() => this.episodePlaybackPositions().size) > 0
) {
return;
}
this.episodePlaybackPositions.set(
reconciled.positionsByTrackingId
);
this.legacyPositionByTrackingId.set(
reconciled.legacyPositionByTrackingId
);
});
effect(() => {
const session = this.externalPlayback.activeSession();
const item = this.displayItem();
@@ -344,7 +419,18 @@ export class StalkerSeriesViewComponent implements OnDestroy {
return;
}
this.updateEpisodePlaybackPosition(data);
// The facade/runtime already saved this row. Repeat the
// idempotent upsert because only this view owns the
// scoped-to-legacy cleanup mapping.
void this.persistSeriesPosition(
playlistId,
data
).catch((error: unknown) => {
this.logger.error(
'Failed to persist runtime series position',
error
);
});
}
) ?? null;
}
@@ -388,22 +474,20 @@ export class StalkerSeriesViewComponent implements OnDestroy {
*/
readonly mappedSeasons = computed<Record<string, XtreamSerieEpisode[]>>(
() => {
const displayItem = this.displayItem();
const base = this.isVodSeries()
? mapVodSeriesEpisodes(
this.vodSeriesSeasons(),
this.displayItem()?.info?.movie_image
)
? mapVodSeriesEpisodes(this.vodSeriesSeasons(), {
parentSeriesId: this.toSeriesId(displayItem?.id ?? 0),
fallbackPoster: displayItem?.info?.movie_image,
})
: mapRegularSeriesEpisodes(
this.regularSeasons(),
this.displayItem()?.info?.movie_image
displayItem?.info?.movie_image
);
// Overlay lazily fetched TMDB episode data (real names,
// overviews, stills) — a no-op while nothing is fetched
return this.tmdbSeasons.overlay(
base,
this.displayItem()?.info?.tmdb_id
);
return this.tmdbSeasons.overlay(base, displayItem?.info?.tmdb_id);
}
);
@@ -750,11 +834,15 @@ export class StalkerSeriesViewComponent implements OnDestroy {
positionSeconds: Math.floor(event.currentTime),
durationSeconds: Math.floor(event.duration),
};
void this.playbackPositions.savePlaybackPosition(
void this.persistSeriesPosition(
playback.contentInfo.playlistId,
position
);
this.updateEpisodePlaybackPosition(position);
).catch((error: unknown) => {
this.logger.error(
'Failed to persist inline series position',
error
);
});
}
showCopyNotification(): void {
@@ -894,20 +982,17 @@ export class StalkerSeriesViewComponent implements OnDestroy {
}
if (request.nextPosition) {
await this.playbackPositions.savePlaybackPosition(
await this.persistSeriesPosition(
playlistId,
request.nextPosition
);
this.updateEpisodePlaybackPosition(request.nextPosition);
return;
}
await this.playbackPositions.clearPlaybackPosition(
await this.clearSeriesPosition(
playlistId,
request.contentXtreamId,
'episode'
request.contentXtreamId
);
this.removeEpisodePlaybackPosition(request.contentXtreamId);
}
async downloadEpisode(episode: XtreamSerieEpisode): Promise<void> {
@@ -975,19 +1060,320 @@ export class StalkerSeriesViewComponent implements OnDestroy {
}
private async loadSeriesPositions(
context: SeriesPositionContext
): Promise<void> {
const generation = ++this.seriesPositionsLoadGeneration;
this.trackPendingSeriesPositionLoad(context, generation);
try {
await this.waitForSeriesPositionMutations(
context.mutationKey
);
if (
generation !== this.seriesPositionsLoadGeneration ||
!this.isSeriesPositionContextActive(context)
) {
return;
}
const positions =
await this.playbackPositions.getSeriesPlaybackPositions(
context.playlistId,
context.seriesXtreamId
);
if (
generation !== this.seriesPositionsLoadGeneration ||
!this.isSeriesPositionContextActive(context)
) {
return;
}
this.rawSeriesPositions.set(positions);
} finally {
this.untrackPendingSeriesPositionLoad(context, generation);
}
}
private activateSeriesPositionContext(
playlistId: string,
seriesXtreamId: number
): SeriesPositionContext {
const context: SeriesPositionContext = {
generation: ++this.seriesPositionContextGeneration,
playlistId,
seriesXtreamId,
mutationKey: JSON.stringify([playlistId, seriesXtreamId]),
};
this.activeSeriesPositionContext = context;
return context;
}
private isSeriesPositionContextActive(
context: SeriesPositionContext
): boolean {
const activeContext = this.activeSeriesPositionContext;
return (
activeContext === context &&
activeContext.generation === context.generation &&
this.stalkerStore.currentPlaylist()?._id ===
context.playlistId &&
this.toSeriesId(this.displayItem()?.id ?? 0) ===
context.seriesXtreamId
);
}
private waitForSeriesPositionMutations(
mutationKey: string
): Promise<void> {
const positions =
await this.playbackPositions.getSeriesPlaybackPositions(
playlistId,
seriesXtreamId
);
const positionsMap = new Map<number, PlaybackPositionData>();
positions.forEach((position) => {
positionsMap.set(position.contentXtreamId, position);
return (
this.seriesPositionMutationQueues.get(mutationKey) ??
Promise.resolve()
);
}
private trackPendingSeriesPositionLoad(
context: SeriesPositionContext,
generation: number
): void {
const generations =
this.pendingSeriesPositionLoads.get(context) ??
new Set<number>();
generations.add(generation);
this.pendingSeriesPositionLoads.set(context, generations);
}
private untrackPendingSeriesPositionLoad(
context: SeriesPositionContext,
generation: number
): void {
const generations =
this.pendingSeriesPositionLoads.get(context);
generations?.delete(generation);
if (generations?.size === 0) {
this.pendingSeriesPositionLoads.delete(context);
}
}
private hasCurrentPendingSeriesPositionLoad(
context: SeriesPositionContext
): boolean {
return Boolean(
this.pendingSeriesPositionLoads
.get(context)
?.has(this.seriesPositionsLoadGeneration)
);
}
private enqueueSeriesPositionMutation(
context: SeriesPositionContext,
operation: () => Promise<void>
): Promise<void> {
if (this.hasCurrentPendingSeriesPositionLoad(context)) {
this.seriesPositionReloadKeys.add(context.mutationKey);
}
this.seriesPositionsLoadGeneration++;
const previous = this.waitForSeriesPositionMutations(
context.mutationKey
);
const result = previous.then(operation);
const barrier = result.then(
() => undefined,
() => undefined
);
this.seriesPositionMutationQueues.set(
context.mutationKey,
barrier
);
void barrier.then(() => {
if (
this.seriesPositionMutationQueues.get(
context.mutationKey
) === barrier
) {
this.seriesPositionMutationQueues.delete(
context.mutationKey
);
this.reloadSeriesPositionsAfterMutations(
context.mutationKey
);
}
});
this.episodePlaybackPositions.set(positionsMap);
return result;
}
private reloadSeriesPositionsAfterMutations(
mutationKey: string
): void {
if (!this.seriesPositionReloadKeys.delete(mutationKey)) {
return;
}
const context = this.activeSeriesPositionContext;
if (
!context ||
context.mutationKey !== mutationKey ||
!this.isSeriesPositionContextActive(context) ||
this.hasCurrentPendingSeriesPositionLoad(context)
) {
return;
}
void this.loadSeriesPositions(context);
}
private getSeriesPositionMutationContext(
playlistId: string,
seriesXtreamId?: number | null
): SeriesPositionContext | null {
const context = this.activeSeriesPositionContext;
if (
!context ||
context.playlistId !== playlistId ||
(seriesXtreamId != null &&
context.seriesXtreamId !== seriesXtreamId)
) {
return null;
}
return context;
}
private persistSeriesPosition(
playlistId: string,
position: PlaybackPositionData
): Promise<void> {
const context = this.getSeriesPositionMutationContext(
playlistId,
position.seriesXtreamId
);
if (!context) {
return Promise.resolve();
}
const legacyPosition =
this.legacyPositionByTrackingId().get(
position.contentXtreamId
);
return this.enqueueSeriesPositionMutation(context, async () => {
let clearedLegacy: boolean;
try {
clearedLegacy = await saveStalkerSeriesPosition({
repository: this.playbackPositions,
playlistId,
position,
legacyPosition,
});
} catch (error) {
if (
error instanceof
StalkerSeriesPositionPartialSaveError &&
this.isSeriesPositionContextActive(context)
) {
this.publishSavedSeriesPosition(
position,
legacyPosition,
false
);
}
throw error;
}
if (!this.isSeriesPositionContextActive(context)) {
return;
}
this.publishSavedSeriesPosition(
position,
legacyPosition,
clearedLegacy
);
});
}
private publishSavedSeriesPosition(
position: PlaybackPositionData,
legacyPosition: PlaybackPositionData | undefined,
clearedLegacy: boolean
): void {
const removedTrackingIds = new Set([
position.contentXtreamId,
]);
if (clearedLegacy && legacyPosition) {
removedTrackingIds.add(legacyPosition.contentXtreamId);
const legacyPositions = new Map(
this.legacyPositionByTrackingId()
);
legacyPositions.delete(position.contentXtreamId);
this.legacyPositionByTrackingId.set(legacyPositions);
}
this.rawSeriesPositions.set([
...this.rawSeriesPositions().filter(
(candidate) =>
!removedTrackingIds.has(
candidate.contentXtreamId
)
),
position,
]);
this.updateEpisodePlaybackPosition(position);
}
private clearSeriesPosition(
playlistId: string,
contentXtreamId: number
): Promise<void> {
const context = this.getSeriesPositionMutationContext(playlistId);
if (!context) {
return Promise.resolve();
}
const position =
this.episodePlaybackPositions().get(contentXtreamId) ?? {
contentXtreamId,
contentType: 'episode',
positionSeconds: 0,
playlistId,
seriesXtreamId: context.seriesXtreamId,
};
const legacyPosition =
this.legacyPositionByTrackingId().get(contentXtreamId);
return this.enqueueSeriesPositionMutation(context, async () => {
const clearedLegacy = await clearStalkerSeriesPosition({
repository: this.playbackPositions,
playlistId,
position,
legacyPosition,
});
if (!this.isSeriesPositionContextActive(context)) {
return;
}
this.publishClearedSeriesPosition(
contentXtreamId,
legacyPosition,
clearedLegacy
);
});
}
private publishClearedSeriesPosition(
contentXtreamId: number,
legacyPosition: PlaybackPositionData | undefined,
clearedLegacy: boolean
): void {
const removedTrackingIds = new Set([contentXtreamId]);
if (clearedLegacy && legacyPosition) {
removedTrackingIds.add(legacyPosition.contentXtreamId);
const legacyPositions = new Map(
this.legacyPositionByTrackingId()
);
legacyPositions.delete(contentXtreamId);
this.legacyPositionByTrackingId.set(legacyPositions);
}
this.rawSeriesPositions.set(
this.rawSeriesPositions().filter(
(candidate) =>
!removedTrackingIds.has(
candidate.contentXtreamId
)
)
);
this.removeEpisodePlaybackPosition(contentXtreamId);
}
private async loadAndPlayVodSeriesSeason(