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:
Ward
2025-09-25 01:47:21 +00:00
committed by GitHub
parent da1601257a
commit 948e6951d8
22 changed files with 1688 additions and 2419 deletions
+2 -2
View File
@@ -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">
+6 -6
View File
@@ -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",
+3 -3
View File
@@ -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"
]
}
}
+80 -64
View File
@@ -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;
+2 -4
View File
@@ -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);
},
+732 -959
View File
File diff suppressed because it is too large Load Diff
+6 -6
View File
@@ -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"
}
}
}
+1 -7
View File
@@ -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>;
};
+40 -18
View File
@@ -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));
}
+28 -29
View File
@@ -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) {
+29 -37
View File
@@ -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;
}
}
}
-74
View File
@@ -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;
}
-147
View File
@@ -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;
}
}
-102
View File
@@ -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('-');
}
+75
View File
@@ -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
);
}
+76 -408
View File
@@ -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;
}
-282
View File
@@ -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
View File
@@ -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")