fix(stalker): merge late connection saves

This commit is contained in:
4gray committed 2026-08-09 18:01:13 +02:00
1 parent ffae919cc8
commit 37575c8d88
10 files changed
+202 -29

No files matched your search

@@ -115,7 +115,9 @@ describe('PlaylistInfoComponent', () => {
disableClose: false,
};
stalkerConnectionEditor = {
applyResolvedConnection: jest.fn().mockResolvedValue(undefined),
applyResolvedConnection: jest.fn(
async (playlist: PlaylistMeta) => playlist
),
resolveConnection: jest.fn(
async (updatedPlaylist: PlaylistMeta) => ({
status: STALKER_PLAYLIST_CONNECTION_EDITOR_STATUS.RESOLVED,
@@ -841,6 +843,14 @@ describe('PlaylistInfoComponent', () => {
stalkerSessionIdentity: 'new-fingerprint',
},
};
const currentPlaylist = {
...resolvedPlaylist,
title: 'Newer title',
epgUrls: ['https://new.example.com/epg.xml'],
};
stalkerConnectionEditor.applyResolvedConnection.mockResolvedValueOnce(
currentPlaylist
);
let finishDiscovery:
| ((value: {
status: 'resolved';
@@ -869,10 +879,12 @@ describe('PlaylistInfoComponent', () => {
expect(
stalkerConnectionEditor.applyResolvedConnection
).toHaveBeenCalledWith(resolvedPlaylist);
).toHaveBeenCalledWith(resolvedPlaylist, {
preserveCurrentMetadata: true,
});
expect(store.dispatch).toHaveBeenCalledWith(
PlaylistActions.updatePlaylistMeta({
playlist: resolvedPlaylist,
playlist: currentPlaylist,
persist: false,
})
);
@@ -891,6 +903,14 @@ describe('PlaylistInfoComponent', () => {
stalkerSessionIdentity: 'new-fingerprint',
},
};
const currentPlaylist = {
...resolvedPlaylist,
title: 'Newer title',
epgUrls: ['https://new.example.com/epg.xml'],
};
stalkerConnectionEditor.applyResolvedConnection.mockResolvedValueOnce(
currentPlaylist
);
let finishDiscovery:
| ((value: {
status: 'resolved';
@@ -919,10 +939,12 @@ describe('PlaylistInfoComponent', () => {
expect(
stalkerConnectionEditor.applyResolvedConnection
).toHaveBeenCalledWith(resolvedPlaylist);
).toHaveBeenCalledWith(resolvedPlaylist, {
preserveCurrentMetadata: true,
});
expect(store.dispatch).toHaveBeenCalledWith(
PlaylistActions.updatePlaylistMeta({
playlist: resolvedPlaylist,
playlist: currentPlaylist,
persist: false,
})
);
@@ -397,6 +397,7 @@ export class PlaylistInfoComponent {
}
let resolvedStalkerConnection = false;
let preserveCurrentMetadata = false;
let normalizedPlaylist: PlaylistMetaUpdate =
this.normalizeStalkerPlaylistMeta(
this.normalizeXtreamPlaylistMeta(submittedPlaylist)
@@ -425,6 +426,8 @@ export class PlaylistInfoComponent {
}
normalizedPlaylist = result.playlist;
resolvedStalkerConnection = true;
preserveCurrentMetadata =
this.dialogClosing || this.destroyRef.destroyed;
} else {
normalizedPlaylist = preserveStalkerConnection(
this.playlist,
@@ -443,9 +446,14 @@ export class PlaylistInfoComponent {
}
if (resolvedStalkerConnection) {
await this.stalkerConnectionEditor.applyResolvedConnection(
normalizedPlaylist
);
normalizedPlaylist = preserveCurrentMetadata
? await this.stalkerConnectionEditor.applyResolvedConnection(
normalizedPlaylist,
{ preserveCurrentMetadata: true }
)
: await this.stalkerConnectionEditor.applyResolvedConnection(
normalizedPlaylist
);
}
// Resolved Stalker edits cross an awaited, atomic persistence
@@ -34,12 +34,20 @@ interface StalkerPlaylistConnectionResultMap {
export type StalkerPlaylistConnectionResult =
StalkerPlaylistConnectionResultMap[keyof StalkerPlaylistConnectionResultMap];
export interface StalkerResolvedConnectionApplyOptions {
/** Merge only connection/session fields into the current persisted row. */
preserveCurrentMetadata?: boolean;
}
export interface StalkerPlaylistConnectionEditor {
resolveConnection(
playlist: PlaylistMeta
): Promise<StalkerPlaylistConnectionResult>;
/** Atomically persists a resolved edit, then synchronizes in-run state. */
applyResolvedConnection(playlist: PlaylistMetaUpdate): Promise<void>;
applyResolvedConnection(
playlist: PlaylistMetaUpdate,
options?: StalkerResolvedConnectionApplyOptions
): Promise<PlaylistMetaUpdate>;
}
export const STALKER_PLAYLIST_CONNECTION_EDITOR =
@@ -23,11 +23,15 @@ describe('Stalker edited-session coordination', () => {
let service: StalkerSessionService;
let getPlaylistById: jest.Mock;
let updatePlaylistMeta: jest.Mock;
let transformPlaylistMeta: jest.Mock;
let updateStalkerSession: jest.Mock;
beforeEach(() => {
getPlaylistById = jest.fn(() => of(oldPlaylist));
updatePlaylistMeta = jest.fn(() => of(oldPlaylist));
transformPlaylistMeta = jest.fn((_id, transform) =>
of(transform(oldPlaylist))
);
updateStalkerSession = jest.fn(() => of(oldPlaylist));
sendIpcEvent = jest.fn();
TestBed.configureTestingModule({
@@ -42,6 +46,7 @@ describe('Stalker edited-session coordination', () => {
useValue: {
getPlaylistById,
updatePlaylistMeta,
transformPlaylistMeta,
updateStalkerSession,
},
},
@@ -318,6 +323,50 @@ describe('Stalker edited-session coordination', () => {
expect(updateStalkerSession).not.toHaveBeenCalled();
});
it('atomically merges only connection fields into a newer row after navigation', async () => {
const currentPlaylist = {
...oldPlaylist,
title: 'Newer title',
epgUrls: ['https://new.example.com/epg.xml'],
} as Playlist;
transformPlaylistMeta.mockImplementationOnce((_id, transform) =>
of(transform(currentPlaylist))
);
const editedPlaylist = {
...oldPlaylist,
title: 'Stale form title',
portalUrl: 'https://new.example.com/server/load.php',
username: 'subscriber',
stalkerToken: 'NEW_TOKEN',
stalkerWatchdogTimeout: 90,
stalkerTimeslot: 5,
} as Playlist;
const fence = await service.beginEditDiscovery(editedPlaylist);
const persisted = await service.replaceSessionAfterEdit(
editedPlaylist,
fence,
{ preserveCurrentMetadata: true }
);
expect(updatePlaylistMeta).not.toHaveBeenCalled();
expect(transformPlaylistMeta).toHaveBeenCalledWith(
oldPlaylist._id,
expect.any(Function)
);
expect(persisted).toEqual(
expect.objectContaining({
title: 'Newer title',
epgUrls: ['https://new.example.com/epg.xml'],
portalUrl: 'https://new.example.com/server/load.php',
username: 'subscriber',
stalkerToken: 'NEW_TOKEN',
stalkerWatchdogTimeout: 90,
stalkerTimeslot: 5,
})
);
});
it('does not adopt a resolved full session when its atomic write fails', async () => {
service.adoptDiscoveredSimplePortal(oldPlaylist);
updatePlaylistMeta.mockReturnValueOnce(
@@ -77,8 +77,7 @@ export class StalkerEditedSessionCoordinator {
if (stalkerSessionFingerprint(playlist) !== sessionFingerprint) {
throw new Error('Stale Stalker playlist configuration');
}
const authoritative =
this.authoritativeConfigurations.get(playlistId);
const authoritative = this.authoritativeConfigurations.get(playlistId);
if (
authoritative &&
authoritative !==
@@ -121,7 +120,11 @@ export class StalkerEditedSessionCoordinator {
}
}
replace(playlist: Playlist, fence?: StalkerEditFence): Promise<Playlist> {
replace(
playlist: Playlist,
fence?: StalkerEditFence,
options: { preserveCurrentMetadata?: boolean } = {}
): Promise<Playlist> {
const playlistId = playlist._id;
const sessionFingerprint = stalkerSessionFingerprint(playlist);
const configurationFingerprint = stalkerConfigurationFingerprint(
@@ -152,7 +155,8 @@ export class StalkerEditedSessionCoordinator {
playlist,
sessionFingerprint,
configurationFingerprint,
owner
owner,
options
)
);
this.replacements.set(playlistId, replacement);
@@ -173,7 +177,8 @@ export class StalkerEditedSessionCoordinator {
playlist: Playlist,
sessionFingerprint: string,
configurationFingerprint: string,
owner: symbol
owner: symbol,
options: { preserveCurrentMetadata?: boolean }
): Promise<Playlist> {
const playlistId = playlist._id;
this.assertFenceCurrent(
@@ -208,10 +213,18 @@ export class StalkerEditedSessionCoordinator {
}
const persistedPlaylist = await firstValueFrom(
playlists.updatePlaylistMeta({
...playlist,
stalkerSessionPatch: sessionPatch,
} as PlaylistMetaUpdate)
options.preserveCurrentMetadata
? playlists.transformPlaylistMeta(playlistId, (current) =>
mergeResolvedStalkerConnection(
current,
playlist,
sessionPatch
)
)
: playlists.updatePlaylistMeta({
...playlist,
stalkerSessionPatch: sessionPatch,
} as PlaylistMetaUpdate)
);
if (!persistedPlaylist) {
throw new Error('Resolved Stalker playlist could not be persisted');
@@ -292,6 +305,32 @@ export class StalkerEditedSessionCoordinator {
}
}
/** Applies remote connection authority without replaying stale form metadata. */
function mergeResolvedStalkerConnection(
current: Playlist,
resolved: Playlist,
sessionPatch: PlaylistMetaUpdate['stalkerSessionPatch']
): Playlist {
return {
...current,
portalUrl: resolved.portalUrl,
isFullStalkerPortal: resolved.isFullStalkerPortal,
macAddress: resolved.macAddress,
username: resolved.username,
password: resolved.password,
stalkerSerialNumber: resolved.stalkerSerialNumber,
stalkerDeviceId1: resolved.stalkerDeviceId1,
stalkerDeviceId2: resolved.stalkerDeviceId2,
stalkerSignature1: resolved.stalkerSignature1,
stalkerSignature2: resolved.stalkerSignature2,
stalkerToken: sessionPatch?.stalkerToken,
stalkerSessionIdentity: sessionPatch?.stalkerSessionIdentity,
stalkerWatchdogTimeout: sessionPatch?.stalkerWatchdogTimeout,
stalkerTimeslot: sessionPatch?.stalkerTimeslot,
stalkerAccountInfo: sessionPatch?.stalkerAccountInfo,
};
}
/** In-run Edit authority also owns the observed full/simple routing mode. */
function stalkerConfigurationFingerprint(
playlist: Playlist,
@@ -286,9 +286,10 @@ export class StalkerSessionService {
*/
replaceSessionAfterEdit(
playlist: Playlist,
fence?: StalkerEditFence
fence?: StalkerEditFence,
options: { preserveCurrentMetadata?: boolean } = {}
): Promise<Playlist> {
return this.editedSessions.replace(playlist, fence);
return this.editedSessions.replace(playlist, fence, options);
}
/**