diff --git a/apps/chrome-extension/src/plex-content.ts b/apps/chrome-extension/src/plex-content.ts index 7daaf3f..2a34d9b 100644 --- a/apps/chrome-extension/src/plex-content.ts +++ b/apps/chrome-extension/src/plex-content.ts @@ -15,7 +15,7 @@ import { BrowserPlaybackRuntime } from './playback-runtime.js'; import { formatPlaybackInterrupted, formatSubtitleUnavailable } from './player-presentation.js'; import { PlexAudioNotice } from './plex-audio-notice.js'; import { PlexErrorPresenter } from './plex-error-presenter.js'; -import { PlexNativePlaybackHost } from './plex-native-host.js'; +import { PlexNativePlaybackHost, type SubtitleFileRead } from './plex-native-host.js'; import { isPlexSubtitleChangedMessage, PLEX_STREAM_SELECTION_MESSAGE, @@ -207,7 +207,7 @@ function requestStreamSelection(notice: PlexMediaSourceNotice): Promise { +): Promise { if (!notice.playbackReportId) return Promise.resolve(undefined); return new Promise((resolve) => { chrome.runtime.sendMessage( @@ -217,6 +217,10 @@ function requestSubtitleFile( resolve(undefined); return; } + if (response?.unreadable === true) { + resolve({ unavailable: 'unsupported_encoding' }); + return; + } resolve(typeof response?.text === 'string' ? response.text : undefined); }, ); diff --git a/apps/chrome-extension/src/plex-native-host.test.ts b/apps/chrome-extension/src/plex-native-host.test.ts index da0ecd2..edeee27 100644 --- a/apps/chrome-extension/src/plex-native-host.test.ts +++ b/apps/chrome-extension/src/plex-native-host.test.ts @@ -15,7 +15,7 @@ import { BrowserPlaybackProbeRequiredError, BrowserPlaybackUnsupportedError, } from './playback-runtime.js'; -import { PlexNativePlaybackHost } from './plex-native-host.js'; +import { PlexNativePlaybackHost, type SubtitleFileRead } from './plex-native-host.js'; const descriptor: MediaDescriptor = { sourceId: 'source-a', @@ -683,10 +683,11 @@ describe('PlexNativePlaybackHost', () => { }); await host.dispose(); }); - it('接管就绪后播放中断时提示用户,并在 video 上记下结束原因', async () => { + it('接管就绪后的失败交给了 Plex 错误框时不叠加中断提示,并在 video 上记下结束原因', async () => { const media = new TestMediaElement(); const completion = deferred(); const interruptions: string[] = []; + const failures: string[] = []; const posted: PlexNativeMessage[] = []; const host = new PlexNativePlaybackHost({ createPlaybackRuntime: () => ({ @@ -700,6 +701,7 @@ describe('PlexNativePlaybackHost', () => { resolveMediaElement: () => media as unknown as HTMLMediaElement, postMessage: (message) => posted.push(message), readStartSeconds: () => undefined, + presentFailure: (failure) => failures.push(failure.code), presentInterruption: (failure) => interruptions.push(failure.code), createNoticeId: () => 'notice_id_1234567890', }); @@ -712,7 +714,8 @@ describe('PlexNativePlaybackHost', () => { completion.reject(new MediaWorkerRemoteError('stream_failed', 'RangeProtocolError')); await flush(); - expect(interruptions).toEqual(['media_read_failed']); + expect(failures).toEqual(['media_read_failed']); + expect(interruptions).toEqual([]); expect(media.dataset.shimweaveSessionEnd).toBe('media_read_failed'); expect(media.dataset.shimweaveSessionEndDetail).toBe( 'MediaWorkerRemoteError stream_failed RangeProtocolError', @@ -721,6 +724,47 @@ describe('PlexNativePlaybackHost', () => { await host.dispose(); }); + it('Hook 回报就绪后的失败没能交给 Plex 时提示一次中断', async () => { + const media = new TestMediaElement(); + const completion = deferred(); + const interruptions: string[] = []; + const host = new PlexNativePlaybackHost({ + createPlaybackRuntime: () => ({ + start: async () => + activePlayback( + vi.fn(async () => undefined), + completion.promise, + ), + stop: vi.fn(async () => undefined), + }), + resolveMediaElement: () => media as unknown as HTMLMediaElement, + postMessage: vi.fn(), + readStartSeconds: () => undefined, + presentInterruption: (failure) => interruptions.push(failure.code), + createNoticeId: () => 'notice_id_1234567890', + }); + claim(host, 'native_session_1234567890'); + await flush(); + + completion.reject(new MediaWorkerRemoteError('stream_failed', 'RangeProtocolError')); + await flush(); + const unsurfaced = (sessionId: string, code: string): PlexNativeMessage => ({ + protocol: PLEX_NATIVE_PROTOCOL, + sender: 'main-hook', + type: 'takeover-error-unsurfaced', + sessionId, + code, + }); + host.acceptPageMessage(unsurfaced('other_session_1234567890', 'media_read_failed')); + host.acceptPageMessage(unsurfaced('native_session_1234567890', 'playback_failed')); + expect(interruptions).toEqual([]); + + host.acceptPageMessage(unsurfaced('native_session_1234567890', 'media_read_failed')); + host.acceptPageMessage(unsurfaced('native_session_1234567890', 'media_read_failed')); + expect(interruptions).toEqual(['media_read_failed']); + await host.dispose(); + }); + it('Plex 结束播放先卸载 video 时静默收尾,不提示中断', async () => { const media = new TestMediaElement(); const completion = deferred(); @@ -962,7 +1006,10 @@ describe('PlexNativePlaybackHost 外挂字幕', () => { await options.holdStart; return playback; }); - const readSubtitleFile = vi.fn(async (_notice: PlexMediaSourceNotice, _key: string) => srt); + const readSubtitleFile = vi.fn( + async (_notice: PlexMediaSourceNotice, _key: string): Promise => + srt, + ); const resolveStreamSelection = vi.fn(async () => selection); const presentSubtitleUnavailable = vi.fn(); const host = new PlexNativePlaybackHost({ @@ -1076,13 +1123,16 @@ describe('PlexNativePlaybackHost 外挂字幕', () => { ); expect(context.readSubtitleFile).not.toHaveBeenCalled(); - context.readSubtitleFile.mockResolvedValueOnce(undefined as unknown as string); + context.readSubtitleFile.mockResolvedValueOnce(undefined); await context.change({ subtitle: external(20) }); context.readSubtitleFile.mockResolvedValueOnce('not subtitles'); await context.change({ subtitle: external(21) }); + context.readSubtitleFile.mockResolvedValueOnce({ unavailable: 'unsupported_encoding' }); + await context.change({ subtitle: external(22) }); expect(context.presentSubtitleUnavailable.mock.calls.slice(1)).toEqual([ ['read_failed'], ['read_failed'], + ['unsupported_encoding'], ]); expect(context.renderers).toHaveLength(0); await context.host.dispose(); diff --git a/apps/chrome-extension/src/plex-native-host.ts b/apps/chrome-extension/src/plex-native-host.ts index 00861ec..7764e79 100644 --- a/apps/chrome-extension/src/plex-native-host.ts +++ b/apps/chrome-extension/src/plex-native-host.ts @@ -62,16 +62,19 @@ export interface PlexNativePlaybackHostOptions { presentSubtitleUnavailable?(reason: SubtitleUnavailableReason, codec?: string): void; /** * 读取外挂字幕文件(`/library/streams/{id}`)的文本,由后台带会话凭据读取; - * 失败时返回 undefined,页面提示字幕不可用。 + * 失败时返回 undefined,读到了却无法解码时返回不可用原因,页面都提示字幕不可用。 */ - readSubtitleFile?(notice: PlexMediaSourceNotice, key: string): Promise; + readSubtitleFile?( + notice: PlexMediaSourceNotice, + key: string, + ): Promise; createSidecarRenderer?( mediaElement: HTMLMediaElement, language: string | undefined, ): SidecarRendererPort; /** - * 接管就绪后播放中断时提示。此时 Plex 的加载早已完成,不会弹出自己的错误框, - * 不提示的话画面只会停在缓冲状态。 + * 接管就绪后播放中断、且 Hook 报告没能交给 Plex 错误弹窗时提示。交给了 Plex 的失败 + * 由它的弹窗呈现,这里不再重复;没交给的话 Plex 自己不会报错,画面只会停在缓冲状态。 */ presentInterruption?(failure: PlaybackFailurePresentation): void; createNoticeId?: () => string; @@ -80,6 +83,9 @@ export interface PlexNativePlaybackHostOptions { sourceTtlMs?: number; } +/** 外挂字幕文件的读取结果:文本,或读到了文件但无法解码的原因。 */ +export type SubtitleFileRead = string | { readonly unavailable: 'unsupported_encoding' }; + interface NativePlaybackSession { readonly generation: number; readonly sessionId: string; @@ -166,6 +172,10 @@ export class PlexNativePlaybackHost { private active: NativePlaybackSession | undefined; private generation = 0; private disposed = false; + /** 就绪后失败的会话,等 Hook 回报是否交给了 Plex 再决定要不要提示中断。 */ + private interruption: + | { readonly sessionId: string; readonly failure: PlaybackFailurePresentation } + | undefined; constructor(options: PlexNativePlaybackHostOptions) { this.options = options; @@ -259,6 +269,14 @@ export class PlexNativePlaybackHost { if (source?.noticeId === message.noticeId) this.releaseSource(message.requestKey, source); return; } + if (message.type === 'takeover-error-unsurfaced') { + const interruption = this.interruption; + if (interruption?.sessionId !== message.sessionId) return; + if (interruption.failure.code !== message.code) return; + this.interruption = undefined; + this.options.presentInterruption?.(interruption.failure); + return; + } if (message.type === 'takeover-stop') { void this.stopSession(message.sessionId).finally(() => { this.options.postMessage({ @@ -274,6 +292,7 @@ export class PlexNativePlaybackHost { async dispose(): Promise { if (this.disposed) return; this.disposed = true; + this.interruption = undefined; this.generation += 1; for (const [requestKey, source] of this.sources) this.releaseSource(requestKey, source); this.sources.clear(); @@ -466,11 +485,15 @@ export class PlexNativePlaybackHost { this.options.presentSubtitleUnavailable?.(resolved.reason, resolved.codec); return; } - const text = await ( + const read = await ( this.options.readSubtitleFile?.(session.notice, resolved.key) ?? Promise.resolve(undefined) ).catch(() => undefined); if (!current()) return; - const cues = text === undefined ? [] : parseSidecarSubtitles(text, resolved.format); + if (typeof read === 'object') { + this.options.presentSubtitleUnavailable?.(read.unavailable); + return; + } + const cues = read === undefined ? [] : parseSidecarSubtitles(read, resolved.format); if (cues.length === 0) { this.options.presentSubtitleUnavailable?.('read_failed'); return; @@ -509,7 +532,7 @@ export class PlexNativePlaybackHost { const interrupted = session.playback !== undefined; await this.stopActive(failure.code, error); this.options.presentFailure?.(failure, formats); - if (interrupted) this.options.presentInterruption?.(failure); + this.interruption = interrupted ? { sessionId: session.sessionId, failure } : undefined; this.postTakeoverError(session.sessionId, failure.code); } diff --git a/apps/chrome-extension/src/plex-stream-selection.test.ts b/apps/chrome-extension/src/plex-stream-selection.test.ts index 311247d..ab1ab87 100644 --- a/apps/chrome-extension/src/plex-stream-selection.test.ts +++ b/apps/chrome-extension/src/plex-stream-selection.test.ts @@ -96,7 +96,7 @@ describe('Plex 外挂字幕文件', () => { expect(isPlexSubtitleFileMessage({ ...valid, reportId: 'short' })).toBe(false); }); - it('用请求头携带 Token 读取,按 UTF-8 解码', async () => { + it('要求 Plex 转成 UTF-8,用请求头携带 Token 读取,按 UTF-8 解码', async () => { const srt = '1\n00:00:01,000 --> 00:00:02,000\n你好\n'; const fetcher = vi.fn(async () => new Response(new TextEncoder().encode(srt), { status: 200 })); @@ -104,11 +104,44 @@ describe('Plex 外挂字幕文件', () => { await fetchPlexSubtitleFile(context, '/library/streams/20', fetcher as typeof fetch), ).toEqual({ text: srt }); const [url, init] = fetcher.mock.calls[0] as unknown as [string, RequestInit]; - expect(url).toBe('https://plex.example.test:32400/library/streams/20'); + expect(url).toBe('https://plex.example.test:32400/library/streams/20?encoding=utf-8'); expect(init.headers).toMatchObject({ 'X-Plex-Token': 'secret-token' }); expect(init.credentials).toBe('omit'); }); + it('Plex 没能转换的 GBK、Big5 字幕按编码不受支持返回,零星坏字节的 UTF-8 照常解码', async () => { + const read = async (bytes: readonly number[]) => + fetchPlexSubtitleFile( + context, + '/library/streams/20', + vi.fn(async () => new Response(Uint8Array.from(bytes), { status: 200 })), + ); + const ascii = (text: string) => Array.from(new TextEncoder().encode(text)); + const cue = (index: number, text: string) => + ascii(`${index}\n00:00:01,000 --> 00:00:02,000\n${text}\n\n`); + + // 「你好世界」的 GBK 与 Big5 字节。 + const gbk = [...cue(1, ''), 0xc4, 0xe3, 0xba, 0xc3, 0xca, 0xc0, 0xbd, 0xe7, 0x0a]; + const big5 = [...cue(1, ''), 0xa7, 0x41, 0xa6, 0x6e, 0xa5, 0x40, 0xac, 0xc9, 0x0a]; + expect(await read(gbk)).toEqual({ unreadable: true }); + expect(await read(big5)).toEqual({ unreadable: true }); + + // 两百句英文里只有一句是 GBK 的「中文」:替换字符不到全文的 0.1%,仍按编码不受支持返回。 + const english = Array.from({ length: 200 }, (_, i) => cue(i + 1, `line ${i + 1}`)).flat(); + expect(await read([...english, ...cue(201, ''), 0xd6, 0xd0, 0xce, 0xc4, 0x0a])).toEqual({ + unreadable: true, + }); + + // 只有一句的小文件里一个坏字节,替换字符占全文约 3%,照常显示。 + const tiny = await read([...cue(1, 'hi'), 0xff, 0x0a]); + expect(tiny.text).toContain('hi'); + // 中文 UTF-8 字幕里夹着几个坏字节,替换字符只占非 ASCII 字符的一小部分,照常显示。 + const chinese = Array.from({ length: 9 }, (_, i) => cue(i + 1, `第 ${i + 1} 句你好`)).flat(); + const stray = await read([...chinese, 0xff, 0xfe, 0xff, 0xfe, 0xff, 0x0a]); + expect(stray.text).toContain('第 9 句你好'); + expect(stray.unreadable).toBeUndefined(); + }); + it('失败、地址不对或超出大小上限时返回空应答', async () => { const failed = vi.fn(async () => new Response('', { status: 404 })); expect(await fetchPlexSubtitleFile(context, '/library/streams/20', failed)).toEqual({}); diff --git a/apps/chrome-extension/src/plex-stream-selection.ts b/apps/chrome-extension/src/plex-stream-selection.ts index e684cb6..81ba786 100644 --- a/apps/chrome-extension/src/plex-stream-selection.ts +++ b/apps/chrome-extension/src/plex-stream-selection.ts @@ -73,8 +73,10 @@ export interface PlexSubtitleFileMessage { } export interface PlexSubtitleFileResponse { - /** 读取失败、超时或超出大小上限时缺失。 */ + /** 读取失败、超时、超出大小上限或文本无法按 UTF-8 读出时缺失。 */ text?: string; + /** 读到了文件,但内容不是 UTF-8,Plex 也没能转换,页面提示编码不受支持。 */ + unreadable?: true; } export const isPlexSubtitleFileMessage = (value: unknown): value is PlexSubtitleFileMessage => { @@ -100,8 +102,18 @@ export const isPlexSubtitleChangedMessage = ( export const PLEX_SUBTITLE_FILE_MAX_BYTES = 8 * 1024 * 1024; /** - * 读取外挂字幕文件的文本。Cirvel 已把字幕统一转为 UTF-8,这里按 UTF-8 解码; - * 失败时返回空应答,页面提示字幕不可用,视频照常播放。 + * 判定文本不是 UTF-8 的替换字符门槛:至少这么多个,且占全部非 ASCII 字符的这一比例以上。 + * GBK、Big5 按 UTF-8 解码时九成以上的非 ASCII 字符是替换字符,即使字幕大多是英文、只夹几个 + * 汉字也能认出;合法 UTF-8 里零星的坏字节只占非 ASCII 字符的一小部分,小文件里一两个坏字节 + * 也达不到个数门槛,照常显示。 + */ +const MIN_REPLACEMENTS = 4; +const MIN_REPLACEMENT_SHARE = 0.5; + +/** + * 读取外挂字幕文件的文本。请求要求 Plex 转成 UTF-8(见 createPlexStreamFileRequest), + * 这里按 UTF-8 解码;Plex 认不出编码时原样给出字节,解码结果大量是替换字符,按编码 + * 不受支持返回,不显示乱码。读取失败时返回空应答,页面提示字幕不可用,视频照常播放。 */ export const fetchPlexSubtitleFile = async ( context: PlexTimelineContext, @@ -126,7 +138,8 @@ export const fetchPlexSubtitleFile = async ( if (Number.isFinite(declared) && declared > PLEX_SUBTITLE_FILE_MAX_BYTES) return {}; const bytes = await response.arrayBuffer(); if (bytes.byteLength > PLEX_SUBTITLE_FILE_MAX_BYTES) return {}; - return { text: new TextDecoder('utf-8').decode(bytes) }; + const text = new TextDecoder('utf-8').decode(bytes); + return isMostlyReplacement(text) ? { unreadable: true } : { text }; } catch { return {}; } finally { @@ -134,5 +147,16 @@ export const fetchPlexSubtitleFile = async ( } }; +const isMostlyReplacement = (text: string): boolean => { + let replacements = 0; + let nonAscii = 0; + for (let index = 0; index < text.length; index += 1) { + const code = text.charCodeAt(index); + if (code > 0x7f) nonAscii += 1; + if (code === 0xfffd) replacements += 1; + } + return replacements >= MIN_REPLACEMENTS && replacements >= nonAscii * MIN_REPLACEMENT_SHARE; +}; + const nonNegativeInteger = (value: unknown): value is number => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; diff --git a/apps/chrome-extension/src/sidecar-subtitles.test.ts b/apps/chrome-extension/src/sidecar-subtitles.test.ts index fbf58d8..cf0e37b 100644 --- a/apps/chrome-extension/src/sidecar-subtitles.test.ts +++ b/apps/chrome-extension/src/sidecar-subtitles.test.ts @@ -57,6 +57,90 @@ describe('外挂字幕文件解析', () => { ]); }); + it('SRT 台词中的空行之后仍属于上一句,时间不合法的块连同续行一起跳过', () => { + const srt = [ + '1', + '00:00:01,000 --> 00:00:02,000', + '第一行', + '', + '空行之后', + '', + '2', + '00:00:03,000 --> 00:00:02,000', + '倒序', + '', + '倒序的续行', + '', + '3', + '00:00:04,000 --> 00:00:05,000', + '第三句', + ].join('\n'); + + expect(parseSidecarSubtitles(srt, 'srt')).toEqual([ + { startSeconds: 1, endSeconds: 2, text: '第一行\n空行之后' }, + { startSeconds: 4, endSeconds: 5, text: '第三句' }, + ]); + }); + + it('SRT 句与句之间隔着多个空行(含 CRLF 与空白行)时仍逐句解析,末尾孤立的序号不并入', () => { + const srt = [ + '', + '1', + '00:00:01,000 --> 00:00:02,000', + '第一句', + '', + '', + '2', + '00:00:03,000 --> 00:00:04,000', + '第二句', + ' ', + '', + '', + '3', + '00:00:05,000 --> 00:00:06,000', + '第三句', + '', + '续行', + '', + '', + '4', + '', + ].join('\r\n'); + + expect(parseSidecarSubtitles(srt, 'srt')).toEqual([ + { startSeconds: 1, endSeconds: 2, text: '第一句' }, + { startSeconds: 3, endSeconds: 4, text: '第二句' }, + { startSeconds: 5, endSeconds: 6, text: '第三句\n续行' }, + ]); + }); + + it('WebVTT cue 之后的 NOTE、STYLE、REGION 块不并入台词', () => { + const vtt = [ + 'WEBVTT', + '', + 'REGION', + 'id:bottom width:40%', + '', + '00:00:01.000 --> 00:00:02.000', + '第一句', + '', + 'NOTE 中途注释', + '', + 'STYLE', + '::cue { color: red }', + '', + '00:00:03.000 --> 00:00:04.000', + '第二句', + '', + 'NOTE 结尾注释', + ].join('\n'); + + expect(parseSidecarSubtitles(vtt, 'webvtt')).toEqual([ + { startSeconds: 1, endSeconds: 2, text: '第一句' }, + { startSeconds: 3, endSeconds: 4, text: '第二句' }, + ]); + }); + it('不是字幕的文本得到空列表', () => { expect(parseSidecarSubtitles('not subtitles', 'srt')).toEqual([]); expect(parseSidecarSubtitles('', 'webvtt')).toEqual([]); diff --git a/apps/chrome-extension/src/sidecar-subtitles.ts b/apps/chrome-extension/src/sidecar-subtitles.ts index 961e77e..635c2c6 100644 --- a/apps/chrome-extension/src/sidecar-subtitles.ts +++ b/apps/chrome-extension/src/sidecar-subtitles.ts @@ -10,29 +10,64 @@ const SRT_TIMING = /** * 把外挂字幕文件解析为按开始时间排序的 cue。时间是原媒体时间;文本与内嵌字幕一样归一为 * WebVTT cue 文本。时间行不合法的块整块跳过,不影响其余字幕。 + * + * SRT 台词里可能出现空行,空行之后、下一个时间行之前的文字仍属于上一句。WebVTT 的空行 + * 结束 cue,之后没有时间行的块是 NOTE、STYLE、REGION 或文件头,不并入台词。 */ export const parseSidecarSubtitles = (text: string, format: TextSubtitleFormat): SubtitleCue[] => { const source = text.replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n'); const cues: SubtitleCue[] = []; - for (const block of source.split(/\n[ \t]*\n/)) { + let pending: PendingCue | undefined; + const finish = (): void => { + if (!pending) return; + const normalized = normalizeSubtitleText(pending.lines.join('\n'), format); + if (normalized) cues.push({ ...pending.timing, ...normalized }); + pending = undefined; + }; + const blocks = source.trimEnd().split(/\n(?:[ \t]*\n)+/); + for (const [blockIndex, block] of blocks.entries()) { const lines = block.split('\n'); + while (lines.length > 0 && lines[0]?.trim() === '') lines.shift(); const timingIndex = lines.findIndex((line) => line.includes('-->')); - // WebVTT 的 NOTE、STYLE、REGION 块与文件头没有时间行;SRT 的时间行前最多一行序号。 - if (timingIndex < 0 || timingIndex > 1) continue; - const timing = SRT_TIMING.exec(lines[timingIndex] ?? ''); - if (!timing) continue; - const startSeconds = timestamp(timing[1], timing[2], timing[3], timing[4]); - const endSeconds = timestamp(timing[5], timing[6], timing[7], timing[8]); - if (startSeconds === undefined || endSeconds === undefined || endSeconds <= startSeconds) { + // SRT 的时间行前最多一行序号;没有时间行的块在 SRT 里是上一句的续行, + // 文件末尾只剩一个序号的块是没写完的下一句,不并入。 + if (timingIndex < 0 || timingIndex > 1) { + const danglingIndex = + blockIndex === blocks.length - 1 && + lines.length === 1 && + /^\s*\d+\s*$/.test(lines[0] ?? ''); + if (format === 'srt' && pending && !danglingIndex) pending.lines.push(...lines); continue; } - const normalized = normalizeSubtitleText(lines.slice(timingIndex + 1).join('\n'), format); - if (!normalized) continue; - cues.push({ startSeconds, endSeconds, ...normalized }); + finish(); + const timing = parseTiming(lines[timingIndex] ?? ''); + if (timing) pending = { timing, lines: lines.slice(timingIndex + 1) }; } + finish(); return cues.sort((left, right) => left.startSeconds - right.startSeconds); }; +interface CueTiming { + readonly startSeconds: number; + readonly endSeconds: number; +} + +interface PendingCue { + readonly timing: CueTiming; + readonly lines: string[]; +} + +const parseTiming = (line: string): CueTiming | undefined => { + const timing = SRT_TIMING.exec(line); + if (!timing) return undefined; + const startSeconds = timestamp(timing[1], timing[2], timing[3], timing[4]); + const endSeconds = timestamp(timing[5], timing[6], timing[7], timing[8]); + if (startSeconds === undefined || endSeconds === undefined || endSeconds <= startSeconds) { + return undefined; + } + return { startSeconds, endSeconds }; +}; + const timestamp = ( hours: string | undefined, minutes: string | undefined, diff --git a/docs/architecture.md b/docs/architecture.md index aaa75b4..19a1aab 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -184,8 +184,10 @@ Input,健康输入可以保留同一 Range 会话和缓存。不能使用会 要求同一 GOP 内的包不早于上一 GOP 已写出的最大呈现时间。开放 GOP 的随机访问点(HEVC CRA、 带恢复点的 H.264 I 帧)在解码顺序上后面可能跟着呈现更早的前导图像,引擎在交给复用器前 按关键帧处理它们。输出起点关键帧的前导图像引用起点之前的图像,无法解码且早于新时间轴零点, -直接丢弃。中途关键帧的前导图像若早于上一 GOP 已写出的图像,则丢弃该关键帧的全部前导图像, -编码规范保证非前导图像不引用它们。关键帧本身早于上一 GOP 已写出的图像时(分段编码拼接处的 +直接丢弃。中途关键帧的前导图像若早于上一 GOP 已写出的图像,则丢弃该关键帧的全部前导图像。 +HEVC 规范保证 CRA 之后的尾随图像不引用前导图像,丢弃后其余图像仍可正确解码。H.264 没有这个 +约束,恢复点 I 帧之后的图像仍可能引用被丢弃的前导参考图像,由解码器隐藏错误,下一个随机访问点 +之前可能出现短暂花屏。保留这些前导图像在起点处同样无法解码,在中途会被复用器拒绝。关键帧本身早于上一 GOP 已写出的图像时(分段编码拼接处的 IDR 可见此排列),把它降为普通图像并入当前 GOP,全部图像保留,只少一个随机访问点。其余开放 GOP 原样输出,连续播放不丢帧。 @@ -305,10 +307,15 @@ Plex 字幕选择与音轨共用同一次元数据读取,取 Part 中标为选 也不静默显示错误字幕。 外挂字幕(没有流索引,带 `/library/streams/{id}`)不在媒体文件里,媒体运行时按不选字幕播放, -宿主在起播后经后台以播放会话凭据读取字幕文件(只接受这一形状的地址,上限 8 MiB,按 UTF-8 -解码,Cirvel 已把编码统一为 UTF-8),解析为与内嵌字幕相同的 cue,在另一条名为 -`ShimWeave sidecar` 的 TextTrack 上显示。只绘制 `srt` 与 `vtt`,ASS/SSA 等格式不读取文件,直接 -提示不可用;读不到文件或文件里没有字幕同样提示不可用。 +宿主在起播后经后台以播放会话凭据读取字幕文件(只接受这一形状的地址,上限 8 MiB),解析为与 +内嵌字幕相同的 cue,在另一条名为 `ShimWeave sidecar` 的 TextTrack 上显示。只绘制 `srt` 与 +`vtt`,ASS/SSA 等格式不读取文件,直接提示不可用;读不到文件或文件里没有字幕同样提示不可用。 + +读取请求带 `encoding=utf-8`,Plex Media Server 据此自己探测文件编码并转成 UTF-8,GBK、Big5 等 +编码的字幕不依赖前面是否有反代转码,经过转发查询参数的反代时收到的也已是 UTF-8。扩展按 UTF-8 +解码。Plex 认不出编码时原样给出字节,解码结果里替换字符至少 4 个且占非 ASCII 字符的一半以上 +(GBK、Big5 按 UTF-8 解码时九成以上是替换字符)时,提示字幕使用了暂不支持的编码,不显示乱码。 +合法 UTF-8 里零星的坏字节达不到这个门槛,照常显示。 Plex Web 在播放器里换字幕时先以 `PUT /library/parts/{id}?subtitleStreamID=` 记下选择,再重新 决策并重新起播会话,扩展随之开始新的接管,按新的选择读取内嵌字幕或外挂字幕,从原位置继续, @@ -386,12 +393,16 @@ MSE 和状态回写句柄,再通过当前 Shaka Player 的原生错误事件 网络、取消或配额错误写成永久格式结论。 ready 之后的其他失败(读取、Worker、缓冲等)不会自然进入 Plex 错误弹窗。接管已经卸载 Shaka 的 -媒体数据面,Plex 只会停在缓冲状态,video 也没有可用媒体,因此扩展在画面上方显示一次中断提示, -沿用失败说明并附上稳定错误码。读取类失败(`media_read_failed`、`source_expired`)无论发生在 -ready 之前还是之后,Hook 都在会话释放后通过当前 Shaka Player 派发一次致命网络错误 -(`HTTP_ERROR`),让 Plex 显示自己的 s1002 错误弹窗。这类失败不写入同媒体阻断, -用户重试时仍会重新接管。释放期间 Plex 已发起新的 load(例如切集或用户关闭)时不再派发, -`media_detached` 等停止原因也不派发。原 Plex 链路本身无法播放这些 STRM 源,所以不在失败后 +媒体数据面,Plex 只会停在缓冲状态,video 也没有可用媒体。读取类失败(`media_read_failed`、 +`source_expired`)无论发生在 ready 之前还是之后,Hook 都在会话释放后通过当前 Shaka Player +派发一次致命网络错误(`HTTP_ERROR`),让 Plex 显示自己的 s1002 错误弹窗。这类失败不写入同媒体 +阻断,用户重试时仍会重新接管。释放期间 Plex 已发起新的 load(例如切集或用户关闭)时不再派发, +`media_detached` 等停止原因也不派发。 + +每次失败只提示一次。ready 之后的失败由 Hook 判断 Plex 的播放器是否真的收到了错误事件,没收到时 +(失败类别没有对应的 Plex 错误表面、当前 Shaka 构造不出错误事件或派发抛错)回报 +`takeover-error-unsurfaced`,宿主这时才在画面上方显示中断提示,沿用失败说明并附上稳定错误码。 +交给了 Plex 的失败只由它的弹窗呈现,Plex 已发起新 load 时两边都不提示。原 Plex 链路本身无法播放这些 STRM 源,所以不在失败后 重新调用 Plex 的原始 load。每次会话结束时,video 的 `data-shimweave-session-end` 记下结束原因(失败为错误码,Hook 或新接管停止为 `stopped`, 播完为 `ended`),失败时 `data-shimweave-session-end-detail` 另记错误名与 Worker 已脱敏的 diff --git a/packages/adapter-plex/src/native-hook.test.ts b/packages/adapter-plex/src/native-hook.test.ts index e42e2cd..fee6909 100644 --- a/packages/adapter-plex/src/native-hook.test.ts +++ b/packages/adapter-plex/src/native-hook.test.ts @@ -233,6 +233,7 @@ describe('PlexNativeHookController', () => { await flush(); expect(fixture.dispatchEvent).toHaveBeenCalledWith({ type: 'error', mediaErrorCode: 3 }); + expect(posted.some((message) => message.type === 'takeover-error-unsurfaced')).toBe(false); const retryUrl = startUrl.replace('playback-1', 'playback-2'); await expect(fixture.player.load(retryUrl)).rejects.toBeInstanceOf(Error); expect(fixture.originalLoad).toHaveBeenCalledOnce(); @@ -284,6 +285,13 @@ describe('PlexNativeHookController', () => { await flush(); expect(fixture.dispatchEvent).not.toHaveBeenCalled(); + expect(posted).toContainEqual({ + protocol: PLEX_NATIVE_PROTOCOL, + sender: 'main-hook', + type: 'takeover-error-unsurfaced', + sessionId: takeover.sessionId, + code: 'media_worker_remote_error', + }); const retryUrl = startUrl.replace('playback-1', 'playback-2'); const retry = fixture.player.load(retryUrl); await flush(); @@ -319,6 +327,7 @@ describe('PlexNativeHookController', () => { await flush(); expect(fixture.dispatchEvent).toHaveBeenCalledExactlyOnceWith({ type: 'error', network: true }); + expect(posted.some((message) => message.type === 'takeover-error-unsurfaced')).toBe(false); const retry = fixture.player.load(startUrl.replace('playback-1', 'playback-2')); await flush(); hook.accept({ @@ -393,9 +402,56 @@ describe('PlexNativeHookController', () => { await flush(); expect(fixture.dispatchEvent).not.toHaveBeenCalled(); + expect(posted.some((message) => message.type === 'takeover-error-unsurfaced')).toBe(false); hook.dispose(); }); + it('就绪后的错误没能交给 Plex 时请宿主自己提示', async () => { + for (const [code, failing] of [ + ['media_read_failed', 'missing'], + ['media_decode_error', 'missing'], + ['source_expired', 'throws'], + ] as const) { + const fixture = createRuntime(async () => Promise.reject(new Error('manifest rejected'))); + if (failing === 'throws') { + fixture.dispatchEvent.mockImplementation(() => { + throw new Error('page changed'); + }); + } + const runtime: PlexShakaRuntime = + failing === 'missing' + ? { Player: fixture.runtime.Player, version: fixture.runtime.version } + : fixture.runtime; + const posted: PlexNativeMessage[] = []; + const hook = new PlexNativeHookController({ + postMessage: (message) => posted.push(message), + rejectionGraceMs: 0, + }); + hook.install(runtime); + const loading = fixture.player.load(startUrl); + await flush(); + hook.accept(sourceAvailable()); + await flush(); + const takeover = posted.find((message) => message.type === 'takeover-start'); + if (takeover?.type !== 'takeover-start') throw new Error('takeover was not started'); + hook.accept(hostMessage('takeover-ready', takeover.sessionId)); + await loading; + + hook.accept(takeoverError(takeover.sessionId, code)); + hook.accept(hostMessage('takeover-stopped', takeover.sessionId)); + await flush(); + + expect(posted.at(-1)).toEqual({ + protocol: PLEX_NATIVE_PROTOCOL, + sender: 'main-hook', + type: 'takeover-error-unsurfaced', + sessionId: takeover.sessionId, + code, + }); + hook.dispose(); + } + }); + it('同一 Part 的 Plex 重试复用已经建立的接管会话', async () => { const fixture = createRuntime(async () => Promise.reject(new Error('manifest rejected'))); const posted: PlexNativeMessage[] = []; diff --git a/packages/adapter-plex/src/native-hook.ts b/packages/adapter-plex/src/native-hook.ts index e09aa8e..93a428b 100644 --- a/packages/adapter-plex/src/native-hook.ts +++ b/packages/adapter-plex/src/native-hook.ts @@ -373,18 +373,32 @@ export class PlexNativeHookController { if (mediaErrorCode !== undefined) { this.blockedSource = { sourceKey: tracked.sourceKey, mediaErrorCode }; } + const takeover = tracked.takeover; const loadRequests = this.loadRequests; await this.stopTakeover(tracked, new PlexNativeTakeoverError(code)); - if (mediaErrorCode === undefined) { + // 收尾期间 Plex 已发起新 load 时它已经离开这次播放,不再报告旧错误。 + if (this.loadRequests !== loadRequests) return; + let surfaced = false; + if (mediaErrorCode !== undefined) { + if (this.blockedSource?.sourceKey !== tracked.sourceKey) return; + surfaced = this.dispatchErrorEvent( + tracked.player, + this.createMediaErrorEvent?.(mediaErrorCode), + ); + } else if (PLEX_NETWORK_FAILURE_CODES.has(code)) { // 读取类失败不阻断重试。接管已卸载 Shaka 的媒体数据面,Plex 自己不会再报错, // 不交给它的错误表面就只剩空白画面和无限缓冲。 - if (PLEX_NETWORK_FAILURE_CODES.has(code) && this.loadRequests === loadRequests) { - this.dispatchErrorEvent(tracked.player, this.createNetworkErrorEvent?.()); - } - return; + surfaced = this.dispatchErrorEvent(tracked.player, this.createNetworkErrorEvent?.()); } - if (this.blockedSource?.sourceKey !== tracked.sourceKey) return; - this.dispatchMediaError(tracked.player, mediaErrorCode); + // 就绪前的失败随 load 的拒绝交给 Plex;就绪后 Plex 没有收到错误事件时由宿主自己提示。 + if (!takeover?.ready || surfaced || code === 'media_detached') return; + this.postMessage({ + protocol: PLEX_NATIVE_PROTOCOL, + sender: 'main-hook', + type: 'takeover-error-unsurfaced', + sessionId: takeover.sessionId, + code, + }); } private rejectBlockedLoad( @@ -397,29 +411,22 @@ export class PlexNativeHookController { type: 'blocked-source-retry', sourceKey: blocked.sourceKey, }); - const event = this.dispatchMediaError(player, blocked.mediaErrorCode); + const event = this.createMediaErrorEvent?.(blocked.mediaErrorCode); + this.dispatchErrorEvent(player, event); const code = blocked.mediaErrorCode === 3 ? 'media_decode_error' : 'media_format_error'; return Promise.reject(event?.detail ?? new PlexNativeTakeoverError(code)); } - private dispatchMediaError( - player: ShakaPlayerLike, - mediaErrorCode: 3 | 4, - ): ShakaEventLike | undefined { - return this.dispatchErrorEvent(player, this.createMediaErrorEvent?.(mediaErrorCode)); - } - - private dispatchErrorEvent( - player: ShakaPlayerLike, - event: ShakaEventLike | undefined, - ): ShakaEventLike | undefined { - if (!event) return undefined; + /** 返回 Plex 的播放器是否收到了错误事件。 */ + private dispatchErrorEvent(player: ShakaPlayerLike, event: ShakaEventLike | undefined): boolean { + if (!event) return false; try { player.dispatchEvent(event); + return true; } catch { // Shaka 错误桥属于失败收口路径,页面实现变化不得反向破坏会话释放。 + return false; } - return event; } private async stopActive(): Promise { diff --git a/packages/adapter-plex/src/native-protocol.test.ts b/packages/adapter-plex/src/native-protocol.test.ts index d3f60e4..070a03d 100644 --- a/packages/adapter-plex/src/native-protocol.test.ts +++ b/packages/adapter-plex/src/native-protocol.test.ts @@ -28,6 +28,24 @@ describe('Plex native bridge protocol', () => { sourceKey: 'plex:source', }), ).toBe(true); + expect( + isPlexNativeMessage({ + protocol: PLEX_NATIVE_PROTOCOL, + sender: 'main-hook', + type: 'takeover-error-unsurfaced', + sessionId: 'native_session_1234567890', + code: 'media_read_failed', + }), + ).toBe(true); + expect( + isPlexNativeMessage({ + protocol: PLEX_NATIVE_PROTOCOL, + sender: 'extension-host', + type: 'takeover-error-unsurfaced', + sessionId: 'native_session_1234567890', + code: 'media_read_failed', + }), + ).toBe(false); expect( isPlexNativeMessage({ protocol: PLEX_NATIVE_PROTOCOL, diff --git a/packages/adapter-plex/src/native-protocol.ts b/packages/adapter-plex/src/native-protocol.ts index 2f9426b..3a6cf85 100644 --- a/packages/adapter-plex/src/native-protocol.ts +++ b/packages/adapter-plex/src/native-protocol.ts @@ -59,6 +59,14 @@ export type PlexNativeMessage = sessionId: string; code: string; } + | { + /** 就绪后的接管失败没能交给 Plex 的错误表面,由宿主自己提示中断。 */ + protocol: typeof PLEX_NATIVE_PROTOCOL; + sender: 'main-hook'; + type: 'takeover-error-unsurfaced'; + sessionId: string; + code: string; + } | { protocol: typeof PLEX_NATIVE_PROTOCOL; sender: 'main-hook'; @@ -137,6 +145,14 @@ export const isPlexNativeMessage = (value: unknown): value is PlexNativeMessage exactKeys(value, [...BASE_KEYS, 'sessionId', 'code']) ); } + if (value.type === 'takeover-error-unsurfaced') { + return ( + value.sender === 'main-hook' && + opaqueId(value.sessionId) && + nonEmptyString(value.code) && + exactKeys(value, [...BASE_KEYS, 'sessionId', 'code']) + ); + } if (value.type === 'takeover-stop') { return ( value.sender === 'main-hook' && diff --git a/packages/adapter-plex/src/stream-selection.test.ts b/packages/adapter-plex/src/stream-selection.test.ts index 20c3989..594e8a7 100644 --- a/packages/adapter-plex/src/stream-selection.test.ts +++ b/packages/adapter-plex/src/stream-selection.test.ts @@ -205,7 +205,7 @@ describe('Plex 字幕选择', () => { }; const request = createPlexStreamFileRequest(context, '/library/streams/20'); expect(request?.url).toBe( - 'https://plex.example.test:32400/library/streams/20?X-Plex-Client-Identifier=client-a', + 'https://plex.example.test:32400/library/streams/20?X-Plex-Client-Identifier=client-a&encoding=utf-8', ); expect(request?.url).not.toContain('secret-token'); expect(request?.token).toBe('secret-token'); diff --git a/packages/adapter-plex/src/stream-selection.ts b/packages/adapter-plex/src/stream-selection.ts index 0eed9dc..38d6280 100644 --- a/packages/adapter-plex/src/stream-selection.ts +++ b/packages/adapter-plex/src/stream-selection.ts @@ -174,7 +174,11 @@ export const resolvePlexExternalSubtitle = ( }; }; -/** 读取外挂字幕文件的请求,与读取元数据一样只把 Token 放在请求头里。 */ +/** + * 读取外挂字幕文件的请求,与读取元数据一样只把 Token 放在请求头里。 + * 带 `encoding=utf-8` 时 Plex Media Server 自己探测文件编码并转成 UTF-8,不带时按磁盘上的 + * 字节原样给出;GBK、Big5 等编码的字幕因此不依赖前面是否有 Cirvel 这样的反代转码。 + */ export const createPlexStreamFileRequest = ( context: PlexTimelineContext, key: string, @@ -182,6 +186,7 @@ export const createPlexStreamFileRequest = ( if (!PLEX_STREAM_KEY.test(key)) return undefined; const url = new URL(key, context.origin); for (const [name, value] of context.clientParameters) url.searchParams.set(name, value); + url.searchParams.set('encoding', 'utf-8'); return { url: url.toString(), token: context.token }; }; diff --git a/packages/engine-mediabunny/src/leading-pictures.ts b/packages/engine-mediabunny/src/leading-pictures.ts index 0c4667b..db308ed 100644 --- a/packages/engine-mediabunny/src/leading-pictures.ts +++ b/packages/engine-mediabunny/src/leading-pictures.ts @@ -22,7 +22,12 @@ const MAX_HELD_LEADING_PICTURES = 64; * 更早的前导图像,它们可以引用该关键帧之前的图像。两种情况下丢弃该关键帧的全部前导图像: * - 输出起点的关键帧:之前的参考图像不在输出里,前导图像无法正确解码,时间也早于新时间轴零点; * - 中途关键帧的前导图像早于已写出图像的最大呈现时间:复用器按 GOP 推导解码时间,无法写入。 - * 编码规范保证非前导图像不引用前导图像,所以丢弃后其余图像仍可正确解码;其余情况原样输出。 + * 其余情况原样输出。 + * + * HEVC 规范保证 CRA 之后的尾随图像不引用前导图像(RASL 与 RADL),丢弃后其余图像仍可 + * 正确解码。H.264 没有这个约束,恢复点 I 帧之后的图像仍可能引用被丢弃的前导参考图像, + * 由解码器隐藏错误,下一个随机访问点之前可能出现短暂花屏。保留这些前导图像也无济于事:起点处 + * 它们本身缺参考,中途处复用器拒绝写入,所以两种编码按同一规则丢弃。 * * 关键帧本身早于已写出图像时(分段编码拼接处常见:短 GOP 末尾的图像呈现时间晚于下一个 IDR), * 复用器同样会拒绝。此时把它降为普通图像并入当前 GOP:解码顺序和全部图像都保留,