fix(embedded-mpv): latch buffered recording outcomes

This commit is contained in:
4gray committed 2026-07-16 16:03:39 +02:00
1 parent 42c31c8cda
commit 1ec47624ed
4 files changed
+142 -113

No files matched your search

@@ -0,0 +1,42 @@
import type { TranslateService } from '@ngx-translate/core';
export const RECORDING_FEEDBACK = {
RAW: 'raw',
TRANSLATED: 'translated',
} as const;
export const RECORDING_TRANSLATION = {
START_FAILED: 'EMBEDDED_MPV.PLAYER.RECORDING_FAILED_TO_START',
STOP_FAILED: 'EMBEDDED_MPV.PLAYER.RECORDING_FAILED_TO_STOP',
SAVED_TO: 'EMBEDDED_MPV.PLAYER.SAVED_TO',
} as const;
type RecordingTranslationKey =
(typeof RECORDING_TRANSLATION)[keyof typeof RECORDING_TRANSLATION];
interface RawRecordingFeedback {
readonly kind: typeof RECORDING_FEEDBACK.RAW;
readonly text: string;
}
interface TranslatedRecordingFeedback {
readonly kind: typeof RECORDING_FEEDBACK.TRANSLATED;
readonly key: RecordingTranslationKey;
readonly params?: Readonly<Record<string, string>>;
}
export type RecordingFeedback =
| RawRecordingFeedback
| TranslatedRecordingFeedback;
export function resolveRecordingFeedback(
feedback: RecordingFeedback | null,
translate: TranslateService
): string | null {
if (!feedback) {
return null;
}
return feedback.kind === RECORDING_FEEDBACK.RAW
? feedback.text
: translate.instant(feedback.key, feedback.params);
}
@@ -3,7 +3,11 @@ import type {
EmbeddedMpvSession,
ResolvedPortalPlayback,
} from '@iptvnator/shared/interfaces';
import type { TranslateService } from '@ngx-translate/core';
import {
RECORDING_FEEDBACK,
RECORDING_TRANSLATION,
type RecordingFeedback,
} from './embedded-mpv-controls-recording-feedback';
import type { EmbeddedMpvSessionController } from './embedded-mpv-session-controller';
const RECORDING_ACK_TIMEOUT_MS = 5000;
@@ -14,40 +18,13 @@ const RECORDING_OPERATION = {
STOP: 'stop',
} as const;
const RECORDING_FEEDBACK = {
RAW: 'raw',
TRANSLATED: 'translated',
} as const;
const RECORDING_TRANSLATION = {
START_FAILED: 'EMBEDDED_MPV.PLAYER.RECORDING_FAILED_TO_START',
STOP_FAILED: 'EMBEDDED_MPV.PLAYER.RECORDING_FAILED_TO_STOP',
SAVED_TO: 'EMBEDDED_MPV.PLAYER.SAVED_TO',
} as const;
type RecordingOperation =
(typeof RECORDING_OPERATION)[keyof typeof RECORDING_OPERATION];
type RecordingTranslationKey =
(typeof RECORDING_TRANSLATION)[keyof typeof RECORDING_TRANSLATION];
interface RawRecordingFeedback {
readonly kind: typeof RECORDING_FEEDBACK.RAW;
readonly text: string;
}
interface TranslatedRecordingFeedback {
readonly kind: typeof RECORDING_FEEDBACK.TRANSLATED;
readonly key: RecordingTranslationKey;
readonly params?: Readonly<Record<string, string>>;
}
export type RecordingFeedback =
| RawRecordingFeedback
| TranslatedRecordingFeedback;
interface RecordingOutcome {
readonly feedback: RecordingFeedback | null;
readonly autoDismiss?: boolean;
readonly expectedActive?: boolean;
}
interface PendingRecordingOperation {
@@ -115,10 +92,7 @@ export class EmbeddedMpvControlsRecording {
sawErrorClear: initialError === null,
};
this.setFeedback(null);
this.acknowledgementTimer = window.setTimeout(
() => this.handleAcknowledgementTimeout(generation),
RECORDING_ACK_TIMEOUT_MS
);
this.scheduleAcknowledgementTimeout(generation);
const command =
kind === RECORDING_OPERATION.START
? this.controller.startRecording(
@@ -149,6 +123,19 @@ export class EmbeddedMpvControlsRecording {
this.setFeedback(null);
return;
}
const observedOutcome = pending.observedOutcome;
if (
pending.commandSettled &&
observedOutcome?.expectedActive !== undefined &&
!this.recordingError(session) &&
session.recording?.active === observedOutcome.expectedActive
) {
this.completePending(
observedOutcome.feedback,
observedOutcome.autoDismiss
);
return;
}
if (
this.recordingSnapshotIdentity(session) ===
pending.baselineSnapshotIdentity
@@ -176,7 +163,10 @@ export class EmbeddedMpvControlsRecording {
return;
}
if (pending.kind === RECORDING_OPERATION.START) {
this.observeOutcome(pending, { feedback: null });
this.observeOutcome(pending, {
feedback: null,
expectedActive: pending.expectedActive,
});
return;
}
const targetPath = recording.targetPath ?? pending.targetPath;
@@ -189,6 +179,7 @@ export class EmbeddedMpvControlsRecording {
}
: null,
autoDismiss: Boolean(targetPath),
expectedActive: pending.expectedActive,
});
}
@@ -235,7 +226,7 @@ export class EmbeddedMpvControlsRecording {
if (
!pending ||
pending.generation !== generation ||
pending.observedOutcome
(pending.observedOutcome && !pending.commandSettled)
) {
return;
}
@@ -282,11 +273,14 @@ export class EmbeddedMpvControlsRecording {
if (!reconciledPending || reconciledPending.generation !== generation) {
return;
}
if (reconciledPending.observedOutcome) {
this.completePending(
reconciledPending.observedOutcome.feedback,
reconciledPending.observedOutcome.autoDismiss
);
const outcome = reconciledPending.observedOutcome;
if (outcome && outcome.expectedActive === undefined) {
this.completePending(outcome.feedback, outcome.autoDismiss);
return;
}
if (outcome) {
this.setFeedback(outcome.feedback);
this.scheduleAcknowledgementTimeout(generation);
return;
}
if (reconciledPending.timeoutFeedback) {
@@ -379,6 +373,14 @@ export class EmbeddedMpvControlsRecording {
}
}
private scheduleAcknowledgementTimeout(generation: number): void {
this.clearAcknowledgementTimer();
this.acknowledgementTimer = window.setTimeout(
() => this.handleAcknowledgementTimeout(generation),
RECORDING_ACK_TIMEOUT_MS
);
}
private clearMessageTimer(): void {
if (this.messageTimer !== null) {
window.clearTimeout(this.messageTimer);
@@ -386,14 +388,3 @@ export class EmbeddedMpvControlsRecording {
}
}
}
export function resolveRecordingFeedback(
feedback: RecordingFeedback | null,
translate: TranslateService
): string | null {
if (!feedback) {
return null;
}
return feedback.kind === RECORDING_FEEDBACK.RAW
? feedback.text
: translate.instant(feedback.key, feedback.params);
}
@@ -21,6 +21,8 @@ interface Deferred<T> {
readonly resolve: (value: T) => void;
}
type RecordingState = EmbeddedMpvSession['recording'] | null;
function deferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
const promise = new Promise<T>((resolver) => {
@@ -92,13 +94,10 @@ function createController() {
.fn<Promise<void>, [string]>()
.mockResolvedValue(undefined),
startRecording: jest
.fn<
Promise<EmbeddedMpvSession['recording'] | null>,
[string | undefined, string]
>()
.fn<Promise<RecordingState>, [string | undefined, string]>()
.mockResolvedValue({ active: false }),
stopRecording: jest
.fn<Promise<EmbeddedMpvSession['recording'] | null>, []>()
.fn<Promise<RecordingState>, []>()
.mockResolvedValue({ active: true }),
};
}
@@ -170,16 +169,13 @@ describe('EmbeddedMpvControlsAdapter command/session ordering', () => {
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();
});
@@ -207,10 +203,8 @@ describe('EmbeddedMpvControlsAdapter command/session ordering', () => {
controller.session.set(session({ recording: startedRecording }));
return startedRecording;
});
adapter.commands.toggleRecording();
await flushPromises();
expect(adapter.state().recording.message).toBe(
`Saved to ${targetPath}`
);
@@ -222,7 +216,7 @@ describe('EmbeddedMpvControlsAdapter command/session ordering', () => {
});
it.each(['start', 'stop'] as const)(
'serializes a delayed %s command and finalizes its stored acknowledgement',
'latches a buffered %s success through stale settlement',
async (operation) => {
const targetPath = '/recordings/live.ts';
const baselineRecording =
@@ -240,63 +234,69 @@ describe('EmbeddedMpvControlsAdapter command/session ordering', () => {
startedAt: '2026-07-16T10:00:03.000Z',
}
: { active: false, targetPath };
const command = deferred<EmbeddedMpvSession['recording'] | null>();
const lateCommand = deferred<
EmbeddedMpvSession['recording'] | null
>();
controller.session.set(session({ recording: baselineRecording }));
if (operation === 'start') {
controller.startRecording
.mockReturnValueOnce(command.promise)
.mockReturnValueOnce(lateCommand.promise);
} else {
controller.stopRecording
.mockReturnValueOnce(command.promise)
.mockReturnValueOnce(lateCommand.promise);
}
const command = deferred<RecordingState>();
const lateCommand = deferred<RecordingState>();
const successMessage =
operation === 'stop' ? `Saved to ${targetPath}` : null;
const failureMessage = `Failed to ${operation} recording`;
const setRecording = (recording: RecordingState) =>
controller.session.set(session({ recording }));
const originalCommand =
operation === 'start'
? controller.startRecording
: controller.stopRecording;
const inverseCommand =
operation === 'start'
? controller.stopRecording
: controller.startRecording;
setRecording(baselineRecording);
originalCommand
.mockReturnValueOnce(command.promise)
.mockReturnValueOnce(lateCommand.promise);
inverseCommand.mockImplementation(async () => {
setRecording(baselineRecording);
return baselineRecording;
});
adapter.commands.toggleRecording();
controller.session.set(
session({ recording: acknowledgedRecording })
);
setRecording(acknowledgedRecording);
jest.advanceTimersByTime(ACK_TIMEOUT_MS + 1);
expect(adapter.state().recording.message).toBeNull();
adapter.commands.toggleRecording();
expect(controller.startRecording).toHaveBeenCalledTimes(
operation === 'start' ? 1 : 0
);
expect(controller.stopRecording).toHaveBeenCalledTimes(
operation === 'stop' ? 1 : 0
);
controller.session.set(session({ recording: baselineRecording }));
expect(originalCommand).toHaveBeenCalledTimes(1);
expect(inverseCommand).not.toHaveBeenCalled();
setRecording(baselineRecording);
command.resolve(baselineRecording);
await flushPromises();
expect(adapter.state().recording.message).toBe(
operation === 'stop' ? `Saved to ${targetPath}` : null
);
expect(adapter.state().recording.message).toBe(successMessage);
adapter.commands.toggleRecording();
expect(originalCommand).toHaveBeenCalledTimes(1);
expect(inverseCommand).not.toHaveBeenCalled();
setRecording(acknowledgedRecording);
TestBed.tick();
adapter.commands.toggleRecording();
expect(originalCommand).toHaveBeenCalledTimes(1);
expect(inverseCommand).toHaveBeenCalledTimes(1);
await flushPromises();
adapter.commands.toggleRecording();
jest.advanceTimersByTime(ACK_TIMEOUT_MS);
expect(adapter.state().recording.message).toBe(
operation === 'start'
? 'Failed to start recording'
: 'Failed to stop recording'
);
controller.session.set(
session({ recording: acknowledgedRecording })
);
expect(adapter.state().recording.message).toBe(failureMessage);
setRecording(acknowledgedRecording);
TestBed.tick();
expect(adapter.state().recording.message).toBe(
operation === 'stop' ? `Saved to ${targetPath}` : null
);
expect(adapter.state().recording.message).toBe(successMessage);
adapter.commands.toggleRecording();
expect(controller.startRecording).toHaveBeenCalledTimes(
operation === 'start' ? 2 : 0
);
expect(controller.stopRecording).toHaveBeenCalledTimes(
operation === 'stop' ? 2 : 0
);
controller.session.set(session({ recording: baselineRecording }));
expect(originalCommand).toHaveBeenCalledTimes(2);
expect(inverseCommand).toHaveBeenCalledTimes(1);
setRecording(baselineRecording);
lateCommand.resolve(baselineRecording);
await flushPromises();
adapter.commands.toggleRecording();
expect(originalCommand).toHaveBeenCalledTimes(2);
expect(inverseCommand).toHaveBeenCalledTimes(1);
jest.advanceTimersByTime(ACK_TIMEOUT_MS);
expect(adapter.state().recording.message).toBe(failureMessage);
adapter.commands.toggleRecording();
expect(originalCommand).toHaveBeenCalledTimes(3);
expect(inverseCommand).toHaveBeenCalledTimes(1);
}
);
@@ -305,7 +305,6 @@ describe('EmbeddedMpvControlsAdapter command/session ordering', () => {
const command = deferred<EmbeddedMpvSession['recording'] | null>();
controller.session.set(session({ recording: { active: true } }));
controller.stopRecording.mockReturnValue(command.promise);
adapter.commands.toggleRecording();
controller.session.set(
session({ recording: { active: false, targetPath } })
@@ -316,7 +315,6 @@ describe('EmbeddedMpvControlsAdapter command/session ordering', () => {
);
command.resolve({ active: true });
await flushPromises();
expect(adapter.state().recording.message).toBeNull();
});
@@ -35,10 +35,8 @@ import {
readStoredVolume,
subtitleTrackLabel,
} from './embedded-mpv-format.utils';
import {
EmbeddedMpvControlsRecording,
resolveRecordingFeedback,
} from './embedded-mpv-controls-recording';
import { resolveRecordingFeedback } from './embedded-mpv-controls-recording-feedback';
import { EmbeddedMpvControlsRecording } from './embedded-mpv-controls-recording';
import { EmbeddedMpvSessionController } from './embedded-mpv-session-controller';
export interface EmbeddedMpvControlsContext {