Update/1.10.11 (#1109)
* Bump version * Froce player id * Improved implementation of SABR * More SABR changes * Minor catch for event listener * Bump tar-fs from 3.0.9 to 3.1.1 in /materialious Bumps [tar-fs](https://github.com/mafintosh/tar-fs) from 3.0.9 to 3.1.1. - [Commits](https://github.com/mafintosh/tar-fs/compare/v3.0.9...v3.1.1) --- updated-dependencies: - dependency-name: tar-fs dependency-version: 3.1.1 dependency-type: indirect ... Signed-off-by: dependabot[bot] <support@github.com> * Bump electron from 38.1.0 to 38.1.2 in /materialious/electron --- updated-dependencies: - dependency-name: electron dependency-version: 38.1.2 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] <support@github.com> * Bump @capacitor/core from 7.4.2 to 7.4.3 in /materialious Bumps [@capacitor/core](https://github.com/ionic-team/capacitor) from 7.4.2 to 7.4.3. - [Release notes](https://github.com/ionic-team/capacitor/releases) - [Changelog](https://github.com/ionic-team/capacitor/blob/7.4.3/CHANGELOG.md) - [Commits](https://github.com/ionic-team/capacitor/compare/7.4.2...7.4.3) --- updated-dependencies: - dependency-name: "@capacitor/core" dependency-version: 7.4.3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] <support@github.com> * Bump human-number from 2.0.4 to 2.0.6 in /materialious Bumps [human-number](https://github.com/Kikobeats/human-number) from 2.0.4 to 2.0.6. - [Release notes](https://github.com/Kikobeats/human-number/releases) - [Changelog](https://github.com/Kikobeats/human-number/blob/master/CHANGELOG.md) - [Commits](https://github.com/Kikobeats/human-number/compare/v2.0.4...v2.0.6) --- updated-dependencies: - dependency-name: human-number dependency-version: 2.0.6 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] <support@github.com> * Bump @vite-pwa/sveltekit from 0.6.8 to 1.0.0 in /materialious Bumps [@vite-pwa/sveltekit](https://github.com/vite-pwa/sveltekit) from 0.6.8 to 1.0.0. - [Release notes](https://github.com/vite-pwa/sveltekit/releases) - [Commits](https://github.com/vite-pwa/sveltekit/compare/v0.6.8...v1.0.0) --- updated-dependencies: - dependency-name: "@vite-pwa/sveltekit" dependency-version: 1.0.0 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <support@github.com> * Update package.json * Dispose of adapter correctly * Minor improvements --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
This commit is contained in:
@@ -7,8 +7,8 @@ android {
|
||||
applicationId "us.materialio.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 151
|
||||
versionName "1.10.10"
|
||||
versionCode 152
|
||||
versionName "1.10.11"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
||||
@@ -68,7 +68,11 @@
|
||||
|
||||
|
||||
|
||||
<release version="1.10.10" date="2025-9-20">
|
||||
|
||||
<release version="1.10.11" date="2025-9-25">
|
||||
<url>https://github.com/Materialious/Materialious/releases/tag/1.10.11</url>
|
||||
</release>
|
||||
<release version="1.10.10" date="2025-9-20">
|
||||
<url>https://github.com/Materialious/Materialious/releases/tag/1.10.10</url>
|
||||
</release>
|
||||
<release version="1.10.9" date="2025-9-19">
|
||||
|
||||
Generated
+6
-6
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "Materialious",
|
||||
"version": "1.10.10",
|
||||
"version": "1.10.11",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "Materialious",
|
||||
"version": "1.10.10",
|
||||
"version": "1.10.11",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@capacitor-community/electron": "^5.0.0",
|
||||
@@ -22,7 +22,7 @@
|
||||
"jsonfile": "^6.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "^38.1.0",
|
||||
"electron": "^38.1.2",
|
||||
"electron-builder": "^26.0.12",
|
||||
"electron-rebuild": "^3.2.9",
|
||||
"typescript": "^5.9.2"
|
||||
@@ -2558,9 +2558,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/electron": {
|
||||
"version": "38.1.0",
|
||||
"resolved": "https://registry.npmjs.org/electron/-/electron-38.1.0.tgz",
|
||||
"integrity": "sha512-ypA8GF8RU4HD5pA1sa0/2U8k+92EPP2c7pX+3XbgB760F7OmqrFXtYkOilVw6HfV4+lk88XxqigmsUKTACQYoQ==",
|
||||
"version": "38.1.2",
|
||||
"resolved": "https://registry.npmjs.org/electron/-/electron-38.1.2.tgz",
|
||||
"integrity": "sha512-WXUcN3W8h8NTTZViA3KNX0rV2YBU0X0mEUM3ubupXTDY4QtIN7tmiqYVOKSKpR2LckTmBWGuEeY4D6xVoffwKQ==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "Materialious",
|
||||
"version": "1.10.10",
|
||||
"version": "1.10.11",
|
||||
"description": "Modern material design for Invidious.",
|
||||
"author": {
|
||||
"name": "Ward Pearce",
|
||||
@@ -35,7 +35,7 @@
|
||||
"jsonfile": "^6.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "^38.1.0",
|
||||
"electron": "^38.1.2",
|
||||
"electron-builder": "^26.0.12",
|
||||
"electron-rebuild": "^3.2.9",
|
||||
"typescript": "^5.9.2"
|
||||
@@ -44,4 +44,4 @@
|
||||
"capacitor",
|
||||
"electron"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,75 +74,91 @@ app.on('activate', async function () {
|
||||
});
|
||||
|
||||
// Place all ipc or other electron api calls and custom functionality under this line
|
||||
ipcMain.handle(
|
||||
'generatePoToken',
|
||||
async (_, bgChallenge: any, requestKey: string, visitorData: string) => {
|
||||
const youtubeUrl = 'https://www.youtube.com/';
|
||||
ipcMain.handle('generatePoToken', async (_, requestKey: string, visitorData: string) => {
|
||||
const youtubeUrl = 'https://www.youtube.com/';
|
||||
|
||||
const dom = new JSDOM(
|
||||
'<!DOCTYPE html><html lang="en"><head><title></title></head><body></body></html>',
|
||||
{
|
||||
url: youtubeUrl,
|
||||
referrer: youtubeUrl,
|
||||
origin: youtubeUrl,
|
||||
USER_AGENT
|
||||
}
|
||||
);
|
||||
|
||||
Object.assign(globalThis, {
|
||||
window: dom.window,
|
||||
document: dom.window.document,
|
||||
location: dom.window.location,
|
||||
origin: dom.window.origin
|
||||
});
|
||||
|
||||
if (!Reflect.has(globalThis, 'navigator')) {
|
||||
Object.defineProperty(globalThis, 'navigator', { value: dom.window.navigator });
|
||||
const dom = new JSDOM(
|
||||
'<!DOCTYPE html><html lang="en"><head><title>YouTube</title></head><body></body></html>',
|
||||
{
|
||||
url: youtubeUrl,
|
||||
referrer: youtubeUrl,
|
||||
origin: youtubeUrl,
|
||||
USER_AGENT
|
||||
}
|
||||
);
|
||||
|
||||
const interpreterUrl =
|
||||
bgChallenge.interpreter_url.private_do_not_access_or_else_trusted_resource_url_wrapped_value;
|
||||
const bgScriptResponse = await fetch(`https:${interpreterUrl}`);
|
||||
const interpreterJavascript = await bgScriptResponse.text();
|
||||
Object.assign(globalThis, {
|
||||
window: dom.window,
|
||||
document: dom.window.document,
|
||||
location: dom.window.location,
|
||||
origin: dom.window.origin
|
||||
});
|
||||
|
||||
if (interpreterJavascript) {
|
||||
new Function(interpreterJavascript)();
|
||||
} else throw new Error('Could not load VM');
|
||||
|
||||
const botguardClient = await BG.BotGuardClient.create({
|
||||
program: bgChallenge.program,
|
||||
globalName: bgChallenge.global_name,
|
||||
globalObj: globalThis
|
||||
});
|
||||
|
||||
const webPoSignalOutput: WebPoSignalOutput = [];
|
||||
const botguardResponse = await botguardClient.snapshot({ webPoSignalOutput });
|
||||
|
||||
const integrityTokenResponse = await fetch(buildURL('GenerateIT', true), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json+protobuf',
|
||||
'x-goog-api-key': GOOG_API_KEY,
|
||||
'x-user-agent': 'grpc-web-javascript/0.1'
|
||||
},
|
||||
body: JSON.stringify([requestKey, botguardResponse])
|
||||
});
|
||||
|
||||
const integrityTokenResponseData = await integrityTokenResponse.json();
|
||||
|
||||
if (typeof integrityTokenResponseData[0] !== 'string')
|
||||
throw new Error('Could not get integrity token');
|
||||
|
||||
const integrityToken = integrityTokenResponseData[0];
|
||||
|
||||
const integrityTokenBasedMinter = await BG.WebPoMinter.create(
|
||||
{ integrityToken },
|
||||
webPoSignalOutput
|
||||
);
|
||||
|
||||
return await integrityTokenBasedMinter.mintAsWebsafeString(decodeURIComponent(visitorData));
|
||||
if (!Reflect.has(globalThis, 'navigator')) {
|
||||
Object.defineProperty(globalThis, 'navigator', { value: dom.window.navigator });
|
||||
}
|
||||
);
|
||||
|
||||
const challengeResponse = await fetch(buildURL('Create', true), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json+protobuf',
|
||||
'x-goog-api-key': GOOG_API_KEY,
|
||||
'x-user-agent': 'grpc-web-javascript/0.1'
|
||||
},
|
||||
body: JSON.stringify([requestKey])
|
||||
});
|
||||
|
||||
const challengeResponseData = await challengeResponse.json();
|
||||
const bgChallenge = BG.Challenge.parseChallengeData(challengeResponseData);
|
||||
|
||||
if (!bgChallenge) throw new Error('Unable to parse challenge data');
|
||||
|
||||
const interpreterJavascript =
|
||||
bgChallenge.interpreterJavascript.privateDoNotAccessOrElseSafeScriptWrappedValue;
|
||||
|
||||
if (!interpreterJavascript) {
|
||||
throw new Error(
|
||||
`Could not get integrity token. Interpreter Hash: ${bgChallenge.interpreterHash}`
|
||||
);
|
||||
}
|
||||
if (interpreterJavascript) {
|
||||
new Function(interpreterJavascript)();
|
||||
} else throw new Error('Could not load VM');
|
||||
|
||||
const botguardClient = await BG.BotGuardClient.create({
|
||||
program: bgChallenge.program,
|
||||
globalName: bgChallenge.globalName,
|
||||
globalObj: globalThis
|
||||
});
|
||||
|
||||
const webPoSignalOutput: WebPoSignalOutput = [];
|
||||
const botguardResponse = await botguardClient.snapshot({ webPoSignalOutput });
|
||||
|
||||
const integrityTokenResponse = await fetch(buildURL('GenerateIT', true), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json+protobuf',
|
||||
'x-goog-api-key': GOOG_API_KEY,
|
||||
'x-user-agent': 'grpc-web-javacript/0.1'
|
||||
},
|
||||
body: JSON.stringify([requestKey, botguardResponse])
|
||||
});
|
||||
|
||||
const integrityTokenResponseData = await integrityTokenResponse.json();
|
||||
const integrityToken = integrityTokenResponseData[0] as string | undefined;
|
||||
|
||||
if (!integrityToken) {
|
||||
throw new Error(
|
||||
`Could not get integrity token. Interpreter Hash: ${bgChallenge.interpreterHash}`
|
||||
);
|
||||
}
|
||||
|
||||
const integrityTokenBasedMinter = await BG.WebPoMinter.create(
|
||||
{ integrityToken },
|
||||
webPoSignalOutput
|
||||
);
|
||||
return await integrityTokenBasedMinter.mintAsWebsafeString(decodeURIComponent(visitorData));
|
||||
});
|
||||
|
||||
ipcMain.handle('setAllowInsecureSSL', async (_, allow) => {
|
||||
allowInsecureSSL = allow;
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { DescrambledChallenge } from 'bgutils-js';
|
||||
|
||||
const { contextBridge, ipcRenderer } = require('electron');
|
||||
require('./rt/electron-rt');
|
||||
|
||||
contextBridge.exposeInMainWorld('electronAPI', {
|
||||
generatePoToken: (bgChallenge: DescrambledChallenge, requestKey: string, visitorData: string) =>
|
||||
ipcRenderer.invoke('generatePoToken', bgChallenge, requestKey, visitorData),
|
||||
generatePoToken: (requestKey: string, visitorData: string) =>
|
||||
ipcRenderer.invoke('generatePoToken', requestKey, visitorData),
|
||||
setAllowInsecureSSL: async (allow: boolean) => {
|
||||
return await ipcRenderer.invoke('setAllowInsecureSSL', allow);
|
||||
},
|
||||
|
||||
Generated
+732
-959
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "materialious",
|
||||
"version": "1.10.10",
|
||||
"version": "1.10.11",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
@@ -27,7 +27,7 @@
|
||||
"@types/mousetrap": "^1.6.15",
|
||||
"@typescript-eslint/eslint-plugin": "^7.16.0",
|
||||
"@typescript-eslint/parser": "^7.18.0",
|
||||
"@vite-pwa/sveltekit": "^0.6.6",
|
||||
"@vite-pwa/sveltekit": "^1.0.0",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-svelte": "^3.11.0",
|
||||
@@ -48,15 +48,15 @@
|
||||
"@capacitor/app": "^7.1.0",
|
||||
"@capacitor/browser": "^7.0.0",
|
||||
"@capacitor/clipboard": "^7.0.0",
|
||||
"@capacitor/core": "^7.0.0",
|
||||
"@capacitor/core": "^7.4.3",
|
||||
"@capacitor/screen-orientation": "^7.0.0",
|
||||
"beercss": "^3.8.0",
|
||||
"bgutils-js": "^3.2.0",
|
||||
"capacitor-nodejs": "https://github.com/hampoelz/capacitor-nodejs/releases/download/v1.0.0-beta.9/capacitor-nodejs.tgz",
|
||||
"fuse.js": "^7.0.0",
|
||||
"googlevideo": "^3.0.0",
|
||||
"googlevideo": "^4.0.4",
|
||||
"he": "^1.2.0",
|
||||
"human-number": "^2.0.4",
|
||||
"human-number": "^2.0.6",
|
||||
"i18next": "^25.5.0",
|
||||
"iso-3166": "^4.3.0",
|
||||
"iso-639-1": "^3.1.5",
|
||||
@@ -70,4 +70,4 @@
|
||||
"svelte-persisted-store": "^0.12.0",
|
||||
"youtubei.js": "^15.1.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+1
-7
@@ -1,7 +1,5 @@
|
||||
// See https://kit.svelte.dev/docs/types#app
|
||||
|
||||
import type { IGetChallengeResponse } from 'youtubei.js';
|
||||
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
@@ -13,11 +11,7 @@ declare global {
|
||||
}
|
||||
interface Window {
|
||||
electronAPI: {
|
||||
generatePoToken: (
|
||||
bgChallenge: IGetChallengeResponse,
|
||||
requestKey: string,
|
||||
visitorData: string
|
||||
) => Promise<string>;
|
||||
generatePoToken: (requestKey: string, visitorData: string) => Promise<string>;
|
||||
setAllowInsecureSSL: (allowInsecureSSL: boolean) => Promoise<boolean>;
|
||||
doUpdateCheck: (disableAutoUpdate: boolean) => Promise<void>;
|
||||
};
|
||||
|
||||
@@ -1,24 +1,45 @@
|
||||
import { BG, buildURL, GOOG_API_KEY, type WebPoSignalOutput } from 'bgutils-js';
|
||||
import type { IBotguardChallenge } from 'youtubei.js';
|
||||
|
||||
export async function androidPoTokenMinter(
|
||||
bgChallenge: IBotguardChallenge,
|
||||
requestKey: string,
|
||||
visitorData: string
|
||||
): Promise<string> {
|
||||
const interpreterUrl =
|
||||
bgChallenge.interpreter_url.private_do_not_access_or_else_trusted_resource_url_wrapped_value;
|
||||
const bgScriptResponse = await fetch(`https:${interpreterUrl}`);
|
||||
const interpreterJavascript = await bgScriptResponse.text();
|
||||
const challengeResponse = await fetch(buildURL('Create', true), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json+protobuf',
|
||||
'x-goog-api-key': GOOG_API_KEY,
|
||||
'x-user-agent': 'grpc-web-javascript/0.1'
|
||||
},
|
||||
body: JSON.stringify([requestKey])
|
||||
});
|
||||
|
||||
if (interpreterJavascript) {
|
||||
new Function(interpreterJavascript)();
|
||||
} else throw new Error('Could not load VM');
|
||||
const challengeResponseData = await challengeResponse.json();
|
||||
const bgChallenge = BG.Challenge.parseChallengeData(challengeResponseData);
|
||||
|
||||
if (!bgChallenge) throw new Error('Unable to parse challenge data');
|
||||
|
||||
const interpreterJavascript =
|
||||
bgChallenge.interpreterJavascript.privateDoNotAccessOrElseSafeScriptWrappedValue;
|
||||
|
||||
if (!interpreterJavascript) {
|
||||
throw new Error(
|
||||
`Could not get integrity token. Interpreter Hash: ${bgChallenge.interpreterHash}`
|
||||
);
|
||||
}
|
||||
|
||||
if (!document.getElementById(bgChallenge.interpreterHash)) {
|
||||
const script = document.createElement('script');
|
||||
script.type = 'text/javascript';
|
||||
script.id = bgChallenge.interpreterHash;
|
||||
script.textContent = interpreterJavascript;
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
|
||||
const botguardClient = await BG.BotGuardClient.create({
|
||||
program: bgChallenge.program,
|
||||
globalName: bgChallenge.global_name,
|
||||
globalObj: globalThis
|
||||
globalObj: globalThis,
|
||||
globalName: bgChallenge.globalName,
|
||||
program: bgChallenge.program
|
||||
});
|
||||
|
||||
const webPoSignalOutput: WebPoSignalOutput = [];
|
||||
@@ -29,22 +50,23 @@ export async function androidPoTokenMinter(
|
||||
headers: {
|
||||
'content-type': 'application/json+protobuf',
|
||||
'x-goog-api-key': GOOG_API_KEY,
|
||||
'x-user-agent': 'grpc-web-javascript/0.1'
|
||||
'x-user-agent': 'grpc-web-javacript/0.1'
|
||||
},
|
||||
body: JSON.stringify([requestKey, botguardResponse])
|
||||
});
|
||||
|
||||
const integrityTokenResponseData = await integrityTokenResponse.json();
|
||||
const integrityToken = integrityTokenResponseData[0] as string | undefined;
|
||||
|
||||
if (typeof integrityTokenResponseData[0] !== 'string')
|
||||
throw new Error('Could not get integrity token');
|
||||
|
||||
const integrityToken = integrityTokenResponseData[0];
|
||||
if (!integrityToken) {
|
||||
throw new Error(
|
||||
`Could not get integrity token. Interpreter Hash: ${bgChallenge.interpreterHash}`
|
||||
);
|
||||
}
|
||||
|
||||
const integrityTokenBasedMinter = await BG.WebPoMinter.create(
|
||||
{ integrityToken },
|
||||
webPoSignalOutput
|
||||
);
|
||||
|
||||
return await integrityTokenBasedMinter.mintAsWebsafeString(decodeURIComponent(visitorData));
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import { ScreenOrientation, type ScreenOrientationResult } from '@capacitor/screen-orientation';
|
||||
import { error, type Page } from '@sveltejs/kit';
|
||||
import { HttpFetchPlugin } from '$lib/sabr/shakaHttpPlugin';
|
||||
import ui from 'beercss';
|
||||
import ISO6391 from 'iso-639-1';
|
||||
import Mousetrap from 'mousetrap';
|
||||
@@ -37,6 +36,7 @@
|
||||
playerStatisticsByDefault,
|
||||
playerYouTubeJsFallback,
|
||||
playlistSettingsStore,
|
||||
poTokenCacheStore,
|
||||
sponsorBlockCategoriesStore,
|
||||
sponsorBlockDisplayToastStore,
|
||||
sponsorBlockStore,
|
||||
@@ -47,7 +47,6 @@
|
||||
themeColorStore
|
||||
} from '../store';
|
||||
import { getDynamicTheme, setStatusBarColor } from '../theme';
|
||||
import { injectSABR } from '$lib/sabr';
|
||||
import { patchYoutubeJs } from '$lib/patches/youtubejs';
|
||||
import { playbackRates } from '$lib/player';
|
||||
import { EndTimeElement } from '$lib/shaka-elements/endTime';
|
||||
@@ -56,6 +55,8 @@
|
||||
import { unsafeRandomItem } from '$lib/misc';
|
||||
import type { PlayerEvents } from '$lib/player';
|
||||
import { dashManifestDomainInclusion } from '$lib/android/youtube/dash';
|
||||
import { injectSabr } from '$lib/sabr';
|
||||
import type { SabrStreamingAdapter } from 'googlevideo/sabr-streaming-adapter';
|
||||
|
||||
interface Props {
|
||||
data: { video: VideoPlay; content: PhasedDescription; playlistId: string | null };
|
||||
@@ -75,11 +76,11 @@
|
||||
let snackBarAlert = $state('');
|
||||
let originalOrigination: ScreenOrientationResult | undefined;
|
||||
let watchProgressTimeout: NodeJS.Timeout;
|
||||
let playerElementResizeObserver: ResizeObserver | undefined;
|
||||
let showVideoRetry = $state(false);
|
||||
|
||||
let player: shaka.Player;
|
||||
let shakaUi: shaka.ui.Overlay;
|
||||
let sabrAdapter: SabrStreamingAdapter | null;
|
||||
|
||||
const STORAGE_KEY_VOLUME = 'shaka-preferred-volume';
|
||||
|
||||
@@ -158,9 +159,8 @@
|
||||
}
|
||||
|
||||
if (selectedTrack) {
|
||||
player.selectVariantTrack(selectedTrack, true);
|
||||
HttpFetchPlugin.cacheManager.clearCache();
|
||||
player.configure({ abr: { enabled: false } });
|
||||
player.selectVariantTrack(selectedTrack, true);
|
||||
} else {
|
||||
player.configure({ abr: { enabled: true } });
|
||||
}
|
||||
@@ -294,8 +294,8 @@
|
||||
|
||||
async function loadVideo() {
|
||||
showVideoRetry = false;
|
||||
// Will inject requirements for SABR if SABR is required.
|
||||
playerElementResizeObserver = injectSABR(player, playerElement as HTMLMediaElement, data.video);
|
||||
|
||||
sabrAdapter = injectSabr(data.video, player);
|
||||
|
||||
try {
|
||||
document.getElementsByClassName('shaka-ad-info')[0].remove();
|
||||
@@ -395,6 +395,7 @@
|
||||
|
||||
restoreQualityPreference();
|
||||
restoreDefaultLanguage();
|
||||
|
||||
if ($playerCCByDefault) {
|
||||
toggleSubtitles();
|
||||
}
|
||||
@@ -424,17 +425,11 @@
|
||||
return;
|
||||
}
|
||||
|
||||
HttpFetchPlugin.cacheManager.clearCache();
|
||||
|
||||
player = new shaka.Player();
|
||||
|
||||
player.configure({
|
||||
abr: {
|
||||
enabled: true,
|
||||
restrictions: {
|
||||
maxWidth: 1920,
|
||||
maxHeight: 1080
|
||||
}
|
||||
enabled: true
|
||||
},
|
||||
streaming: {
|
||||
failureCallback: (error: shaka.util.Error) => {
|
||||
@@ -442,7 +437,7 @@
|
||||
player.retryStreaming(5);
|
||||
},
|
||||
bufferingGoal: 120,
|
||||
rebufferingGoal: 0.01,
|
||||
rebufferingGoal: 2,
|
||||
bufferBehind: 300,
|
||||
retryParameters: {
|
||||
maxAttempts: 8,
|
||||
@@ -456,11 +451,9 @@
|
||||
// Change instantly to stop video from being loud for a second
|
||||
restoreVolumePreference();
|
||||
|
||||
shakaUi = new shaka.ui.Overlay(
|
||||
player,
|
||||
document.getElementById('shaka-container') as HTMLElement,
|
||||
playerElement
|
||||
);
|
||||
const playerContainer = document.getElementById('shaka-container') as HTMLElement;
|
||||
|
||||
shakaUi = new shaka.ui.Overlay(player, playerContainer, playerElement);
|
||||
|
||||
await player.attach(playerElement);
|
||||
|
||||
@@ -471,6 +464,7 @@
|
||||
});
|
||||
|
||||
shakaUi.configure({
|
||||
addBigPlayButton: Capacitor.getPlatform() === 'android',
|
||||
controlPanelElements: [
|
||||
'play_pause',
|
||||
Capacitor.getPlatform() === 'android' ? '' : 'volume',
|
||||
@@ -596,6 +590,17 @@
|
||||
});
|
||||
}
|
||||
|
||||
const volumeContainer = playerContainer.getElementsByClassName('shaka-volume-bar-container');
|
||||
volumeContainer[0]?.addEventListener('mousewheel', (event) => {
|
||||
event.preventDefault();
|
||||
const delta = Math.sign((event as any).deltaY);
|
||||
const newVolume = Math.max(
|
||||
0,
|
||||
Math.min(1, (playerElement as HTMLMediaElement).volume - delta * 0.05)
|
||||
);
|
||||
(playerElement as HTMLMediaElement).volume = newVolume;
|
||||
});
|
||||
|
||||
Mousetrap.bind('c', () => {
|
||||
toggleSubtitles();
|
||||
return false;
|
||||
@@ -781,18 +786,12 @@
|
||||
clearTimeout(watchProgressTimeout);
|
||||
}
|
||||
|
||||
HttpFetchPlugin.cacheManager.clearCache();
|
||||
|
||||
if (playerElementResizeObserver) {
|
||||
playerElementResizeObserver.disconnect();
|
||||
}
|
||||
|
||||
if (playerElement) {
|
||||
playerElement.src = '';
|
||||
playerElement.load();
|
||||
if (sabrAdapter) {
|
||||
sabrAdapter.dispose();
|
||||
}
|
||||
|
||||
if (player) {
|
||||
player.unload();
|
||||
player.destroy();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
.player-theme {
|
||||
font-family: var(--font);
|
||||
color: var(--on-surface);
|
||||
font-family: var(--font) !important;
|
||||
color: var(--on-surface) !important;
|
||||
}
|
||||
|
||||
.player-theme .shaka-video-container * {
|
||||
font-family: var(--font);
|
||||
color: var(--on-surface);
|
||||
font-family: var(--font) !important;
|
||||
color: var(--on-surface) !important;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 650px) {
|
||||
|
||||
@@ -7,7 +7,6 @@ import type {
|
||||
VideoBase,
|
||||
VideoPlay
|
||||
} from '$lib/api/model';
|
||||
import { fromFormat } from '$lib/sabr/formatKeyUtils';
|
||||
import { interfaceRegionStore, poTokenCacheStore } from '$lib/store';
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import BG, { USER_AGENT } from 'bgutils-js';
|
||||
@@ -24,40 +23,32 @@ export async function patchYoutubeJs(videoId: string): Promise<VideoPlay> {
|
||||
cache: new UniversalCache(false),
|
||||
location: get(interfaceRegionStore),
|
||||
user_agent: USER_AGENT,
|
||||
enable_session_cache: false
|
||||
player_id: '0004de42'
|
||||
});
|
||||
|
||||
const visitorData = youtube.session.context.client.visitorData ?? '';
|
||||
|
||||
const requestKey = 'O43z0dpjhgX20SCx4KAo';
|
||||
const challengeResponse = await youtube.getAttestationChallenge('ENGAGEMENT_TYPE_UNBOUND');
|
||||
|
||||
if (!challengeResponse.bg_challenge) throw new Error('Could not get challenge');
|
||||
|
||||
const platformMinter =
|
||||
Capacitor.getPlatform() === 'android'
|
||||
? androidPoTokenMinter
|
||||
: window.electronAPI.generatePoToken;
|
||||
|
||||
const extraArgs: Record<string, any> = {
|
||||
playbackContext: {
|
||||
contentPlaybackContext: {
|
||||
vis: 0,
|
||||
splay: false,
|
||||
lactMilliseconds: '-1',
|
||||
signatureTimestamp: youtube.session.player?.sts
|
||||
}
|
||||
},
|
||||
contentCheckOk: true,
|
||||
racyCheckOk: true
|
||||
};
|
||||
|
||||
const clientPlaybackNonce = Utils.generateRandomString(12);
|
||||
const clientPlaybackNonce = Utils.generateRandomString(16);
|
||||
|
||||
const watchEndpoint = new YTNodes.NavigationEndpoint({ watchEndpoint: { videoId } });
|
||||
const rawPlayerResponse = await watchEndpoint.call(youtube.actions, {
|
||||
...extraArgs,
|
||||
parse: false
|
||||
contentCheckOk: true,
|
||||
racyCheckOk: true,
|
||||
playbackContext: {
|
||||
adPlaybackContext: {
|
||||
pyv: true
|
||||
},
|
||||
contentPlaybackContext: {
|
||||
signatureTimestamp: youtube.session.player?.sts
|
||||
}
|
||||
}
|
||||
});
|
||||
const rawNextResponse = await watchEndpoint.call(youtube.actions, {
|
||||
override_endpoint: '/next'
|
||||
@@ -73,38 +64,39 @@ export async function patchYoutubeJs(videoId: string): Promise<VideoPlay> {
|
||||
}
|
||||
|
||||
if (video.basic_info.is_live) {
|
||||
poTokenCacheStore.set(
|
||||
await platformMinter(challengeResponse.bg_challenge, requestKey, visitorData)
|
||||
);
|
||||
poTokenCacheStore.set(await platformMinter(requestKey, visitorData));
|
||||
} else {
|
||||
poTokenCacheStore.set(BG.PoToken.generateColdStartToken(visitorData));
|
||||
|
||||
platformMinter(challengeResponse.bg_challenge, requestKey, visitorData).then((poToken) =>
|
||||
poTokenCacheStore.set(poToken)
|
||||
);
|
||||
platformMinter(requestKey, visitorData).then((poToken) => poTokenCacheStore.set(poToken));
|
||||
}
|
||||
|
||||
let dashUri: string | undefined;
|
||||
|
||||
if (video.streaming_data) {
|
||||
video.streaming_data.adaptive_formats.forEach((format) => {
|
||||
const formatKey = fromFormat(format) || '';
|
||||
format.url = `https://sabr?___key=${formatKey}`;
|
||||
format.signature_cipher = undefined;
|
||||
format.decipher = () => format.url || '';
|
||||
return format;
|
||||
});
|
||||
const isPostLiveDVR =
|
||||
!!video.basic_info.is_post_live_dvr ||
|
||||
(video.basic_info.is_live_content &&
|
||||
!!(video.streaming_data?.dash_manifest_url || video.streaming_data?.hls_manifest_url));
|
||||
|
||||
if (video.streaming_data) {
|
||||
if (video.basic_info.is_live) {
|
||||
dashUri = video.streaming_data.dash_manifest_url
|
||||
? `${video.streaming_data.dash_manifest_url}/mpd_version/7`
|
||||
: video.streaming_data.hls_manifest_url;
|
||||
} else if (video.streaming_data.dash_manifest_url && video.basic_info.is_post_live_dvr) {
|
||||
} else if (isPostLiveDVR) {
|
||||
dashUri = video.streaming_data.hls_manifest_url
|
||||
? video.streaming_data.hls_manifest_url // HLS is preferred for DVR streams.
|
||||
: `${video.streaming_data.dash_manifest_url}/mpd_version/7`;
|
||||
} else {
|
||||
dashUri = `data:application/dash+xml;base64,${btoa(await video.toDash({ manifest_options: { captions_format: 'vtt' } }))}`;
|
||||
dashUri = `data:application/dash+xml;base64,${btoa(
|
||||
await video.toDash({
|
||||
manifest_options: {
|
||||
is_sabr: true,
|
||||
captions_format: 'vtt',
|
||||
include_thumbnails: false
|
||||
}
|
||||
})
|
||||
)}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,598 @@
|
||||
import shaka from 'shaka-player/dist/shaka-player.ui';
|
||||
|
||||
import {
|
||||
FormatKeyUtils,
|
||||
type CacheManager,
|
||||
type RequestMetadataManager,
|
||||
isGoogleVideoURL
|
||||
} from 'googlevideo/utils';
|
||||
|
||||
import type { SabrFormat } from 'googlevideo/shared-types';
|
||||
|
||||
import {
|
||||
SabrUmpProcessor,
|
||||
type RequestFilter,
|
||||
type ResponseFilter,
|
||||
type SabrPlayerAdapter,
|
||||
type SabrRequestMetadata,
|
||||
type UmpProcessingResult
|
||||
} from 'googlevideo/sabr-streaming-adapter';
|
||||
|
||||
import {
|
||||
asMap,
|
||||
createRecoverableError,
|
||||
headersToGenericObject,
|
||||
makeResponse
|
||||
} from '$lib/sabr/helpers';
|
||||
|
||||
interface ShakaResponseArgs {
|
||||
uri: string;
|
||||
request: shaka.extern.Request;
|
||||
requestType: shaka.net.NetworkingEngine.RequestType;
|
||||
response: Response;
|
||||
arrayBuffer?: Uint8Array | ArrayBuffer;
|
||||
}
|
||||
|
||||
export class ShakaPlayerAdapter implements SabrPlayerAdapter {
|
||||
protected player: shaka.Player | null = null;
|
||||
private requestMetadataManager?: RequestMetadataManager;
|
||||
private cacheManager?: CacheManager;
|
||||
private abortController?: AbortController;
|
||||
|
||||
private requestFilter?: (
|
||||
type: shaka.net.NetworkingEngine.RequestType,
|
||||
request: shaka.extern.Request,
|
||||
context?: shaka.extern.RequestContext
|
||||
) => Promise<void>;
|
||||
private responseFilter?: (
|
||||
type: shaka.net.NetworkingEngine.RequestType,
|
||||
response: shaka.extern.Response,
|
||||
context?: shaka.extern.RequestContext
|
||||
) => Promise<void>;
|
||||
|
||||
public initialize(
|
||||
player: shaka.Player,
|
||||
requestMetadataManager: RequestMetadataManager,
|
||||
cacheManager: CacheManager
|
||||
): void {
|
||||
this.player = player;
|
||||
this.requestMetadataManager = requestMetadataManager;
|
||||
this.cacheManager = cacheManager;
|
||||
|
||||
const networkingEngine = shaka.net.NetworkingEngine;
|
||||
const schemes = ['http', 'https'];
|
||||
|
||||
if (!shaka.net.HttpFetchPlugin.isSupported())
|
||||
throw new Error('The Fetch API is not supported in this browser.');
|
||||
|
||||
schemes.forEach((scheme) => {
|
||||
networkingEngine.registerScheme(
|
||||
scheme,
|
||||
this.parseRequest.bind(this),
|
||||
networkingEngine.PluginPriority.PREFERRED
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private parseRequest(
|
||||
uri: string,
|
||||
request: shaka.extern.Request,
|
||||
requestType: shaka.net.NetworkingEngine.RequestType,
|
||||
progressUpdated: shaka.extern.ProgressUpdated,
|
||||
headersReceived: shaka.extern.HeadersReceived,
|
||||
config: shaka.extern.SchemePluginConfig
|
||||
): shaka.extern.IAbortableOperation<shaka.extern.Response> {
|
||||
const headers = new Headers();
|
||||
asMap(request.headers).forEach((value, key) => {
|
||||
headers.append(key as string, value);
|
||||
});
|
||||
|
||||
const controller = new AbortController();
|
||||
this.abortController = controller;
|
||||
|
||||
const init: RequestInit = {
|
||||
body: (request.body as any) || undefined,
|
||||
headers,
|
||||
method: request.method,
|
||||
signal: this.abortController.signal,
|
||||
credentials: request.allowCrossSiteCredentials ? 'include' : undefined
|
||||
};
|
||||
|
||||
const abortStatus = { canceled: false, timedOut: false };
|
||||
|
||||
const minBytes = config.minBytesForProgressEvents || 0;
|
||||
|
||||
const pendingRequest = this.request(
|
||||
uri,
|
||||
request,
|
||||
requestType,
|
||||
init,
|
||||
controller,
|
||||
abortStatus,
|
||||
progressUpdated,
|
||||
headersReceived,
|
||||
minBytes
|
||||
);
|
||||
|
||||
const operation = new shaka.util.AbortableOperation(pendingRequest, () => {
|
||||
abortStatus.canceled = true;
|
||||
controller.abort();
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
const timeoutMs = request.retryParameters.timeout;
|
||||
if (timeoutMs) {
|
||||
const timer = new shaka.util.Timer(() => {
|
||||
abortStatus.timedOut = true;
|
||||
controller.abort();
|
||||
console.warn('[ShakaPlayerAdapter]', 'Request aborted due to timeout:', uri, requestType);
|
||||
});
|
||||
timer.tickAfter(timeoutMs / 1000);
|
||||
operation.finally(() => timer.stop());
|
||||
}
|
||||
|
||||
return operation;
|
||||
}
|
||||
|
||||
private async handleCachedRequest(
|
||||
requestMetadata: SabrRequestMetadata,
|
||||
uri: string,
|
||||
request: shaka.extern.Request,
|
||||
progressUpdated: shaka.extern.ProgressUpdated,
|
||||
headersReceived: shaka.extern.HeadersReceived,
|
||||
requestType: shaka.net.NetworkingEngine.RequestType
|
||||
): Promise<shaka.extern.Response | null> {
|
||||
if (!requestMetadata.byteRange || !this.cacheManager) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const segmentKey = FormatKeyUtils.createSegmentCacheKeyFromMetadata(requestMetadata);
|
||||
|
||||
let arrayBuffer = (
|
||||
requestMetadata.isInit
|
||||
? this.cacheManager.getInitSegment(segmentKey)
|
||||
: this.cacheManager.getSegment(segmentKey)
|
||||
)?.buffer as ArrayBuffer;
|
||||
|
||||
if (!arrayBuffer) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (requestMetadata.isInit) {
|
||||
arrayBuffer = arrayBuffer.slice(
|
||||
requestMetadata.byteRange.start,
|
||||
requestMetadata.byteRange.end + 1
|
||||
);
|
||||
}
|
||||
|
||||
const headers = {
|
||||
'content-type': requestMetadata.format?.mimeType?.split(';')[0] || '',
|
||||
'content-length': arrayBuffer.byteLength.toString(),
|
||||
'x-shaka-from-cache': 'true'
|
||||
};
|
||||
|
||||
headersReceived(headers);
|
||||
progressUpdated(0, arrayBuffer.byteLength, 0);
|
||||
|
||||
return makeResponse(headers, arrayBuffer, 200, uri, uri, request, requestType);
|
||||
}
|
||||
|
||||
private async handleUmpResponse(
|
||||
response: Response,
|
||||
requestMetadata: SabrRequestMetadata,
|
||||
uri: string,
|
||||
request: shaka.extern.Request,
|
||||
requestType: shaka.net.NetworkingEngine.RequestType,
|
||||
progressUpdated: shaka.extern.ProgressUpdated,
|
||||
abortController: AbortController,
|
||||
minBytes: number
|
||||
): Promise<shaka.extern.Response> {
|
||||
let lastTime = Date.now();
|
||||
|
||||
const sabrUmpReader = new SabrUmpProcessor(requestMetadata, this.cacheManager);
|
||||
|
||||
const checkResultIntegrity = (result: UmpProcessingResult) => {
|
||||
if (
|
||||
!result.data &&
|
||||
(!!requestMetadata.error ||
|
||||
requestMetadata.streamInfo?.streamProtectionStatus?.status === 3) &&
|
||||
!requestMetadata.streamInfo?.sabrContextUpdate
|
||||
) {
|
||||
throw createRecoverableError('Server streaming error', requestMetadata);
|
||||
}
|
||||
};
|
||||
|
||||
const shouldReturnEmptyResponse = () => {
|
||||
return (
|
||||
requestMetadata.isSABR &&
|
||||
(requestMetadata.streamInfo?.redirect || requestMetadata.streamInfo?.sabrContextUpdate)
|
||||
);
|
||||
};
|
||||
|
||||
// Fetch returning a ReadableStream response body is not currently
|
||||
// supported by all browsers.
|
||||
// Browser compatibility:
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API
|
||||
// If it is not supported, returning the whole segment when
|
||||
// it's ready (as xhr)
|
||||
if (!response.body) {
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const currentTime = Date.now();
|
||||
|
||||
progressUpdated(currentTime - lastTime, arrayBuffer.byteLength, 0);
|
||||
|
||||
const result = await sabrUmpReader.processChunk(new Uint8Array(arrayBuffer));
|
||||
|
||||
if (result) {
|
||||
checkResultIntegrity(result);
|
||||
return this.createShakaResponse({
|
||||
uri,
|
||||
request,
|
||||
requestType,
|
||||
response,
|
||||
arrayBuffer: result.data
|
||||
});
|
||||
}
|
||||
|
||||
if (shouldReturnEmptyResponse()) {
|
||||
return this.createShakaResponse({
|
||||
uri,
|
||||
request,
|
||||
requestType,
|
||||
response,
|
||||
arrayBuffer: undefined
|
||||
});
|
||||
}
|
||||
|
||||
throw createRecoverableError('Empty response with no redirect information', requestMetadata);
|
||||
} else {
|
||||
const reader = response.body.getReader();
|
||||
|
||||
let loaded = 0;
|
||||
let lastLoaded = 0;
|
||||
let contentLength;
|
||||
|
||||
while (!abortController.signal.aborted) {
|
||||
let readObj;
|
||||
try {
|
||||
readObj = await reader.read();
|
||||
} catch {
|
||||
// If we abort the request while reading, we'll get an error here. Just ignore it.
|
||||
break;
|
||||
}
|
||||
|
||||
const { value, done } = readObj;
|
||||
|
||||
if (done) {
|
||||
// If we got here, we read the whole response but there was no segment data; it means we must follow a
|
||||
// redirect, or handle protocol updates.
|
||||
if (shouldReturnEmptyResponse()) {
|
||||
return this.createShakaResponse({
|
||||
uri,
|
||||
request,
|
||||
requestType,
|
||||
response,
|
||||
arrayBuffer: undefined
|
||||
});
|
||||
}
|
||||
throw createRecoverableError(
|
||||
'Empty response with no redirect information',
|
||||
requestMetadata
|
||||
);
|
||||
}
|
||||
|
||||
const result = await sabrUmpReader.processChunk(value);
|
||||
|
||||
const segmentInfo = sabrUmpReader.getSegmentInfo();
|
||||
|
||||
if (segmentInfo) {
|
||||
if (!contentLength) {
|
||||
contentLength = segmentInfo.mediaHeader.contentLength;
|
||||
}
|
||||
|
||||
loaded += segmentInfo.lastChunkSize || 0;
|
||||
segmentInfo.lastChunkSize = 0;
|
||||
}
|
||||
|
||||
const currentTime = Date.now();
|
||||
const chunkSize = loaded - lastLoaded;
|
||||
|
||||
// If the time between last time and this time we got
|
||||
// progress event is long enough, or if a whole segment
|
||||
// is downloaded, call progressUpdated().
|
||||
if ((currentTime - lastTime > 100 && chunkSize >= minBytes) || result) {
|
||||
// If we have a result, check its integrity before attempting anything.
|
||||
if (result) checkResultIntegrity(result);
|
||||
if (contentLength) {
|
||||
const numBytesRemaining = result ? 0 : parseInt(contentLength) - loaded;
|
||||
try {
|
||||
progressUpdated(currentTime - lastTime, chunkSize, numBytesRemaining);
|
||||
} catch {
|
||||
/** no-op */
|
||||
} finally {
|
||||
lastLoaded = loaded;
|
||||
lastTime = currentTime;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (result) {
|
||||
abortController.abort();
|
||||
return this.createShakaResponse({
|
||||
uri,
|
||||
request,
|
||||
requestType,
|
||||
response,
|
||||
arrayBuffer: result.data
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Unreachable if the loop is aborted correctly.
|
||||
throw createRecoverableError(
|
||||
'UMP stream processing was aborted but did not produce a result.',
|
||||
requestMetadata
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async request(
|
||||
uri: string,
|
||||
request: shaka.extern.Request,
|
||||
requestType: shaka.net.NetworkingEngine.RequestType,
|
||||
init: RequestInit,
|
||||
abortController: AbortController,
|
||||
abortStatus: { canceled: boolean; timedOut: boolean },
|
||||
progressUpdated: shaka.extern.ProgressUpdated,
|
||||
headersReceived: shaka.extern.HeadersReceived,
|
||||
minBytes: number
|
||||
): Promise<shaka.extern.Response> {
|
||||
try {
|
||||
const requestMetadata = this.requestMetadataManager?.getRequestMetadata(uri);
|
||||
|
||||
// Check the cache first.
|
||||
if (requestMetadata) {
|
||||
const cachedResponse = await this.handleCachedRequest(
|
||||
requestMetadata,
|
||||
uri,
|
||||
request,
|
||||
progressUpdated,
|
||||
headersReceived,
|
||||
requestType
|
||||
);
|
||||
if (cachedResponse) {
|
||||
return cachedResponse;
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(uri, init);
|
||||
headersReceived(headersToGenericObject(response.headers));
|
||||
|
||||
if (
|
||||
requestMetadata &&
|
||||
init.method !== 'HEAD' &&
|
||||
response.headers.get('content-type') === 'application/vnd.yt-ump'
|
||||
) {
|
||||
return this.handleUmpResponse(
|
||||
response,
|
||||
requestMetadata,
|
||||
uri,
|
||||
request,
|
||||
requestType,
|
||||
progressUpdated,
|
||||
abortController,
|
||||
minBytes
|
||||
);
|
||||
}
|
||||
|
||||
// Handle other requests normally.
|
||||
const lastTime = Date.now();
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const currentTime = Date.now();
|
||||
|
||||
progressUpdated(currentTime - lastTime, arrayBuffer.byteLength, 0);
|
||||
|
||||
return this.createShakaResponse({
|
||||
uri,
|
||||
request,
|
||||
requestType,
|
||||
response,
|
||||
arrayBuffer
|
||||
});
|
||||
} catch (error) {
|
||||
if (abortStatus.canceled) {
|
||||
throw new shaka.util.Error(
|
||||
shaka.util.Error.Severity.RECOVERABLE,
|
||||
shaka.util.Error.Category.NETWORK,
|
||||
shaka.util.Error.Code.OPERATION_ABORTED,
|
||||
uri,
|
||||
requestType
|
||||
);
|
||||
} else if (abortStatus.timedOut) {
|
||||
throw new shaka.util.Error(
|
||||
shaka.util.Error.Severity.RECOVERABLE,
|
||||
shaka.util.Error.Category.NETWORK,
|
||||
shaka.util.Error.Code.TIMEOUT,
|
||||
uri,
|
||||
requestType
|
||||
);
|
||||
}
|
||||
throw new shaka.util.Error(
|
||||
shaka.util.Error.Severity.RECOVERABLE,
|
||||
shaka.util.Error.Category.NETWORK,
|
||||
shaka.util.Error.Code.HTTP_ERROR,
|
||||
uri,
|
||||
error,
|
||||
requestType
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public checkPlayerStatus(): asserts this is { player: shaka.Player } & this {
|
||||
if (!this.player) {
|
||||
throw new Error('Player not initialized');
|
||||
}
|
||||
}
|
||||
|
||||
public getPlayerTime() {
|
||||
this.checkPlayerStatus();
|
||||
return this.player.getMediaElement()?.currentTime || 0;
|
||||
}
|
||||
|
||||
public getPlaybackRate() {
|
||||
this.checkPlayerStatus();
|
||||
return this.player.getPlaybackRate();
|
||||
}
|
||||
|
||||
public getBandwidthEstimate() {
|
||||
this.checkPlayerStatus();
|
||||
return this.player.getStats().estimatedBandwidth;
|
||||
}
|
||||
|
||||
public getActiveTrackFormats(
|
||||
activeFormat: SabrFormat,
|
||||
sabrFormats: SabrFormat[]
|
||||
): {
|
||||
videoFormat?: SabrFormat;
|
||||
audioFormat?: SabrFormat;
|
||||
} {
|
||||
this.checkPlayerStatus();
|
||||
|
||||
const activeVariant = this.player
|
||||
.getVariantTracks()
|
||||
.find(
|
||||
(track) =>
|
||||
FormatKeyUtils.getUniqueFormatId(activeFormat) ===
|
||||
(activeFormat.width ? track.originalVideoId : track.originalAudioId)
|
||||
);
|
||||
|
||||
if (!activeVariant) {
|
||||
return { videoFormat: undefined, audioFormat: undefined };
|
||||
}
|
||||
|
||||
const formatMap = new Map(
|
||||
sabrFormats.map((format) => [FormatKeyUtils.getUniqueFormatId(format), format])
|
||||
);
|
||||
|
||||
return {
|
||||
videoFormat: activeVariant.originalVideoId
|
||||
? formatMap.get(activeVariant.originalVideoId)
|
||||
: undefined,
|
||||
audioFormat: activeVariant.originalAudioId
|
||||
? formatMap.get(activeVariant.originalAudioId)
|
||||
: undefined
|
||||
};
|
||||
}
|
||||
|
||||
public registerRequestInterceptor(interceptor: RequestFilter): void {
|
||||
this.checkPlayerStatus();
|
||||
|
||||
const networkingEngine = this.player.getNetworkingEngine();
|
||||
if (!networkingEngine) return;
|
||||
|
||||
this.requestFilter = async (type, request, context) => {
|
||||
if (
|
||||
type !== shaka.net.NetworkingEngine.RequestType.SEGMENT ||
|
||||
!isGoogleVideoURL(request.uris[0])
|
||||
)
|
||||
return;
|
||||
|
||||
const modifiedRequest = await interceptor({
|
||||
headers: request.headers,
|
||||
url: request.uris[0],
|
||||
method: request.method,
|
||||
segment: {
|
||||
getStartTime: () => context?.segment?.getStartTime() ?? null,
|
||||
isInit: () => !context?.segment
|
||||
},
|
||||
body: request.body
|
||||
});
|
||||
|
||||
if (modifiedRequest) {
|
||||
request.uris = modifiedRequest.url ? [modifiedRequest.url] : request.uris;
|
||||
request.method = modifiedRequest.method || request.method;
|
||||
request.headers = modifiedRequest.headers || request.headers;
|
||||
request.body = modifiedRequest.body || request.body;
|
||||
}
|
||||
};
|
||||
|
||||
networkingEngine.registerRequestFilter(this.requestFilter);
|
||||
}
|
||||
|
||||
public registerResponseInterceptor(interceptor: ResponseFilter): void {
|
||||
this.checkPlayerStatus();
|
||||
const networkingEngine = this.player.getNetworkingEngine();
|
||||
if (!networkingEngine) return;
|
||||
|
||||
this.responseFilter = async (type, response, context) => {
|
||||
if (
|
||||
type !== shaka.net.NetworkingEngine.RequestType.SEGMENT ||
|
||||
!isGoogleVideoURL(response.uri)
|
||||
)
|
||||
return;
|
||||
|
||||
const modifiedResponse = await interceptor({
|
||||
url: response.originalRequest.uris[0],
|
||||
method: response.originalRequest.method,
|
||||
headers: response.headers,
|
||||
data: response.data,
|
||||
makeRequest: async (url: string, headers: Record<string, string>) => {
|
||||
const retryParameters = this.player!.getConfiguration().streaming.retryParameters;
|
||||
const redirectRequest = shaka.net.NetworkingEngine.makeRequest([url], retryParameters);
|
||||
Object.assign(redirectRequest.headers, headers);
|
||||
|
||||
const requestOperation = networkingEngine.request(type, redirectRequest, context);
|
||||
const redirectResponse = await requestOperation.promise;
|
||||
|
||||
return {
|
||||
url: redirectResponse.uri,
|
||||
method: redirectResponse.originalRequest.method,
|
||||
headers: redirectResponse.headers,
|
||||
data: redirectResponse.data
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
if (modifiedResponse) {
|
||||
response.data = modifiedResponse.data ?? response.data;
|
||||
Object.assign(response.headers, modifiedResponse.headers);
|
||||
}
|
||||
};
|
||||
|
||||
networkingEngine.registerResponseFilter(this.responseFilter);
|
||||
}
|
||||
|
||||
public createShakaResponse(args: ShakaResponseArgs): shaka.extern.Response {
|
||||
return makeResponse(
|
||||
headersToGenericObject(args.response.headers),
|
||||
(args.arrayBuffer as any) || new ArrayBuffer(0),
|
||||
args.response.status,
|
||||
args.uri,
|
||||
args.response.url,
|
||||
args.request,
|
||||
args.requestType
|
||||
);
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
if (this.abortController) {
|
||||
this.abortController.abort();
|
||||
this.abortController = undefined;
|
||||
}
|
||||
|
||||
if (this.player) {
|
||||
const networkingEngine = this.player.getNetworkingEngine();
|
||||
|
||||
if (networkingEngine && this.requestFilter && this.responseFilter) {
|
||||
networkingEngine.unregisterRequestFilter(this.requestFilter);
|
||||
networkingEngine.unregisterResponseFilter(this.responseFilter);
|
||||
}
|
||||
|
||||
shaka.net.NetworkingEngine.unregisterScheme('http');
|
||||
shaka.net.NetworkingEngine.unregisterScheme('https');
|
||||
|
||||
this.player = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
import type shaka from 'shaka-player/dist/shaka-player.ui';
|
||||
import type { Misc } from 'youtubei.js';
|
||||
|
||||
import type { SabrStreamingContext } from './shakaHttpPlugin';
|
||||
import { HttpFetchPlugin } from './shakaHttpPlugin';
|
||||
import { createSegmentCacheKey, createSegmentCacheKeyFromContext } from './formatKeyUtils';
|
||||
import type { Segment } from './sabrUmpParser';
|
||||
|
||||
/**
|
||||
* Caches a segment with its associated format
|
||||
* @param segment - The segment to cache
|
||||
* @param format - The format object
|
||||
*/
|
||||
export function cacheSegment(segment: Segment, format: Misc.Format) {
|
||||
const isInit = segment.mediaHeader.isInitSeg;
|
||||
const segmentKey = createSegmentCacheKey(segment.mediaHeader, isInit, format);
|
||||
|
||||
if (isInit) {
|
||||
HttpFetchPlugin.cacheManager.setInitSegment(
|
||||
segmentKey,
|
||||
segment.data as Uint8Array<ArrayBuffer>
|
||||
);
|
||||
} else {
|
||||
HttpFetchPlugin.cacheManager.setSegment(segmentKey, segment.data as Uint8Array<ArrayBuffer>);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a cached segment based on streaming context
|
||||
* @param decodedStreamingContext - The SABR streaming context
|
||||
* @param request - The original request.
|
||||
* @param requestType - The Shaka request type
|
||||
* @param uri - The request URI
|
||||
* @returns A Shaka response object or undefined if not found
|
||||
*/
|
||||
export function retrieveCachedSegment(
|
||||
decodedStreamingContext: SabrStreamingContext,
|
||||
request: shaka.extern.Request,
|
||||
requestType: shaka.net.NetworkingEngine.RequestType,
|
||||
uri: string
|
||||
): shaka.extern.Response | null {
|
||||
if (!decodedStreamingContext.byteRange || !decodedStreamingContext.format) return null;
|
||||
|
||||
const segmentKey = createSegmentCacheKeyFromContext({
|
||||
...decodedStreamingContext,
|
||||
isInit: decodedStreamingContext.isInit
|
||||
});
|
||||
|
||||
let arrayBuffer: ArrayBuffer | undefined;
|
||||
|
||||
arrayBuffer = (
|
||||
decodedStreamingContext.isInit
|
||||
? HttpFetchPlugin.cacheManager.getInitSegment(segmentKey)
|
||||
: HttpFetchPlugin.cacheManager.getSegment(segmentKey)
|
||||
)?.buffer;
|
||||
|
||||
if (arrayBuffer) {
|
||||
if (decodedStreamingContext.isInit) {
|
||||
arrayBuffer = arrayBuffer.slice(
|
||||
decodedStreamingContext.byteRange.start,
|
||||
decodedStreamingContext.byteRange.end + 1
|
||||
);
|
||||
}
|
||||
|
||||
const headers = HttpFetchPlugin.headersToGenericObject_(new Headers());
|
||||
headers['X-Streaming-Context'] = btoa(JSON.stringify(decodedStreamingContext));
|
||||
headers['content-type'] = decodedStreamingContext.format.mime_type.split(';')[0];
|
||||
headers['content-length'] = arrayBuffer.byteLength.toString();
|
||||
|
||||
return HttpFetchPlugin.makeResponse(headers, arrayBuffer, 200, uri, uri, request, requestType);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
export interface CacheEntry {
|
||||
data: Uint8Array<ArrayBuffer>;
|
||||
timestamp: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A "proper" cache for storing segments.
|
||||
*/
|
||||
export class CacheManager {
|
||||
private initSegmentCache = new Map<string, CacheEntry>();
|
||||
private segmentCache = new Map<string, CacheEntry>();
|
||||
private currentSize = 0;
|
||||
private readonly maxCacheSize: number;
|
||||
private readonly maxAge: number;
|
||||
|
||||
constructor(maxSizeMB = 50, maxAgeSeconds = 300) {
|
||||
this.maxCacheSize = maxSizeMB * 1024 * 1024;
|
||||
this.maxAge = maxAgeSeconds * 1000;
|
||||
this.startGarbageCollection();
|
||||
}
|
||||
|
||||
public setInitSegment(key: string, data: Uint8Array<ArrayBuffer>): void {
|
||||
const entry: CacheEntry = {
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
size: data.byteLength
|
||||
};
|
||||
|
||||
if (!this.initSegmentCache.has(key)) {
|
||||
this.currentSize += entry.size;
|
||||
this.enforceStorageLimit();
|
||||
}
|
||||
|
||||
this.initSegmentCache.set(key, entry);
|
||||
}
|
||||
|
||||
public setSegment(key: string, data: Uint8Array<ArrayBuffer>): void {
|
||||
const entry: CacheEntry = {
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
size: data.byteLength
|
||||
};
|
||||
|
||||
this.currentSize += entry.size;
|
||||
this.enforceStorageLimit();
|
||||
this.segmentCache.set(key, entry);
|
||||
}
|
||||
|
||||
public getInitSegment(key: string): Uint8Array<ArrayBuffer> | undefined {
|
||||
const entry = this.initSegmentCache.get(key);
|
||||
|
||||
if (entry && !this.isExpired(entry)) {
|
||||
entry.timestamp = Date.now(); // Update last access time
|
||||
return entry.data;
|
||||
}
|
||||
|
||||
// Expired. Get rid of it.
|
||||
if (entry) {
|
||||
this.initSegmentCache.delete(key);
|
||||
this.currentSize -= entry.size;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
public getSegment(key: string): Uint8Array<ArrayBuffer> | undefined {
|
||||
const entry = this.segmentCache.get(key);
|
||||
if (entry && !this.isExpired(entry)) {
|
||||
const data = entry.data;
|
||||
this.segmentCache.delete(key);
|
||||
this.currentSize -= entry.size;
|
||||
return data;
|
||||
}
|
||||
|
||||
// Expired. Get rid of it.
|
||||
if (entry) {
|
||||
this.segmentCache.delete(key);
|
||||
this.currentSize -= entry.size;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private isExpired(entry: CacheEntry): boolean {
|
||||
return Date.now() - entry.timestamp > this.maxAge;
|
||||
}
|
||||
|
||||
private enforceStorageLimit(): void {
|
||||
if (this.currentSize <= this.maxCacheSize) return;
|
||||
|
||||
this.clearExpiredEntries();
|
||||
|
||||
// If still over limit, remove oldest entries
|
||||
if (this.currentSize > this.maxCacheSize) {
|
||||
this.removeOldestEntries();
|
||||
}
|
||||
}
|
||||
|
||||
private clearExpiredEntries(): void {
|
||||
const now = Date.now();
|
||||
|
||||
for (const [key, entry] of this.segmentCache.entries()) {
|
||||
if (now - entry.timestamp > this.maxAge) {
|
||||
this.segmentCache.delete(key);
|
||||
this.currentSize -= entry.size;
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, entry] of this.initSegmentCache.entries()) {
|
||||
if (now - entry.timestamp > this.maxAge) {
|
||||
this.initSegmentCache.delete(key);
|
||||
this.currentSize -= entry.size;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private removeOldestEntries(): void {
|
||||
const segments = Array.from(this.segmentCache.entries());
|
||||
const initSegments = Array.from(this.initSegmentCache.entries());
|
||||
|
||||
const allEntries = [...segments, ...initSegments].sort(
|
||||
(a, b) => a[1].timestamp - b[1].timestamp
|
||||
);
|
||||
|
||||
// Remove oldest entries until under limit
|
||||
while (this.currentSize > this.maxCacheSize && allEntries.length > 0) {
|
||||
const [key, entry] = allEntries.shift()!;
|
||||
this.segmentCache.delete(key);
|
||||
this.initSegmentCache.delete(key);
|
||||
this.currentSize -= entry.size;
|
||||
}
|
||||
}
|
||||
|
||||
private startGarbageCollection(): void {
|
||||
// Should there be a way to stop this? :p
|
||||
setInterval(() => {
|
||||
this.clearExpiredEntries();
|
||||
}, 60000);
|
||||
}
|
||||
|
||||
public clearCache(): void {
|
||||
this.initSegmentCache.clear();
|
||||
this.segmentCache.clear();
|
||||
this.currentSize = 0;
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import type { Misc } from 'youtubei.js/web';
|
||||
import type { Protos } from 'googlevideo';
|
||||
import type { SabrStreamingContext } from './shakaHttpPlugin';
|
||||
|
||||
/**
|
||||
* Creates a format key based on itag and xtags
|
||||
* @param itag - The itag value
|
||||
* @param xtags - The xtags value (optional)
|
||||
* @returns A string format key
|
||||
*/
|
||||
export function createKey(itag: number | undefined, xtags: string | undefined): string {
|
||||
return `${itag || ''}:${xtags || ''}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a format key from a Format object
|
||||
* @param format - The format object
|
||||
* @returns A string format key or undefined if format is undefined
|
||||
*/
|
||||
export function fromFormat(format: Misc.Format | undefined): string | undefined {
|
||||
if (!format) return undefined;
|
||||
return createKey(format.itag, format.xtags);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a format key from a MediaHeader object
|
||||
* @param mediaHeader - The MediaHeader object
|
||||
* @returns A string format key
|
||||
*/
|
||||
export function fromMediaHeader(mediaHeader: Protos.MediaHeader): string {
|
||||
return createKey(mediaHeader.itag, mediaHeader.xtags);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a format key from FormatInitializationMetadata
|
||||
* @param formatInitMetadata - The FormatInitializationMetadata object
|
||||
* @returns A string format key or undefined if formatId is undefined
|
||||
*/
|
||||
export function fromFormatInitializationMetadata(
|
||||
formatInitMetadata: Protos.FormatInitializationMetadata
|
||||
): string {
|
||||
if (!formatInitMetadata.formatId) return '';
|
||||
return createKey(formatInitMetadata.formatId.itag, formatInitMetadata.formatId.xtags);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a segment cache key
|
||||
* @param mediaHeader - The MediaHeader object
|
||||
* @param isInit - Whether it's an initialization segment
|
||||
* @param format - Format object (needed for init segments)
|
||||
* @returns A string key for caching segments
|
||||
*/
|
||||
export function createSegmentCacheKey(
|
||||
mediaHeader: Protos.MediaHeader,
|
||||
isInit?: boolean,
|
||||
format?: Misc.Format
|
||||
): string {
|
||||
if (isInit && format) {
|
||||
return `${mediaHeader.itag}:${mediaHeader.xtags || ''}:${format.content_length || ''}:${format.mime_type || ''}`;
|
||||
}
|
||||
// @ts-expect-error Invalid typing
|
||||
return `${mediaHeader.startRange || '0'}-${mediaHeader.itag}-${mediaHeader.xtags || ''}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a segment cache key from a SabrStreamingContext object
|
||||
* @param context - The SabrStreamingContext object
|
||||
* @returns A string key for caching segments
|
||||
*/
|
||||
export function createSegmentCacheKeyFromContext(context: SabrStreamingContext): string {
|
||||
if (!context.byteRange || !context.format)
|
||||
throw new Error('Invalid context: byteRange or format is missing');
|
||||
|
||||
const pseudoMediaHeader = {
|
||||
itag: context.format.itag,
|
||||
xtags: context.format.xtags || '',
|
||||
startDataRange: context.byteRange.start,
|
||||
isInitSeg: context.isInit
|
||||
};
|
||||
|
||||
return createSegmentCacheKey(
|
||||
pseudoMediaHeader as any,
|
||||
!!context.isInit,
|
||||
context.isInit ? context.format : undefined
|
||||
);
|
||||
}
|
||||
|
||||
export function getUniqueFormatId(format: Misc.Format) {
|
||||
if (format.has_video) return format.itag.toString();
|
||||
|
||||
const uid_parts = [format.itag.toString()];
|
||||
|
||||
if (format.audio_track) {
|
||||
uid_parts.push(format.audio_track.id);
|
||||
}
|
||||
|
||||
if (format.is_drc) {
|
||||
uid_parts.push('drc');
|
||||
}
|
||||
|
||||
return uid_parts.join('-');
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import shaka from 'shaka-player/dist/shaka-player.ui';
|
||||
|
||||
export function asMap<K, V>(object: Record<string, V>): Map<K, V> {
|
||||
const map = new Map<K, V>();
|
||||
for (const key of Object.keys(object)) {
|
||||
map.set(key as K, object[key]);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
export function createRecoverableError(message: string, info?: Record<string, any>) {
|
||||
return new shaka.util.Error(
|
||||
shaka.util.Error.Severity.RECOVERABLE,
|
||||
shaka.util.Error.Category.NETWORK,
|
||||
shaka.util.Error.Code.HTTP_ERROR,
|
||||
message,
|
||||
{ info }
|
||||
);
|
||||
}
|
||||
|
||||
export function headersToGenericObject(headers: Headers): Record<string, string> {
|
||||
const headersObj: Record<string, string> = {};
|
||||
headers.forEach((value, key) => {
|
||||
// Since Edge incorrectly returns the header with a leading new line
|
||||
// character ('\n'), we trim the header here.
|
||||
headersObj[key.trim()] = value;
|
||||
});
|
||||
return headersObj;
|
||||
}
|
||||
|
||||
export function makeResponse(
|
||||
headers: Record<string, string>,
|
||||
data: BufferSource,
|
||||
status: number,
|
||||
uri: string,
|
||||
responseURL: string,
|
||||
request: shaka.extern.Request,
|
||||
requestType: shaka.net.NetworkingEngine.RequestType
|
||||
): shaka.extern.Response & { originalRequest: shaka.extern.Request } {
|
||||
if (status >= 200 && status <= 299 && status !== 202) {
|
||||
return {
|
||||
uri: responseURL || uri,
|
||||
originalUri: uri,
|
||||
data,
|
||||
status,
|
||||
headers,
|
||||
originalRequest: request,
|
||||
fromCache: !!headers['x-shaka-from-cache']
|
||||
};
|
||||
}
|
||||
|
||||
let responseText: string | null = null;
|
||||
try {
|
||||
responseText = shaka.util.StringUtils.fromBytesAutoDetect(data);
|
||||
} catch {
|
||||
/* no-op */
|
||||
}
|
||||
|
||||
const severity =
|
||||
status === 401 || status === 403
|
||||
? shaka.util.Error.Severity.CRITICAL
|
||||
: shaka.util.Error.Severity.RECOVERABLE;
|
||||
|
||||
throw new shaka.util.Error(
|
||||
severity,
|
||||
shaka.util.Error.Category.NETWORK,
|
||||
shaka.util.Error.Code.BAD_HTTP_STATUS,
|
||||
uri,
|
||||
status,
|
||||
responseText,
|
||||
headers,
|
||||
requestType,
|
||||
responseURL || uri
|
||||
);
|
||||
}
|
||||
@@ -1,428 +1,96 @@
|
||||
import shaka from 'shaka-player/dist/shaka-player.ui';
|
||||
import { base64ToU8, Protos } from 'googlevideo';
|
||||
import { Constants, type Misc } from 'youtubei.js';
|
||||
import { HttpFetchPlugin, type SabrStreamingContext } from '$lib/sabr/shakaHttpPlugin';
|
||||
import {
|
||||
fromFormat,
|
||||
fromFormatInitializationMetadata,
|
||||
fromMediaHeader,
|
||||
getUniqueFormatId
|
||||
} from '$lib/sabr/formatKeyUtils';
|
||||
import type { VideoPlay } from '$lib/api/model';
|
||||
import { poTokenCacheStore } from '$lib/store';
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import { SabrStreamingAdapter } from 'googlevideo/sabr-streaming-adapter';
|
||||
import type shaka from 'shaka-player/dist/shaka-player.ui';
|
||||
import { ShakaPlayerAdapter } from './ShakaPlayerAdapter';
|
||||
import { Constants, YT } from 'youtubei.js';
|
||||
import { get } from 'svelte/store';
|
||||
import { poTokenCacheStore } from '$lib/store';
|
||||
import { buildSabrFormat } from 'googlevideo/utils';
|
||||
|
||||
export function injectSABR(
|
||||
player: shaka.Player,
|
||||
playerElement: HTMLMediaElement,
|
||||
video: VideoPlay
|
||||
): ResizeObserver | undefined {
|
||||
if (!video.ytjs) return;
|
||||
export function injectSabr(video: VideoPlay, player: shaka.Player): SabrStreamingAdapter | null {
|
||||
if (!video.ytjs || !Capacitor.isNativePlatform()) return null;
|
||||
|
||||
shaka.net.NetworkingEngine.registerScheme(
|
||||
'http',
|
||||
HttpFetchPlugin.parse,
|
||||
shaka.net.NetworkingEngine.PluginPriority.PREFERRED
|
||||
);
|
||||
shaka.net.NetworkingEngine.registerScheme(
|
||||
'https',
|
||||
HttpFetchPlugin.parse,
|
||||
shaka.net.NetworkingEngine.PluginPriority.PREFERRED
|
||||
);
|
||||
|
||||
const sessionId = Array.from(Array(16), () => Math.floor(Math.random() * 36).toString(36)).join(
|
||||
''
|
||||
);
|
||||
let videoPlaybackUstreamerConfig: string | undefined;
|
||||
let serverAbrStreamingUrl: URL | undefined = undefined;
|
||||
let drmParams: string | undefined;
|
||||
const lastRequestMs = 0;
|
||||
let lastSeekMs = 0;
|
||||
let lastManualFormatSelectionMs = 0;
|
||||
let lastActionMs = 0;
|
||||
let clientViewportHeight = playerElement?.clientHeight || 0;
|
||||
let clientViewportWidth = playerElement?.clientWidth || 0;
|
||||
let lastPlaybackCookie: Protos.PlaybackCookie | undefined;
|
||||
const initializedFormats = new Map<
|
||||
string,
|
||||
{
|
||||
lastSegmentMetadata?: {
|
||||
formatId: Protos.FormatId;
|
||||
startTimeMs: number;
|
||||
startSequenceNumber: number;
|
||||
endSequenceNumber: number;
|
||||
durationMs: number;
|
||||
};
|
||||
formatInitializationMetadata: Protos.FormatInitializationMetadata;
|
||||
}
|
||||
>();
|
||||
let formatList: Misc.Format[] = [];
|
||||
|
||||
const playerElementResizeObserver = new ResizeObserver(() => {
|
||||
if (playerElement) {
|
||||
clientViewportHeight = playerElement.clientHeight;
|
||||
clientViewportWidth = playerElement.clientWidth;
|
||||
const sabrAdapter = new SabrStreamingAdapter({
|
||||
playerAdapter: new ShakaPlayerAdapter(),
|
||||
clientInfo: {
|
||||
osName: video.ytjs.innertube.session.context.client.osName,
|
||||
osVersion: video.ytjs.innertube.session.context.client.osVersion,
|
||||
clientName: parseInt(
|
||||
Constants.CLIENT_NAME_IDS[
|
||||
video.ytjs.innertube.session.context.client
|
||||
.clientName as keyof typeof Constants.CLIENT_NAME_IDS
|
||||
]
|
||||
),
|
||||
clientVersion: video.ytjs.innertube.session.context.client.clientVersion
|
||||
}
|
||||
});
|
||||
|
||||
playerElementResizeObserver?.observe(playerElement);
|
||||
sabrAdapter.onMintPoToken(async () => {
|
||||
return get(poTokenCacheStore) || '';
|
||||
});
|
||||
|
||||
if (
|
||||
video.ytjs.rawApiResponse.data.streamingData &&
|
||||
(video.ytjs.rawApiResponse.data.streamingData as any).drmParams
|
||||
) {
|
||||
drmParams = (video.ytjs.rawApiResponse.data.streamingData as any).drmParams;
|
||||
player.configure({
|
||||
drm: {
|
||||
servers: {
|
||||
'com.widevine.alpha':
|
||||
'https://www.youtube.com/youtubei/v1/player/get_drm_license?alt=json'
|
||||
}
|
||||
sabrAdapter.onReloadPlayerResponse(async (reloadContext) => {
|
||||
console.log('[SABR]', 'Reloading player response...');
|
||||
|
||||
if (!video.ytjs) return;
|
||||
|
||||
const reloadedInfo = await video.ytjs.innertube.actions.execute('/player', {
|
||||
videoId: video.videoId,
|
||||
contentCheckOk: true,
|
||||
racyCheckOk: true,
|
||||
playbackContext: {
|
||||
adPlaybackContext: {
|
||||
pyv: true
|
||||
},
|
||||
contentPlaybackContext: {
|
||||
signatureTimestamp: video.ytjs.innertube.session.player?.sts
|
||||
},
|
||||
reloadPlaybackContext: reloadContext
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
videoPlaybackUstreamerConfig =
|
||||
video.ytjs.video.page[0].player_config?.media_common_config.media_ustreamer_request_config
|
||||
?.video_playback_ustreamer_config;
|
||||
|
||||
if (video.ytjs.video.streaming_data) {
|
||||
formatList = video.ytjs.video.streaming_data.adaptive_formats;
|
||||
}
|
||||
|
||||
if (video.ytjs.video.streaming_data?.server_abr_streaming_url)
|
||||
serverAbrStreamingUrl = new URL(
|
||||
const parsedInfo = new YT.VideoInfo(
|
||||
[reloadedInfo],
|
||||
video.ytjs.innertube.actions,
|
||||
video.ytjs.video.cpn
|
||||
);
|
||||
sabrAdapter.setStreamingURL(
|
||||
video.ytjs.innertube.session.player!.decipher(
|
||||
video.ytjs.video.streaming_data.server_abr_streaming_url
|
||||
parsedInfo.streaming_data?.server_abr_streaming_url
|
||||
)
|
||||
);
|
||||
|
||||
playerElement?.addEventListener('seeked', () => (lastSeekMs = Date.now()));
|
||||
|
||||
player?.addEventListener('variantchanged', (event) => {
|
||||
// Technically, all variant changes here are manual, given we don't handle ABR updates from the server.
|
||||
if (event.type !== 'variant') {
|
||||
lastManualFormatSelectionMs = Date.now();
|
||||
}
|
||||
|
||||
lastActionMs = Date.now();
|
||||
sabrAdapter.setUstreamerConfig(
|
||||
video.ytjs.video.player_config?.media_common_config.media_ustreamer_request_config
|
||||
?.video_playback_ustreamer_config
|
||||
);
|
||||
});
|
||||
|
||||
const networkingEngine = player.getNetworkingEngine();
|
||||
const isLive = video.ytjs.video.basic_info.is_live;
|
||||
const isPostLiveDVR =
|
||||
!!video.ytjs.video.basic_info.is_post_live_dvr ||
|
||||
(video.ytjs.video.basic_info.is_live_content &&
|
||||
!!(
|
||||
video.ytjs.video.streaming_data?.dash_manifest_url ||
|
||||
video.ytjs.video.streaming_data?.hls_manifest_url
|
||||
));
|
||||
|
||||
if (!networkingEngine) return;
|
||||
if (video.ytjs.video.streaming_data && !isPostLiveDVR && !isLive) {
|
||||
sabrAdapter.setStreamingURL(
|
||||
video.ytjs.innertube.session.player!.decipher(
|
||||
video.ytjs.video.streaming_data?.server_abr_streaming_url
|
||||
)
|
||||
);
|
||||
sabrAdapter.setUstreamerConfig(
|
||||
video.ytjs.video.player_config?.media_common_config.media_ustreamer_request_config
|
||||
?.video_playback_ustreamer_config
|
||||
);
|
||||
sabrAdapter.setServerAbrFormats(
|
||||
video.ytjs.video.streaming_data.adaptive_formats.map(buildSabrFormat)
|
||||
);
|
||||
}
|
||||
|
||||
// Based off the following
|
||||
// https://github.com/FreeTubeApp/FreeTube/blob/d270c9e251a433f1e4246a3f6a37acef707d22aa/src/renderer/components/ft-shaka-video-player/ft-shaka-video-player.js#L1206
|
||||
// https://github.com/LuanRT/BgUtils/blob/6b121166be1ccb0b952dee1bdac488808365ae6b/examples/browser/web/src/main.ts#L293
|
||||
// https://github.com/LuanRT/yt-sabr-shaka-demo/blob/main/src/components/VideoPlayer.vue
|
||||
networkingEngine.registerRequestFilter(async (type, request, context) => {
|
||||
if (!player) return;
|
||||
sabrAdapter.attach(player);
|
||||
|
||||
const originalUrl = new URL(request.uris[0]);
|
||||
const url = originalUrl.hostname === 'sabr' ? serverAbrStreamingUrl : originalUrl;
|
||||
const headers = request.headers;
|
||||
|
||||
if (!url) return;
|
||||
|
||||
if (
|
||||
type === shaka.net.NetworkingEngine.RequestType.SEGMENT &&
|
||||
url.pathname.includes('videoplayback')
|
||||
) {
|
||||
if (!video.liveNow && !video.ytjs?.video.basic_info.is_post_live_dvr) {
|
||||
const currentFormat = formatList.find(
|
||||
(format) =>
|
||||
fromFormat(format) === (new URL(request.uris[0]).searchParams.get('___key') || '')
|
||||
);
|
||||
|
||||
if (!videoPlaybackUstreamerConfig) throw new Error('Ustreamer config not found.');
|
||||
|
||||
if (!currentFormat) throw new Error('No format found for SABR request.');
|
||||
|
||||
if (!playerElement) throw new Error('No video element found.');
|
||||
|
||||
const activeVariant = player
|
||||
.getVariantTracks()
|
||||
.find(
|
||||
(track) =>
|
||||
getUniqueFormatId(currentFormat) ===
|
||||
(currentFormat.has_video ? track.originalVideoId : track.originalAudioId)
|
||||
);
|
||||
|
||||
let videoFormat: Misc.Format | undefined;
|
||||
let audioFormat: Misc.Format | undefined;
|
||||
let videoFormatId: Protos.FormatId | undefined;
|
||||
let audioFormatId: Protos.FormatId | undefined;
|
||||
|
||||
if (activeVariant) {
|
||||
for (const fmt of formatList) {
|
||||
const uniqueFormatId = getUniqueFormatId(fmt);
|
||||
if (uniqueFormatId === activeVariant.originalVideoId) {
|
||||
videoFormat = fmt;
|
||||
} else if (uniqueFormatId === activeVariant.originalAudioId) {
|
||||
audioFormat = fmt;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (videoFormat) {
|
||||
videoFormatId = {
|
||||
itag: videoFormat.itag,
|
||||
lastModified: parseInt(videoFormat.last_modified_ms),
|
||||
xtags: videoFormat.xtags
|
||||
};
|
||||
}
|
||||
|
||||
if (audioFormat) {
|
||||
audioFormatId = {
|
||||
itag: audioFormat.itag,
|
||||
lastModified: parseInt(audioFormat.last_modified_ms),
|
||||
xtags: audioFormat.xtags
|
||||
};
|
||||
}
|
||||
|
||||
const isInit = context ? !context.segment : true;
|
||||
|
||||
const videoPlaybackAbrRequest: Protos.VideoPlaybackAbrRequest = {
|
||||
clientAbrState: {
|
||||
playbackRate: player.getPlaybackRate(),
|
||||
playerTimeMs: Math.round(
|
||||
(context?.segment?.getStartTime() ?? playerElement.currentTime) * 1000
|
||||
),
|
||||
elapsedWallTimeMs: Date.now() - lastRequestMs,
|
||||
timeSinceLastSeek: lastSeekMs === 0 ? 0 : Date.now() - lastSeekMs,
|
||||
timeSinceLastActionMs: lastActionMs === 0 ? 0 : Date.now() - lastActionMs,
|
||||
timeSinceLastManualFormatSelectionMs:
|
||||
lastManualFormatSelectionMs === 0 ? 0 : Date.now() - lastManualFormatSelectionMs,
|
||||
clientViewportIsFlexible: false,
|
||||
bandwidthEstimate: Math.round(player.getStats().estimatedBandwidth),
|
||||
drcEnabled: currentFormat.is_drc === true,
|
||||
enabledTrackTypesBitfield: currentFormat.has_audio ? 1 : 2,
|
||||
clientViewportHeight,
|
||||
clientViewportWidth
|
||||
},
|
||||
bufferedRanges: [],
|
||||
selectedFormatIds: [],
|
||||
selectedAudioFormatIds: [audioFormatId || {}],
|
||||
selectedVideoFormatIds: [videoFormatId || {}],
|
||||
videoPlaybackUstreamerConfig: base64ToU8(videoPlaybackUstreamerConfig),
|
||||
streamerContext: {
|
||||
poToken: base64ToU8(get(poTokenCacheStore) ?? ''),
|
||||
playbackCookie: lastPlaybackCookie
|
||||
? Protos.PlaybackCookie.encode(lastPlaybackCookie).finish()
|
||||
: undefined,
|
||||
clientInfo: {
|
||||
clientName: parseInt(Constants.CLIENT_NAME_IDS.WEB),
|
||||
clientVersion: video.ytjs?.innertube.session.context.client.clientVersion,
|
||||
osName: 'Windows',
|
||||
osVersion: '10.0'
|
||||
},
|
||||
field5: [],
|
||||
field6: []
|
||||
},
|
||||
field1000: []
|
||||
};
|
||||
|
||||
// Normalize the resolution.
|
||||
if (currentFormat.width && currentFormat.height) {
|
||||
let resolution = currentFormat.height;
|
||||
|
||||
const aspectRatio = currentFormat.height / currentFormat.width;
|
||||
|
||||
if (aspectRatio > 16 / 9) {
|
||||
resolution = Math.round((currentFormat.width * 9) / 16);
|
||||
}
|
||||
|
||||
if (resolution && videoPlaybackAbrRequest.clientAbrState) {
|
||||
videoPlaybackAbrRequest.clientAbrState.stickyResolution = resolution;
|
||||
videoPlaybackAbrRequest.clientAbrState.lastManualSelectedResolution = resolution;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isInit) {
|
||||
// Add the currently/previously active formats to the list of buffered ranges and selected formats
|
||||
// so that the server doesn't send its init data again.
|
||||
const initializedFormatsArray = Array.from(initializedFormats.values());
|
||||
|
||||
for (const initializedFormat of initializedFormatsArray) {
|
||||
if (initializedFormat.lastSegmentMetadata) {
|
||||
videoPlaybackAbrRequest.bufferedRanges.push({
|
||||
formatId: initializedFormat.lastSegmentMetadata.formatId,
|
||||
startSegmentIndex: initializedFormat.lastSegmentMetadata.startSequenceNumber,
|
||||
durationMs: initializedFormat.lastSegmentMetadata.durationMs,
|
||||
startTimeMs: 0,
|
||||
endSegmentIndex: initializedFormat.lastSegmentMetadata.endSequenceNumber
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (audioFormatId) videoPlaybackAbrRequest.selectedFormatIds.push(audioFormatId);
|
||||
|
||||
if (videoFormatId) videoPlaybackAbrRequest.selectedFormatIds.push(videoFormatId);
|
||||
}
|
||||
|
||||
request.body = Protos.VideoPlaybackAbrRequest.encode(videoPlaybackAbrRequest).finish();
|
||||
|
||||
const byteRange = headers.Range
|
||||
? {
|
||||
start: Number(headers.Range.split('=')[1].split('-')[0]),
|
||||
end: Number(headers.Range.split('=')[1].split('-')[1])
|
||||
}
|
||||
: null;
|
||||
|
||||
const sabrStreamingContext = {
|
||||
byteRange,
|
||||
format: currentFormat,
|
||||
isInit,
|
||||
isUMP: true,
|
||||
isSABR: true,
|
||||
playerTimeMs: videoPlaybackAbrRequest.clientAbrState?.playerTimeMs
|
||||
};
|
||||
|
||||
// @NOTE: Not a real header. See the http plugin code for more info.
|
||||
request.headers['X-Streaming-Context'] = btoa(JSON.stringify(sabrStreamingContext));
|
||||
delete headers.Range;
|
||||
} else {
|
||||
if (!video.liveNow && !video.ytjs?.video.basic_info.is_post_live_dvr) {
|
||||
url.searchParams.set('ump', '1');
|
||||
url.searchParams.set('srfvp', '1');
|
||||
if (headers.Range) {
|
||||
url.searchParams.set('range', headers.Range?.split('=')[1]);
|
||||
delete headers.Range;
|
||||
}
|
||||
} else {
|
||||
url.pathname += '/ump/1';
|
||||
url.pathname += '/srfvp/1';
|
||||
}
|
||||
|
||||
request.headers['X-Streaming-Context'] = btoa(
|
||||
JSON.stringify({
|
||||
isUMP: true,
|
||||
isSABR: false
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
request.method = 'POST';
|
||||
} else if (type == shaka.net.NetworkingEngine.RequestType.LICENSE) {
|
||||
const wrapped = {} as Record<string, any>;
|
||||
wrapped.context = video.ytjs?.innertube.session.context;
|
||||
wrapped.cpn = video.ytjs?.clientPlaybackNonce;
|
||||
wrapped.drmParams = decodeURIComponent(drmParams || '');
|
||||
wrapped.drmSystem = 'DRM_SYSTEM_WIDEVINE';
|
||||
wrapped.drmVideoFeature = 'DRM_VIDEO_FEATURE_SDR';
|
||||
wrapped.licenseRequest = shaka.util.Uint8ArrayUtils.toBase64(
|
||||
request.body as ArrayBuffer | ArrayBufferView
|
||||
);
|
||||
wrapped.sessionId = sessionId;
|
||||
wrapped.videoId = video.videoId;
|
||||
request.body = shaka.util.StringUtils.toUTF8(JSON.stringify(wrapped));
|
||||
}
|
||||
|
||||
request.uris[0] = url.toString();
|
||||
});
|
||||
|
||||
networkingEngine.registerResponseFilter(async (type, response, context) => {
|
||||
if (type === shaka.net.NetworkingEngine.RequestType.SEGMENT) {
|
||||
const url = new URL(response.uri);
|
||||
if (
|
||||
url.hostname.endsWith('.youtube.com') &&
|
||||
url.pathname === '/api/timedtext' &&
|
||||
url.searchParams.get('caps') === 'asr' &&
|
||||
url.searchParams.get('kind') === 'asr' &&
|
||||
url.searchParams.get('fmt') === 'vtt'
|
||||
) {
|
||||
const stringBody = new TextDecoder().decode(response.data);
|
||||
// position:0% for LTR text and position:100% for RTL text
|
||||
const cleaned = stringBody.replaceAll(/ align:start position:(?:10)?0%$/gm, '');
|
||||
|
||||
/* @ts-ignore */
|
||||
response.data = new TextEncoder().encode(cleaned).buffer;
|
||||
} else {
|
||||
const sabrStreamingContext = response.headers['X-Streaming-Context'];
|
||||
|
||||
if (sabrStreamingContext) {
|
||||
const { streamInfo, format, byteRange } = JSON.parse(
|
||||
atob(sabrStreamingContext)
|
||||
) as SabrStreamingContext;
|
||||
|
||||
if (streamInfo) {
|
||||
const sabrRedirect = streamInfo.redirect;
|
||||
const playbackCookie = streamInfo.playbackCookie;
|
||||
const streamProtectionStatus = streamInfo.streamProtectionStatus;
|
||||
const formatInitMetadata = streamInfo.formatInitMetadata || [];
|
||||
const mainSegmentMediaHeader = streamInfo.mediaHeader;
|
||||
|
||||
// If we have a redirect, follow it.
|
||||
if (sabrRedirect?.url && !response.data.byteLength) {
|
||||
let redirectUrl = new URL(sabrRedirect.url);
|
||||
|
||||
serverAbrStreamingUrl = redirectUrl;
|
||||
redirectUrl = new URL(`https://sabr?___key=${fromFormat(format) || ''}`);
|
||||
|
||||
const retryParameters = player!.getConfiguration().streaming.retryParameters;
|
||||
|
||||
const redirectRequest = shaka.net.NetworkingEngine.makeRequest(
|
||||
[redirectUrl.toString()],
|
||||
retryParameters
|
||||
);
|
||||
|
||||
// Keep range so we can slice the response (only if it's the init segment).
|
||||
if (typeof byteRange !== 'undefined') {
|
||||
redirectRequest.headers['Range'] = `bytes=${byteRange.start}-${byteRange.end}`;
|
||||
}
|
||||
|
||||
const requestOperation = player!
|
||||
.getNetworkingEngine()
|
||||
?.request(type, redirectRequest, context);
|
||||
const redirectResponse = await requestOperation!.promise;
|
||||
|
||||
// Modify the original response to contain the results of the redirect
|
||||
// response.
|
||||
Object.assign(response, redirectResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
if (playbackCookie) lastPlaybackCookie = streamInfo.playbackCookie;
|
||||
|
||||
if (streamProtectionStatus && streamProtectionStatus.status === 3) {
|
||||
console.warn('[UMP] Attestation required.');
|
||||
}
|
||||
|
||||
for (const metadata of formatInitMetadata) {
|
||||
const key = fromFormatInitializationMetadata(metadata);
|
||||
if (!initializedFormats.has(key)) {
|
||||
initializedFormats.set(key, {
|
||||
formatInitializationMetadata: metadata
|
||||
});
|
||||
console.log(`[SABR] Initialized format ${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (mainSegmentMediaHeader) {
|
||||
const formatKey = fromMediaHeader(mainSegmentMediaHeader);
|
||||
const initializedFormat = initializedFormats.get(formatKey);
|
||||
|
||||
if (initializedFormat) {
|
||||
initializedFormat.lastSegmentMetadata = {
|
||||
formatId: mainSegmentMediaHeader.formatId!,
|
||||
startTimeMs: mainSegmentMediaHeader.startMs || 0,
|
||||
startSequenceNumber: mainSegmentMediaHeader.sequenceNumber || 1,
|
||||
endSequenceNumber: mainSegmentMediaHeader.sequenceNumber || 1,
|
||||
durationMs: mainSegmentMediaHeader.durationMs || 0
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (type == shaka.net.NetworkingEngine.RequestType.LICENSE) {
|
||||
const wrappedString = shaka.util.StringUtils.fromUTF8(response.data);
|
||||
const wrapped = JSON.parse(wrappedString);
|
||||
const rawLicenseBase64 = wrapped.license;
|
||||
response.data = shaka.util.Uint8ArrayUtils.fromBase64(rawLicenseBase64);
|
||||
}
|
||||
});
|
||||
|
||||
return playerElementResizeObserver;
|
||||
return sabrAdapter;
|
||||
}
|
||||
|
||||
@@ -1,282 +0,0 @@
|
||||
import { GoogleVideo, Protos, concatenateChunks, type Part, PART } from 'googlevideo';
|
||||
import shaka from 'shaka-player/dist/shaka-player.ui';
|
||||
|
||||
import { cacheSegment } from './cacheHelper';
|
||||
import { fromFormat, fromMediaHeader } from './formatKeyUtils';
|
||||
import { HttpFetchPlugin } from './shakaHttpPlugin';
|
||||
import type { SabrStreamingContext } from './shakaHttpPlugin';
|
||||
|
||||
export interface Segment {
|
||||
headerId?: number;
|
||||
mediaHeader: Protos.MediaHeader;
|
||||
complete?: boolean;
|
||||
data: Uint8Array<ArrayBufferLike>;
|
||||
}
|
||||
|
||||
export class SabrUmpParser {
|
||||
private partialPart?: Part;
|
||||
private formatInitMetadata: Protos.FormatInitializationMetadata[] = [];
|
||||
private playbackCookie?: Protos.PlaybackCookie;
|
||||
private targetHeaderId?: number;
|
||||
private targetSegment?: Segment;
|
||||
|
||||
constructor(
|
||||
private response: Response,
|
||||
private decodedStreamingContext: SabrStreamingContext,
|
||||
private uri: string,
|
||||
private request: shaka.extern.Request,
|
||||
private requestType: shaka.net.NetworkingEngine.RequestType,
|
||||
private abortController: AbortController
|
||||
) {}
|
||||
|
||||
async parse(): Promise<shaka.extern.Response> {
|
||||
const reader = this.response.clone().body!.getReader();
|
||||
|
||||
while (!this.abortController.signal.aborted) {
|
||||
const { value, done } = await reader.read();
|
||||
|
||||
if (done) {
|
||||
// If we got here, we read the whole stream but there was no data; it means we must follow this redirect.
|
||||
if (
|
||||
this.decodedStreamingContext.isSABR &&
|
||||
this.decodedStreamingContext.streamInfo?.redirect
|
||||
) {
|
||||
return this.createShakaResponse();
|
||||
}
|
||||
|
||||
// We should never reach here, but if we do, it means we got nothing at all.
|
||||
throw this.createRecoverableError(
|
||||
'Empty response with no redirect information',
|
||||
this.decodedStreamingContext
|
||||
);
|
||||
}
|
||||
|
||||
let chunk;
|
||||
|
||||
if (this.partialPart) {
|
||||
chunk = this.partialPart.data;
|
||||
chunk.append(value);
|
||||
} else {
|
||||
chunk = new GoogleVideo.ChunkedDataBuffer([value]);
|
||||
}
|
||||
|
||||
const result = await this.process(chunk);
|
||||
if (result) {
|
||||
if (this.shouldThrowServerError(result)) {
|
||||
throw this.createRecoverableError('Server streaming error', this.decodedStreamingContext);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// Never happens. Here just in case.
|
||||
throw this.createRecoverableError(
|
||||
"Couldn't read any data from the stream",
|
||||
this.decodedStreamingContext
|
||||
);
|
||||
}
|
||||
|
||||
private shouldThrowServerError(result: shaka.extern.Response): boolean {
|
||||
return (
|
||||
!result.data.byteLength &&
|
||||
(!!this.decodedStreamingContext.error ||
|
||||
this.decodedStreamingContext.streamInfo?.streamProtectionStatus?.status === 3)
|
||||
);
|
||||
}
|
||||
|
||||
private createRecoverableError(message: string, info?: Record<string, any>) {
|
||||
return new shaka.util.Error(
|
||||
shaka.util.Error.Severity.RECOVERABLE,
|
||||
shaka.util.Error.Category.NETWORK,
|
||||
shaka.util.Error.Code.HTTP_ERROR,
|
||||
message,
|
||||
{ info }
|
||||
);
|
||||
}
|
||||
|
||||
private process(
|
||||
value: GoogleVideo.ChunkedDataBuffer
|
||||
): Promise<shaka.extern.Response | undefined> {
|
||||
return new Promise((resolve) => {
|
||||
const ump = new GoogleVideo.UMP(value);
|
||||
|
||||
this.partialPart = ump.parse((part: Part) => {
|
||||
const result = this.handlePart(part);
|
||||
if (result) {
|
||||
resolve(result);
|
||||
}
|
||||
});
|
||||
|
||||
resolve(undefined);
|
||||
});
|
||||
}
|
||||
|
||||
private handlePart(part: Part): shaka.extern.Response | undefined {
|
||||
try {
|
||||
switch (part.type) {
|
||||
case PART.FORMAT_INITIALIZATION_METADATA:
|
||||
this.handleFormatInitMetadata(part);
|
||||
break;
|
||||
case PART.NEXT_REQUEST_POLICY:
|
||||
this.handleNextRequestPolicy(part);
|
||||
break;
|
||||
case PART.MEDIA_HEADER:
|
||||
this.handleMediaHeader(part);
|
||||
break;
|
||||
case PART.MEDIA:
|
||||
this.handleMedia(part);
|
||||
break;
|
||||
case PART.MEDIA_END:
|
||||
return this.handleMediaEnd(part);
|
||||
case PART.SABR_ERROR:
|
||||
return this.handleSabrError(part);
|
||||
case PART.STREAM_PROTECTION_STATUS:
|
||||
return this.handleStreamProtectionStatus(part);
|
||||
case PART.SABR_REDIRECT:
|
||||
return this.handleSabrRedirect(part);
|
||||
default:
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
private handleFormatInitMetadata(part: Part) {
|
||||
const formatInitMetadata = Protos.FormatInitializationMetadata.decode(part.data.chunks[0]);
|
||||
this.formatInitMetadata.push(formatInitMetadata);
|
||||
}
|
||||
|
||||
private handleNextRequestPolicy(part: Part) {
|
||||
const nextRequestPolicy = Protos.NextRequestPolicy.decode(part.data.chunks[0]);
|
||||
if (this.decodedStreamingContext.format?.has_video) {
|
||||
this.playbackCookie = nextRequestPolicy.playbackCookie;
|
||||
}
|
||||
}
|
||||
|
||||
private handleMediaHeader(part: Part) {
|
||||
const mediaHeader = Protos.MediaHeader.decode(part.data.chunks[0]);
|
||||
const formatKey = fromFormat(this.decodedStreamingContext.format);
|
||||
const segmentFormatKey = fromMediaHeader(mediaHeader);
|
||||
|
||||
if (!this.decodedStreamingContext.isSABR || segmentFormatKey === formatKey) {
|
||||
if (!this.targetHeaderId) {
|
||||
this.targetHeaderId = mediaHeader.headerId;
|
||||
this.targetSegment = {
|
||||
headerId: mediaHeader.headerId,
|
||||
mediaHeader: mediaHeader,
|
||||
data: new Uint8Array()
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleMedia(part: Part) {
|
||||
const headerId = part.data.getUint8(0);
|
||||
const buffer = part.data.split(1).remainingBuffer;
|
||||
|
||||
if (this.targetSegment && headerId === this.targetHeaderId) {
|
||||
this.targetSegment.data = concatenateChunks([this.targetSegment.data, ...buffer.chunks]);
|
||||
}
|
||||
}
|
||||
|
||||
private handleMediaEnd(part: Part) {
|
||||
const headerId = part.data.getUint8(0);
|
||||
|
||||
if (this.targetSegment && headerId === this.targetHeaderId) {
|
||||
if (this.decodedStreamingContext) {
|
||||
this.decodedStreamingContext.streamInfo = {
|
||||
...this.decodedStreamingContext.streamInfo,
|
||||
playbackCookie: this.playbackCookie,
|
||||
formatInitMetadata: this.formatInitMetadata,
|
||||
mediaHeader: this.targetSegment.mediaHeader.isInitSeg
|
||||
? undefined
|
||||
: this.targetSegment.mediaHeader
|
||||
};
|
||||
}
|
||||
|
||||
let arrayBuffer: Uint8Array;
|
||||
|
||||
// Why cache the init segment? Well, SABR responses are still a bit larger than usual - caching the init segment
|
||||
// helps reduce the delay when switching between different qualities or initializing a new stream.
|
||||
if (
|
||||
this.decodedStreamingContext.isInit &&
|
||||
this.decodedStreamingContext.format &&
|
||||
this.decodedStreamingContext.byteRange
|
||||
) {
|
||||
cacheSegment(this.targetSegment, this.decodedStreamingContext.format);
|
||||
arrayBuffer = this.targetSegment.data.slice(
|
||||
this.decodedStreamingContext.byteRange.start,
|
||||
this.decodedStreamingContext.byteRange.end + 1
|
||||
);
|
||||
} else {
|
||||
arrayBuffer = this.targetSegment.data;
|
||||
}
|
||||
|
||||
// We got what we wanted; close the stream and abort the request.
|
||||
this.abortController.abort();
|
||||
return this.createShakaResponse(arrayBuffer);
|
||||
}
|
||||
}
|
||||
|
||||
private handleSabrError(part: Part) {
|
||||
const sabrError = Protos.SabrError.decode(part.data.chunks[0]);
|
||||
|
||||
if (this.decodedStreamingContext) {
|
||||
this.decodedStreamingContext.error = { sabrError };
|
||||
}
|
||||
|
||||
this.abortController.abort();
|
||||
return this.createShakaResponse();
|
||||
}
|
||||
|
||||
private handleStreamProtectionStatus(part: Part) {
|
||||
const streamProtectionStatus = Protos.StreamProtectionStatus.decode(part.data.chunks[0]);
|
||||
|
||||
if (this.decodedStreamingContext) {
|
||||
this.decodedStreamingContext.streamInfo = {
|
||||
...this.decodedStreamingContext.streamInfo,
|
||||
streamProtectionStatus
|
||||
};
|
||||
}
|
||||
|
||||
if (streamProtectionStatus.status === 3) {
|
||||
this.abortController.abort();
|
||||
return this.createShakaResponse();
|
||||
}
|
||||
}
|
||||
|
||||
private handleSabrRedirect(part: Part) {
|
||||
const redirect = Protos.SabrRedirect.decode(part.data.chunks[0]);
|
||||
|
||||
if (this.decodedStreamingContext) {
|
||||
this.decodedStreamingContext.streamInfo = {
|
||||
...this.decodedStreamingContext.streamInfo,
|
||||
redirect
|
||||
};
|
||||
}
|
||||
|
||||
// With pure UMP, redirects should be followed immediately.
|
||||
if (this.decodedStreamingContext.isUMP && !this.decodedStreamingContext.isSABR) {
|
||||
this.abortController.abort();
|
||||
return this.createShakaResponse();
|
||||
}
|
||||
}
|
||||
|
||||
private createShakaResponse(body?: Uint8Array): shaka.extern.Response {
|
||||
return HttpFetchPlugin.makeResponse(
|
||||
this.createContextHeaders(),
|
||||
body || new ArrayBuffer(0),
|
||||
this.response.status,
|
||||
this.uri,
|
||||
this.response.url,
|
||||
this.request,
|
||||
this.requestType
|
||||
);
|
||||
}
|
||||
|
||||
private createContextHeaders(): Record<string, any> {
|
||||
const headers = HttpFetchPlugin.headersToGenericObject_(this.response.headers);
|
||||
if (this.decodedStreamingContext) {
|
||||
headers['X-Streaming-Context'] = btoa(JSON.stringify(this.decodedStreamingContext));
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
}
|
||||
@@ -1,265 +0,0 @@
|
||||
import shaka from 'shaka-player/dist/shaka-player.ui';
|
||||
import type { Misc } from 'youtubei.js/web';
|
||||
import type { Protos } from 'googlevideo';
|
||||
|
||||
import { retrieveCachedSegment } from './cacheHelper';
|
||||
import { SabrUmpParser } from './sabrUmpParser';
|
||||
import { CacheManager } from './cacheManager';
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
|
||||
export interface SabrStreamingContext {
|
||||
byteRange?: { start: number; end: number };
|
||||
format?: Misc.Format;
|
||||
isInit?: boolean;
|
||||
isUMP?: boolean;
|
||||
isSABR?: boolean;
|
||||
playerTimeMs?: number;
|
||||
streamInfo?: {
|
||||
playbackCookie?: Protos.PlaybackCookie;
|
||||
formatInitMetadata?: Protos.FormatInitializationMetadata[];
|
||||
streamProtectionStatus?: Protos.StreamProtectionStatus;
|
||||
mediaHeader?: Protos.MediaHeader;
|
||||
redirect?: Protos.SabrRedirect;
|
||||
};
|
||||
error?: {
|
||||
sabrError?: Protos.SabrError;
|
||||
};
|
||||
}
|
||||
|
||||
export class HttpFetchPlugin {
|
||||
private static fetch_ = window.fetch;
|
||||
private static AbortController_ = window.AbortController;
|
||||
private static Headers_ = window.Headers;
|
||||
public static cacheManager = new CacheManager();
|
||||
|
||||
private static asMap<K, V>(object: Record<string, V>): Map<K, V> {
|
||||
const map = new Map<K, V>();
|
||||
for (const key of Object.keys(object)) {
|
||||
map.set(key as K, object[key]);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
static parse(
|
||||
uri: string,
|
||||
request: shaka.extern.Request,
|
||||
requestType: shaka.net.NetworkingEngine.RequestType,
|
||||
_progressUpdated: shaka.extern.ProgressUpdated,
|
||||
headersReceived: shaka.extern.HeadersReceived
|
||||
): shaka.extern.IAbortableOperation<shaka.extern.Response> {
|
||||
const headers = new HttpFetchPlugin.Headers_();
|
||||
|
||||
HttpFetchPlugin.asMap(request.headers).forEach((value, key) => {
|
||||
headers.append(key as string, value);
|
||||
});
|
||||
|
||||
let sabrStreamingContext: string | null = null;
|
||||
|
||||
// Save the streaming context for later use, then remove it from the headers
|
||||
// as it's only used by the player and not the server.
|
||||
if (headers.has('X-Streaming-Context')) {
|
||||
sabrStreamingContext = headers.get('X-Streaming-Context');
|
||||
headers.delete('X-Streaming-Context');
|
||||
}
|
||||
|
||||
// Parse uri and remove "___key" query param (same situation as above).
|
||||
const url = new URL(uri);
|
||||
url.searchParams.delete('___key');
|
||||
uri = url.toString();
|
||||
|
||||
const controller = new HttpFetchPlugin.AbortController_();
|
||||
const init: RequestInit = {
|
||||
body: request.body || undefined,
|
||||
headers,
|
||||
method: request.method,
|
||||
signal: controller.signal,
|
||||
credentials: request.allowCrossSiteCredentials ? 'include' : undefined
|
||||
};
|
||||
|
||||
const abortStatus = {
|
||||
canceled: false,
|
||||
timedOut: false
|
||||
};
|
||||
|
||||
const pendingRequest = HttpFetchPlugin.request_(
|
||||
uri,
|
||||
request,
|
||||
requestType,
|
||||
init,
|
||||
controller,
|
||||
abortStatus,
|
||||
headersReceived,
|
||||
sabrStreamingContext
|
||||
);
|
||||
|
||||
const op = new shaka.util.AbortableOperation(pendingRequest, () => {
|
||||
abortStatus.canceled = true;
|
||||
controller.abort();
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
const timeoutMs = request.retryParameters.timeout;
|
||||
if (timeoutMs && Capacitor.getPlatform() !== 'android') {
|
||||
const timer = new shaka.util.Timer(() => {
|
||||
abortStatus.timedOut = true;
|
||||
controller.abort();
|
||||
});
|
||||
|
||||
timer.tickAfter(timeoutMs / 1000);
|
||||
op.finally(() => timer.stop());
|
||||
}
|
||||
|
||||
return op;
|
||||
}
|
||||
|
||||
private static async request_(
|
||||
uri: string,
|
||||
request: shaka.extern.Request,
|
||||
requestType: shaka.net.NetworkingEngine.RequestType,
|
||||
init: RequestInit,
|
||||
abortController: AbortController,
|
||||
abortStatus: { canceled: boolean; timedOut: boolean },
|
||||
headersReceived: shaka.extern.HeadersReceived,
|
||||
streamingContext: string | null
|
||||
): Promise<shaka.extern.Response> {
|
||||
const fetch = HttpFetchPlugin.fetch_;
|
||||
const decodedStreamingContext = streamingContext
|
||||
? (JSON.parse(atob(streamingContext)) as SabrStreamingContext)
|
||||
: undefined;
|
||||
|
||||
let response: Response;
|
||||
let arrayBuffer: ArrayBuffer | undefined;
|
||||
|
||||
try {
|
||||
// Try to use cached segment first
|
||||
if (decodedStreamingContext && decodedStreamingContext.format) {
|
||||
const cachedResponse = retrieveCachedSegment(
|
||||
decodedStreamingContext,
|
||||
request,
|
||||
requestType,
|
||||
uri
|
||||
);
|
||||
if (cachedResponse) {
|
||||
return cachedResponse;
|
||||
}
|
||||
}
|
||||
|
||||
response = await fetch(uri, init);
|
||||
headersReceived(HttpFetchPlugin.headersToGenericObject_(response.headers));
|
||||
|
||||
// Handle UMP responses
|
||||
if (
|
||||
init.method !== 'HEAD' &&
|
||||
decodedStreamingContext &&
|
||||
response.headers.get('content-type') === 'application/vnd.yt-ump'
|
||||
) {
|
||||
const parser = new SabrUmpParser(
|
||||
response,
|
||||
decodedStreamingContext,
|
||||
uri,
|
||||
request,
|
||||
requestType,
|
||||
abortController
|
||||
);
|
||||
return parser.parse();
|
||||
}
|
||||
|
||||
arrayBuffer = await response.arrayBuffer();
|
||||
} catch (error) {
|
||||
if (abortStatus.canceled) {
|
||||
throw new shaka.util.Error(
|
||||
shaka.util.Error.Severity.RECOVERABLE,
|
||||
shaka.util.Error.Category.NETWORK,
|
||||
shaka.util.Error.Code.OPERATION_ABORTED,
|
||||
uri,
|
||||
requestType
|
||||
);
|
||||
} else if (abortStatus.timedOut) {
|
||||
throw new shaka.util.Error(
|
||||
shaka.util.Error.Severity.RECOVERABLE,
|
||||
shaka.util.Error.Category.NETWORK,
|
||||
shaka.util.Error.Code.TIMEOUT,
|
||||
uri,
|
||||
requestType
|
||||
);
|
||||
}
|
||||
throw new shaka.util.Error(
|
||||
shaka.util.Error.Severity.RECOVERABLE,
|
||||
shaka.util.Error.Category.NETWORK,
|
||||
shaka.util.Error.Code.HTTP_ERROR,
|
||||
uri,
|
||||
error,
|
||||
requestType
|
||||
);
|
||||
}
|
||||
|
||||
const headers = HttpFetchPlugin.headersToGenericObject_(response.headers);
|
||||
if (streamingContext) {
|
||||
headers['X-Streaming-Context'] = streamingContext;
|
||||
}
|
||||
|
||||
return HttpFetchPlugin.makeResponse(
|
||||
headers,
|
||||
arrayBuffer!,
|
||||
response.status,
|
||||
uri,
|
||||
response.url,
|
||||
request,
|
||||
requestType
|
||||
);
|
||||
}
|
||||
|
||||
public static makeResponse(
|
||||
headers: Record<string, string>,
|
||||
data: BufferSource,
|
||||
status: number,
|
||||
uri: string,
|
||||
responseURL: string,
|
||||
request: shaka.extern.Request,
|
||||
requestType: shaka.net.NetworkingEngine.RequestType
|
||||
): shaka.extern.Response & { originalRequest: shaka.extern.Request } {
|
||||
if (status >= 200 && status <= 299 && status !== 202) {
|
||||
return {
|
||||
uri: responseURL || uri,
|
||||
originalUri: uri,
|
||||
data,
|
||||
status,
|
||||
headers,
|
||||
originalRequest: request,
|
||||
fromCache: !!headers['x-shaka-from-cache']
|
||||
};
|
||||
}
|
||||
|
||||
let responseText: string | null = null;
|
||||
try {
|
||||
responseText = shaka.util.StringUtils.fromBytesAutoDetect(data);
|
||||
} catch {
|
||||
/* no-op */
|
||||
}
|
||||
|
||||
const severity =
|
||||
status === 401 || status === 403
|
||||
? shaka.util.Error.Severity.CRITICAL
|
||||
: shaka.util.Error.Severity.RECOVERABLE;
|
||||
|
||||
throw new shaka.util.Error(
|
||||
severity,
|
||||
shaka.util.Error.Category.NETWORK,
|
||||
shaka.util.Error.Code.BAD_HTTP_STATUS,
|
||||
uri,
|
||||
status,
|
||||
responseText,
|
||||
headers,
|
||||
requestType,
|
||||
responseURL || uri
|
||||
);
|
||||
}
|
||||
|
||||
public static headersToGenericObject_(headers: Headers): Record<string, string> {
|
||||
const headersObj: Record<string, string> = {};
|
||||
headers.forEach((value, key) => {
|
||||
headersObj[key.trim()] = value;
|
||||
});
|
||||
return headersObj;
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -3,7 +3,7 @@ import os
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
LATEST_VERSION = "1.10.10"
|
||||
LATEST_VERSION = "1.10.11"
|
||||
RELEASE_DATE = datetime.now().strftime("%Y-%-m-%d") # Format: YYYY-M-D
|
||||
|
||||
WORKING_DIR = os.path.join(os.path.dirname(os.path.realpath(__file__)), "materialious")
|
||||
|
||||
Reference in New Issue
Block a user