mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 05:36:17 -08:00
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>
396 lines
11 KiB
TypeScript
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('; ')}`
|
|
);
|
|
}
|
|
}
|