Files
iptvnator/apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged-fixtures.ts
T
4grayandClaude Opus 5.5 e74a03b8e0 feat(playback): report Embedded MPV file chapters on the timeline (re-land #1771) (#1874)
Re-lands #1771, which was merged into the branch of #1768 after #1768
itself had been squash-merged and so never reached master:

- Native: the macOS addon, the Windows libmpv addon and the frame-copy
  helper observe mpv `chapter-list`; snapshots carry
  `chapters: [{ timeSeconds, title? }]`, cleared on every START_FILE.
- Main: `normalizeEmbeddedMpvChapters` validates the field into
  `EmbeddedMpvSession.chapters` (older binaries give []).
- Renderer: `buildChapterTimelineSegments` maps chapters to timeline
  segments; host catch-up segments keep precedence, and a closing-credits
  chapter times the Up next card.

Chapters flow through the same timelineSegments path as catch-up
programmes, so they get #1854's keyboard and screen-reader naming,
bounded label and 3:1 separators; specs and the frame-copy E2E now
assert the chapter name reaches the slider's aria-valuetext. The packaged
Linux smoke registers each media server for cleanup as it starts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 16:57:46 +02:00

396 lines
11 KiB
TypeScript

import type {
EmbeddedMpvSession,
EmbeddedMpvSupport,
} from '@iptvnator/shared/interfaces';
import { spawnSync } from 'child_process';
import { createServer, type Server } from 'http';
import sharp = require('sharp');
import {
closeElectronApp,
expect,
type LaunchedElectronApp,
} from './electron-test-fixtures';
import type {
DisposablePackagedLinuxApp,
PackagedEntryGuard,
} from './embedded-mpv-frame-copy-packaged-filesystem';
declare global {
interface Window {
__packagedEmbeddedMpvSessions?: EmbeddedMpvSession[];
__packagedEmbeddedMpvUnsubscribe?: () => void;
}
}
export type LocalMediaServer = {
close: () => Promise<void>;
url: string;
};
/**
* `current`, now also closing `server`. Register each server this way the
* moment it starts: one created later can throw, and the cleanup must
* still reach every server already listening.
*/
export function alsoClosing(
current: LocalMediaServer,
server: LocalMediaServer
): LocalMediaServer {
return {
url: current.url,
close: async () => {
await Promise.all([current.close(), server.close()]);
},
};
}
/**
* Length of the generated Y4M clip. Long enough for the relative-seek burst
* in the packaged smoke (three +2 s steps land at 6 s) plus the playing
* section that follows it without hitting EOF; at 64x36 / 10 fps this is
* still well under half a megabyte.
*/
export const Y4M_FIXTURE_DURATION_SECONDS = 12;
function createY4mFixture(): Buffer {
const width = 64;
const height = 36;
const framesPerSecond = 10;
const frameCount = framesPerSecond * Y4M_FIXTURE_DURATION_SECONDS;
const yPlaneBytes = width * height;
const chromaPlaneBytes = (width / 2) * (height / 2);
const chunks: Buffer[] = [
Buffer.from(
`YUV4MPEG2 W${width} H${height} F${framesPerSecond}:1 Ip A1:1 C420jpeg\n`,
'ascii'
),
];
for (let index = 0; index < frameCount; index += 1) {
const evenFrame = index % 2 === 0;
chunks.push(
Buffer.from('FRAME\n', 'ascii'),
Buffer.alloc(yPlaneBytes, evenFrame ? 76 : 150),
Buffer.alloc(chromaPlaneBytes, evenFrame ? 84 : 44),
Buffer.alloc(chromaPlaneBytes, evenFrame ? 255 : 21)
);
}
return Buffer.concat(chunks);
}
/** Silent mono PCM lets the real MPV smoke revoke audio observations. */
export function createWavFixture(): Buffer {
const sampleRate = 8000;
const dataBytes = sampleRate * 2 * 30;
const body = Buffer.alloc(44 + dataBytes);
body.write('RIFF', 0);
body.writeUInt32LE(36 + dataBytes, 4);
body.write('WAVEfmt ', 8);
body.writeUInt32LE(16, 16);
body.writeUInt16LE(1, 20); // PCM
body.writeUInt16LE(1, 22); // mono
body.writeUInt32LE(sampleRate, 24);
body.writeUInt32LE(sampleRate * 2, 28);
body.writeUInt16LE(2, 32);
body.writeUInt16LE(16, 34);
body.write('data', 36);
body.writeUInt32LE(dataBytes, 40);
return body;
}
async function listen(server: Server): Promise<void> {
await new Promise<void>((resolvePromise, rejectPromise) => {
const onError = (error: Error) => {
server.off('listening', onListening);
rejectPromise(error);
};
const onListening = () => {
server.off('error', onError);
resolvePromise();
};
server.once('error', onError);
server.once('listening', onListening);
server.listen(0, '127.0.0.1');
});
}
async function closeServer(server: Server): Promise<void> {
await new Promise<void>((resolvePromise, rejectPromise) => {
server.close((error) => {
if (error) {
rejectPromise(error);
return;
}
resolvePromise();
});
});
}
export async function createLocalMediaServer(options?: {
body: Buffer;
resourcePath: string;
contentType: string;
}): Promise<LocalMediaServer> {
const body = options?.body ?? createY4mFixture();
const resourcePath =
options?.resourcePath ?? '/embedded-mpv-frame-copy-smoke.y4m';
const contentType = options?.contentType ?? 'video/x-yuv4mpeg';
const server = createServer((request, response) => {
const pathname = (request.url ?? '').split('?')[0];
if (pathname !== resourcePath) {
response.writeHead(404).end();
return;
}
const range = request.headers.range?.match(/^bytes=(\d+)-(\d*)$/);
if (!range) {
response.writeHead(200, {
'Accept-Ranges': 'bytes',
'Content-Length': body.length,
'Content-Type': contentType,
});
response.end(request.method === 'HEAD' ? undefined : body);
return;
}
const start = Number(range[1]);
const requestedEnd = range[2] ? Number(range[2]) : body.length - 1;
const end = Math.min(requestedEnd, body.length - 1);
if (
!Number.isSafeInteger(start) ||
!Number.isSafeInteger(end) ||
start < 0 ||
start > end ||
start >= body.length
) {
response.writeHead(416, {
'Content-Range': `bytes */${body.length}`,
});
response.end();
return;
}
response.writeHead(206, {
'Accept-Ranges': 'bytes',
'Content-Length': end - start + 1,
'Content-Range': `bytes ${start}-${end}/${body.length}`,
'Content-Type': contentType,
});
response.end(
request.method === 'HEAD'
? undefined
: body.subarray(start, end + 1)
);
});
await listen(server);
const address = server.address();
if (!address || typeof address === 'string') {
await closeServer(server);
throw new Error('Unable to resolve the local media server address.');
}
return {
close: () => closeServer(server),
url: `http://127.0.0.1:${address.port}${resourcePath}`,
};
}
export function assertNativeFallbackPrerequisites(): void {
if (!process.env['DISPLAY']) {
throw new Error(
'The packaged native-view fallback smoke requires DISPLAY (run it under Xvfb/X11).'
);
}
const mpv = spawnSync('mpv', ['--version'], {
stdio: 'ignore',
timeout: 3000,
});
if (mpv.status !== 0) {
throw new Error(
'The packaged native-view fallback smoke requires a working system mpv CLI on PATH.'
);
}
}
export async function installEmbeddedMpvSessionCapture(
app: LaunchedElectronApp
): Promise<void> {
await app.mainWindow.evaluate(() => {
window.__packagedEmbeddedMpvUnsubscribe?.();
window.__packagedEmbeddedMpvSessions = [];
window.__packagedEmbeddedMpvUnsubscribe =
window.electron.onEmbeddedMpvSessionUpdate?.((session) => {
window.__packagedEmbeddedMpvSessions?.push(session);
});
});
}
export async function installFrameCanvasAndSessionCapture(
app: LaunchedElectronApp
): Promise<void> {
await installEmbeddedMpvSessionCapture(app);
await app.mainWindow.evaluate(() => {
document.querySelector('canvas[data-embedded-mpv-frame]')?.remove();
const canvas = document.createElement('canvas');
canvas.dataset['embeddedMpvFrame'] = '';
canvas.dataset['testId'] = 'packaged-embedded-mpv-frame';
Object.assign(canvas.style, {
background: '#000',
height: '180px',
left: '0',
position: 'fixed',
top: '0',
width: '320px',
zIndex: '2147483647',
});
document.body.append(canvas);
});
}
export async function getEmbeddedMpvSupport(
app: LaunchedElectronApp
): Promise<EmbeddedMpvSupport> {
return app.mainWindow.evaluate(async () => {
return window.electron.getEmbeddedMpvSupport();
});
}
export async function getLatestSession(
app: LaunchedElectronApp,
sessionId: string
): Promise<EmbeddedMpvSession | null> {
return app.mainWindow.evaluate((id) => {
const sessions =
window.__packagedEmbeddedMpvSessions?.filter(
(session) => session.id === id
) ?? [];
return sessions.at(-1) ?? null;
}, sessionId);
}
export function isMeaningfulNativePlaybackSnapshot(
snapshot: {
durationSeconds: number | null;
error?: string;
status: string;
streamUrl: string;
} | null,
expectedUrl: string
): boolean {
if (
!snapshot ||
snapshot.error ||
!['paused', 'playing'].includes(snapshot.status) ||
typeof snapshot.durationSeconds !== 'number' ||
!Number.isFinite(snapshot.durationSeconds) ||
snapshot.durationSeconds <= 0
) {
return false;
}
const normalizeUrl = (value: string): string => {
try {
const url = new URL(value);
url.hash = '';
return url.href;
} catch {
return value.trim();
}
};
return normalizeUrl(snapshot.streamUrl) === normalizeUrl(expectedUrl);
}
export async function renderedFrameSignal(
app: LaunchedElectronApp
): Promise<number> {
const canvas = app.mainWindow.getByTestId('packaged-embedded-mpv-frame');
const png = await canvas.screenshot();
const { data, info } = await sharp(png)
.removeAlpha()
.raw()
.toBuffer({ resolveWithObject: true });
let signal = 0;
for (let offset = 0; offset < data.length; offset += info.channels) {
if (data[offset] + data[offset + 1] + data[offset + 2] > 30) {
signal += 1;
}
}
return signal;
}
export async function closeAndWaitForExit(
app: LaunchedElectronApp
): Promise<void> {
const processHandle = app.electronApp.process();
await closeElectronApp(app);
await expect
.poll(
() =>
processHandle.exitCode !== null ||
processHandle.signalCode !== null,
{ timeout: 10000 }
)
.toBe(true);
}
export async function cleanupPackagedFrameCopySmoke(options: {
apps: Array<LaunchedElectronApp | undefined>;
hiddenRuntimeEntry?: PackagedEntryGuard;
media?: LocalMediaServer;
packageClone?: DisposablePackagedLinuxApp;
}): Promise<void> {
const errors: unknown[] = [];
for (const app of options.apps) {
if (!app) {
continue;
}
try {
await closeAndWaitForExit(app);
} catch (error) {
errors.push(error);
}
}
if (options.hiddenRuntimeEntry) {
try {
options.hiddenRuntimeEntry.restore();
} catch (error) {
errors.push(error);
}
}
if (options.media) {
try {
await options.media.close();
} catch (error) {
errors.push(error);
}
}
if (options.packageClone) {
try {
options.packageClone.cleanup();
} catch (error) {
errors.push(error);
}
}
if (errors.length > 0) {
throw new Error(
`Packaged frame-copy smoke cleanup failed: ${errors
.map((error) =>
error instanceof Error ? error.message : String(error)
)
.join('; ')}`
);
}
}