mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 20:16:15 -08:00
fix(embedded-mpv): latch buffered recording outcomes
This commit is contained in:
1 parent
42c31c8cda
commit
1ec47624ed
4 files changed
+142
-113
No files matched your search
+42
@@ -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);
|
||||
}
|
||||
+56
-58
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user