mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 03:56:15 -08:00
fix(stalker): preserve progress with scoped episode IDs
This commit is contained in:
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 {
|
||||
|
||||
+573
@@ -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);
|
||||
});
|
||||
});
|
||||
+203
@@ -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;
|
||||
}
|
||||
+423
-37
@@ -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(
|
||||
|
||||
+1182
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user