mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 18:36:15 -08:00
fix(embedded-mpv): correlate recording control updates
This commit is contained in:
1 parent
bace3540e9
commit
7035807f5d
4 files changed
+905
-234
No files matched your search
@@ -0,0 +1,339 @@
|
||||
import { signal } from '@angular/core';
|
||||
import type {
|
||||
EmbeddedMpvSession,
|
||||
ResolvedPortalPlayback,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import type { TranslateService } from '@ngx-translate/core';
|
||||
import type { EmbeddedMpvSessionController } from './embedded-mpv-session-controller';
|
||||
|
||||
const RECORDING_ACK_TIMEOUT_MS = 5000;
|
||||
const RECORDING_MESSAGE_DISMISS_DELAY_MS = 5000;
|
||||
|
||||
const RECORDING_OPERATION = {
|
||||
START: 'start',
|
||||
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 PendingRecordingOperation {
|
||||
readonly generation: number;
|
||||
readonly kind: RecordingOperation;
|
||||
readonly playbackIdentity: string;
|
||||
readonly sessionId: string;
|
||||
readonly expectedActive: boolean;
|
||||
readonly initialError: string | null;
|
||||
readonly targetPath: string | null;
|
||||
commandSnapshotIdentity: string | null;
|
||||
commandSettled: boolean;
|
||||
sawErrorClear: boolean;
|
||||
}
|
||||
|
||||
export interface RecordingToggleContext {
|
||||
readonly folder: string;
|
||||
readonly playback: ResolvedPortalPlayback;
|
||||
readonly playbackIdentity: string;
|
||||
readonly session: EmbeddedMpvSession;
|
||||
}
|
||||
|
||||
export class EmbeddedMpvControlsRecording {
|
||||
private readonly feedbackState = signal<RecordingFeedback | null>(null);
|
||||
readonly feedback = this.feedbackState.asReadonly();
|
||||
|
||||
private pending: PendingRecordingOperation | null = null;
|
||||
private operationGeneration = 0;
|
||||
private acknowledgementTimer: number | null = null;
|
||||
private messageTimer: number | null = null;
|
||||
private ownerIdentity: string | null | undefined;
|
||||
private destroyed = false;
|
||||
|
||||
constructor(private readonly controller: EmbeddedMpvSessionController) {}
|
||||
|
||||
toggle(context: RecordingToggleContext): void {
|
||||
if (this.destroyed || this.pending) {
|
||||
return;
|
||||
}
|
||||
|
||||
const recording = context.session.recording;
|
||||
const initialError = this.recordingError(context.session);
|
||||
const kind = recording?.active
|
||||
? RECORDING_OPERATION.STOP
|
||||
: RECORDING_OPERATION.START;
|
||||
const generation = ++this.operationGeneration;
|
||||
this.pending = {
|
||||
generation,
|
||||
kind,
|
||||
playbackIdentity: context.playbackIdentity,
|
||||
sessionId: context.session.id,
|
||||
expectedActive: kind === RECORDING_OPERATION.START,
|
||||
initialError,
|
||||
targetPath: recording?.targetPath ?? null,
|
||||
commandSnapshotIdentity: null,
|
||||
commandSettled: false,
|
||||
sawErrorClear: initialError === null,
|
||||
};
|
||||
this.setFeedback(null);
|
||||
this.acknowledgementTimer = window.setTimeout(
|
||||
() => this.handleAcknowledgementTimeout(generation),
|
||||
RECORDING_ACK_TIMEOUT_MS
|
||||
);
|
||||
|
||||
const command =
|
||||
kind === RECORDING_OPERATION.START
|
||||
? this.controller.startRecording(
|
||||
context.folder,
|
||||
context.playback.title
|
||||
)
|
||||
: this.controller.stopRecording();
|
||||
void command.then(
|
||||
() => this.markCommandSettled(generation),
|
||||
() => this.markCommandSettled(generation)
|
||||
);
|
||||
}
|
||||
|
||||
reconcile(
|
||||
session: EmbeddedMpvSession | null,
|
||||
playbackIdentity: string | null
|
||||
): void {
|
||||
const pending = this.pending;
|
||||
if (!pending) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!session ||
|
||||
session.id !== pending.sessionId ||
|
||||
playbackIdentity !== pending.playbackIdentity
|
||||
) {
|
||||
this.cancelPending();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!pending.commandSettled ||
|
||||
this.recordingSnapshotIdentity(session) ===
|
||||
pending.commandSnapshotIdentity
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const recording = session.recording;
|
||||
if (!recording) {
|
||||
return;
|
||||
}
|
||||
const error = this.recordingError(session);
|
||||
if (error) {
|
||||
if (pending.sawErrorClear || error !== pending.initialError) {
|
||||
this.completePending({
|
||||
kind: RECORDING_FEEDBACK.RAW,
|
||||
text: error,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
pending.sawErrorClear = true;
|
||||
if (recording.active !== pending.expectedActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (pending.kind === RECORDING_OPERATION.START) {
|
||||
this.completePending(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const targetPath = recording.targetPath ?? pending.targetPath;
|
||||
this.completePending(
|
||||
targetPath
|
||||
? {
|
||||
kind: RECORDING_FEEDBACK.TRANSLATED,
|
||||
key: RECORDING_TRANSLATION.SAVED_TO,
|
||||
params: { path: targetPath },
|
||||
}
|
||||
: null,
|
||||
Boolean(targetPath)
|
||||
);
|
||||
}
|
||||
|
||||
syncOwner(playbackIdentity: string | null, sessionId: string | null): void {
|
||||
const nextIdentity =
|
||||
playbackIdentity === null
|
||||
? null
|
||||
: JSON.stringify([playbackIdentity, sessionId]);
|
||||
if (this.ownerIdentity === undefined) {
|
||||
this.ownerIdentity = nextIdentity;
|
||||
return;
|
||||
}
|
||||
if (this.ownerIdentity === nextIdentity) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.ownerIdentity = nextIdentity;
|
||||
this.setFeedback(null);
|
||||
if (
|
||||
this.pending &&
|
||||
(this.pending.playbackIdentity !== playbackIdentity ||
|
||||
this.pending.sessionId !== sessionId)
|
||||
) {
|
||||
this.cancelPending();
|
||||
}
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.destroyed = true;
|
||||
this.cancelPending();
|
||||
this.clearMessageTimer();
|
||||
}
|
||||
|
||||
private handleAcknowledgementTimeout(generation: number): void {
|
||||
const pending = this.pending;
|
||||
if (!pending || pending.generation !== generation) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentSession = this.controller.session();
|
||||
const currentOwnerIdentity = JSON.stringify([
|
||||
pending.playbackIdentity,
|
||||
pending.sessionId,
|
||||
]);
|
||||
const error =
|
||||
currentSession?.id === pending.sessionId &&
|
||||
this.ownerIdentity === currentOwnerIdentity
|
||||
? this.recordingError(currentSession)
|
||||
: null;
|
||||
this.completePending({
|
||||
...(error
|
||||
? { kind: RECORDING_FEEDBACK.RAW, text: error }
|
||||
: {
|
||||
kind: RECORDING_FEEDBACK.TRANSLATED,
|
||||
key:
|
||||
pending.kind === RECORDING_OPERATION.START
|
||||
? RECORDING_TRANSLATION.START_FAILED
|
||||
: RECORDING_TRANSLATION.STOP_FAILED,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
private markCommandSettled(generation: number): void {
|
||||
const pending = this.pending;
|
||||
if (!pending || pending.generation !== generation) {
|
||||
return;
|
||||
}
|
||||
pending.commandSettled = true;
|
||||
pending.commandSnapshotIdentity = this.recordingSnapshotIdentity(
|
||||
this.controller.session()
|
||||
);
|
||||
}
|
||||
|
||||
private recordingSnapshotIdentity(
|
||||
session: EmbeddedMpvSession | null
|
||||
): string | null {
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
const recording = session.recording;
|
||||
return JSON.stringify([
|
||||
session.id,
|
||||
recording?.active ?? false,
|
||||
recording?.targetPath ?? null,
|
||||
recording?.startedAt ?? null,
|
||||
recording?.error ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
private recordingError(session: EmbeddedMpvSession): string | null {
|
||||
const error = session.recording?.error;
|
||||
return error?.trim() ? error : null;
|
||||
}
|
||||
|
||||
private completePending(
|
||||
feedback: RecordingFeedback | null,
|
||||
autoDismiss = false
|
||||
): void {
|
||||
this.clearAcknowledgementTimer();
|
||||
this.pending = null;
|
||||
this.setFeedback(feedback, autoDismiss);
|
||||
}
|
||||
|
||||
private cancelPending(): void {
|
||||
this.operationGeneration += 1;
|
||||
this.pending = null;
|
||||
this.clearAcknowledgementTimer();
|
||||
}
|
||||
|
||||
private setFeedback(
|
||||
feedback: RecordingFeedback | null,
|
||||
autoDismiss = false
|
||||
): void {
|
||||
if (this.destroyed) {
|
||||
return;
|
||||
}
|
||||
this.clearMessageTimer();
|
||||
this.feedbackState.set(feedback);
|
||||
if (!feedback || !autoDismiss) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timerId = window.setTimeout(() => {
|
||||
if (!this.destroyed && this.feedbackState() === feedback) {
|
||||
this.feedbackState.set(null);
|
||||
}
|
||||
if (this.messageTimer === timerId) {
|
||||
this.messageTimer = null;
|
||||
}
|
||||
}, RECORDING_MESSAGE_DISMISS_DELAY_MS);
|
||||
this.messageTimer = timerId;
|
||||
}
|
||||
|
||||
private clearAcknowledgementTimer(): void {
|
||||
if (this.acknowledgementTimer !== null) {
|
||||
window.clearTimeout(this.acknowledgementTimer);
|
||||
this.acknowledgementTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private clearMessageTimer(): void {
|
||||
if (this.messageTimer !== null) {
|
||||
window.clearTimeout(this.messageTimer);
|
||||
this.messageTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
+123
-121
@@ -190,88 +190,6 @@ describe('EmbeddedMpvControlsAdapter recording messages and lifecycle', () => {
|
||||
expect(controller.startRecording).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('starts recording with the configured folder and playback title', async () => {
|
||||
controller.startRecording.mockResolvedValue({
|
||||
active: true,
|
||||
startedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
adapter.commands.toggleRecording();
|
||||
await flushPromises();
|
||||
|
||||
expect(controller.startRecording).toHaveBeenCalledWith(
|
||||
'/recordings',
|
||||
'Live news'
|
||||
);
|
||||
expect(adapter.state().recording.message).toBeNull();
|
||||
});
|
||||
|
||||
it('reports detailed and translated generic recording-start failures', async () => {
|
||||
controller.startRecording.mockResolvedValue({
|
||||
active: false,
|
||||
error: 'Disk is read-only',
|
||||
});
|
||||
adapter.commands.toggleRecording();
|
||||
await flushPromises();
|
||||
expect(adapter.state().recording.message).toBe('Disk is read-only');
|
||||
|
||||
controller.startRecording.mockResolvedValue(null);
|
||||
adapter.commands.toggleRecording();
|
||||
await flushPromises();
|
||||
expect(adapter.state().recording.message).toBe(
|
||||
'Failed to start recording'
|
||||
);
|
||||
});
|
||||
|
||||
it('reports a saved path after stop and auto-dismisses it after five seconds', async () => {
|
||||
controller.session.set(
|
||||
session({
|
||||
recording: {
|
||||
active: true,
|
||||
startedAt: '2026-07-16T10:00:00.000Z',
|
||||
},
|
||||
})
|
||||
);
|
||||
controller.stopRecording.mockResolvedValue({
|
||||
active: false,
|
||||
targetPath: '/recordings/live-news.ts',
|
||||
});
|
||||
|
||||
adapter.commands.toggleRecording();
|
||||
await flushPromises();
|
||||
|
||||
expect(controller.stopRecording).toHaveBeenCalledTimes(1);
|
||||
expect(adapter.state().recording.message).toBe(
|
||||
'Saved to /recordings/live-news.ts'
|
||||
);
|
||||
|
||||
jest.advanceTimersByTime(4999);
|
||||
expect(adapter.state().recording.message).toBe(
|
||||
'Saved to /recordings/live-news.ts'
|
||||
);
|
||||
|
||||
jest.advanceTimersByTime(1);
|
||||
expect(adapter.state().recording.message).toBeNull();
|
||||
});
|
||||
|
||||
it('reports detailed and translated generic recording-stop failures', async () => {
|
||||
controller.session.set(session({ recording: { active: true } }));
|
||||
controller.stopRecording.mockResolvedValue({
|
||||
active: false,
|
||||
error: 'Muxer failed',
|
||||
});
|
||||
adapter.commands.toggleRecording();
|
||||
await flushPromises();
|
||||
expect(adapter.state().recording.message).toBe('Muxer failed');
|
||||
|
||||
controller.stopRecording.mockResolvedValue(null);
|
||||
adapter.commands.toggleRecording();
|
||||
await flushPromises();
|
||||
expect(adapter.state().recording.message).toBe(
|
||||
'Failed to stop recording'
|
||||
);
|
||||
});
|
||||
|
||||
it('computes recording elapsed time and refreshes it every second', () => {
|
||||
controller.session.set(
|
||||
session({
|
||||
@@ -330,31 +248,98 @@ describe('EmbeddedMpvControlsAdapter recording messages and lifecycle', () => {
|
||||
expect(adapter.state().audioTracks[0].label).toBe('DE Audio 1');
|
||||
});
|
||||
|
||||
it('keeps a newer persistent message when an older saved-message timer expires', async () => {
|
||||
controller.session.set(session({ recording: { active: true } }));
|
||||
controller.stopRecording.mockResolvedValue({
|
||||
active: false,
|
||||
targetPath: '/recordings/first.ts',
|
||||
});
|
||||
it('relocalizes saved feedback for every translate event source', async () => {
|
||||
const targetPath = '/recordings/live.ts';
|
||||
controller.session.set(
|
||||
session({ recording: { active: true, targetPath } })
|
||||
);
|
||||
adapter.commands.toggleRecording();
|
||||
await flushPromises();
|
||||
controller.session.set(
|
||||
session({ recording: { active: false, targetPath } })
|
||||
);
|
||||
TestBed.tick();
|
||||
expect(adapter.state().recording.message).toBe(
|
||||
'Saved to /recordings/first.ts'
|
||||
`Saved to ${targetPath}`
|
||||
);
|
||||
|
||||
controller.stopRecording.mockResolvedValue({
|
||||
active: false,
|
||||
error: 'Newer failure',
|
||||
});
|
||||
adapter.commands.toggleRecording();
|
||||
await flushPromises();
|
||||
expect(adapter.state().recording.message).toBe('Newer failure');
|
||||
translate.setTranslation('de', translations('DE '));
|
||||
translate.use('de');
|
||||
expect(adapter.state().recording.message).toBe(
|
||||
`DE Saved to ${targetPath}`
|
||||
);
|
||||
|
||||
jest.advanceTimersByTime(5000);
|
||||
expect(adapter.state().recording.message).toBe('Newer failure');
|
||||
translate.setTranslation('de', translations('Updated '));
|
||||
expect(adapter.state().recording.message).toBe(
|
||||
`Updated Saved to ${targetPath}`
|
||||
);
|
||||
|
||||
translate.setTranslation('fr', translations('FR '));
|
||||
translate.use('');
|
||||
translate.setDefaultLang('fr');
|
||||
expect(adapter.state().recording.message).toBe(
|
||||
`FR Saved to ${targetPath}`
|
||||
);
|
||||
});
|
||||
|
||||
it('clears the elapsed interval when recording stops', () => {
|
||||
it('times out and relocalizes generic feedback for every translate event source', async () => {
|
||||
adapter.commands.toggleRecording();
|
||||
await flushPromises();
|
||||
jest.advanceTimersByTime(5000);
|
||||
expect(adapter.state().recording.message).toBe(
|
||||
'Failed to start recording'
|
||||
);
|
||||
|
||||
translate.setTranslation('de', translations('DE '));
|
||||
translate.use('de');
|
||||
expect(adapter.state().recording.message).toBe(
|
||||
'DE Failed to start recording'
|
||||
);
|
||||
|
||||
translate.setTranslation('de', translations('Updated '));
|
||||
expect(adapter.state().recording.message).toBe(
|
||||
'Updated Failed to start recording'
|
||||
);
|
||||
|
||||
translate.setTranslation('fr', translations('FR '));
|
||||
translate.use('');
|
||||
translate.setDefaultLang('fr');
|
||||
expect(adapter.state().recording.message).toBe(
|
||||
'FR Failed to start recording'
|
||||
);
|
||||
});
|
||||
|
||||
it('retains the pre-stop path and protects newer feedback from the saved timer', async () => {
|
||||
const targetPath = '/recordings/original.ts';
|
||||
controller.session.set(
|
||||
session({ recording: { active: true, targetPath } })
|
||||
);
|
||||
adapter.commands.toggleRecording();
|
||||
await flushPromises();
|
||||
controller.session.set(session({ recording: { active: false } }));
|
||||
TestBed.tick();
|
||||
expect(adapter.state().recording.message).toBe(
|
||||
`Saved to ${targetPath}`
|
||||
);
|
||||
|
||||
adapter.commands.toggleRecording();
|
||||
await flushPromises();
|
||||
controller.session.set(
|
||||
session({
|
||||
recording: {
|
||||
active: false,
|
||||
error: 'Newer addon failure',
|
||||
},
|
||||
})
|
||||
);
|
||||
TestBed.tick();
|
||||
jest.advanceTimersByTime(5000);
|
||||
|
||||
expect(adapter.state().recording.message).toBe('Newer addon failure');
|
||||
});
|
||||
|
||||
it('does not restart the elapsed interval for position-only snapshots', () => {
|
||||
const setIntervalSpy = jest.spyOn(window, 'setInterval');
|
||||
const clearIntervalSpy = jest.spyOn(window, 'clearInterval');
|
||||
controller.session.set(
|
||||
session({
|
||||
@@ -366,31 +351,48 @@ describe('EmbeddedMpvControlsAdapter recording messages and lifecycle', () => {
|
||||
);
|
||||
TestBed.tick();
|
||||
|
||||
controller.session.set(session({ recording: { active: false } }));
|
||||
controller.session.set(
|
||||
session({
|
||||
positionSeconds: 1,
|
||||
recording: {
|
||||
active: true,
|
||||
startedAt: '2026-07-16T10:00:00.000Z',
|
||||
},
|
||||
})
|
||||
);
|
||||
TestBed.tick();
|
||||
controller.session.set(
|
||||
session({
|
||||
positionSeconds: 2,
|
||||
recording: {
|
||||
active: true,
|
||||
startedAt: '2026-07-16T10:00:00.000Z',
|
||||
},
|
||||
})
|
||||
);
|
||||
TestBed.tick();
|
||||
|
||||
expect(setIntervalSpy).toHaveBeenCalledTimes(1);
|
||||
expect(clearIntervalSpy).not.toHaveBeenCalled();
|
||||
setIntervalSpy.mockRestore();
|
||||
clearIntervalSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('clears an active elapsed interval on destruction', () => {
|
||||
const clearIntervalSpy = jest.spyOn(window, 'clearInterval');
|
||||
controller.session.set(
|
||||
session({
|
||||
recording: {
|
||||
active: true,
|
||||
startedAt: '2026-07-16T10:00:00.000Z',
|
||||
},
|
||||
})
|
||||
);
|
||||
TestBed.tick();
|
||||
|
||||
TestBed.resetTestingModule();
|
||||
|
||||
expect(clearIntervalSpy).toHaveBeenCalled();
|
||||
clearIntervalSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('clears message timers on destruction and prevents late mutation', async () => {
|
||||
const clearTimeoutSpy = jest.spyOn(window, 'clearTimeout');
|
||||
controller.session.set(session({ recording: { active: true } }));
|
||||
controller.stopRecording.mockResolvedValue({
|
||||
active: false,
|
||||
targetPath: '/recordings/final.ts',
|
||||
});
|
||||
adapter.commands.toggleRecording();
|
||||
await flushPromises();
|
||||
|
||||
const state = adapter.state;
|
||||
expect(state().recording.message).toBe('Saved to /recordings/final.ts');
|
||||
|
||||
TestBed.resetTestingModule();
|
||||
expect(clearTimeoutSpy).toHaveBeenCalled();
|
||||
jest.advanceTimersByTime(5000);
|
||||
|
||||
expect(state().recording.message).toBe('Saved to /recordings/final.ts');
|
||||
clearTimeoutSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
+395
@@ -0,0 +1,395 @@
|
||||
import { signal, WritableSignal } 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,
|
||||
};
|
||||
const REPLACEMENT_PLAYBACK: ResolvedPortalPlayback = {
|
||||
streamUrl: 'https://example.com/replacement',
|
||||
title: 'Replacement news',
|
||||
isLive: true,
|
||||
};
|
||||
|
||||
interface Deferred<T> {
|
||||
promise: Promise<T>;
|
||||
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(prefix = ''): object {
|
||||
return {
|
||||
EMBEDDED_MPV: {
|
||||
PLAYER: {
|
||||
PLAYBACK_FAILED: `${prefix}Playback failed`,
|
||||
CHECKING_SUPPORT: `${prefix}Checking support`,
|
||||
NOT_AVAILABLE: `${prefix}Not available`,
|
||||
LOADING_STREAM: `${prefix}Loading stream`,
|
||||
TRACK_DEFAULT: `${prefix}Default`,
|
||||
AUDIO_TRACK_FALLBACK: `${prefix}Audio {{index}}`,
|
||||
SUBTITLE_TRACK_FALLBACK: `${prefix}Subtitle {{index}}`,
|
||||
RECORDING_FAILED_TO_START: `${prefix}Failed to start recording`,
|
||||
RECORDING_FAILED_TO_STOP: `${prefix}Failed to stop recording`,
|
||||
SAVED_TO: `${prefix}Saved to {{path}}`,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function flushPromises(): Promise<void> {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
describe('EmbeddedMpvControlsAdapter recording acknowledgement races', () => {
|
||||
let adapter: EmbeddedMpvControlsAdapter;
|
||||
let controller: ReturnType<typeof createController>;
|
||||
let translate: TranslateService;
|
||||
let playback: WritableSignal<ResolvedPortalPlayback>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
controller = createController();
|
||||
TestBed.configureTestingModule({
|
||||
imports: [TranslateModule.forRoot()],
|
||||
providers: [
|
||||
EmbeddedMpvControlsAdapter,
|
||||
{
|
||||
provide: EmbeddedMpvSessionController,
|
||||
useValue: controller,
|
||||
},
|
||||
],
|
||||
});
|
||||
translate = TestBed.inject(TranslateService);
|
||||
translate.setTranslation('en', translations());
|
||||
translate.setDefaultLang('en');
|
||||
translate.use('en');
|
||||
adapter = TestBed.inject(EmbeddedMpvControlsAdapter);
|
||||
playback = signal(LIVE_PLAYBACK);
|
||||
adapter.configure({
|
||||
playback,
|
||||
seriesNavigation: signal(null),
|
||||
recordingFolder: signal('/recordings'),
|
||||
});
|
||||
TestBed.tick();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
TestBed.resetTestingModule();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('waits for a same-session active transition instead of trusting a stale start result', async () => {
|
||||
controller.startRecording.mockImplementation(async () => {
|
||||
const staleRecording = {
|
||||
active: false,
|
||||
error: 'stale error',
|
||||
};
|
||||
controller.session.set(session({ recording: staleRecording }));
|
||||
return staleRecording;
|
||||
});
|
||||
|
||||
adapter.commands.toggleRecording();
|
||||
await flushPromises();
|
||||
TestBed.tick();
|
||||
|
||||
expect(controller.startRecording).toHaveBeenCalledWith(
|
||||
'/recordings',
|
||||
LIVE_PLAYBACK.title
|
||||
);
|
||||
expect(adapter.state().recording.message).toBeNull();
|
||||
controller.session.update((current) => ({
|
||||
...(current ?? session()),
|
||||
positionSeconds: 1,
|
||||
updatedAt: '2026-07-16T10:00:02.000Z',
|
||||
}));
|
||||
TestBed.tick();
|
||||
controller.session.set(
|
||||
session({
|
||||
recording: {
|
||||
active: true,
|
||||
targetPath: '/recordings/live.ts',
|
||||
startedAt: '2026-07-16T10:00:02.000Z',
|
||||
},
|
||||
})
|
||||
);
|
||||
TestBed.tick();
|
||||
jest.advanceTimersByTime(ACK_TIMEOUT_MS);
|
||||
|
||||
expect(adapter.state().recording.active).toBe(true);
|
||||
expect(adapter.state().recording.message).toBeNull();
|
||||
});
|
||||
|
||||
it('waits for an inactive transition before reporting a saved stop', async () => {
|
||||
const targetPath = '/recordings/live.ts';
|
||||
controller.session.set(
|
||||
session({
|
||||
recording: {
|
||||
active: true,
|
||||
targetPath,
|
||||
startedAt: '2026-07-16T10:00:02.000Z',
|
||||
},
|
||||
})
|
||||
);
|
||||
controller.stopRecording.mockResolvedValue({
|
||||
active: true,
|
||||
targetPath,
|
||||
});
|
||||
|
||||
adapter.commands.toggleRecording();
|
||||
await flushPromises();
|
||||
expect(adapter.state().recording.message).toBeNull();
|
||||
|
||||
controller.session.set(
|
||||
session({ recording: { active: false, targetPath } })
|
||||
);
|
||||
TestBed.tick();
|
||||
|
||||
expect(adapter.state().recording.message).toBe(
|
||||
`Saved to ${targetPath}`
|
||||
);
|
||||
jest.advanceTimersByTime(ACK_TIMEOUT_MS - 1);
|
||||
expect(adapter.state().recording.message).toBe(
|
||||
`Saved to ${targetPath}`
|
||||
);
|
||||
jest.advanceTimersByTime(1);
|
||||
expect(adapter.state().recording.message).toBeNull();
|
||||
});
|
||||
|
||||
it('uses a later same-session recording error as raw failure feedback', async () => {
|
||||
const addonError = ' Addon rejected stream-record ';
|
||||
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 '));
|
||||
expect(adapter.state().recording.message).toBe(addonError);
|
||||
});
|
||||
|
||||
it('reports the same addon error again when a retry times out', async () => {
|
||||
const addonError = 'Disk is still full';
|
||||
controller.session.set(
|
||||
session({ recording: { active: false, error: addonError } })
|
||||
);
|
||||
adapter.commands.toggleRecording();
|
||||
await flushPromises();
|
||||
controller.session.set(
|
||||
session({
|
||||
updatedAt: '2026-07-16T10:00:03.000Z',
|
||||
recording: { active: false, error: addonError },
|
||||
})
|
||||
);
|
||||
TestBed.tick();
|
||||
jest.advanceTimersByTime(ACK_TIMEOUT_MS);
|
||||
|
||||
expect(adapter.state().recording.message).toBe(addonError);
|
||||
});
|
||||
|
||||
it('serializes two immediate start toggles while acknowledgement is pending', async () => {
|
||||
const command = deferred<EmbeddedMpvSession['recording'] | null>();
|
||||
controller.startRecording.mockReturnValue(command.promise);
|
||||
|
||||
adapter.commands.toggleRecording();
|
||||
adapter.commands.toggleRecording();
|
||||
expect(controller.startRecording).toHaveBeenCalledTimes(1);
|
||||
command.resolve({ active: false });
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
it('serializes two immediate stop toggles while acknowledgement is pending', async () => {
|
||||
controller.session.set(
|
||||
session({
|
||||
recording: {
|
||||
active: true,
|
||||
targetPath: '/recordings/live.ts',
|
||||
},
|
||||
})
|
||||
);
|
||||
const command = deferred<EmbeddedMpvSession['recording'] | null>();
|
||||
controller.stopRecording.mockReturnValue(command.promise);
|
||||
|
||||
adapter.commands.toggleRecording();
|
||||
adapter.commands.toggleRecording();
|
||||
expect(controller.stopRecording).toHaveBeenCalledTimes(1);
|
||||
command.resolve({ active: true });
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
it.each(['playback', 'session'] as const)(
|
||||
'invalidates a pending result after %s identity replacement',
|
||||
async (replacement) => {
|
||||
const command = deferred<EmbeddedMpvSession['recording'] | null>();
|
||||
controller.startRecording.mockReturnValue(command.promise);
|
||||
adapter.commands.toggleRecording();
|
||||
|
||||
if (replacement === 'playback') {
|
||||
playback.set(REPLACEMENT_PLAYBACK);
|
||||
} else {
|
||||
controller.session.set(
|
||||
session({
|
||||
id: 'session-2',
|
||||
streamUrl: REPLACEMENT_PLAYBACK.streamUrl,
|
||||
})
|
||||
);
|
||||
}
|
||||
TestBed.tick();
|
||||
command.resolve(null);
|
||||
await flushPromises();
|
||||
jest.advanceTimersByTime(ACK_TIMEOUT_MS);
|
||||
expect(adapter.state().recording.message).toBeNull();
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['playback', 'session'] as const)(
|
||||
'clears recording feedback after %s identity changes',
|
||||
async (replacement) => {
|
||||
adapter.commands.toggleRecording();
|
||||
await flushPromises();
|
||||
controller.session.set(
|
||||
session({
|
||||
recording: {
|
||||
active: false,
|
||||
error: 'Current channel failure',
|
||||
},
|
||||
})
|
||||
);
|
||||
TestBed.tick();
|
||||
expect(adapter.state().recording.message).toBe(
|
||||
'Current channel failure'
|
||||
);
|
||||
|
||||
if (replacement === 'playback') {
|
||||
playback.set(REPLACEMENT_PLAYBACK);
|
||||
} else {
|
||||
controller.session.set(session({ id: 'session-2' }));
|
||||
}
|
||||
TestBed.tick();
|
||||
|
||||
expect(adapter.state().recording.message).toBeNull();
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['start', 'stop'] as const)(
|
||||
'ignores a deferred %s result after destruction',
|
||||
async (operation) => {
|
||||
const command = deferred<EmbeddedMpvSession['recording'] | null>();
|
||||
if (operation === 'start') {
|
||||
controller.startRecording.mockReturnValue(command.promise);
|
||||
} else {
|
||||
controller.session.set(
|
||||
session({ recording: { active: true } })
|
||||
);
|
||||
controller.stopRecording.mockReturnValue(command.promise);
|
||||
}
|
||||
adapter.commands.toggleRecording();
|
||||
const state = adapter.state;
|
||||
|
||||
TestBed.resetTestingModule();
|
||||
command.resolve(null);
|
||||
await flushPromises();
|
||||
jest.advanceTimersByTime(ACK_TIMEOUT_MS);
|
||||
expect(state().recording.message).toBeNull();
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -35,10 +35,12 @@ import {
|
||||
readStoredVolume,
|
||||
subtitleTrackLabel,
|
||||
} from './embedded-mpv-format.utils';
|
||||
import {
|
||||
EmbeddedMpvControlsRecording,
|
||||
resolveRecordingFeedback,
|
||||
} from './embedded-mpv-controls-recording';
|
||||
import { EmbeddedMpvSessionController } from './embedded-mpv-session-controller';
|
||||
|
||||
const RECORDING_MESSAGE_DISMISS_DELAY_MS = 5000;
|
||||
|
||||
export interface EmbeddedMpvControlsContext {
|
||||
readonly playback: Signal<ResolvedPortalPlayback>;
|
||||
readonly seriesNavigation: Signal<SeriesPlaybackNavigation | null>;
|
||||
@@ -66,10 +68,20 @@ export class EmbeddedMpvControlsAdapter implements PlayerController {
|
||||
|
||||
private readonly configuredContext =
|
||||
signal<EmbeddedMpvControlsContext | null>(null);
|
||||
private readonly recordingControls = new EmbeddedMpvControlsRecording(
|
||||
this.controller
|
||||
);
|
||||
private readonly recordingActive = computed(
|
||||
() => this.controller.session()?.recording?.active === true
|
||||
);
|
||||
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());
|
||||
private readonly recordingMessage = signal<string | null>(null);
|
||||
private recordingMessageTimer: number | null = null;
|
||||
private destroyed = false;
|
||||
|
||||
readonly capabilities = computed<PlayerControlsCapabilities>(() => {
|
||||
const context = this.configuredContext();
|
||||
@@ -159,7 +171,10 @@ export class EmbeddedMpvControlsAdapter implements PlayerController {
|
||||
elapsedSeconds: this.recordingElapsedSeconds(
|
||||
recording?.startedAt
|
||||
),
|
||||
message: this.recordingMessage(),
|
||||
message: resolveRecordingFeedback(
|
||||
this.recordingControls.feedback(),
|
||||
this.translate
|
||||
),
|
||||
},
|
||||
canPreviousEpisode:
|
||||
hasSeriesNavigation && seriesNavigation?.canPrevious === true,
|
||||
@@ -182,7 +197,7 @@ export class EmbeddedMpvControlsAdapter implements PlayerController {
|
||||
|
||||
constructor() {
|
||||
effect((onCleanup) => {
|
||||
if (this.controller.session()?.recording?.active !== true) {
|
||||
if (!this.recordingActive()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -194,10 +209,23 @@ export class EmbeddedMpvControlsAdapter implements PlayerController {
|
||||
onCleanup(() => window.clearInterval(intervalId));
|
||||
});
|
||||
|
||||
this.destroyRef.onDestroy(() => {
|
||||
this.destroyed = true;
|
||||
this.clearRecordingMessageTimer();
|
||||
effect(() => {
|
||||
const playbackIdentity = this.playbackIdentity();
|
||||
const sessionId = this.activeSessionId();
|
||||
untracked(() =>
|
||||
this.recordingControls.syncOwner(playbackIdentity, sessionId)
|
||||
);
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
const session = this.controller.session();
|
||||
const playbackIdentity = this.playbackIdentity();
|
||||
untracked(() =>
|
||||
this.recordingControls.reconcile(session, playbackIdentity)
|
||||
);
|
||||
});
|
||||
|
||||
this.destroyRef.onDestroy(() => this.recordingControls.destroy());
|
||||
}
|
||||
|
||||
configure(context: EmbeddedMpvControlsContext): void {
|
||||
@@ -276,12 +304,15 @@ export class EmbeddedMpvControlsAdapter implements PlayerController {
|
||||
return Math.max(0, Math.floor((Date.now() - startedAtMs) / 1000));
|
||||
}
|
||||
|
||||
private async toggleRecording(): Promise<void> {
|
||||
private toggleRecording(): void {
|
||||
const context = this.configuredContext();
|
||||
const support = this.controller.support();
|
||||
const session = this.controller.session();
|
||||
const playbackIdentity = this.playbackIdentity();
|
||||
if (
|
||||
!context ||
|
||||
!session ||
|
||||
!playbackIdentity ||
|
||||
!support?.supported ||
|
||||
support.capabilities?.recording !== true ||
|
||||
!this.isLivePlayback(context.playback()) ||
|
||||
@@ -290,107 +321,11 @@ export class EmbeddedMpvControlsAdapter implements PlayerController {
|
||||
return;
|
||||
}
|
||||
|
||||
if (session?.recording?.active === true) {
|
||||
await this.stopRecording();
|
||||
return;
|
||||
}
|
||||
|
||||
await this.startRecording(context);
|
||||
}
|
||||
|
||||
private async startRecording(
|
||||
context: EmbeddedMpvControlsContext
|
||||
): Promise<void> {
|
||||
this.setRecordingMessage(null);
|
||||
|
||||
try {
|
||||
const recording = await this.controller.startRecording(
|
||||
context.recordingFolder(),
|
||||
context.playback().title
|
||||
);
|
||||
if (this.destroyed || recording?.active) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setRecordingMessage(
|
||||
recording?.error ??
|
||||
this.translate.instant(
|
||||
'EMBEDDED_MPV.PLAYER.RECORDING_FAILED_TO_START'
|
||||
)
|
||||
);
|
||||
} catch {
|
||||
this.setRecordingMessage(
|
||||
this.translate.instant(
|
||||
'EMBEDDED_MPV.PLAYER.RECORDING_FAILED_TO_START'
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async stopRecording(): Promise<void> {
|
||||
try {
|
||||
const recording = await this.controller.stopRecording();
|
||||
if (this.destroyed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (recording?.targetPath) {
|
||||
this.setRecordingMessage(
|
||||
this.translate.instant('EMBEDDED_MPV.PLAYER.SAVED_TO', {
|
||||
path: recording.targetPath,
|
||||
}),
|
||||
{ autoDismiss: true }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.setRecordingMessage(
|
||||
recording?.error ??
|
||||
this.translate.instant(
|
||||
'EMBEDDED_MPV.PLAYER.RECORDING_FAILED_TO_STOP'
|
||||
)
|
||||
);
|
||||
} catch {
|
||||
this.setRecordingMessage(
|
||||
this.translate.instant(
|
||||
'EMBEDDED_MPV.PLAYER.RECORDING_FAILED_TO_STOP'
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private setRecordingMessage(
|
||||
message: string | null,
|
||||
options: { autoDismiss?: boolean } = {}
|
||||
): void {
|
||||
if (this.destroyed) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.clearRecordingMessageTimer();
|
||||
this.recordingMessage.set(message);
|
||||
|
||||
if (!message || !options.autoDismiss) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timerId = window.setTimeout(() => {
|
||||
if (!this.destroyed && this.recordingMessage() === message) {
|
||||
this.recordingMessage.set(null);
|
||||
}
|
||||
if (this.recordingMessageTimer === timerId) {
|
||||
this.recordingMessageTimer = null;
|
||||
}
|
||||
}, RECORDING_MESSAGE_DISMISS_DELAY_MS);
|
||||
this.recordingMessageTimer = timerId;
|
||||
}
|
||||
|
||||
private clearRecordingMessageTimer(): void {
|
||||
if (this.recordingMessageTimer === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.clearTimeout(this.recordingMessageTimer);
|
||||
this.recordingMessageTimer = null;
|
||||
this.recordingControls.toggle({
|
||||
folder: context.recordingFolder(),
|
||||
playback: context.playback(),
|
||||
playbackIdentity,
|
||||
session,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user