diff --git a/apps/electron-backend-e2e/src/embedded-mpv-chapters.e2e.ts b/apps/electron-backend-e2e/src/embedded-mpv-chapters.e2e.ts index b2b1ff58d..4087d46a7 100644 --- a/apps/electron-backend-e2e/src/embedded-mpv-chapters.e2e.ts +++ b/apps/electron-backend-e2e/src/embedded-mpv-chapters.e2e.ts @@ -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(); + } } }); diff --git a/apps/electron-backend/native/helper/mpv_frame_helper.cpp b/apps/electron-backend/native/helper/mpv_frame_helper.cpp index 5647bd7e5..785a64a65 100644 --- a/apps/electron-backend/native/helper/mpv_frame_helper.cpp +++ b/apps/electron-backend/native/helper/mpv_frame_helper.cpp @@ -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(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 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)); diff --git a/apps/electron-backend/native/src/embedded_mpv.mm b/apps/electron-backend/native/src/embedded_mpv.mm index 7045ba0e1..7bc62bbad 100644 --- a/apps/electron-backend/native/src/embedded_mpv.mm +++ b/apps/electron-backend/native/src/embedded_mpv.mm @@ -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(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); } diff --git a/apps/electron-backend/native/src/embedded_mpv_wid_common.h b/apps/electron-backend/native/src/embedded_mpv_wid_common.h index 70c161c56..9997a6790 100644 --- a/apps/electron-backend/native/src/embedded_mpv_wid_common.h +++ b/apps/electron-backend/native/src/embedded_mpv_wid_common.h @@ -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(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); } diff --git a/apps/electron-backend/src/app/services/embedded-mpv-chapters.util.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-chapters.util.spec.ts index 85924b537..9a1848f6f 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-chapters.util.spec.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-chapters.util.spec.ts @@ -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([]); diff --git a/apps/electron-backend/src/app/services/embedded-mpv-chapters.util.ts b/apps/electron-backend/src/app/services/embedded-mpv-chapters.util.ts index 9dd6120a9..7c76fb99a 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-chapters.util.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-chapters.util.ts @@ -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;