mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
fix(playback): bound chapter payloads and harden the chapters E2E
Cap the chapter list at 256 entries and titles at 256 bytes in every native parser (UTF-8 safe) and again in main's normalizer, so a chapter-heavy file cannot inflate each snapshot and IPC update. Launch the E2E app inside the cleanup scope so a failed launch still closes the media server, and retry the frame view whenever it stalls while waiting for chapters. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0b96fe70fa
commit
ff9bdd4bbd
6 files changed
+169
-58
No files matched your search
@@ -12,6 +12,7 @@ import {
|
||||
saveSettings,
|
||||
test,
|
||||
workspaceRoot,
|
||||
type LaunchedElectronApp,
|
||||
} from './electron-test-fixtures';
|
||||
import {
|
||||
createLocalMediaServer,
|
||||
@@ -37,74 +38,68 @@ test('@playback @electron @embedded-mpv frame-copy draws file chapters on the ti
|
||||
resourcePath: '/episode-chapters.webm',
|
||||
contentType: 'video/webm',
|
||||
});
|
||||
const app = await launchElectronApp(dataDir, {
|
||||
env: {
|
||||
IPTVNATOR_ENABLE_EMBEDDED_MPV_EXPERIMENT: '1',
|
||||
IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY: '1',
|
||||
IPTVNATOR_EMBEDDED_MPV_ALLOW_HOMEBREW: '1',
|
||||
},
|
||||
});
|
||||
// The launch sits inside the cleanup scope: a failed launch must still
|
||||
// close the media server.
|
||||
let app: LaunchedElectronApp | undefined;
|
||||
try {
|
||||
const support = await app.mainWindow.evaluate(() =>
|
||||
const launched = await launchElectronApp(dataDir, {
|
||||
env: {
|
||||
IPTVNATOR_ENABLE_EMBEDDED_MPV_EXPERIMENT: '1',
|
||||
IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY: '1',
|
||||
IPTVNATOR_EMBEDDED_MPV_ALLOW_HOMEBREW: '1',
|
||||
},
|
||||
});
|
||||
app = launched;
|
||||
const support = await launched.mainWindow.evaluate(() =>
|
||||
window.electron.getEmbeddedMpvSupport()
|
||||
);
|
||||
test.skip(
|
||||
!support.supported || support.engine !== 'frame-copy',
|
||||
`Frame-copy runtime unavailable: ${JSON.stringify(support)}`
|
||||
);
|
||||
await openSettings(app.mainWindow);
|
||||
await openSettingsSection(app.mainWindow, 'playback');
|
||||
await app.mainWindow.getByTestId('select-video-player').click();
|
||||
await app.mainWindow.getByTestId('embedded-mpv').click();
|
||||
await saveSettings(app.mainWindow);
|
||||
await goToDashboard(app.mainWindow);
|
||||
await openSettings(launched.mainWindow);
|
||||
await openSettingsSection(launched.mainWindow, 'playback');
|
||||
await launched.mainWindow.getByTestId('select-video-player').click();
|
||||
await launched.mainWindow.getByTestId('embedded-mpv').click();
|
||||
await saveSettings(launched.mainWindow);
|
||||
await goToDashboard(launched.mainWindow);
|
||||
|
||||
const playlist = join(dataDir, 'chapters.m3u');
|
||||
writeFileSync(
|
||||
playlist,
|
||||
`#EXTM3U\n#EXTINF:-1,Chaptered fixture\n${media.url}\n`
|
||||
);
|
||||
await installEmbeddedMpvSessionCapture(app);
|
||||
await importM3uPlaylistFromNativeDialog(app, playlist);
|
||||
await channelItemByTitle(app.mainWindow, 'Chaptered fixture')
|
||||
await installEmbeddedMpvSessionCapture(launched);
|
||||
await importM3uPlaylistFromNativeDialog(launched, playlist);
|
||||
await channelItemByTitle(launched.mainWindow, 'Chaptered fixture')
|
||||
.first()
|
||||
.click();
|
||||
|
||||
// The local frame-copy runtime can fail its first frame-view
|
||||
// initialization; one user Retry recovers it (see player-theme).
|
||||
await expect
|
||||
.poll(() =>
|
||||
app.mainWindow.evaluate(
|
||||
// The local frame-copy runtime can fail a frame-view initialization,
|
||||
// and the failure can surface at any point while the session starts;
|
||||
// a user Retry recovers it (see player-theme). Retry whenever the
|
||||
// stalled state shows until mpv's chapters arrive.
|
||||
const stalled = launched.mainWindow.locator(
|
||||
'.embedded-mpv-player__stalled'
|
||||
);
|
||||
await expect(async () => {
|
||||
if (await stalled.isVisible()) {
|
||||
await stalled.getByRole('button', { name: 'Retry' }).click();
|
||||
}
|
||||
expect(
|
||||
await launched.mainWindow.evaluate(
|
||||
() =>
|
||||
(window.__packagedEmbeddedMpvSessions?.at(-1)?.chapters
|
||||
?.length ?? 0) > 0 ||
|
||||
!!document.querySelector('.embedded-mpv-player__stalled')
|
||||
),
|
||||
{ timeout: 20000 }
|
||||
)
|
||||
.toBe(true);
|
||||
const stalled = app.mainWindow.locator('.embedded-mpv-player__stalled');
|
||||
if (await stalled.isVisible()) {
|
||||
await stalled.getByRole('button', { name: 'Retry' }).click();
|
||||
}
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
app.mainWindow.evaluate(
|
||||
() =>
|
||||
window.__packagedEmbeddedMpvSessions?.at(-1)
|
||||
?.chapters ?? []
|
||||
),
|
||||
{ timeout: 20000 }
|
||||
)
|
||||
.toEqual([
|
||||
window.__packagedEmbeddedMpvSessions?.at(-1)
|
||||
?.chapters ?? []
|
||||
)
|
||||
).toEqual([
|
||||
{ timeSeconds: 0, title: 'Intro' },
|
||||
{ timeSeconds: 5, title: 'Episode' },
|
||||
{ timeSeconds: 24, title: 'Credits' },
|
||||
]);
|
||||
}).toPass({ timeout: 30000 });
|
||||
|
||||
const segments = app.mainWindow.locator(
|
||||
const segments = launched.mainWindow.locator(
|
||||
'app-player-controls .player-controls__timeline-segment'
|
||||
);
|
||||
await expect(segments).toHaveCount(3);
|
||||
@@ -123,7 +118,12 @@ test('@playback @electron @embedded-mpv frame-copy draws file chapters on the ti
|
||||
{ title: 'Credits', left: 80 },
|
||||
]);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
await media.close();
|
||||
try {
|
||||
if (app) {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
} finally {
|
||||
await media.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -408,12 +408,33 @@ void updateTracksFromNode(const mpv_node& trackListNode) {
|
||||
g_state.snapshot.subtitleTracks = std::move(subs);
|
||||
}
|
||||
|
||||
/* Snapshots repeat the chapter list on every emit, so a file with thousands
|
||||
* of chapters or a huge title must not inflate every snapshot. Beyond these
|
||||
* bounds a timeline cannot draw anything useful anyway. */
|
||||
constexpr size_t kMaxChapters = 256;
|
||||
constexpr size_t kMaxChapterTitleBytes = 256;
|
||||
|
||||
/* Cut to at most `maxBytes` without splitting a UTF-8 sequence. */
|
||||
std::string truncateUtf8(const char* value, size_t maxBytes) {
|
||||
std::string text = value ? value : "";
|
||||
if (text.size() <= maxBytes) return text;
|
||||
size_t end = maxBytes;
|
||||
while (end > 0 &&
|
||||
(static_cast<unsigned char>(text[end]) & 0xC0) == 0x80) {
|
||||
end--;
|
||||
}
|
||||
text.resize(end);
|
||||
return text;
|
||||
}
|
||||
|
||||
/* mpv `chapter-list`: an array of { time, title? } maps in file order. */
|
||||
void updateChaptersFromNode(const mpv_node& chapterListNode) {
|
||||
std::vector<ChapterInfo> chapters;
|
||||
if (chapterListNode.format == MPV_FORMAT_NODE_ARRAY &&
|
||||
chapterListNode.u.list) {
|
||||
for (int i = 0; i < chapterListNode.u.list->num; i++) {
|
||||
for (int i = 0; i < chapterListNode.u.list->num &&
|
||||
chapters.size() < kMaxChapters;
|
||||
i++) {
|
||||
const mpv_node& entry = chapterListNode.u.list->values[i];
|
||||
if (entry.format != MPV_FORMAT_NODE_MAP || !entry.u.list) continue;
|
||||
ChapterInfo chapter;
|
||||
@@ -434,7 +455,8 @@ void updateChaptersFromNode(const mpv_node& chapterListNode) {
|
||||
} else if (std::strcmp(key, "title") == 0 &&
|
||||
value.format == MPV_FORMAT_STRING &&
|
||||
value.u.string) {
|
||||
chapter.title = value.u.string;
|
||||
chapter.title =
|
||||
truncateUtf8(value.u.string, kMaxChapterTitleBytes);
|
||||
}
|
||||
}
|
||||
if (hasTime) chapters.push_back(std::move(chapter));
|
||||
|
||||
@@ -1094,6 +1094,26 @@ void updateAudioTracksFromNode(SessionSnapshot& snapshot, const mpv_node& node)
|
||||
);
|
||||
}
|
||||
|
||||
// Snapshots repeat the chapter list on every emit, so a file with thousands
|
||||
// of chapters or a huge title must not inflate every snapshot. Beyond these
|
||||
// bounds a timeline cannot draw anything useful anyway.
|
||||
constexpr size_t kMaxChapters = 256;
|
||||
constexpr size_t kMaxChapterTitleBytes = 256;
|
||||
|
||||
// Cut to at most `maxBytes` without splitting a UTF-8 sequence.
|
||||
std::string truncateUtf8(const std::string& value, size_t maxBytes)
|
||||
{
|
||||
std::string text = value;
|
||||
if (text.size() <= maxBytes) return text;
|
||||
size_t end = maxBytes;
|
||||
while (end > 0 &&
|
||||
(static_cast<unsigned char>(text[end]) & 0xC0) == 0x80) {
|
||||
end--;
|
||||
}
|
||||
text.resize(end);
|
||||
return text;
|
||||
}
|
||||
|
||||
// mpv `chapter-list`: an array of { time, title? } maps in file order.
|
||||
void updateChaptersFromNode(SessionSnapshot& snapshot, const mpv_node& node)
|
||||
{
|
||||
@@ -1104,7 +1124,9 @@ void updateChaptersFromNode(SessionSnapshot& snapshot, const mpv_node& node)
|
||||
return;
|
||||
}
|
||||
|
||||
for (int index = 0; index < node.u.list->num; index += 1) {
|
||||
for (int index = 0;
|
||||
index < node.u.list->num && snapshot.chapters.size() < kMaxChapters;
|
||||
index += 1) {
|
||||
const mpv_node& chapterNode = node.u.list->values[index];
|
||||
if (chapterNode.format != MPV_FORMAT_NODE_MAP) {
|
||||
continue;
|
||||
@@ -1126,7 +1148,8 @@ void updateChaptersFromNode(SessionSnapshot& snapshot, const mpv_node& node)
|
||||
continue;
|
||||
}
|
||||
if (const mpv_node* titleNode = getNodeMapValue(chapterNode, "title")) {
|
||||
chapter.title = readNodeString(*titleNode);
|
||||
chapter.title =
|
||||
truncateUtf8(readNodeString(*titleNode), kMaxChapterTitleBytes);
|
||||
}
|
||||
snapshot.chapters.push_back(chapter);
|
||||
}
|
||||
|
||||
@@ -1408,6 +1408,26 @@ void updateAudioTracksFromNode(SessionSnapshot& snapshot, const mpv_node& node)
|
||||
}
|
||||
}
|
||||
|
||||
/* Snapshots repeat the chapter list on every emit, so a file with thousands
|
||||
* of chapters or a huge title must not inflate every snapshot. Beyond these
|
||||
* bounds a timeline cannot draw anything useful anyway. */
|
||||
constexpr size_t kMaxChapters = 256;
|
||||
constexpr size_t kMaxChapterTitleBytes = 256;
|
||||
|
||||
/* Cut to at most `maxBytes` without splitting a UTF-8 sequence. */
|
||||
std::string truncateUtf8(const std::string& value, size_t maxBytes)
|
||||
{
|
||||
std::string text = value;
|
||||
if (text.size() <= maxBytes) return text;
|
||||
size_t end = maxBytes;
|
||||
while (end > 0 &&
|
||||
(static_cast<unsigned char>(text[end]) & 0xC0) == 0x80) {
|
||||
end--;
|
||||
}
|
||||
text.resize(end);
|
||||
return text;
|
||||
}
|
||||
|
||||
/** mpv `chapter-list`: an array of { time, title? } maps in file order. */
|
||||
void updateChaptersFromNode(SessionSnapshot& snapshot, const mpv_node& node)
|
||||
{
|
||||
@@ -1417,7 +1437,9 @@ void updateChaptersFromNode(SessionSnapshot& snapshot, const mpv_node& node)
|
||||
return;
|
||||
}
|
||||
|
||||
for (int index = 0; index < node.u.list->num; index += 1) {
|
||||
for (int index = 0;
|
||||
index < node.u.list->num && snapshot.chapters.size() < kMaxChapters;
|
||||
index += 1) {
|
||||
const mpv_node& chapterNode = node.u.list->values[index];
|
||||
if (chapterNode.format != MPV_FORMAT_NODE_MAP) {
|
||||
continue;
|
||||
@@ -1438,7 +1460,8 @@ void updateChaptersFromNode(SessionSnapshot& snapshot, const mpv_node& node)
|
||||
continue;
|
||||
}
|
||||
if (const mpv_node* titleNode = getNodeMapValue(chapterNode, "title")) {
|
||||
chapter.title = readNodeString(*titleNode);
|
||||
chapter.title =
|
||||
truncateUtf8(readNodeString(*titleNode), kMaxChapterTitleBytes);
|
||||
}
|
||||
snapshot.chapters.push_back(chapter);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { normalizeEmbeddedMpvChapters } from './embedded-mpv-chapters.util';
|
||||
import {
|
||||
MAX_EMBEDDED_MPV_CHAPTERS,
|
||||
MAX_EMBEDDED_MPV_CHAPTER_TITLE_LENGTH,
|
||||
normalizeEmbeddedMpvChapters,
|
||||
} from './embedded-mpv-chapters.util';
|
||||
|
||||
describe('normalizeEmbeddedMpvChapters', () => {
|
||||
it('keeps valid chapters and trims titles', () => {
|
||||
@@ -29,6 +33,27 @@ describe('normalizeEmbeddedMpvChapters', () => {
|
||||
).toEqual([{ timeSeconds: 5 }]);
|
||||
});
|
||||
|
||||
it('bounds the chapter count and title length', () => {
|
||||
const chapters = normalizeEmbeddedMpvChapters(
|
||||
Array.from({ length: MAX_EMBEDDED_MPV_CHAPTERS + 50 }, (_, i) => ({
|
||||
timeSeconds: i,
|
||||
title: '😀'.repeat(MAX_EMBEDDED_MPV_CHAPTER_TITLE_LENGTH + 5),
|
||||
}))
|
||||
);
|
||||
|
||||
expect(chapters).toHaveLength(MAX_EMBEDDED_MPV_CHAPTERS);
|
||||
expect(chapters.at(-1)?.timeSeconds).toBe(
|
||||
MAX_EMBEDDED_MPV_CHAPTERS - 1
|
||||
);
|
||||
// Cut by code point, so no surrogate pair is split.
|
||||
expect(Array.from(chapters[0].title ?? '')).toHaveLength(
|
||||
MAX_EMBEDDED_MPV_CHAPTER_TITLE_LENGTH
|
||||
);
|
||||
expect(chapters[0].title).toBe(
|
||||
'😀'.repeat(MAX_EMBEDDED_MPV_CHAPTER_TITLE_LENGTH)
|
||||
);
|
||||
});
|
||||
|
||||
it('treats a missing field from an older binary as no chapters', () => {
|
||||
expect(normalizeEmbeddedMpvChapters(undefined)).toEqual([]);
|
||||
expect(normalizeEmbeddedMpvChapters({})).toEqual([]);
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import type { EmbeddedMpvChapter } from '@iptvnator/shared/interfaces';
|
||||
|
||||
/**
|
||||
* Every session update repeats the list, so a chapter-heavy file must not
|
||||
* inflate each IPC payload; the native parsers apply the same bounds.
|
||||
*/
|
||||
export const MAX_EMBEDDED_MPV_CHAPTERS = 256;
|
||||
export const MAX_EMBEDDED_MPV_CHAPTER_TITLE_LENGTH = 256;
|
||||
|
||||
/**
|
||||
* Validate the addon's or helper's `chapters` snapshot field before it
|
||||
* reaches the renderer: an older binary omits it, and a malformed entry must
|
||||
* not draw a segment. Order is left to the renderer's timeline mapping.
|
||||
* not draw a segment; the list and each title are bounded. Order is left to the renderer's timeline mapping.
|
||||
*/
|
||||
export function normalizeEmbeddedMpvChapters(
|
||||
value: unknown
|
||||
@@ -13,6 +20,9 @@ export function normalizeEmbeddedMpvChapters(
|
||||
}
|
||||
const chapters: EmbeddedMpvChapter[] = [];
|
||||
for (const entry of value) {
|
||||
if (chapters.length >= MAX_EMBEDDED_MPV_CHAPTERS) {
|
||||
break;
|
||||
}
|
||||
if (!entry || typeof entry !== 'object') {
|
||||
continue;
|
||||
}
|
||||
@@ -24,9 +34,17 @@ export function normalizeEmbeddedMpvChapters(
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const trimmedTitle = typeof title === 'string' ? title.trim() : '';
|
||||
const trimmedTitle =
|
||||
typeof title === 'string'
|
||||
? Array.from(title.trim())
|
||||
.slice(0, MAX_EMBEDDED_MPV_CHAPTER_TITLE_LENGTH)
|
||||
.join('')
|
||||
.trim()
|
||||
: '';
|
||||
chapters.push(
|
||||
trimmedTitle ? { timeSeconds, title: trimmedTitle } : { timeSeconds }
|
||||
trimmedTitle
|
||||
? { timeSeconds, title: trimmedTitle }
|
||||
: { timeSeconds }
|
||||
);
|
||||
}
|
||||
return chapters;
|
||||
|
||||
Reference in new issue
Block a user