Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions apps/chrome-extension/src/plex-content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -207,7 +207,7 @@ function requestStreamSelection(notice: PlexMediaSourceNotice): Promise<PlexStre
function requestSubtitleFile(
notice: PlexMediaSourceNotice,
key: string,
): Promise<string | undefined> {
): Promise<SubtitleFileRead | undefined> {
if (!notice.playbackReportId) return Promise.resolve(undefined);
return new Promise((resolve) => {
chrome.runtime.sendMessage(
Expand All @@ -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);
},
);
Expand Down
60 changes: 55 additions & 5 deletions apps/chrome-extension/src/plex-native-host.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -683,10 +683,11 @@ describe('PlexNativePlaybackHost', () => {
});
await host.dispose();
});
it('接管就绪后播放中断时提示用户,并在 video 上记下结束原因', async () => {
it('接管就绪后的失败交给了 Plex 错误框时不叠加中断提示,并在 video 上记下结束原因', async () => {
const media = new TestMediaElement();
const completion = deferred<void>();
const interruptions: string[] = [];
const failures: string[] = [];
const posted: PlexNativeMessage[] = [];
const host = new PlexNativePlaybackHost({
createPlaybackRuntime: () => ({
Expand All @@ -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',
});
Expand All @@ -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',
Expand All @@ -721,6 +724,47 @@ describe('PlexNativePlaybackHost', () => {
await host.dispose();
});

it('Hook 回报就绪后的失败没能交给 Plex 时提示一次中断', async () => {
const media = new TestMediaElement();
const completion = deferred<void>();
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<void>();
Expand Down Expand Up @@ -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<SubtitleFileRead | undefined> =>
srt,
);
const resolveStreamSelection = vi.fn(async () => selection);
const presentSubtitleUnavailable = vi.fn();
const host = new PlexNativePlaybackHost({
Expand Down Expand Up @@ -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();
Expand Down
37 changes: 30 additions & 7 deletions apps/chrome-extension/src/plex-native-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,16 +62,19 @@ export interface PlexNativePlaybackHostOptions {
presentSubtitleUnavailable?(reason: SubtitleUnavailableReason, codec?: string): void;
/**
* 读取外挂字幕文件(`/library/streams/{id}`)的文本,由后台带会话凭据读取;
* 失败时返回 undefined,页面提示字幕不可用。
* 失败时返回 undefined,读到了却无法解码时返回不可用原因,页面都提示字幕不可用。
*/
readSubtitleFile?(notice: PlexMediaSourceNotice, key: string): Promise<string | undefined>;
readSubtitleFile?(
notice: PlexMediaSourceNotice,
key: string,
): Promise<SubtitleFileRead | undefined>;
createSidecarRenderer?(
mediaElement: HTMLMediaElement,
language: string | undefined,
): SidecarRendererPort;
/**
* 接管就绪后播放中断时提示。此时 Plex 的加载早已完成,不会弹出自己的错误框,
* 不提示的话画面只会停在缓冲状态。
* 接管就绪后播放中断、且 Hook 报告没能交给 Plex 错误弹窗时提示。交给了 Plex 的失败
* 由它的弹窗呈现,这里不再重复;没交给的话 Plex 自己不会报错,画面只会停在缓冲状态。
*/
presentInterruption?(failure: PlaybackFailurePresentation): void;
createNoticeId?: () => string;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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({
Expand All @@ -274,6 +292,7 @@ export class PlexNativePlaybackHost {
async dispose(): Promise<void> {
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();
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}

Expand Down
37 changes: 35 additions & 2 deletions apps/chrome-extension/src/plex-stream-selection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,19 +96,52 @@ 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 }));

expect(
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({});
Expand Down
32 changes: 28 additions & 4 deletions apps/chrome-extension/src/plex-stream-selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand All @@ -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,
Expand All @@ -126,13 +138,25 @@ 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 {
clearTimeout(timeout);
}
};

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;
Loading
Loading