mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 18:36:15 -08:00
fix(embedded-mpv): accept recording ack before command resolve
This commit is contained in:
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()
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+258
@@ -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);
|
||||
});
|
||||
});
|
||||
+6
-12
@@ -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>(() => {
|
||||
|
||||
Reference in new issue
Block a user