mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 13:46:16 -08:00
fix(stalker): serialize edit discovery
This commit is contained in:
1 parent
3505eafdd1
commit
b387fe2a55
9 files changed
+370
-54
No files matched your search
@@ -18,10 +18,16 @@ describe('AppStalkerPlaylistConnectionEditorService', () => {
|
||||
};
|
||||
const portalRepair = {
|
||||
applyOverride: jest.fn((playlist: PlaylistMeta) => playlist),
|
||||
fenceForPlaylistEdit: jest.fn(),
|
||||
fenceForPlaylistEdit: jest.fn(() => Promise.resolve()),
|
||||
releasePlaylistEdit: jest.fn(),
|
||||
commitPlaylistEdit: jest.fn(),
|
||||
};
|
||||
const stalkerSession = {
|
||||
beginEditDiscovery: jest.fn(async (playlist: PlaylistMeta) => ({
|
||||
playlistId: playlist._id,
|
||||
owner: Symbol('edit-fence'),
|
||||
})),
|
||||
cancelEditDiscovery: jest.fn(),
|
||||
replaceSessionAfterEdit: jest.fn(
|
||||
async (playlist: PlaylistMeta) => playlist
|
||||
),
|
||||
@@ -193,6 +199,50 @@ describe('AppStalkerPlaylistConnectionEditorService', () => {
|
||||
status: STALKER_PLAYLIST_CONNECTION_EDITOR_STATUS.UNREACHABLE,
|
||||
message: 'HOME.STALKER_PORTAL.EDIT_UNREACHABLE',
|
||||
});
|
||||
expect(stalkerSession.cancelEditDiscovery).toHaveBeenCalled();
|
||||
expect(portalRepair.releasePlaylistEdit).toHaveBeenCalledWith(
|
||||
draft._id
|
||||
);
|
||||
});
|
||||
|
||||
it('does not start discovery until old authentication and repair work drain', async () => {
|
||||
let finishSessionFence: (fence: {
|
||||
playlistId: string;
|
||||
owner: symbol;
|
||||
}) => void = () => undefined;
|
||||
let finishRepairFence: () => void = () => undefined;
|
||||
stalkerSession.beginEditDiscovery.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
finishSessionFence = resolve;
|
||||
})
|
||||
);
|
||||
portalRepair.fenceForPlaylistEdit.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
finishRepairFence = resolve;
|
||||
})
|
||||
);
|
||||
discovery.discover.mockResolvedValue({
|
||||
status: 'resolved',
|
||||
portalUrl: 'https://portal.example.com/portal.php',
|
||||
isFullStalkerPortal: false,
|
||||
});
|
||||
|
||||
const resolving = service.resolveConnection(draft);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(discovery.discover).not.toHaveBeenCalled();
|
||||
|
||||
finishSessionFence({
|
||||
playlistId: draft._id,
|
||||
owner: Symbol('edit-fence'),
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(discovery.discover).not.toHaveBeenCalled();
|
||||
|
||||
finishRepairFence();
|
||||
await resolving;
|
||||
|
||||
expect(discovery.discover).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('replaces the active runtime playlist and session after a resolved full-portal edit', async () => {
|
||||
@@ -227,7 +277,8 @@ describe('AppStalkerPlaylistConnectionEditorService', () => {
|
||||
stalkerSessionIdentity: 'new-fingerprint',
|
||||
stalkerWatchdogTimeout: 90,
|
||||
stalkerTimeslot: 5,
|
||||
})
|
||||
}),
|
||||
expect.objectContaining({ playlistId: draft._id })
|
||||
);
|
||||
expect(stalkerStore.setCurrentPlaylist).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -301,7 +352,8 @@ describe('AppStalkerPlaylistConnectionEditorService', () => {
|
||||
isFullStalkerPortal: false,
|
||||
stalkerToken: undefined,
|
||||
stalkerSessionIdentity: undefined,
|
||||
})
|
||||
}),
|
||||
expect.objectContaining({ playlistId: draft._id })
|
||||
);
|
||||
expect(activePlaylist).toEqual(
|
||||
expect.objectContaining({
|
||||
@@ -373,6 +425,9 @@ describe('AppStalkerPlaylistConnectionEditorService', () => {
|
||||
draft._id
|
||||
);
|
||||
expect(portalRepair.commitPlaylistEdit).not.toHaveBeenCalled();
|
||||
expect(portalRepair.releasePlaylistEdit).toHaveBeenCalledWith(
|
||||
draft._id
|
||||
);
|
||||
expect(stalkerStore.setCurrentPlaylist).not.toHaveBeenCalled();
|
||||
expect(activePlaylist?.portalUrl).toBe(
|
||||
'https://old.example.com/server/load.php'
|
||||
@@ -421,7 +476,8 @@ describe('AppStalkerPlaylistConnectionEditorService', () => {
|
||||
portalUrl: 'https://portal.example.com/server/load.php',
|
||||
username: 'new-user',
|
||||
stalkerToken: 'NEW_TOKEN',
|
||||
})
|
||||
}),
|
||||
expect.objectContaining({ playlistId: draft._id })
|
||||
);
|
||||
expect(stalkerStore.setCurrentPlaylist).toHaveBeenCalledTimes(
|
||||
isActive ? 1 : 0
|
||||
|
||||
@@ -32,6 +32,10 @@ const STALKER_EDIT_ERROR_KEY_BY_KIND: Readonly<
|
||||
'auth-failed': 'HOME.STALKER_PORTAL.AUTH_FAILED',
|
||||
};
|
||||
|
||||
type StalkerEditFence = Awaited<
|
||||
ReturnType<StalkerSessionService['beginEditDiscovery']>
|
||||
>;
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AppStalkerPlaylistConnectionEditorService implements StalkerPlaylistConnectionEditor {
|
||||
private readonly discovery = inject(StalkerPortalDiscoveryService);
|
||||
@@ -39,6 +43,7 @@ export class AppStalkerPlaylistConnectionEditorService implements StalkerPlaylis
|
||||
private readonly stalkerSession = inject(StalkerSessionService);
|
||||
private readonly stalkerStore = inject(StalkerStore);
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly editFences = new Map<string, StalkerEditFence>();
|
||||
|
||||
async applyResolvedConnection(playlist: PlaylistMetaUpdate): Promise<void> {
|
||||
// Edit discovery is newer and more authoritative than a lazy repair
|
||||
@@ -46,16 +51,29 @@ export class AppStalkerPlaylistConnectionEditorService implements StalkerPlaylis
|
||||
// the resolved row could turn a credential-only A→A edit back into
|
||||
// the repair's old A→B result.
|
||||
const runtimePlaylist = this.toRuntimePlaylist(playlist);
|
||||
this.portalRepair.fenceForPlaylistEdit(runtimePlaylist._id);
|
||||
const persistedPlaylist =
|
||||
await this.stalkerSession.replaceSessionAfterEdit(runtimePlaylist);
|
||||
this.portalRepair.commitPlaylistEdit(runtimePlaylist._id);
|
||||
const fence =
|
||||
this.editFences.get(runtimePlaylist._id) ??
|
||||
(await this.beginEditFence(runtimePlaylist));
|
||||
try {
|
||||
const persistedPlaylist =
|
||||
await this.stalkerSession.replaceSessionAfterEdit(
|
||||
runtimePlaylist,
|
||||
fence
|
||||
);
|
||||
this.portalRepair.commitPlaylistEdit(runtimePlaylist._id);
|
||||
this.editFences.delete(runtimePlaylist._id);
|
||||
|
||||
if (this.stalkerStore.currentPlaylist()?._id === runtimePlaylist._id) {
|
||||
// The persistence result is the complete row merged by
|
||||
// PlaylistsService, so backup-restored playback headers and other
|
||||
// metadata absent from the form survive the active replacement.
|
||||
await this.stalkerStore.setCurrentPlaylist(persistedPlaylist);
|
||||
if (
|
||||
this.stalkerStore.currentPlaylist()?._id === runtimePlaylist._id
|
||||
) {
|
||||
// The persistence result is the complete row merged by
|
||||
// PlaylistsService, so backup-restored playback headers and
|
||||
// other metadata absent from the form survive replacement.
|
||||
await this.stalkerStore.setCurrentPlaylist(persistedPlaylist);
|
||||
}
|
||||
} catch (error) {
|
||||
this.releaseEditFence(runtimePlaylist._id, fence);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,19 +95,31 @@ export class AppStalkerPlaylistConnectionEditorService implements StalkerPlaylis
|
||||
stalkerSignature1: identity.signature1 ?? '',
|
||||
stalkerSignature2: identity.signature2 ?? '',
|
||||
};
|
||||
const outcome = await this.discovery.discover(
|
||||
playlist.portalUrl ?? '',
|
||||
playlist.macAddress ?? '',
|
||||
identity,
|
||||
{
|
||||
credentials: {
|
||||
username: playlist.username ?? '',
|
||||
password: playlist.password ?? '',
|
||||
},
|
||||
}
|
||||
const fence = await this.beginEditFence(
|
||||
this.toRuntimePlaylist(normalizedPlaylist)
|
||||
);
|
||||
let outcome: Awaited<
|
||||
ReturnType<StalkerPortalDiscoveryService['discover']>
|
||||
>;
|
||||
try {
|
||||
outcome = await this.discovery.discover(
|
||||
playlist.portalUrl ?? '',
|
||||
playlist.macAddress ?? '',
|
||||
identity,
|
||||
{
|
||||
credentials: {
|
||||
username: playlist.username ?? '',
|
||||
password: playlist.password ?? '',
|
||||
},
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
this.releaseEditFence(playlist._id, fence);
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (outcome.status === 'unreachable') {
|
||||
this.releaseEditFence(playlist._id, fence);
|
||||
return {
|
||||
status: STALKER_PLAYLIST_CONNECTION_EDITOR_STATUS.UNREACHABLE,
|
||||
message: this.translate.instant(
|
||||
@@ -99,6 +129,7 @@ export class AppStalkerPlaylistConnectionEditorService implements StalkerPlaylis
|
||||
}
|
||||
|
||||
if (outcome.status === 'auth-rejected') {
|
||||
this.releaseEditFence(playlist._id, fence);
|
||||
return {
|
||||
status: STALKER_PLAYLIST_CONNECTION_EDITOR_STATUS.AUTH_REJECTED,
|
||||
message: this.buildAuthErrorMessage(outcome.error),
|
||||
@@ -122,6 +153,7 @@ export class AppStalkerPlaylistConnectionEditorService implements StalkerPlaylis
|
||||
}
|
||||
|
||||
if (!outcome.token) {
|
||||
this.releaseEditFence(playlist._id, fence);
|
||||
return {
|
||||
status: STALKER_PLAYLIST_CONNECTION_EDITOR_STATUS.AUTH_REJECTED,
|
||||
message: this.translate.instant(
|
||||
@@ -156,6 +188,41 @@ export class AppStalkerPlaylistConnectionEditorService implements StalkerPlaylis
|
||||
};
|
||||
}
|
||||
|
||||
private async beginEditFence(
|
||||
playlist: Playlist
|
||||
): Promise<StalkerEditFence> {
|
||||
// Both fences are installed synchronously before either drain is
|
||||
// awaited. No new authentication or lazy repair can start while the
|
||||
// work that predates this Edit is settling.
|
||||
const repairDrain = this.portalRepair.fenceForPlaylistEdit(
|
||||
playlist._id
|
||||
);
|
||||
let fence: StalkerEditFence | undefined;
|
||||
try {
|
||||
fence = await this.stalkerSession.beginEditDiscovery(playlist);
|
||||
await repairDrain;
|
||||
this.editFences.set(playlist._id, fence);
|
||||
return fence;
|
||||
} catch (error) {
|
||||
if (fence) {
|
||||
this.stalkerSession.cancelEditDiscovery(fence);
|
||||
}
|
||||
this.portalRepair.releasePlaylistEdit(playlist._id);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private releaseEditFence(
|
||||
playlistId: string,
|
||||
fence: StalkerEditFence
|
||||
): void {
|
||||
if (this.editFences.get(playlistId) === fence) {
|
||||
this.editFences.delete(playlistId);
|
||||
}
|
||||
this.stalkerSession.cancelEditDiscovery(fence);
|
||||
this.portalRepair.releasePlaylistEdit(playlistId);
|
||||
}
|
||||
|
||||
private buildAuthErrorMessage(error: unknown): string {
|
||||
const portalError = asStalkerPortalError(error);
|
||||
const headline = this.translate.instant(
|
||||
|
||||
Reference in new issue
Block a user