mirror of
https://github.com/ilyhalight/voice-over-translation.git
synced 2026-10-19 07:43:33 +00:00
Fix Safari SABR and WebABR
This commit is contained in:
parent
16348de4ac
commit
7fe59bc833
6 changed files with 1823 additions and 124 deletions
|
|
@ -761,10 +761,96 @@ async function handleTopRequest(
|
|||
audioDownloadType,
|
||||
host: targetWindow.location.hostname,
|
||||
});
|
||||
|
||||
// Run WebABR/SABR directly in the real current YouTube page context.
|
||||
// The hidden /embed player is retained only for the legacy MSE fallback.
|
||||
if (audioDownloadType === AudioDownloadType.WEB_ABR) {
|
||||
const controller = new AbortController();
|
||||
let settled = false;
|
||||
|
||||
const abort = (abortEvent: MessageEvent<MseMessage>) => {
|
||||
const data = abortEvent.data;
|
||||
if (
|
||||
abortEvent.source === source &&
|
||||
abortEvent.origin === event.origin &&
|
||||
data.messageId === message.messageId &&
|
||||
data.messageType === MESSAGE_TYPE &&
|
||||
data.messageDirection === "request" &&
|
||||
data.isAborted
|
||||
) {
|
||||
controller.abort(data.payload);
|
||||
}
|
||||
};
|
||||
targetWindow.addEventListener("message", abort);
|
||||
|
||||
const postProgress = () => {
|
||||
if (settled) return;
|
||||
postResponse(source, event.origin, {
|
||||
...message,
|
||||
messageDirection: "response",
|
||||
payload: undefined,
|
||||
isProgress: true,
|
||||
});
|
||||
};
|
||||
|
||||
const heartbeat = setInterval(postProgress, 30_000);
|
||||
postProgress();
|
||||
debug.log("Audio downloader. direct WebABR/SABR request started", {
|
||||
videoId,
|
||||
messageId: message.messageId,
|
||||
host: targetWindow.location.hostname,
|
||||
});
|
||||
|
||||
try {
|
||||
const chunks = getWebAbrAudioChunks(
|
||||
targetWindow,
|
||||
videoId,
|
||||
controller.signal,
|
||||
getSourceLanguage(message),
|
||||
);
|
||||
|
||||
for await (const chunk of chunks) {
|
||||
postResponse(source, event.origin, {
|
||||
...message,
|
||||
messageDirection: "response",
|
||||
payload: chunk,
|
||||
});
|
||||
}
|
||||
|
||||
settled = true;
|
||||
debug.log("Audio downloader. direct WebABR/SABR stream finished", {
|
||||
videoId,
|
||||
messageId: message.messageId,
|
||||
});
|
||||
postResponse(source, event.origin, {
|
||||
...message,
|
||||
messageDirection: "response",
|
||||
payload: undefined,
|
||||
isStreamFinished: true,
|
||||
});
|
||||
} catch (error) {
|
||||
settled = true;
|
||||
debug.error("Audio downloader. direct WebABR/SABR request failed", {
|
||||
videoId,
|
||||
messageId: message.messageId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
postResponse(source, event.origin, {
|
||||
...message,
|
||||
messageDirection: "response",
|
||||
payload: undefined,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
isAborted: controller.signal.aborted || isAbortError(error),
|
||||
});
|
||||
} finally {
|
||||
clearInterval(heartbeat);
|
||||
targetWindow.removeEventListener("message", abort);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// MSE fallback keeps the original auxiliary /embed player path.
|
||||
const iframe = targetWindow.document.createElement("iframe");
|
||||
// display:none iframes have no layout box, and YouTube defers media
|
||||
// loading for non-rendered players (no src is ever assigned). Keep the
|
||||
// frame rendered but invisible: 2x2px, off the visual path.
|
||||
iframe.style.cssText =
|
||||
"position:fixed;right:0;bottom:0;width:2px;height:2px;border:0;" +
|
||||
"padding:0;margin:0;opacity:0;visibility:hidden;pointer-events:none;";
|
||||
|
|
|
|||
293
src/audioDownloader/strategies/safariPageBridge.ts
Normal file
293
src/audioDownloader/strategies/safariPageBridge.ts
Normal file
|
|
@ -0,0 +1,293 @@
|
|||
import debug from "../../utils/debug";
|
||||
|
||||
const REQUEST_EVENT = "vot:safari-page-po-token:request";
|
||||
const RESPONSE_EVENT = "vot:safari-page-po-token:response";
|
||||
const BRIDGE_MARKER = "data-vot-safari-po-token-bridge";
|
||||
|
||||
const TOP_REQUEST_MESSAGE = "vot:safari-page-po-token:top-request";
|
||||
const TOP_RESPONSE_MESSAGE = "vot:safari-page-po-token:top-response";
|
||||
let topBrokerInstalled = false;
|
||||
|
||||
type TopPoTokenMessage = {
|
||||
type?: string;
|
||||
requestId?: string;
|
||||
binding?: string;
|
||||
token?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type PoTokenResponse = {
|
||||
requestId?: string;
|
||||
token?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
function isSafariBrowser(): boolean {
|
||||
const ua = navigator.userAgent;
|
||||
return (
|
||||
/Safari\//.test(ua) &&
|
||||
!/(?:Chrome|Chromium|CriOS|Edg|OPR|Firefox|FxiOS)\//.test(ua)
|
||||
);
|
||||
}
|
||||
|
||||
function installSafariPageBridge(): boolean {
|
||||
if (!isSafariBrowser()) return false;
|
||||
if (document.documentElement.hasAttribute(BRIDGE_MARKER)) return true;
|
||||
|
||||
const script = document.createElement("script");
|
||||
const nonceSource =
|
||||
document.querySelector<HTMLScriptElement>("script[nonce]");
|
||||
if (nonceSource?.nonce) script.nonce = nonceSource.nonce;
|
||||
|
||||
// This source is intentionally self-contained. The injected script executes
|
||||
// in YouTube's real page realm, where bevasrsg/havuokmhhs-* is available.
|
||||
script.textContent = `(() => {
|
||||
const REQUEST_EVENT = ${JSON.stringify(REQUEST_EVENT)};
|
||||
const RESPONSE_EVENT = ${JSON.stringify(RESPONSE_EVENT)};
|
||||
const MARKER = ${JSON.stringify(BRIDGE_MARKER)};
|
||||
if (document.documentElement.hasAttribute(MARKER)) return;
|
||||
document.documentElement.setAttribute(MARKER, "1");
|
||||
|
||||
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
async function mint(binding) {
|
||||
const keys = Object.getOwnPropertyNames(globalThis).filter(
|
||||
(key) => key === "bevasrsg" || key.startsWith("havuokmhhs-"),
|
||||
);
|
||||
if (!keys.length) {
|
||||
throw new Error("YouTube PO token provider is unavailable in page realm");
|
||||
}
|
||||
|
||||
for (const key of keys) {
|
||||
let bevasrs;
|
||||
try {
|
||||
bevasrs = globalThis[key]?.bevasrs;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const wpc = bevasrs?.wpc;
|
||||
if (typeof wpc !== "function") continue;
|
||||
|
||||
for (let attempt = 0; attempt < 10; attempt++) {
|
||||
try {
|
||||
const minter = await wpc.call(bevasrs);
|
||||
const token = await minter?.mws?.({
|
||||
c: binding,
|
||||
mc: false,
|
||||
me: false,
|
||||
});
|
||||
if (typeof token === "string" && token) return token;
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
if (!message.includes("SDF:notready")) break;
|
||||
}
|
||||
await delay(500);
|
||||
}
|
||||
}
|
||||
throw new Error("YouTube PO token mint failed in page realm");
|
||||
}
|
||||
|
||||
addEventListener(REQUEST_EVENT, async (event) => {
|
||||
const detail = event?.detail;
|
||||
const requestId = detail?.requestId;
|
||||
const binding = detail?.binding;
|
||||
if (typeof requestId !== "string" || typeof binding !== "string" || !binding) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const token = await mint(binding);
|
||||
dispatchEvent(new CustomEvent(RESPONSE_EVENT, {
|
||||
detail: { requestId, token },
|
||||
}));
|
||||
} catch (error) {
|
||||
dispatchEvent(new CustomEvent(RESPONSE_EVENT, {
|
||||
detail: {
|
||||
requestId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
}));
|
||||
}
|
||||
});
|
||||
})();`;
|
||||
|
||||
(document.documentElement || document.head).append(script);
|
||||
script.remove();
|
||||
return document.documentElement.hasAttribute(BRIDGE_MARKER);
|
||||
}
|
||||
|
||||
async function requestSafariPagePoTokenLocal(
|
||||
binding: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<string | undefined> {
|
||||
if (!isSafariBrowser()) return undefined;
|
||||
signal.throwIfAborted();
|
||||
|
||||
if (!installSafariPageBridge()) {
|
||||
debug.error("[VOT][PO_TOKEN] Safari page bridge injection failed");
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const requestId = crypto.randomUUID();
|
||||
debug.log("[VOT][PO_TOKEN] trying Safari page-realm bridge", {
|
||||
bindingLength: binding.length,
|
||||
});
|
||||
|
||||
return await new Promise<string | undefined>((resolve, reject) => {
|
||||
let settled = false;
|
||||
const timeout = setTimeout(() => finish(undefined), 8000);
|
||||
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeout);
|
||||
removeEventListener(RESPONSE_EVENT, onResponse as EventListener);
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
};
|
||||
|
||||
const finish = (value: string | undefined) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
resolve(value);
|
||||
};
|
||||
|
||||
const onAbort = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
reject(signal.reason);
|
||||
};
|
||||
|
||||
const onResponse = (event: Event) => {
|
||||
const detail = (event as CustomEvent<PoTokenResponse>).detail;
|
||||
if (detail?.requestId !== requestId) return;
|
||||
if (typeof detail.token === "string" && detail.token) {
|
||||
finish(detail.token);
|
||||
return;
|
||||
}
|
||||
if (detail.error) {
|
||||
debug.error("[VOT][PO_TOKEN] Safari page-realm mint failed", {
|
||||
message: detail.error,
|
||||
});
|
||||
}
|
||||
finish(undefined);
|
||||
};
|
||||
|
||||
addEventListener(RESPONSE_EVENT, onResponse as EventListener);
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
dispatchEvent(
|
||||
new CustomEvent(REQUEST_EVENT, {
|
||||
detail: { requestId, binding },
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** Install a same-origin token broker in the top YouTube document.
|
||||
* WebABR may keep running in its legacy /embed player; only PO-token minting
|
||||
* is delegated to the top player document where Safari exposes the provider.
|
||||
*/
|
||||
export function initSafariPagePoTokenBroker(): void {
|
||||
if (!isSafariBrowser() || window !== window.top || topBrokerInstalled) return;
|
||||
topBrokerInstalled = true;
|
||||
addEventListener("message", (event: MessageEvent<TopPoTokenMessage>) => {
|
||||
const data = event.data;
|
||||
if (event.origin !== location.origin) return;
|
||||
if (data?.type !== TOP_REQUEST_MESSAGE) return;
|
||||
if (
|
||||
typeof data.requestId !== "string" ||
|
||||
typeof data.binding !== "string" ||
|
||||
!data.binding
|
||||
)
|
||||
return;
|
||||
const source = event.source as Window | null;
|
||||
if (!source) return;
|
||||
const controller = new AbortController();
|
||||
void requestSafariPagePoTokenLocal(data.binding, controller.signal)
|
||||
.then((token) => {
|
||||
source.postMessage(
|
||||
{ type: TOP_RESPONSE_MESSAGE, requestId: data.requestId, token },
|
||||
event.origin,
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
source.postMessage(
|
||||
{
|
||||
type: TOP_RESPONSE_MESSAGE,
|
||||
requestId: data.requestId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
event.origin,
|
||||
);
|
||||
});
|
||||
});
|
||||
debug.log("[VOT][PO_TOKEN] top player token broker installed");
|
||||
}
|
||||
|
||||
async function requestTopSafariPagePoToken(
|
||||
binding: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<string | undefined> {
|
||||
const requestId = crypto.randomUUID();
|
||||
debug.log("[VOT][PO_TOKEN] requesting token from top YouTube player", {
|
||||
bindingLength: binding.length,
|
||||
});
|
||||
return await new Promise<string | undefined>((resolve, reject) => {
|
||||
let settled = false;
|
||||
const timeout = setTimeout(() => finish(undefined), 8000);
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeout);
|
||||
removeEventListener("message", onMessage);
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
};
|
||||
const finish = (value: string | undefined) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
resolve(value);
|
||||
};
|
||||
const onAbort = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
reject(signal.reason);
|
||||
};
|
||||
const onMessage = (event: MessageEvent<TopPoTokenMessage>) => {
|
||||
if (event.source !== window.top || event.origin !== location.origin)
|
||||
return;
|
||||
const data = event.data;
|
||||
if (data?.type !== TOP_RESPONSE_MESSAGE || data.requestId !== requestId)
|
||||
return;
|
||||
if (typeof data.token === "string" && data.token) {
|
||||
debug.log("[VOT][PO_TOKEN] token received from top YouTube player", {
|
||||
tokenLength: data.token.length,
|
||||
});
|
||||
finish(data.token);
|
||||
} else {
|
||||
if (data.error)
|
||||
debug.error("[VOT][PO_TOKEN] top player token mint failed", {
|
||||
message: data.error,
|
||||
});
|
||||
finish(undefined);
|
||||
}
|
||||
};
|
||||
addEventListener("message", onMessage);
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
window.top?.postMessage(
|
||||
{ type: TOP_REQUEST_MESSAGE, requestId, binding },
|
||||
location.origin,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export async function requestSafariPagePoToken(
|
||||
binding: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<string | undefined> {
|
||||
if (!isSafariBrowser()) return undefined;
|
||||
signal.throwIfAborted();
|
||||
if (window !== window.top) {
|
||||
return requestTopSafariPagePoToken(binding, signal);
|
||||
}
|
||||
return requestSafariPagePoTokenLocal(binding, signal);
|
||||
}
|
||||
|
|
@ -167,83 +167,14 @@ export function buildMediaRanges(
|
|||
return ranges;
|
||||
}
|
||||
|
||||
export async function mintPagePoToken(
|
||||
pageWindow: WebAbrWindow,
|
||||
binding: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<string | undefined> {
|
||||
const realms = new Set<WebAbrWindow>([pageWindow]);
|
||||
try {
|
||||
realms.add(pageWindow.parent as WebAbrWindow);
|
||||
realms.add(pageWindow.top as WebAbrWindow);
|
||||
} catch {
|
||||
// Cross-origin access is denied.
|
||||
}
|
||||
for (const realm of realms) {
|
||||
let keys: string[];
|
||||
try {
|
||||
keys = Object.getOwnPropertyNames(realm).filter(
|
||||
(key) => key === "bevasrsg" || key.startsWith("havuokmhhs-"),
|
||||
);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const key of keys) {
|
||||
let bevasrs: { wpc?: unknown } | undefined;
|
||||
try {
|
||||
bevasrs = (
|
||||
(realm as unknown as Record<string, unknown>)[key] as {
|
||||
bevasrs?: { wpc?: unknown };
|
||||
}
|
||||
)?.bevasrs;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const wpc = bevasrs?.wpc;
|
||||
if (typeof wpc !== "function") continue;
|
||||
for (let attempt = 0; attempt < 10; attempt++) {
|
||||
if (signal.aborted) throw signal.reason;
|
||||
try {
|
||||
const minter = await wpc.call(bevasrs);
|
||||
const token = await minter?.mws?.({
|
||||
c: binding,
|
||||
mc: false,
|
||||
me: false,
|
||||
});
|
||||
if (typeof token === "string" && token) return token;
|
||||
} catch (error) {
|
||||
if (!String(error).includes("SDF:notready")) break;
|
||||
}
|
||||
await createAbortableDelay(500, signal);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// PO-token handling belongs to the dedicated YouTube player layer.
|
||||
// Re-export it here to keep existing SABR imports/API compatible.
|
||||
export {
|
||||
mintPagePoToken,
|
||||
selectGvsPoTokenBinding,
|
||||
} from "./youtubePlayer";
|
||||
|
||||
export function selectGvsPoTokenBinding(
|
||||
videoId: string,
|
||||
options: {
|
||||
loggedIn: boolean;
|
||||
dataSyncId: unknown;
|
||||
visitorData: unknown;
|
||||
experimentFlags: string[];
|
||||
},
|
||||
): { kind: "video" | "datasync" | "visitor"; value: string } | undefined {
|
||||
if (
|
||||
options.experimentFlags.some(
|
||||
(flags) =>
|
||||
new URLSearchParams(flags)
|
||||
.getAll("html5_generate_content_po_token")
|
||||
.at(-1) === "true",
|
||||
)
|
||||
) {
|
||||
return { kind: "video", value: videoId };
|
||||
}
|
||||
// Authenticated GVS uses the full datasync ID, including the || separator.
|
||||
const value = options.loggedIn ? options.dataSyncId : options.visitorData;
|
||||
if (typeof value !== "string" || !value) return;
|
||||
return { kind: options.loggedIn ? "datasync" : "visitor", value };
|
||||
}
|
||||
import { mintPagePoToken, selectGvsPoTokenBinding } from "./youtubePlayer";
|
||||
|
||||
export function getConfigValue(config: YouTubeConfig, key: string): unknown {
|
||||
return config.get?.(key) ?? config.data_?.[key];
|
||||
|
|
|
|||
1426
src/audioDownloader/strategies/youtubePlayer.ts
Normal file
1426
src/audioDownloader/strategies/youtubePlayer.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,54 +1,15 @@
|
|||
import type { SabrFormat } from "googlevideo/shared-types";
|
||||
import { createAbortableDelay } from "../../utils/abort";
|
||||
import debug from "../../utils/debug";
|
||||
import {
|
||||
getYoutubeAudioFormatLanguage as getAudioFormatLanguage,
|
||||
normalizeAudioLanguageTag as normalizeAudioLanguage,
|
||||
} from "../utils";
|
||||
import {
|
||||
audioLanguageMatches,
|
||||
getAudioFormatLanguage,
|
||||
getTopPageWindow,
|
||||
isDrcAudioFormat,
|
||||
normalizeAudioLanguage,
|
||||
type WebAbrWindow,
|
||||
type WebEmbeddedFormat,
|
||||
} from "./webAbr";
|
||||
import { getTopPageWindow } from "./youtubeSabrPlayer";
|
||||
|
||||
export function splitTopLevelProto(
|
||||
bytes: Uint8Array,
|
||||
): Array<{ field: number; start: number; end: number }> {
|
||||
const out: Array<{ field: number; start: number; end: number }> = [];
|
||||
const readVarint = (offset: number) => {
|
||||
let value = 0;
|
||||
let shift = 0;
|
||||
let index = offset;
|
||||
while (index < bytes.length && shift <= 35) {
|
||||
const byte = bytes[index++];
|
||||
value += (byte & 0x7f) * 2 ** shift;
|
||||
if ((byte & 0x80) === 0) return { value, next: index };
|
||||
shift += 7;
|
||||
}
|
||||
throw new Error("invalid protobuf varint");
|
||||
};
|
||||
|
||||
let offset = 0;
|
||||
while (offset < bytes.length) {
|
||||
const start = offset;
|
||||
const tag = readVarint(offset);
|
||||
offset = tag.next;
|
||||
const field = Math.floor(tag.value / 8);
|
||||
const wire = tag.value & 7;
|
||||
if (wire === 0) offset = readVarint(offset).next;
|
||||
else if (wire === 1) offset += 8;
|
||||
else if (wire === 2) {
|
||||
const length = readVarint(offset);
|
||||
offset = length.next + length.value;
|
||||
} else if (wire === 5) offset += 4;
|
||||
else throw new Error(`unsupported protobuf wire ${wire}`);
|
||||
if (offset > bytes.length) throw new Error("truncated protobuf field");
|
||||
out.push({ field, start, end: offset });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
} from "./youtubePlayer";
|
||||
|
||||
export function toSabrFormat(
|
||||
format: WebEmbeddedFormat,
|
||||
|
|
@ -172,7 +133,7 @@ export function setGeneratedSabrAudioTrackId(
|
|||
const payloadChunks = fields
|
||||
.filter((field) => field.field !== 69)
|
||||
.map((field) => payload.slice(field.start, field.end));
|
||||
payloadChunks.push(new Uint8Array(trackField));
|
||||
payloadChunks.push(trackField);
|
||||
const patchedPayload = concatBytes(payloadChunks);
|
||||
|
||||
chunks.push(
|
||||
|
|
|
|||
|
|
@ -181,6 +181,8 @@
|
|||
"cloudflare-dns.com",
|
||||
"porntn.com",
|
||||
"youtube.com",
|
||||
"googlevideo.com"
|
||||
"googlevideo.com",
|
||||
"vtrans.eu.cc",
|
||||
"vot-worker.vtrans.eu.cc"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue