fix(embedded-mpv): accept recording ack before command resolve

This commit is contained in:
4gray committed 2026-07-16 15:19:26 +02:00
1 parent 7035807f5d
commit 58287f4122
4 files changed
+282 -23

No files matched your search

@@ -53,7 +53,7 @@ interface PendingRecordingOperation {
readonly expectedActive: boolean;
readonly initialError: string | null;
readonly targetPath: string | null;
commandSnapshotIdentity: string | null;
readonly baselineSnapshotIdentity: string | null;
commandSettled: boolean;
sawErrorClear: boolean;
}
@@ -76,7 +76,10 @@ export class EmbeddedMpvControlsRecording {
private ownerIdentity: string | null | undefined;
private destroyed = false;
constructor(private readonly controller: EmbeddedMpvSessionController) {}
constructor(
private readonly controller: EmbeddedMpvSessionController,
private readonly currentPlaybackIdentity: () => string | null
) {}
toggle(context: RecordingToggleContext): void {
if (this.destroyed || this.pending) {
@@ -97,7 +100,9 @@ export class EmbeddedMpvControlsRecording {
expectedActive: kind === RECORDING_OPERATION.START,
initialError,
targetPath: recording?.targetPath ?? null,
commandSnapshotIdentity: null,
baselineSnapshotIdentity: this.recordingSnapshotIdentity(
context.session
),
commandSettled: false,
sawErrorClear: initialError === null,
};
@@ -139,7 +144,7 @@ export class EmbeddedMpvControlsRecording {
if (
!pending.commandSettled ||
this.recordingSnapshotIdentity(session) ===
pending.commandSnapshotIdentity
pending.baselineSnapshotIdentity
) {
return;
}
@@ -246,8 +251,9 @@ export class EmbeddedMpvControlsRecording {
return;
}
pending.commandSettled = true;
pending.commandSnapshotIdentity = this.recordingSnapshotIdentity(
this.controller.session()
this.reconcile(
this.controller.session(),
this.currentPlaybackIdentity()
);
}
@@ -0,0 +1,258 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import {
EmbeddedMpvSession,
EmbeddedMpvSupport,
ResolvedPortalPlayback,
} from '@iptvnator/shared/interfaces';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { EmbeddedMpvControlsAdapter } from './embedded-mpv-controls.adapter';
import { EmbeddedMpvSessionController } from './embedded-mpv-session-controller';
const ACK_TIMEOUT_MS = 5000;
const LIVE_PLAYBACK: ResolvedPortalPlayback = {
streamUrl: 'https://example.com/live',
title: 'Live news',
isLive: true,
};
interface Deferred<T> {
readonly promise: Promise<T>;
readonly resolve: (value: T) => void;
}
function deferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
const promise = new Promise<T>((resolver) => {
resolve = resolver;
});
return { promise, resolve };
}
function support(): EmbeddedMpvSupport {
return {
supported: true,
platform: 'darwin',
engine: 'frame-copy',
capabilities: {
subtitles: true,
playbackSpeed: true,
aspectOverride: true,
screenshot: false,
recording: true,
},
};
}
function session(
overrides: Partial<EmbeddedMpvSession> = {}
): EmbeddedMpvSession {
return {
id: 'session-1',
title: LIVE_PLAYBACK.title,
streamUrl: LIVE_PLAYBACK.streamUrl,
status: 'playing',
positionSeconds: 0,
durationSeconds: null,
volume: 1,
audioTracks: [],
selectedAudioTrackId: null,
subtitleTracks: [],
selectedSubtitleTrackId: null,
playbackSpeed: 1,
aspectOverride: 'no',
recording: { active: false },
startedAt: '2026-07-16T10:00:00.000Z',
updatedAt: '2026-07-16T10:00:01.000Z',
...overrides,
};
}
function createController() {
return {
support: signal<EmbeddedMpvSupport | null>(support()),
session: signal<EmbeddedMpvSession | null>(session()),
stalled: signal(false),
togglePaused: jest.fn<Promise<void>, []>().mockResolvedValue(undefined),
seekTo: jest.fn<Promise<void>, [number]>().mockResolvedValue(undefined),
seekBy: jest.fn<Promise<boolean>, [number]>().mockResolvedValue(true),
applyVolume: jest
.fn<Promise<void>, [number]>()
.mockResolvedValue(undefined),
setAudioTrack: jest
.fn<Promise<void>, [number]>()
.mockResolvedValue(undefined),
setSubtitleTrack: jest
.fn<Promise<void>, [number]>()
.mockResolvedValue(undefined),
setSpeed: jest
.fn<Promise<void>, [number]>()
.mockResolvedValue(undefined),
setAspect: jest
.fn<Promise<void>, [string]>()
.mockResolvedValue(undefined),
startRecording: jest
.fn<
Promise<EmbeddedMpvSession['recording'] | null>,
[string | undefined, string]
>()
.mockResolvedValue({ active: false }),
stopRecording: jest
.fn<Promise<EmbeddedMpvSession['recording'] | null>, []>()
.mockResolvedValue({ active: true }),
};
}
function translations(): object {
return {
EMBEDDED_MPV: {
PLAYER: {
RECORDING_FAILED_TO_START: 'Failed to start recording',
RECORDING_FAILED_TO_STOP: 'Failed to stop recording',
SAVED_TO: 'Saved to {{path}}',
},
},
};
}
async function flushPromises(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
}
describe('EmbeddedMpvControlsAdapter command/session ordering', () => {
let adapter: EmbeddedMpvControlsAdapter;
let controller: ReturnType<typeof createController>;
beforeEach(() => {
jest.useFakeTimers();
controller = createController();
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot()],
providers: [
EmbeddedMpvControlsAdapter,
{
provide: EmbeddedMpvSessionController,
useValue: controller,
},
],
});
const translate = TestBed.inject(TranslateService);
translate.setTranslation('en', translations());
translate.setDefaultLang('en');
translate.use('en');
adapter = TestBed.inject(EmbeddedMpvControlsAdapter);
adapter.configure({
playback: signal(LIVE_PLAYBACK),
seriesNavigation: signal(null),
recordingFolder: signal('/recordings'),
});
TestBed.tick();
});
afterEach(() => {
TestBed.resetTestingModule();
jest.useRealTimers();
});
it('accepts a start acknowledgement written immediately before command resolution', async () => {
const startedRecording = {
active: true,
startedAt: '2026-07-16T10:00:02.000Z',
};
controller.startRecording.mockImplementation(async () => {
controller.session.set(session({ recording: startedRecording }));
return startedRecording;
});
controller.stopRecording.mockImplementation(async () => {
const stoppedRecording = { active: false };
controller.session.set(session({ recording: stoppedRecording }));
return stoppedRecording;
});
adapter.commands.toggleRecording();
await flushPromises();
expect(adapter.state().recording.active).toBe(true);
adapter.commands.toggleRecording();
expect(controller.stopRecording).toHaveBeenCalledTimes(1);
await flushPromises();
jest.advanceTimersByTime(ACK_TIMEOUT_MS);
expect(adapter.state().recording.message).toBeNull();
});
it('accepts a stop acknowledgement written immediately before command resolution', async () => {
const targetPath = '/recordings/live.ts';
const stoppedRecording = { active: false, targetPath };
controller.session.set(
session({
recording: {
active: true,
targetPath,
startedAt: '2026-07-16T10:00:02.000Z',
},
})
);
controller.stopRecording.mockImplementation(async () => {
controller.session.set(session({ recording: stoppedRecording }));
return stoppedRecording;
});
controller.startRecording.mockImplementation(async () => {
const startedRecording = {
active: true,
startedAt: '2026-07-16T10:00:03.000Z',
};
controller.session.set(session({ recording: startedRecording }));
return startedRecording;
});
adapter.commands.toggleRecording();
await flushPromises();
expect(adapter.state().recording.message).toBe(
`Saved to ${targetPath}`
);
adapter.commands.toggleRecording();
expect(controller.startRecording).toHaveBeenCalledTimes(1);
await flushPromises();
jest.advanceTimersByTime(ACK_TIMEOUT_MS);
expect(adapter.state().recording.message).toBeNull();
});
it('waits for a post-settlement acknowledgement when a stale response restores the baseline', async () => {
const command = deferred<EmbeddedMpvSession['recording'] | null>();
controller.startRecording.mockReturnValue(command.promise);
adapter.commands.toggleRecording();
controller.session.set(
session({
recording: {
active: true,
startedAt: '2026-07-16T10:00:02.000Z',
},
})
);
TestBed.tick();
controller.session.set(session({ recording: { active: false } }));
command.resolve({ active: false });
await flushPromises();
adapter.commands.toggleRecording();
expect(controller.startRecording).toHaveBeenCalledTimes(1);
controller.session.set(
session({
recording: {
active: true,
startedAt: '2026-07-16T10:00:03.000Z',
},
})
);
TestBed.tick();
adapter.commands.toggleRecording();
expect(controller.stopRecording).toHaveBeenCalledTimes(1);
});
});
@@ -174,7 +174,6 @@ describe('EmbeddedMpvControlsAdapter recording acknowledgement races', () => {
controller.startRecording.mockImplementation(async () => {
const staleRecording = {
active: false,
error: 'stale error',
};
controller.session.set(session({ recording: staleRecording }));
return staleRecording;
@@ -247,20 +246,15 @@ describe('EmbeddedMpvControlsAdapter recording acknowledgement races', () => {
expect(adapter.state().recording.message).toBeNull();
});
it('uses a later same-session recording error as raw failure feedback', async () => {
it('uses a same-session recording error present at command settlement', async () => {
const addonError = ' Addon rejected stream-record ';
controller.startRecording.mockImplementation(async () => {
const failedRecording = { active: false, error: addonError };
controller.session.set(session({ recording: failedRecording }));
return failedRecording;
});
adapter.commands.toggleRecording();
await flushPromises();
expect(adapter.state().recording.message).toBeNull();
controller.session.set(
session({
recording: {
active: false,
error: addonError,
},
})
);
TestBed.tick();
expect(adapter.state().recording.message).toBe(addonError);
translate.setTranslation('en', translations('Updated '));
@@ -68,8 +68,13 @@ export class EmbeddedMpvControlsAdapter implements PlayerController {
private readonly configuredContext =
signal<EmbeddedMpvControlsContext | null>(null);
private readonly playbackIdentity = computed(() => {
const playback = this.configuredContext()?.playback();
return playback ? JSON.stringify(playback) : null;
});
private readonly recordingControls = new EmbeddedMpvControlsRecording(
this.controller
this.controller,
() => this.playbackIdentity()
);
private readonly recordingActive = computed(
() => this.controller.session()?.recording?.active === true
@@ -77,10 +82,6 @@ export class EmbeddedMpvControlsAdapter implements PlayerController {
private readonly activeSessionId = computed(
() => this.controller.session()?.id ?? null
);
private readonly playbackIdentity = computed(() => {
const playback = this.configuredContext()?.playback();
return playback ? JSON.stringify(playback) : null;
});
private readonly recordingTick = signal(Date.now());
readonly capabilities = computed<PlayerControlsCapabilities>(() => {