Files
Vineless/content_script.js
Ingan121 36df571076 * Fix audio/video codec support detection to check for real browser support for them
* Fix logic error when comparing the key system name
* Fix for services that checks all of the availalbe kids in keyStatus
  * Also work around for some services that expect flipped bytes
* Handle cases where a website sends a mixture of WV and PR PSSH
* Add handler for MediaKeySession.close
2025-07-06 20:45:27 +09:00

720 lines
27 KiB
JavaScript

function uint8ArrayToBase64(uint8array) {
return btoa(String.fromCharCode.apply(null, uint8array));
}
function uint8ArrayToString(uint8array) {
return String.fromCharCode.apply(null, uint8array)
}
function base64toUint8Array(base64_string){
return Uint8Array.from(atob(base64_string), c => c.charCodeAt(0))
}
function compareUint8Arrays(arr1, arr2) {
if (arr1.length !== arr2.length)
return false;
return Array.from(arr1).every((value, index) => value === arr2[index]);
}
function base64ToBase64Url(b64) {
return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
function hexToBase64(hexstring) {
return btoa(hexstring.match(/\w{2}/g).map(function(a) {
return String.fromCharCode(parseInt(a, 16));
}).join(""));
}
function hexToUint8Array(hex) {
if (typeof hex !== 'string' || hex.length % 2 !== 0)
throw new Error("Invalid hex string");
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.substr(i, 2), 16);
}
return bytes;
}
function generateClearKeyLicense(keys) {
return JSON.stringify({
keys: keys.map(({ k, kid }) => ({
kty: "oct",
alg: "A128KW",
k: base64ToBase64Url(hexToBase64(k)),
kid: base64ToBase64Url(hexToBase64(kid))
})),
type: "temporary"
});
}
function makeCkInitData(keys) {
const systemId = new Uint8Array([
0x10, 0x77, 0xef, 0xec,
0xc0, 0xb2,
0x4d, 0x02,
0xac, 0xe3,
0x3c, 0x1e, 0x52, 0xe2, 0xfb, 0x4b
]);
const kidCount = keys.length;
const kidDataLength = kidCount * 16;
const dataSize = 0;
const size = 4 + 4 + 4 + 16 + 4 + kidDataLength + 4 + dataSize;
const buffer = new ArrayBuffer(size);
const view = new DataView(buffer);
let offset = 0;
view.setUint32(offset, size); offset += 4;
view.setUint32(offset, 0x70737368); offset += 4; // 'pssh'
view.setUint8(offset++, 0x01); // version 1
view.setUint8(offset++, 0x00); // flags (3 bytes)
view.setUint8(offset++, 0x00);
view.setUint8(offset++, 0x00);
new Uint8Array(buffer, offset, 16).set(systemId); offset += 16;
view.setUint32(offset, kidCount); offset += 4;
for (const key of keys) {
const kidBytes = hexToUint8Array(key.kid);
if (kidBytes.length !== 16) throw new Error("Invalid KID length");
new Uint8Array(buffer, offset, 16).set(kidBytes);
offset += 16;
}
view.setUint32(offset, dataSize); offset += 4;
return new Uint8Array(buffer);
}
function emitAndWaitForResponse(type, data) {
return new Promise((resolve) => {
const requestId = Math.random().toString(16).substring(2, 9);
const responseHandler = (event) => {
const { detail } = event;
if (detail.substring(0, 7) === requestId) {
document.removeEventListener('responseReceived', responseHandler);
resolve(detail.substring(7));
}
};
document.addEventListener('responseReceived', responseHandler);
const requestEvent = new CustomEvent('response', {
detail: {
type: type,
body: data,
requestId: requestId,
}
});
document.dispatchEvent(requestEvent);
});
}
const fnproxy = (object, func) => new Proxy(object, { apply: func });
const proxy = (object, key, func) => Object.hasOwnProperty.call(object, key) && Object.defineProperty(object, key, {
value: fnproxy(object[key], func)
});
function getEventListeners(type) {
if (this == null) return [];
const store = this[Symbol.for(getEventListeners)];
if (store == null || store[type] == null) return [];
return store[type];
}
class Evaluator {
static isDASH(text) {
return text.includes('<mpd') && text.includes('</mpd>');
}
static isHLS(text) {
return text.includes('#extm3u');
}
static isHLSMaster(text) {
return text.includes('#ext-x-stream-inf');
}
static isMSS(text) {
return text.includes('<smoothstreamingmedia') && text.includes('</smoothstreamingmedia>');
}
static getManifestType(text) {
const lower = text.toLowerCase();
if (this.isDASH(lower)) {
return "DASH";
} else if (this.isHLS(lower)) {
if (this.isHLSMaster(lower)) {
return "HLS_MASTER";
} else {
return "HLS_PLAYLIST";
}
} else if (this.isMSS(lower)) {
return "MSS";
}
}
}
async function sanitizeConfigForClearKey(configOrConfigs) {
const configs = Array.isArray(configOrConfigs) ? configOrConfigs : [configOrConfigs];
const supportedConfigs = [];
for (const config of configs) {
const videoCaps = config.videoCapabilities || [];
const audioCaps = config.audioCapabilities || [];
const initDataTypes = config.initDataTypes || ["cenc"];
const sessionTypes = config.sessionTypes || ["temporary"];
const cleanVideoCaps = [];
const cleanAudioCaps = [];
for (const [type, caps, cleanList] of [
["video", videoCaps, cleanVideoCaps],
["audio", audioCaps, cleanAudioCaps]
]) {
for (const cap of caps) {
const contentType = cap.contentType;
if (!contentType || !MediaSource.isTypeSupported(contentType)) {
console.debug("[Vineless] Unsupported contentType:", contentType);
continue; // skip if not playable
}
const mediaConfig = {
type: "media-source",
[type]: {
contentType,
robustness: "", // ClearKey must use empty robustness
bitrate: 100000,
framerate: 30,
channels: type === "audio" ? 2 : undefined,
width: type === "video" ? 1920 : undefined,
height: type === "video" ? 1080 : undefined,
samplerate: type === "audio" ? 48000 : undefined
},
keySystemConfiguration: {
keySystem: "org.w3.clearkey",
initDataType: "cenc",
distinctiveIdentifier: "not-allowed",
persistentState: "optional",
sessionTypes
},
_ck: true
};
let supported = true;
if (navigator.mediaCapabilities?.decodingInfo) {
try {
const result = await navigator.mediaCapabilities.decodingInfo(mediaConfig);
supported = result.supported;
} catch (e) {
supported = false;
}
}
if (supported) {
cleanList.push({ contentType, robustness: "" });
}
}
}
if (cleanVideoCaps.length || cleanAudioCaps.length) {
supportedConfigs.push({
initDataTypes,
distinctiveIdentifier: "not-allowed",
persistentState: "optional",
sessionTypes,
videoCapabilities: cleanVideoCaps.length ? cleanVideoCaps : undefined,
audioCapabilities: cleanAudioCaps.length ? cleanAudioCaps : undefined
});
}
}
if (!supportedConfigs.length) {
console.warn("[Vineless] No supported configs for ClearKey, returning empty array");
return [];
}
console.debug("[Vineless] Sanitized config for ClearKey:", supportedConfigs);
return supportedConfigs;
}
function hookKeySystem(interface) {
const origKeySystemDescriptor = Object.getOwnPropertyDescriptor(interface.prototype, 'keySystem');
const origKeySystemGetter = origKeySystemDescriptor?.get;
if (typeof origKeySystemGetter !== 'undefined') {
Object.defineProperty(interface.prototype, 'keySystem', {
get() {
if (this._emeShim?.origKeySystem) {
console.debug("[Vineless] Shimmed keySystem");
return this._emeShim.origKeySystem;
}
return origKeySystemGetter.call(this);
}
});
}
}
async function getEnabledForKeySystem(keySystem, includeClearKey = true) {
if (!keySystem) {
return false;
}
const enabledData = JSON.parse(await emitAndWaitForResponse("GET_ENABLED"));
if (!enabledData) {
return false;
}
if (keySystem.startsWith( "com.widevine.alpha")) {
return enabledData.wv;
} else if (keySystem.startsWith("com.microsoft.playready")) {
return enabledData.pr;
} else if (keySystem === "org.w3.clearkey") {
return includeClearKey;
}
console.error("[Vineless] Unsupported keySystem!");
return false;
}
function flipUUIDByteOrder(u8arr) {
const out = new Uint8Array(16);
out.set([
u8arr[3], u8arr[2], u8arr[1], u8arr[0], // 4 bytes reversed
u8arr[5], u8arr[4], // 2 bytes reversed
u8arr[7], u8arr[6], // 2 bytes reversed
...u8arr.slice(8) // last 8 bytes unchanged
]);
return out;
}
(async () => {
const requestMediaKeySystemAccessUnaltered = navigator.requestMediaKeySystemAccess;
if (!requestMediaKeySystemAccessUnaltered) {
console.error("[Vineless] EME not available!");
return;
}
if (typeof Navigator !== 'undefined') {
proxy(Navigator.prototype, 'requestMediaKeySystemAccess', async (_target, _this, _args) => {
console.log("[Vineless] requestMediaKeySystemAccess", structuredClone(_args));
const origKeySystem = _args[0];
if (await getEnabledForKeySystem(origKeySystem, false)) {
_args[0] = "org.w3.clearkey";
_args[1] = await sanitizeConfigForClearKey(_args[1]);
}
const systemAccess = await _target.apply(_this, _args);
systemAccess._emeShim = {
origKeySystem
};
console.debug("[Vineless] requestMediaKeySystemAccess SUCCESS", systemAccess);
return systemAccess;
});
}
if (typeof MediaCapabilities !== 'undefined') {
proxy(MediaCapabilities.prototype, 'decodingInfo', async (_target, _this, _args) => {
const [config] = _args;
if (_args._ck) {
return await _target.apply(_this, _args);
}
const origKeySystem = config?.keySystemConfiguration?.keySystem;
if (await getEnabledForKeySystem(origKeySystem, false)) {
console.log("[Vineless] Intercepted decodingInfo for", origKeySystem);
try {
const ckConfig = structuredClone(config);
// Convert decodingInfo-like config to RMKSA-like structure
const ksc = ckConfig.keySystemConfiguration = {
initDataTypes: ["cenc"],
distinctiveIdentifier: "not-allowed",
persistentState: "optional",
sessionTypes: ["temporary"],
videoCapabilities: [],
audioCapabilities: []
};
if (ckConfig.video?.contentType) {
ksc.videoCapabilities.push({
contentType: ckConfig.video.contentType,
robustness: ckConfig.video.robustness || ""
});
}
if (ckConfig.audio?.contentType) {
ksc.audioCapabilities.push({
contentType: ckConfig.audio.contentType,
robustness: ckConfig.audio.robustness || ""
});
}
const sanitized = await sanitizeConfigForClearKey(ksc);
if (!sanitized.length) {
console.warn("[Vineless] decodingInfo: no valid config after sanitization");
return {
supported: false,
smooth: false,
powerEfficient: false,
keySystemAccess: null
};
}
const originalVideo = ksc.videoCapabilities?.length;
const originalAudio = ksc.audioCapabilities?.length;
const sanitizedVideo = sanitized[0].videoCapabilities?.length;
const sanitizedAudio = sanitized[0].audioCapabilities?.length;
if (
(originalVideo && !sanitizedVideo) ||
(originalAudio && !sanitizedAudio)
) {
console.warn("[Vineless] decodingInfo: partial capability failure detected");
return {
supported: false,
smooth: false,
powerEfficient: false,
keySystemAccess: null
};
}
ckConfig.keySystemConfiguration = sanitized[0];
ckConfig.keySystemConfiguration.keySystem = "org.w3.clearkey";
const ckResult = await _target.call(_this, ckConfig);
// Generate a real MediaKeySystemAccess to attach
const access = await requestMediaKeySystemAccessUnaltered.call(navigator, "org.w3.clearkey", [ckConfig.keySystemConfiguration]);
// Shim .keySystem
access._emeShim = { origKeySystem };
// Also patch `getConfiguration()` to reflect original input
const originalGetConfig = access.getConfiguration.bind(access);
access.getConfiguration = () => ({
...originalGetConfig(),
videoCapabilities: ckConfig.keySystemConfiguration.videoCapabilities,
audioCapabilities: ckConfig.keySystemConfiguration.audioCapabilities,
sessionTypes: ckConfig.keySystemConfiguration.sessionTypes,
initDataTypes: ckConfig.keySystemConfiguration.initDataTypes
});
return {
...ckResult,
supported: true,
smooth: true,
powerEfficient: true,
keySystemAccess: access
};
} catch (e) {
console.warn("[Vineless] decodingInfo fallback failed");
return {
supported: true,
smooth: true,
powerEfficient: false,
keySystemAccess: null
};
}
}
return await _target.apply(_this, _args);
});
}
if (typeof HTMLMediaElement !== 'undefined') {
proxy(HTMLMediaElement.prototype, 'setMediaKeys', async (_target, _this, _args) => {
console.log("[Vineless] setMediaKeys", _args);
const keys = _args[0];
const keySystem = keys?._emeShim?.origKeySystem;
if (!await getEnabledForKeySystem(keySystem)) {
return await _target.apply(_this, _args);
}
// Replace with our own ClearKey MediaKeys
if (keys._ckConfig) {
if (!keys._ckKeys) {
const ckAccess = await requestMediaKeySystemAccessUnaltered.call(navigator, 'org.w3.clearkey', [keys._ckConfig]);
keys._ckKeys = await ckAccess.createMediaKeys();
keys._ckKeys._emeShim = {
origMediaKeys: keys
};
}
console.log("[Vineless] Replaced mediaKeys with ClearKey one");
return _target.call(_this, keys._ckKeys);
}
return _target.apply(_this, _args);
});
const origMediaKeysDescriptor = Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'mediaKeys');
const origMediaKeysGetter = origMediaKeysDescriptor?.get;
if (typeof origMediaKeysGetter !== 'undefined') {
Object.defineProperty(HTMLMediaElement.prototype, 'mediaKeys', {
get() {
const result = origMediaKeysGetter.call(this);
if (result?._emeShim?.origMediaKeys) {
console.debug("[Vineless] Shimmed HTMLMediaElement.mediaKeys");
return result._emeShim.origMediaKeys;
}
return result;
}
});
}
}
if (typeof MediaKeySystemAccess !== 'undefined') {
proxy(MediaKeySystemAccess.prototype, 'createMediaKeys', async (_target, _this, _args) => {
console.log("[Vineless] createMediaKeys");
const realKeys = _target.apply(_this, _args);
realKeys.then(res => {
res._ckConfig = _this.getConfiguration();
res._emeShim = _this._emeShim;
});
return realKeys;
});
hookKeySystem(MediaKeySystemAccess);
}
if (typeof MediaKeys !== 'undefined') {
proxy(MediaKeys.prototype, 'createSession', (_target, _this, _args) => {
console.log("[Vineless] createSession");
const session = _target.apply(_this, _args);
session._mediaKeys = _this;
// Create a controlled closed Promise
let closeResolver;
session._closedPromise = new Promise(resolve => {
closeResolver = resolve;
});
session._closeResolver = closeResolver;
Object.defineProperty(session, 'closed', {
get: () => session._closedPromise
});
return session;
});
hookKeySystem(MediaKeys);
}
if (typeof MediaKeySession !== 'undefined') {
proxy(MediaKeySession.prototype, 'generateRequest', async (_target, _this, _args) => {
const keySystem = _this._mediaKeys?._emeShim?.origKeySystem;
if (!await getEnabledForKeySystem(keySystem) || _this._ck) {
return await _target.apply(_this, _args);
}
console.log("[Vineless] generateRequest", _args);
try {
Object.defineProperty(_this, "sessionId", {
value: "vl-" + Math.random().toString(36),
writable: false
});
const base64Pssh = uint8ArrayToBase64(new Uint8Array(_args[1]));
const data = keySystem.startsWith("com.microsoft.playready") ? `pr:${_this.sessionId}:${base64Pssh}` : base64Pssh;
const challenge = await emitAndWaitForResponse("REQUEST", data);
const challengeBytes = base64toUint8Array(challenge);
const evt = new MediaKeyMessageEvent("message", {
message: challengeBytes.buffer,
messageType: "license-request"
});
_this.dispatchEvent(evt);
} catch (e) {
console.error("[Vineless] generateRequest FAILED,", e);
throw e;
}
return;
});
proxy(MediaKeySession.prototype, 'update', async (_target, _this, _args) => {
const keySystem = _this._mediaKeys?._emeShim?.origKeySystem;
if (!await getEnabledForKeySystem(keySystem) || _this._ck) {
return await _target.apply(_this, _args);
}
const [response] = _args;
console.log("[Vineless] update");
const base64Response = uint8ArrayToBase64(new Uint8Array(response));
const data = keySystem.startsWith("com.microsoft.playready") ? `pr:${_this.sessionId}:${base64Response}` : base64Response;
const bgResponse = await emitAndWaitForResponse("RESPONSE", data);
try {
const parsed = JSON.parse(bgResponse);
console.log(parsed, _this);
if (parsed && _this._mediaKeys) {
if (!_this._mediaKeys._ckKeys) {
const ckAccess = await requestMediaKeySystemAccessUnaltered.call(navigator, 'org.w3.clearkey', [_this._mediaKeys._ckConfig]);
_this._mediaKeys._ckKeys = await ckAccess.createMediaKeys();
_this._mediaKeys._ckKeys._emeShim = {
origMediaKeys: _this._mediaKeys
};
}
const ckLicense = generateClearKeyLicense(parsed.keys);
_this._ckSession = _this._mediaKeys._ckKeys.createSession();
_this._ckSession._ck = true;
try {
await _this._ckSession.generateRequest('cenc', parsed.pssh);
} catch {
const pssh = makeCkInitData(parsed.keys);
await _this._ckSession.generateRequest('cenc', pssh);
}
const encoder = new TextEncoder();
const encodedLicense = encoder.encode(ckLicense);
await _this._ckSession.update(encodedLicense);
const keyStatuses = new Map();
const addedKeys = new Set();
for (const { kid } of parsed.keys) {
// Some require unflipped one, others require flipped one
// So include both unless duplicate
const raw = hexToUint8Array(kid);
const flipped = flipUUIDByteOrder(raw);
for (const keyBytes of [raw, flipped]) {
const keyHex = Array.from(keyBytes).map(b => b.toString(16).padStart(2, '0')).join('');
if (!addedKeys.has(keyHex)) {
keyStatuses.set(keyBytes, "usable");
addedKeys.add(keyHex);
}
}
}
Object.defineProperty(_this, "keyStatuses", {
value: keyStatuses,
writable: false
});
const keyStatusEvent = new Event("keystatuseschange");
_this.dispatchEvent(keyStatusEvent);
return;
} else {
console.error("[Vineless] update FAILED, no MediaKeys available!");
}
} catch (e) {
console.error("[Vineless] update FAILED,", e);
// If parsing failed, fall through to original Widevine path
}
return await _target.apply(_this, _args);
});
proxy(MediaKeySession.prototype, 'close', async (_target, _this, _args) => {
const keySystem = _this?._mediaKeys?._emeShim?.origKeySystem;
// Mark closed
if (_this._closeResolver) {
_this._closeResolver();
}
if (!await getEnabledForKeySystem(keySystem) || _this._ck) {
return await _target.apply(_this, _args);
}
// Close internal session if found
if (_this._ckSession) {
try {
await _this._ckSession.close();
} catch (e) {}
}
return Promise.resolve();
});
}
})();
const originalFetch = window.fetch;
window.fetch = function() {
return new Promise(async (resolve, reject) => {
originalFetch.apply(this, arguments).then((response) => {
if (response) {
response.clone().text().then((text) => {
const manifest_type = Evaluator.getManifestType(text);
if (manifest_type) {
if (arguments.length === 1) {
emitAndWaitForResponse("MANIFEST", JSON.stringify({
"url": arguments[0].url,
"type": manifest_type,
}));
} else if (arguments.length === 2) {
emitAndWaitForResponse("MANIFEST", JSON.stringify({
"url": arguments[0],
"type": manifest_type,
}));
}
}
resolve(response);
}).catch(() => {
resolve(response);
})
} else {
resolve(response);
}
}).catch(() => {
resolve();
})
})
}
const open = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function(method, url) {
this._method = method;
return open.apply(this, arguments);
};
const send = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.send = function(postData) {
this.addEventListener('load', async function() {
if (this._method === "GET") {
let body = void 0;
switch (this.responseType) {
case "":
case "text":
body = this.responseText ?? this.response;
break;
case "json":
// TODO: untested
body = JSON.stringify(this.response);
break;
case "arraybuffer":
// TODO: untested
if (this.response.byteLength) {
const response = new Uint8Array(this.response);
body = uint8ArrayToString(new Uint8Array([...response.slice(0, 2000), ...response.slice(-2000)]));
}
break;
case "document":
// todo
break;
case "blob":
body = await this.response.text();
break;
}
if (body) {
const manifest_type = Evaluator.getManifestType(body);
if (manifest_type) {
emitAndWaitForResponse("MANIFEST", JSON.stringify({
"url": this.responseURL,
"type": manifest_type,
}));
}
}
}
});
return send.apply(this, arguments);
};