mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 13:46:16 -08:00
fix(embedded-mpv): serialize delayed recording commands
This commit is contained in:
1 parent
58287f4122
commit
42c31c8cda
3 files changed
+229
-28
No files matched your search
@@ -45,6 +45,11 @@ export type RecordingFeedback =
|
||||
| RawRecordingFeedback
|
||||
| TranslatedRecordingFeedback;
|
||||
|
||||
interface RecordingOutcome {
|
||||
readonly feedback: RecordingFeedback | null;
|
||||
readonly autoDismiss?: boolean;
|
||||
}
|
||||
|
||||
interface PendingRecordingOperation {
|
||||
readonly generation: number;
|
||||
readonly kind: RecordingOperation;
|
||||
@@ -54,6 +59,8 @@ interface PendingRecordingOperation {
|
||||
readonly initialError: string | null;
|
||||
readonly targetPath: string | null;
|
||||
readonly baselineSnapshotIdentity: string | null;
|
||||
observedOutcome: RecordingOutcome | null;
|
||||
timeoutFeedback: RecordingFeedback | null;
|
||||
commandSettled: boolean;
|
||||
sawErrorClear: boolean;
|
||||
}
|
||||
@@ -85,7 +92,6 @@ export class EmbeddedMpvControlsRecording {
|
||||
if (this.destroyed || this.pending) {
|
||||
return;
|
||||
}
|
||||
|
||||
const recording = context.session.recording;
|
||||
const initialError = this.recordingError(context.session);
|
||||
const kind = recording?.active
|
||||
@@ -103,6 +109,8 @@ export class EmbeddedMpvControlsRecording {
|
||||
baselineSnapshotIdentity: this.recordingSnapshotIdentity(
|
||||
context.session
|
||||
),
|
||||
observedOutcome: null,
|
||||
timeoutFeedback: null,
|
||||
commandSettled: false,
|
||||
sawErrorClear: initialError === null,
|
||||
};
|
||||
@@ -111,7 +119,6 @@ export class EmbeddedMpvControlsRecording {
|
||||
() => this.handleAcknowledgementTimeout(generation),
|
||||
RECORDING_ACK_TIMEOUT_MS
|
||||
);
|
||||
|
||||
const command =
|
||||
kind === RECORDING_OPERATION.START
|
||||
? this.controller.startRecording(
|
||||
@@ -139,16 +146,15 @@ export class EmbeddedMpvControlsRecording {
|
||||
playbackIdentity !== pending.playbackIdentity
|
||||
) {
|
||||
this.cancelPending();
|
||||
this.setFeedback(null);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!pending.commandSettled ||
|
||||
this.recordingSnapshotIdentity(session) ===
|
||||
pending.baselineSnapshotIdentity
|
||||
pending.baselineSnapshotIdentity
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const recording = session.recording;
|
||||
if (!recording) {
|
||||
return;
|
||||
@@ -156,9 +162,11 @@ export class EmbeddedMpvControlsRecording {
|
||||
const error = this.recordingError(session);
|
||||
if (error) {
|
||||
if (pending.sawErrorClear || error !== pending.initialError) {
|
||||
this.completePending({
|
||||
kind: RECORDING_FEEDBACK.RAW,
|
||||
text: error,
|
||||
this.observeOutcome(pending, {
|
||||
feedback: {
|
||||
kind: RECORDING_FEEDBACK.RAW,
|
||||
text: error,
|
||||
},
|
||||
});
|
||||
}
|
||||
return;
|
||||
@@ -167,23 +175,21 @@ export class EmbeddedMpvControlsRecording {
|
||||
if (recording.active !== pending.expectedActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (pending.kind === RECORDING_OPERATION.START) {
|
||||
this.completePending(null);
|
||||
this.observeOutcome(pending, { feedback: null });
|
||||
return;
|
||||
}
|
||||
|
||||
const targetPath = recording.targetPath ?? pending.targetPath;
|
||||
this.completePending(
|
||||
targetPath
|
||||
this.observeOutcome(pending, {
|
||||
feedback: targetPath
|
||||
? {
|
||||
kind: RECORDING_FEEDBACK.TRANSLATED,
|
||||
key: RECORDING_TRANSLATION.SAVED_TO,
|
||||
params: { path: targetPath },
|
||||
}
|
||||
: null,
|
||||
Boolean(targetPath)
|
||||
);
|
||||
autoDismiss: Boolean(targetPath),
|
||||
});
|
||||
}
|
||||
|
||||
syncOwner(playbackIdentity: string | null, sessionId: string | null): void {
|
||||
@@ -198,7 +204,6 @@ export class EmbeddedMpvControlsRecording {
|
||||
if (this.ownerIdentity === nextIdentity) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.ownerIdentity = nextIdentity;
|
||||
this.setFeedback(null);
|
||||
if (
|
||||
@@ -211,17 +216,29 @@ export class EmbeddedMpvControlsRecording {
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.setFeedback(null);
|
||||
this.destroyed = true;
|
||||
this.cancelPending();
|
||||
this.clearMessageTimer();
|
||||
}
|
||||
|
||||
private handleAcknowledgementTimeout(generation: number): void {
|
||||
const pending = this.pending;
|
||||
let pending = this.pending;
|
||||
if (!pending || pending.generation !== generation) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.acknowledgementTimer = null;
|
||||
this.reconcile(
|
||||
this.controller.session(),
|
||||
this.currentPlaybackIdentity()
|
||||
);
|
||||
pending = this.pending;
|
||||
if (
|
||||
!pending ||
|
||||
pending.generation !== generation ||
|
||||
pending.observedOutcome
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const currentSession = this.controller.session();
|
||||
const currentOwnerIdentity = JSON.stringify([
|
||||
pending.playbackIdentity,
|
||||
@@ -232,7 +249,7 @@ export class EmbeddedMpvControlsRecording {
|
||||
this.ownerIdentity === currentOwnerIdentity
|
||||
? this.recordingError(currentSession)
|
||||
: null;
|
||||
this.completePending({
|
||||
const feedback: RecordingFeedback = {
|
||||
...(error
|
||||
? { kind: RECORDING_FEEDBACK.RAW, text: error }
|
||||
: {
|
||||
@@ -242,7 +259,13 @@ export class EmbeddedMpvControlsRecording {
|
||||
? RECORDING_TRANSLATION.START_FAILED
|
||||
: RECORDING_TRANSLATION.STOP_FAILED,
|
||||
}),
|
||||
});
|
||||
};
|
||||
pending.timeoutFeedback = feedback;
|
||||
if (pending.commandSettled) {
|
||||
this.completePending(feedback);
|
||||
return;
|
||||
}
|
||||
this.setFeedback(feedback);
|
||||
}
|
||||
|
||||
private markCommandSettled(generation: number): void {
|
||||
@@ -255,6 +278,39 @@ export class EmbeddedMpvControlsRecording {
|
||||
this.controller.session(),
|
||||
this.currentPlaybackIdentity()
|
||||
);
|
||||
const reconciledPending = this.pending;
|
||||
if (!reconciledPending || reconciledPending.generation !== generation) {
|
||||
return;
|
||||
}
|
||||
if (reconciledPending.observedOutcome) {
|
||||
this.completePending(
|
||||
reconciledPending.observedOutcome.feedback,
|
||||
reconciledPending.observedOutcome.autoDismiss
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (reconciledPending.timeoutFeedback) {
|
||||
this.completePending(reconciledPending.timeoutFeedback);
|
||||
}
|
||||
}
|
||||
|
||||
private observeOutcome(
|
||||
pending: PendingRecordingOperation,
|
||||
outcome: RecordingOutcome
|
||||
): void {
|
||||
if (this.pending !== pending || pending.observedOutcome) {
|
||||
return;
|
||||
}
|
||||
pending.observedOutcome = outcome;
|
||||
this.clearAcknowledgementTimer();
|
||||
if (pending.commandSettled) {
|
||||
this.completePending(outcome.feedback, outcome.autoDismiss);
|
||||
} else if (
|
||||
pending.timeoutFeedback ||
|
||||
outcome.feedback?.kind === RECORDING_FEEDBACK.RAW
|
||||
) {
|
||||
this.setFeedback(outcome.feedback, outcome.autoDismiss);
|
||||
}
|
||||
}
|
||||
|
||||
private recordingSnapshotIdentity(
|
||||
@@ -305,7 +361,6 @@ export class EmbeddedMpvControlsRecording {
|
||||
if (!feedback || !autoDismiss) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timerId = window.setTimeout(() => {
|
||||
if (!this.destroyed && this.feedbackState() === feedback) {
|
||||
this.feedbackState.set(null);
|
||||
@@ -331,7 +386,6 @@ export class EmbeddedMpvControlsRecording {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveRecordingFeedback(
|
||||
feedback: RecordingFeedback | null,
|
||||
translate: TranslateService
|
||||
|
||||
+146
-5
@@ -221,20 +221,161 @@ describe('EmbeddedMpvControlsAdapter command/session ordering', () => {
|
||||
expect(adapter.state().recording.message).toBeNull();
|
||||
});
|
||||
|
||||
it('waits for a post-settlement acknowledgement when a stale response restores the baseline', async () => {
|
||||
it.each(['start', 'stop'] as const)(
|
||||
'serializes a delayed %s command and finalizes its stored acknowledgement',
|
||||
async (operation) => {
|
||||
const targetPath = '/recordings/live.ts';
|
||||
const baselineRecording =
|
||||
operation === 'start'
|
||||
? { active: false }
|
||||
: {
|
||||
active: true,
|
||||
startedAt: '2026-07-16T10:00:02.000Z',
|
||||
};
|
||||
const acknowledgedRecording =
|
||||
operation === 'start'
|
||||
? {
|
||||
active: true,
|
||||
targetPath,
|
||||
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);
|
||||
}
|
||||
adapter.commands.toggleRecording();
|
||||
controller.session.set(
|
||||
session({ recording: 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 }));
|
||||
command.resolve(baselineRecording);
|
||||
await flushPromises();
|
||||
expect(adapter.state().recording.message).toBe(
|
||||
operation === 'stop' ? `Saved to ${targetPath}` : null
|
||||
);
|
||||
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 })
|
||||
);
|
||||
TestBed.tick();
|
||||
expect(adapter.state().recording.message).toBe(
|
||||
operation === 'stop' ? `Saved to ${targetPath}` : null
|
||||
);
|
||||
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 }));
|
||||
lateCommand.resolve(baselineRecording);
|
||||
await flushPromises();
|
||||
}
|
||||
);
|
||||
|
||||
it('discards a stored acknowledgement when the session changes before settlement', async () => {
|
||||
const targetPath = '/recordings/live.ts';
|
||||
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 } })
|
||||
);
|
||||
TestBed.tick();
|
||||
controller.session.set(
|
||||
session({ id: 'session-2', recording: { active: false } })
|
||||
);
|
||||
command.resolve({ active: true });
|
||||
await flushPromises();
|
||||
|
||||
expect(adapter.state().recording.message).toBeNull();
|
||||
});
|
||||
|
||||
it('shows a pre-settlement recording error without unlocking the command', async () => {
|
||||
const addonError = ' Addon rejected stream-record ';
|
||||
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',
|
||||
},
|
||||
recording: { active: false, error: addonError },
|
||||
})
|
||||
);
|
||||
TestBed.tick();
|
||||
|
||||
expect(adapter.state().recording.message).toBe(addonError);
|
||||
jest.advanceTimersByTime(ACK_TIMEOUT_MS + 1);
|
||||
expect(adapter.state().recording.message).toBe(addonError);
|
||||
adapter.commands.toggleRecording();
|
||||
expect(controller.startRecording).toHaveBeenCalledTimes(1);
|
||||
|
||||
controller.session.set(session({ recording: { active: false } }));
|
||||
command.resolve({ active: false });
|
||||
await flushPromises();
|
||||
|
||||
expect(adapter.state().recording.message).toBe(addonError);
|
||||
adapter.commands.toggleRecording();
|
||||
expect(controller.startRecording).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('keeps an unacknowledged timed-out command serialized until its promise settles', async () => {
|
||||
const command = deferred<EmbeddedMpvSession['recording'] | null>();
|
||||
controller.startRecording.mockReturnValue(command.promise);
|
||||
|
||||
adapter.commands.toggleRecording();
|
||||
jest.advanceTimersByTime(ACK_TIMEOUT_MS);
|
||||
|
||||
expect(adapter.state().recording.message).toBe(
|
||||
'Failed to start recording'
|
||||
);
|
||||
adapter.commands.toggleRecording();
|
||||
expect(controller.startRecording).toHaveBeenCalledTimes(1);
|
||||
|
||||
command.resolve({ active: false });
|
||||
await flushPromises();
|
||||
|
||||
expect(adapter.state().recording.message).toBe(
|
||||
'Failed to start recording'
|
||||
);
|
||||
adapter.commands.toggleRecording();
|
||||
expect(controller.startRecording).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('waits for a post-settlement acknowledgement when only baseline snapshots arrive', async () => {
|
||||
const command = deferred<EmbeddedMpvSession['recording'] | null>();
|
||||
controller.startRecording.mockReturnValue(command.promise);
|
||||
|
||||
adapter.commands.toggleRecording();
|
||||
controller.session.set(session({ recording: { active: false } }));
|
||||
command.resolve({ active: false });
|
||||
await flushPromises();
|
||||
|
||||
+6
@@ -378,6 +378,12 @@ describe('EmbeddedMpvControlsAdapter recording acknowledgement races', () => {
|
||||
}
|
||||
adapter.commands.toggleRecording();
|
||||
const state = adapter.state;
|
||||
const bufferedRecording =
|
||||
operation === 'start'
|
||||
? { active: false, error: 'Buffered failure' }
|
||||
: { active: false, targetPath: '/recordings/live.ts' };
|
||||
controller.session.set(session({ recording: bufferedRecording }));
|
||||
TestBed.tick();
|
||||
|
||||
TestBed.resetTestingModule();
|
||||
command.resolve(null);
|
||||
|
||||
Reference in new issue
Block a user