Progress towards SABR support
This commit is contained in:
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "Materialious",
|
||||
"version": "1.7.21",
|
||||
"version": "1.8.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "Materialious",
|
||||
"version": "1.7.21",
|
||||
"version": "1.8.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@capacitor-community/electron": "^5.0.0",
|
||||
|
||||
Generated
+321
-274
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,4 @@
|
||||
import type { YT } from 'youtubei.js';
|
||||
|
||||
import type { ApiResponse, Innertube, YT } from 'youtubei.js';
|
||||
|
||||
export interface Image {
|
||||
url: string;
|
||||
@@ -101,7 +100,12 @@ export interface VideoPlay extends Video {
|
||||
authorThumbnails: Image[];
|
||||
captions: Captions[];
|
||||
storyboards?: StoryBoard[];
|
||||
ytJsVideoInfo?: YT.VideoInfo,
|
||||
ytjs?: {
|
||||
innertube: Innertube;
|
||||
video: YT.VideoInfo;
|
||||
clientPlaybackNonce: string;
|
||||
rawApiResponse: ApiResponse;
|
||||
};
|
||||
fallbackPatch?: 'youtubejs' | 'piped';
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
import { StatusBar, Style } from '@capacitor/status-bar';
|
||||
import { NavigationBar } from '@hugotomazi/capacitor-navigation-bar';
|
||||
import { type Page } from '@sveltejs/kit';
|
||||
import { GoogleVideo, Protos } from 'googlevideo';
|
||||
import { base64ToU8, Protos } from 'googlevideo';
|
||||
import ui from 'beercss';
|
||||
import ISO6391 from 'iso-639-1';
|
||||
import Mousetrap from 'mousetrap';
|
||||
import 'shaka-player/dist/controls.css';
|
||||
@@ -37,6 +38,14 @@
|
||||
synciousStore
|
||||
} from '../store';
|
||||
import { getDynamicTheme, setStatusBarColor } from '../theme';
|
||||
import { HttpFetchPlugin, type SabrStreamingContext } from '$lib/sabr/shakaHttpPlugin';
|
||||
import { Constants, Innertube, type Misc } from 'youtubei.js';
|
||||
import {
|
||||
fromFormat,
|
||||
fromFormatInitializationMetadata,
|
||||
fromMediaHeader,
|
||||
getUniqueFormatId
|
||||
} from '$lib/sabr/formatKeyUtils';
|
||||
|
||||
interface Props {
|
||||
data: { video: VideoPlay; content: PhasedDescription; playlistId: string | null };
|
||||
@@ -61,6 +70,49 @@
|
||||
let player: shaka.Player;
|
||||
let shakaUi: shaka.ui.Overlay;
|
||||
|
||||
// Shaka player required variables for SABR based off LuanRT's example
|
||||
const sessionId = Array.from(Array(16), () => Math.floor(Math.random() * 36).toString(36)).join(
|
||||
''
|
||||
);
|
||||
let isLive = false;
|
||||
let isPostLiveDVR = false;
|
||||
let videoPlaybackUstreamerConfig: string | undefined;
|
||||
let serverAbrStreamingUrl: URL | undefined = undefined;
|
||||
let drmParams: string | undefined;
|
||||
let 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[] = [];
|
||||
|
||||
shaka.net.NetworkingEngine.registerScheme(
|
||||
'http',
|
||||
HttpFetchPlugin.parse,
|
||||
shaka.net.NetworkingEngine.PluginPriority.PREFERRED
|
||||
);
|
||||
shaka.net.NetworkingEngine.registerScheme(
|
||||
'https',
|
||||
HttpFetchPlugin.parse,
|
||||
shaka.net.NetworkingEngine.PluginPriority.PREFERRED
|
||||
);
|
||||
// Shaka player SABR var end
|
||||
|
||||
const STORAGE_KEY_QUALITY = 'shaka-preferred-quality';
|
||||
const STORAGE_KEY_VOLUME = 'shaka-preferred-volume';
|
||||
|
||||
@@ -122,6 +174,8 @@
|
||||
return;
|
||||
}
|
||||
|
||||
let dashUrl: string | undefined = undefined;
|
||||
|
||||
player = new shaka.Player();
|
||||
playerElement = document.getElementById('player') as HTMLMediaElement;
|
||||
|
||||
@@ -166,35 +220,94 @@
|
||||
});
|
||||
|
||||
player.configure({
|
||||
streaming: {
|
||||
bufferingGoal: data.video.ytJsVideoInfo
|
||||
? (data.video.ytJsVideoInfo.page[0].player_config?.media_common_config
|
||||
.dynamic_readahead_config.max_read_ahead_media_time_ms || 0) / 1000
|
||||
: 180,
|
||||
rebufferingGoal: data.video.ytJsVideoInfo
|
||||
? (data.video.ytJsVideoInfo.page[0].player_config?.media_common_config
|
||||
.dynamic_readahead_config.read_ahead_growth_rate_ms || 0) / 1000
|
||||
: 0.02,
|
||||
bufferBehind: 300,
|
||||
autoLowLatencyMode: true
|
||||
},
|
||||
|
||||
abr: {
|
||||
enabled: true,
|
||||
restrictToElementSize: true,
|
||||
restrictions: {
|
||||
maxBandwidth: data.video.ytJsVideoInfo
|
||||
? Number(
|
||||
data.video.ytJsVideoInfo.page[0].player_config?.stream_selection_config.max_bitrate
|
||||
)
|
||||
: null
|
||||
maxWidth: 1920,
|
||||
maxHeight: 1080
|
||||
}
|
||||
},
|
||||
preferredDecodingAttributes: !data.video.hlsUrl ? ['smooth', 'powerEfficient'] : [],
|
||||
autoShowText: shaka.config.AutoShowText.NEVER
|
||||
streaming: {
|
||||
bufferingGoal: 120,
|
||||
rebufferingGoal: 0.01,
|
||||
bufferBehind: 300,
|
||||
retryParameters: {
|
||||
maxAttempts: 30,
|
||||
baseDelay: 1500,
|
||||
backoffFactor: 2.5,
|
||||
fuzzFactor: 0.7,
|
||||
timeout: 120000
|
||||
},
|
||||
stallThreshold: 2,
|
||||
stallSkip: 0.5
|
||||
}
|
||||
});
|
||||
|
||||
if (data.video.fallbackPatch === 'youtubejs') {
|
||||
if (data.video.fallbackPatch === 'youtubejs' && data.video.ytjs) {
|
||||
isLive = !!data.video.ytjs.video.basic_info.is_live;
|
||||
isPostLiveDVR = !!data.video.ytjs.video.basic_info.is_post_live_dvr;
|
||||
|
||||
if (
|
||||
data.video.ytjs.rawApiResponse.data.streamingData &&
|
||||
(data.video.ytjs.rawApiResponse.data.streamingData as any).drmParams
|
||||
) {
|
||||
drmParams = (data.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'
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
videoPlaybackUstreamerConfig =
|
||||
data.video.ytjs.video.page[0].player_config?.media_common_config
|
||||
.media_ustreamer_request_config?.video_playback_ustreamer_config;
|
||||
|
||||
if (data.video.ytjs.video.streaming_data) {
|
||||
formatList = data.video.ytjs.video.streaming_data.adaptive_formats.map((format) => {
|
||||
const formatKey = fromFormat(format) || '';
|
||||
format.url = `https://sabr?___key=${formatKey}`;
|
||||
format.signature_cipher = undefined;
|
||||
format.decipher = () => format.url || '';
|
||||
return format;
|
||||
});
|
||||
}
|
||||
|
||||
if (data.video.ytjs.video.streaming_data?.server_abr_streaming_url)
|
||||
serverAbrStreamingUrl = new URL(
|
||||
data.video.ytjs.innertube.session.player!.decipher(
|
||||
data.video.ytjs.video.streaming_data.server_abr_streaming_url
|
||||
)
|
||||
);
|
||||
|
||||
if (data.video.ytjs.video.streaming_data) {
|
||||
if (isLive) {
|
||||
dashUrl = data.video.ytjs.video.streaming_data.dash_manifest_url
|
||||
? `${data.video.ytjs.video.streaming_data.dash_manifest_url}/mpd_version/7`
|
||||
: data.video.ytjs.video.streaming_data.hls_manifest_url;
|
||||
} else if (data.video.ytjs.video.streaming_data.dash_manifest_url && isPostLiveDVR) {
|
||||
dashUrl = data.video.ytjs.video.streaming_data.hls_manifest_url
|
||||
? data.video.ytjs.video.streaming_data.hls_manifest_url // HLS is preferred for DVR streams.
|
||||
: `${data.video.ytjs.video.streaming_data.dash_manifest_url}/mpd_version/7`;
|
||||
} else {
|
||||
dashUrl = `data:application/dash+xml;base64,${btoa(await data.video.ytjs.video.toDash(undefined, undefined, { captions_format: 'vtt' }))}`;
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
const networkingEngine = player.getNetworkingEngine();
|
||||
|
||||
if (!networkingEngine) return;
|
||||
@@ -202,131 +315,329 @@
|
||||
// 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) => {
|
||||
if (type === shaka.net.NetworkingEngine.RequestType.SEGMENT) {
|
||||
const url = new URL(request.uris[0]);
|
||||
networkingEngine.registerRequestFilter(async (type, request, context) => {
|
||||
if (!player) return;
|
||||
|
||||
if (url.hostname.endsWith('.googlevideo.com') && url.pathname === '/videoplayback') {
|
||||
if (request.headers.Range) {
|
||||
url.searchParams.set('range', request.headers.Range.split('=')[1]);
|
||||
url.searchParams.set('ump', '1');
|
||||
url.searchParams.set('srfvp', '1');
|
||||
const originalUrl = new URL(request.uris[0]);
|
||||
let url = originalUrl.hostname === 'sabr' ? serverAbrStreamingUrl : originalUrl;
|
||||
const headers = request.headers;
|
||||
|
||||
const cachedPoToken = get(poTokenCacheStore);
|
||||
if (cachedPoToken) url.searchParams.set('pot', cachedPoToken);
|
||||
if (!url) return;
|
||||
|
||||
delete request.headers.Range;
|
||||
}
|
||||
if (
|
||||
type === shaka.net.NetworkingEngine.RequestType.SEGMENT &&
|
||||
url.pathname.includes('videoplayback')
|
||||
) {
|
||||
const isUmp = url.searchParams.get('ump') === '1';
|
||||
const isSabr = url.searchParams.get('sabr') === '1';
|
||||
|
||||
request.method = 'POST';
|
||||
request.body = new Uint8Array([120, 0]);
|
||||
}
|
||||
|
||||
request.uris[0] = url.toString();
|
||||
}
|
||||
});
|
||||
|
||||
networkingEngine.registerResponseFilter(async (type, response) => {
|
||||
let mediaData = new Uint8Array(0);
|
||||
|
||||
const handleRedirect = async (redirectData: Protos.SabrRedirect) => {
|
||||
const redirectRequest = shaka.net.NetworkingEngine.makeRequest(
|
||||
[redirectData.url!],
|
||||
player!.getConfiguration().streaming.retryParameters
|
||||
);
|
||||
const requestOperation = player!.getNetworkingEngine()!.request(type, redirectRequest);
|
||||
const redirectResponse = await requestOperation.promise;
|
||||
|
||||
response.data = redirectResponse.data;
|
||||
response.headers = redirectResponse.headers;
|
||||
response.uri = redirectResponse.uri;
|
||||
};
|
||||
|
||||
const handleMediaData = async (data: Uint8Array) => {
|
||||
const combinedLength = mediaData.length + data.length;
|
||||
const tempMediaData = new Uint8Array(combinedLength);
|
||||
|
||||
tempMediaData.set(mediaData);
|
||||
tempMediaData.set(data, mediaData.length);
|
||||
|
||||
mediaData = tempMediaData;
|
||||
};
|
||||
|
||||
if (type == shaka.net.NetworkingEngine.RequestType.SEGMENT) {
|
||||
const url = new URL(response.uri);
|
||||
|
||||
// Fix positioning for auto-generated subtitles
|
||||
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, '');
|
||||
|
||||
response.data = new TextEncoder().encode(cleaned).buffer as ArrayBuffer;
|
||||
} else {
|
||||
const googUmp = new GoogleVideo.UMP(
|
||||
new GoogleVideo.ChunkedDataBuffer([new Uint8Array(response.data as ArrayBuffer)])
|
||||
if (isSabr) {
|
||||
const currentFormat = formatList.find(
|
||||
(format) =>
|
||||
fromFormat(format) === (new URL(request.uris[0]).searchParams.get('___key') || '')
|
||||
);
|
||||
|
||||
let redirect: Protos.SabrRedirect | undefined;
|
||||
if (!videoPlaybackUstreamerConfig) throw new Error('Ustreamer config not found.');
|
||||
|
||||
googUmp.parse((part) => {
|
||||
try {
|
||||
const data = part.data.chunks[0];
|
||||
switch (part.type) {
|
||||
case 20: {
|
||||
const mediaHeader = Protos.MediaHeader.decode(data);
|
||||
console.info('[MediaHeader]:', mediaHeader);
|
||||
break;
|
||||
}
|
||||
case 21: {
|
||||
handleMediaData(part.data.split(1).remainingBuffer.chunks[0]);
|
||||
break;
|
||||
}
|
||||
case 43: {
|
||||
redirect = Protos.SabrRedirect.decode(data);
|
||||
console.info('[SABRRedirect]:', redirect);
|
||||
break;
|
||||
}
|
||||
case 58: {
|
||||
const streamProtectionStatus = Protos.StreamProtectionStatus.decode(data);
|
||||
switch (streamProtectionStatus.status) {
|
||||
case 1:
|
||||
console.info('[StreamProtectionStatus]: Ok');
|
||||
break;
|
||||
case 2:
|
||||
console.error('[StreamProtectionStatus]: Attestation pending');
|
||||
break;
|
||||
case 3:
|
||||
console.error('[StreamProtectionStatus]: Attestation required');
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
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;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('An error occurred while processing the part:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (redirect) return handleRedirect(redirect);
|
||||
if (videoFormat) {
|
||||
videoFormatId = {
|
||||
itag: videoFormat.itag,
|
||||
lastModified: parseInt(videoFormat.last_modified_ms),
|
||||
xtags: videoFormat.xtags
|
||||
};
|
||||
}
|
||||
|
||||
if (mediaData.length) response.data = mediaData;
|
||||
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,
|
||||
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: data.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 (isUmp) {
|
||||
if (!isLive && !isPostLiveDVR) {
|
||||
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';
|
||||
|
||||
if (!isSabr) {
|
||||
if (request.method === 'POST') {
|
||||
request.body = new Uint8Array([120, 0]);
|
||||
}
|
||||
|
||||
const poToken = get(poTokenCacheStore);
|
||||
|
||||
if (poToken) {
|
||||
// Set Proof of Origin Token
|
||||
if (isLive || isPostLiveDVR) {
|
||||
url.pathname += '/pot/' + poToken;
|
||||
} else {
|
||||
url.searchParams.set('pot', poToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (type == shaka.net.NetworkingEngine.RequestType.LICENSE) {
|
||||
const innertube = await Innertube.create({ fetch: fetch });
|
||||
const wrapped = {} as Record<string, any>;
|
||||
wrapped.context = innertube.session.context;
|
||||
wrapped.cpn = data.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 = data.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 sabrStreamingContext = response.headers['X-Streaming-Context'];
|
||||
|
||||
if (sabrStreamingContext) {
|
||||
const { streamInfo, isSABR, 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);
|
||||
|
||||
// For SABR, create a fake URL so we can identify it in the request filter.
|
||||
if (isSABR) {
|
||||
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 (isSABR && byteRange) {
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!data.video.hlsUrl) {
|
||||
let dashUrl = data.video.dashUrl;
|
||||
if (!dashUrl) {
|
||||
dashUrl = data.video.dashUrl;
|
||||
}
|
||||
|
||||
if (!data.video.fallbackPatch && (!Capacitor.isNativePlatform() || proxyVideos)) {
|
||||
dashUrl += '?local=true';
|
||||
@@ -608,6 +919,8 @@
|
||||
}
|
||||
|
||||
onDestroy(async () => {
|
||||
HttpFetchPlugin.cacheManager.clearCache();
|
||||
|
||||
if (Capacitor.getPlatform() === 'android') {
|
||||
if (originalOrigination) {
|
||||
await StatusBar.setOverlaysWebView({ overlay: false });
|
||||
@@ -623,7 +936,6 @@
|
||||
try {
|
||||
savePlayerPos();
|
||||
} catch (error) {}
|
||||
await playerElement.pause();
|
||||
await player.destroy();
|
||||
await shakaUi.destroy();
|
||||
playerPosSet = false;
|
||||
|
||||
@@ -1,195 +1,214 @@
|
||||
import { androidPoTokenMinter } from '$lib/android/youtube/minter';
|
||||
import type { AdaptiveFormats, Captions, Image, StoryBoard, Thumbnail, VideoBase, VideoPlay } from '$lib/api/model';
|
||||
import type {
|
||||
AdaptiveFormats,
|
||||
Captions,
|
||||
Image,
|
||||
StoryBoard,
|
||||
Thumbnail,
|
||||
VideoBase,
|
||||
VideoPlay
|
||||
} from '$lib/api/model';
|
||||
import { numberWithCommas } from '$lib/numbers';
|
||||
import { interfaceRegionStore, poTokenCacheStore } from '$lib/store';
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import { USER_AGENT } from 'bgutils-js';
|
||||
import { Buffer } from 'buffer';
|
||||
import { get } from 'svelte/store';
|
||||
import { Innertube, UniversalCache, YT, YTNodes } from 'youtubei.js';
|
||||
import { Innertube, UniversalCache, Utils, YT, YTNodes } from 'youtubei.js';
|
||||
|
||||
export async function patchYoutubeJs(videoId: string): Promise<VideoPlay> {
|
||||
if (!Capacitor.isNativePlatform()) {
|
||||
throw new Error('Yt.js: Platform not supported');
|
||||
}
|
||||
if (!Capacitor.isNativePlatform()) {
|
||||
throw new Error('Platform not supported');
|
||||
}
|
||||
|
||||
const youtube = await Innertube.create({
|
||||
fetch: fetch,
|
||||
cache: new UniversalCache(false),
|
||||
location: get(interfaceRegionStore),
|
||||
user_agent: USER_AGENT,
|
||||
enable_session_cache: false
|
||||
});
|
||||
const youtube = await Innertube.create({
|
||||
fetch: fetch,
|
||||
cache: new UniversalCache(false),
|
||||
location: get(interfaceRegionStore),
|
||||
user_agent: USER_AGENT,
|
||||
enable_session_cache: false
|
||||
});
|
||||
|
||||
let sessionPoToken: string;
|
||||
let contentPoToken: string;
|
||||
let sessionPoToken: string;
|
||||
let contentPoToken: string;
|
||||
|
||||
const requestKey = 'O43z0dpjhgX20SCx4KAo';
|
||||
const challengeResponse = await youtube.getAttestationChallenge('ENGAGEMENT_TYPE_UNBOUND');
|
||||
const requestKey = 'O43z0dpjhgX20SCx4KAo';
|
||||
const challengeResponse = await youtube.getAttestationChallenge('ENGAGEMENT_TYPE_UNBOUND');
|
||||
|
||||
if (Capacitor.getPlatform() === 'android') {
|
||||
[sessionPoToken, contentPoToken] = await androidPoTokenMinter(
|
||||
challengeResponse,
|
||||
requestKey,
|
||||
youtube.session.context.client.visitorData ?? '',
|
||||
videoId
|
||||
);
|
||||
} else {
|
||||
[sessionPoToken, contentPoToken] = await window.electronAPI.generatePoToken(
|
||||
challengeResponse,
|
||||
requestKey,
|
||||
youtube.session.context.client.visitorData ?? '',
|
||||
videoId
|
||||
);
|
||||
}
|
||||
if (Capacitor.getPlatform() === 'android') {
|
||||
[sessionPoToken, contentPoToken] = await androidPoTokenMinter(
|
||||
challengeResponse,
|
||||
requestKey,
|
||||
youtube.session.context.client.visitorData ?? '',
|
||||
videoId
|
||||
);
|
||||
} else {
|
||||
[sessionPoToken, contentPoToken] = await window.electronAPI.generatePoToken(
|
||||
challengeResponse,
|
||||
requestKey,
|
||||
youtube.session.context.client.visitorData ?? '',
|
||||
videoId
|
||||
);
|
||||
}
|
||||
|
||||
poTokenCacheStore.set(sessionPoToken);
|
||||
poTokenCacheStore.set(sessionPoToken);
|
||||
|
||||
const extraArgs: Record<string, any> = {
|
||||
playbackContext: {
|
||||
contentPlaybackContext: {
|
||||
vis: 0,
|
||||
splay: false,
|
||||
lactMilliseconds: '-1',
|
||||
signatureTimestamp: youtube.session.player?.sts
|
||||
}
|
||||
},
|
||||
serviceIntegrityDimensions: {
|
||||
poToken: contentPoToken
|
||||
},
|
||||
contentCheckOk: true,
|
||||
racyCheckOk: true
|
||||
};
|
||||
const extraArgs: Record<string, any> = {
|
||||
playbackContext: {
|
||||
contentPlaybackContext: {
|
||||
vis: 0,
|
||||
splay: false,
|
||||
lactMilliseconds: '-1',
|
||||
signatureTimestamp: youtube.session.player?.sts
|
||||
}
|
||||
},
|
||||
contentCheckOk: true,
|
||||
racyCheckOk: true
|
||||
};
|
||||
|
||||
const watchEndpoint = new YTNodes.NavigationEndpoint({ watchEndpoint: { videoId } });
|
||||
const rawPlayerResponse = await watchEndpoint.call(youtube.actions, extraArgs);
|
||||
const rawNextResponse = await watchEndpoint.call(youtube.actions, {
|
||||
override_endpoint: '/next',
|
||||
racyCheckOk: true,
|
||||
contentCheckOk: true
|
||||
});
|
||||
const clientPlaybackNonce = Utils.generateRandomString(12);
|
||||
|
||||
const video = new YT.VideoInfo([rawPlayerResponse, rawNextResponse], youtube!.actions, '');
|
||||
const watchEndpoint = new YTNodes.NavigationEndpoint({ watchEndpoint: { videoId } });
|
||||
const rawPlayerResponse = await watchEndpoint.call(youtube.actions, {
|
||||
...extraArgs,
|
||||
parse: false
|
||||
});
|
||||
const rawNextResponse = await watchEndpoint.call(youtube.actions, {
|
||||
override_endpoint: '/next',
|
||||
racyCheckOk: true,
|
||||
contentCheckOk: true,
|
||||
parse: false
|
||||
});
|
||||
const video = new YT.VideoInfo(
|
||||
[rawPlayerResponse, rawNextResponse],
|
||||
youtube!.actions,
|
||||
clientPlaybackNonce
|
||||
);
|
||||
|
||||
console.log(video);
|
||||
if (!video.primary_info || !video.secondary_info) {
|
||||
throw new Error('Unable to pull video info from youtube.js');
|
||||
}
|
||||
|
||||
if (!video.primary_info || !video.secondary_info) {
|
||||
throw new Error('Yt.js: Unable to pull video info from youtube.js');
|
||||
}
|
||||
let dashUri: string = '';
|
||||
|
||||
let dashUri: string = '';
|
||||
if (!video.basic_info.is_live) {
|
||||
const manifest = await video.toDash();
|
||||
dashUri = `data:application/dash+xml;charset=utf-8;base64,${Buffer.from(manifest).toString('base64')}`;
|
||||
}
|
||||
|
||||
if (!video.basic_info.is_live) {
|
||||
const manifest = await video.toDash();
|
||||
dashUri = `data:application/dash+xml;charset=utf-8;base64,${Buffer.from(manifest).toString('base64')}`;
|
||||
}
|
||||
const descString = video.secondary_info.description?.toString() || '';
|
||||
|
||||
const descString = video.secondary_info.description?.toString() || '';
|
||||
let authorThumbnails: Image[];
|
||||
if (video.basic_info.channel_id) {
|
||||
const channel = await youtube.getChannel(video.basic_info.channel_id);
|
||||
authorThumbnails = channel.metadata.avatar as Image[];
|
||||
} else {
|
||||
authorThumbnails = [];
|
||||
}
|
||||
|
||||
let authorThumbnails: Image[];
|
||||
if (video.basic_info.channel_id) {
|
||||
const channel = await youtube.getChannel(video.basic_info.channel_id);
|
||||
authorThumbnails = channel.metadata.avatar as Image[];
|
||||
} else {
|
||||
authorThumbnails = [];
|
||||
}
|
||||
const recommendedVideos: VideoBase[] = [];
|
||||
video.watch_next_feed?.forEach((recommended: Record<string, any>) => {
|
||||
recommendedVideos.push({
|
||||
videoThumbnails: (recommended?.thumbnails as Thumbnail[]) || [],
|
||||
videoId: recommended?.id || '',
|
||||
title: recommended?.title?.toString() || '',
|
||||
viewCountText: recommended.view_count
|
||||
? numberWithCommas(Number(recommended?.view_count.toString().replace(/\D/g, ''))) || ''
|
||||
: '',
|
||||
lengthSeconds: recommended?.duration?.seconds || 0,
|
||||
author: recommended?.author?.name || '',
|
||||
authorId: recommended?.author?.id || ''
|
||||
});
|
||||
});
|
||||
|
||||
let recommendedVideos: VideoBase[] = [];
|
||||
video.watch_next_feed?.forEach((recommended: Record<string, any>) => {
|
||||
recommendedVideos.push({
|
||||
videoThumbnails: recommended?.thumbnails as Thumbnail[] || [],
|
||||
videoId: recommended?.id || '',
|
||||
title: recommended?.title?.toString() || '',
|
||||
viewCountText: recommended.view_count ? numberWithCommas(Number(recommended?.view_count.toString().replace(/\D/g, ''))) || '' : '',
|
||||
lengthSeconds: recommended?.duration?.seconds || 0,
|
||||
author: recommended?.author?.name || '',
|
||||
authorId: recommended?.author?.id || ''
|
||||
});
|
||||
});
|
||||
const captions: Captions[] = [];
|
||||
video.captions?.caption_tracks?.forEach((caption) => {
|
||||
captions.push({
|
||||
label: caption.name?.toString() || '',
|
||||
language_code: caption.language_code,
|
||||
// Add correct format to url.
|
||||
url: caption.base_url + '&fmt=vtt'
|
||||
});
|
||||
});
|
||||
|
||||
let captions: Captions[] = [];
|
||||
video.captions?.caption_tracks?.forEach(caption => {
|
||||
captions.push({
|
||||
label: caption.name?.toString() || '',
|
||||
language_code: caption.language_code,
|
||||
// Add correct format to url.
|
||||
url: caption.base_url + '&fmt=vtt'
|
||||
});
|
||||
});
|
||||
const adaptiveFormats: AdaptiveFormats[] = [];
|
||||
video.streaming_data?.adaptive_formats.forEach((format) => {
|
||||
adaptiveFormats.push({
|
||||
index: format.index_range?.start?.toString() || '',
|
||||
bitrate: format.bitrate?.toString() || '',
|
||||
init: format.init_range?.start?.toString() || '',
|
||||
url: format.url || '',
|
||||
itag: format.itag?.toString() || '',
|
||||
type: format.mime_type,
|
||||
clen: '',
|
||||
lmt: '',
|
||||
projectionType: 0,
|
||||
resolution: format.width ? `${format.width}x${format.height}` : undefined
|
||||
});
|
||||
});
|
||||
|
||||
let adaptiveFormats: AdaptiveFormats[] = [];
|
||||
video.streaming_data?.adaptive_formats.forEach(format => {
|
||||
adaptiveFormats.push({
|
||||
index: format.index_range?.start?.toString() || '',
|
||||
bitrate: format.bitrate?.toString() || '',
|
||||
init: format.init_range?.start?.toString() || '',
|
||||
url: format.url || '',
|
||||
itag: format.itag?.toString() || '',
|
||||
type: format.mime_type,
|
||||
clen: '',
|
||||
lmt: '',
|
||||
projectionType: 0,
|
||||
resolution: format.width ? `${format.width}x${format.height}` : undefined
|
||||
});
|
||||
});
|
||||
const storyboard: StoryBoard[] = [];
|
||||
if (video.storyboards && 'boards' in video.storyboards) {
|
||||
video.storyboards.boards.forEach((board) => {
|
||||
storyboard.push({
|
||||
templateUrl: board.template_url,
|
||||
url: board.template_url,
|
||||
count: board.storyboard_count,
|
||||
height: board.thumbnail_height,
|
||||
width: board.thumbnail_width,
|
||||
interval: board.interval,
|
||||
storyboardCount: board.storyboard_count,
|
||||
storyboardHeight: board.thumbnail_height,
|
||||
storyboardWidth: board.thumbnail_width
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
let storyboard: StoryBoard[] = [];
|
||||
if (video.storyboards && 'boards' in video.storyboards) {
|
||||
video.storyboards.boards.forEach(board => {
|
||||
storyboard.push({
|
||||
templateUrl: board.template_url,
|
||||
url: board.template_url,
|
||||
count: board.storyboard_count,
|
||||
height: board.thumbnail_height,
|
||||
width: board.thumbnail_width,
|
||||
interval: board.interval,
|
||||
storyboardCount: board.storyboard_count,
|
||||
storyboardHeight: board.thumbnail_height,
|
||||
storyboardWidth: board.thumbnail_width
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'video',
|
||||
title: video.primary_info.title?.toString() || '',
|
||||
viewCount: Number(video.basic_info.view_count || 0),
|
||||
viewCountText: video.basic_info.view_count?.toString() || '0',
|
||||
likeCount: video.basic_info.like_count || 0,
|
||||
dislikeCount: 0,
|
||||
allowRatings: false,
|
||||
rating: 0,
|
||||
isListed: 0,
|
||||
isFamilyFriendly: video.basic_info.is_family_safe || true,
|
||||
genre: video.basic_info.category || '',
|
||||
genreUrl: '',
|
||||
dashUrl: dashUri,
|
||||
adaptiveFormats: adaptiveFormats,
|
||||
formatStreams: [],
|
||||
recommendedVideos: recommendedVideos,
|
||||
authorThumbnails: authorThumbnails,
|
||||
captions: captions,
|
||||
authorId: video.basic_info.channel_id || '',
|
||||
authorUrl: `/channel/${video.basic_info.channel_id}`,
|
||||
authorVerified: false,
|
||||
description: descString,
|
||||
descriptionHtml: video.secondary_info.description?.toHTML() || descString,
|
||||
published: 0,
|
||||
publishedText: video.primary_info.published?.toString() || '',
|
||||
premiereTimestamp: 0,
|
||||
hlsUrl: video.streaming_data?.hls_manifest_url || undefined,
|
||||
liveNow: video.basic_info.is_live || false,
|
||||
premium: false,
|
||||
storyboards: storyboard,
|
||||
isUpcoming: false,
|
||||
videoId: videoId,
|
||||
videoThumbnails: video.basic_info.thumbnail as Thumbnail[],
|
||||
author: video.basic_info.author || 'Unknown',
|
||||
lengthSeconds: video.basic_info.duration || 0,
|
||||
subCountText: '',
|
||||
keywords: video.basic_info.keywords || [],
|
||||
allowedRegions: [],
|
||||
ytJsVideoInfo: video,
|
||||
fallbackPatch: 'youtubejs',
|
||||
};
|
||||
return {
|
||||
type: 'video',
|
||||
title: video.primary_info.title?.toString() || '',
|
||||
viewCount: Number(video.basic_info.view_count || 0),
|
||||
viewCountText: video.basic_info.view_count?.toString() || '0',
|
||||
likeCount: video.basic_info.like_count || 0,
|
||||
dislikeCount: 0,
|
||||
allowRatings: false,
|
||||
rating: 0,
|
||||
isListed: 0,
|
||||
isFamilyFriendly: video.basic_info.is_family_safe || true,
|
||||
genre: video.basic_info.category || '',
|
||||
genreUrl: '',
|
||||
dashUrl: dashUri,
|
||||
adaptiveFormats: adaptiveFormats,
|
||||
formatStreams: [],
|
||||
recommendedVideos: recommendedVideos,
|
||||
authorThumbnails: authorThumbnails,
|
||||
captions: captions,
|
||||
authorId: video.basic_info.channel_id || '',
|
||||
authorUrl: `/channel/${video.basic_info.channel_id}`,
|
||||
authorVerified: false,
|
||||
description: descString,
|
||||
descriptionHtml: video.secondary_info.description?.toHTML() || descString,
|
||||
published: 0,
|
||||
publishedText: video.primary_info.published?.toString() || '',
|
||||
premiereTimestamp: 0,
|
||||
hlsUrl: video.streaming_data?.hls_manifest_url || undefined,
|
||||
liveNow: video.basic_info.is_live || false,
|
||||
premium: false,
|
||||
storyboards: storyboard,
|
||||
isUpcoming: false,
|
||||
videoId: videoId,
|
||||
videoThumbnails: video.basic_info.thumbnail as Thumbnail[],
|
||||
author: video.basic_info.author || 'Unknown',
|
||||
lengthSeconds: video.basic_info.duration || 0,
|
||||
subCountText: '',
|
||||
keywords: video.basic_info.keywords || [],
|
||||
allowedRegions: [],
|
||||
ytjs: {
|
||||
innertube: youtube,
|
||||
video: video,
|
||||
clientPlaybackNonce: clientPlaybackNonce,
|
||||
rawApiResponse: rawResponse
|
||||
},
|
||||
fallbackPatch: 'youtubejs'
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
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 || ''}`;
|
||||
}
|
||||
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,280 @@
|
||||
import { GoogleVideo, Protos, concatenateChunks, 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 {
|
||||
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:
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
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';
|
||||
|
||||
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) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user