@@ -8,7 +8,7 @@ android {
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 104
|
||||
versionName "1.7.22"
|
||||
versionName "1.8.0"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
||||
@@ -65,9 +65,12 @@
|
||||
|
||||
|
||||
|
||||
<release version="1.7.22" date="2025-4-26">
|
||||
<url>https://github.com/Materialious/Materialious/releases/tag/1.7.22</url>
|
||||
</release>
|
||||
<release version="1.8.0" date="2025-4-30">
|
||||
<url>https://github.com/Materialious/Materialious/releases/tag/1.8.0</url>
|
||||
</release>
|
||||
<release version="1.7.22" date="2025-4-26">
|
||||
<url>https://github.com/Materialious/Materialious/releases/tag/1.7.22</url>
|
||||
</release>
|
||||
<release version="1.7.21" date="2025-4-15">
|
||||
<url>https://github.com/Materialious/Materialious/releases/tag/1.7.21</url>
|
||||
</release>
|
||||
|
||||
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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "Materialious",
|
||||
"version": "1.7.22",
|
||||
"version": "1.8.0",
|
||||
"description": "Modern material design for Invidious.",
|
||||
"author": {
|
||||
"name": "Ward Pearce",
|
||||
|
||||
Generated
+322
-301
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "materialious",
|
||||
"version": "1.7.22",
|
||||
"version": "1.8.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
@@ -26,7 +26,6 @@
|
||||
"@typescript-eslint/eslint-plugin": "^7.16.0",
|
||||
"@typescript-eslint/parser": "^7.18.0",
|
||||
"@vite-pwa/sveltekit": "^0.6.6",
|
||||
"buffer": "^6.0.3",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-svelte": "^2.45.1",
|
||||
@@ -37,7 +36,7 @@
|
||||
"svelte-check": "^4.0.0",
|
||||
"tslib": "^2.7.0",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^6.2.6"
|
||||
"vite": "^6.3.4"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
@@ -55,7 +54,7 @@
|
||||
"bgutils-js": "^3.2.0",
|
||||
"capacitor-nodejs": "https://github.com/EdenwareApps/Capacitor-NodeJS/releases/download/v1.0.0-beta.7/capacitor6-nodejs.tgz",
|
||||
"fuse.js": "^7.0.0",
|
||||
"googlevideo": "^2.0.0",
|
||||
"googlevideo": "^3.0.0",
|
||||
"he": "^1.2.0",
|
||||
"human-number": "^2.0.4",
|
||||
"iso-3166": "^4.3.0",
|
||||
@@ -72,4 +71,4 @@
|
||||
"terser": "^5.34.1",
|
||||
"youtubei.js": "^13.4.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,52 +1,56 @@
|
||||
import { goto } from "$app/navigation";
|
||||
import { Capacitor } from "@capacitor/core";
|
||||
import { goto } from '$app/navigation';
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
|
||||
const originalFetch = window.fetch;
|
||||
const corsProxyUrl: string = 'http://localhost:3000/';
|
||||
|
||||
function needsProxying(target: string): boolean {
|
||||
if (!target.startsWith('http')) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export const androidFetch = async (
|
||||
requestInput: string | URL | Request,
|
||||
requestOptions?: RequestInit
|
||||
): Promise<Response> => {
|
||||
const uri = requestInput instanceof Request ? requestInput.url : requestInput.toString();
|
||||
|
||||
if (needsProxying(uri)) {
|
||||
if (requestInput instanceof Request) {
|
||||
requestInput = new Request(corsProxyUrl + uri, {
|
||||
method: requestInput.method,
|
||||
headers: requestInput.headers,
|
||||
body: requestInput.body,
|
||||
mode: requestInput.mode,
|
||||
credentials: requestInput.credentials,
|
||||
cache: requestInput.cache,
|
||||
redirect: requestInput.redirect,
|
||||
referrer: requestInput.referrer,
|
||||
integrity: requestInput.integrity,
|
||||
keepalive: requestInput.keepalive,
|
||||
...(requestInput.body ? { duplex: 'half' } : {})
|
||||
});
|
||||
} else {
|
||||
requestInput = corsProxyUrl + uri;
|
||||
}
|
||||
}
|
||||
|
||||
// Use the original fetch with the proxied URL and options
|
||||
return originalFetch(requestInput, requestOptions);
|
||||
};
|
||||
|
||||
if (Capacitor.getPlatform() === 'android') {
|
||||
const originalFetch = window.fetch;
|
||||
window.fetch = androidFetch;
|
||||
|
||||
function needsProxying(target: string): boolean {
|
||||
if (!target.startsWith('http')) return false;
|
||||
return true;
|
||||
}
|
||||
const originalXhrOpen = XMLHttpRequest.prototype.open;
|
||||
|
||||
const corsProxyUrl: string = 'http://localhost:3000/';
|
||||
XMLHttpRequest.prototype.open = function (...args: any[]): void {
|
||||
if (needsProxying(args[1])) {
|
||||
args[1] = corsProxyUrl + args[1];
|
||||
}
|
||||
/* @ts-ignore */
|
||||
return originalXhrOpen.apply(this, args);
|
||||
};
|
||||
|
||||
window.fetch = async (requestInput: string | URL | Request, requestOptions?: RequestInit): Promise<Response> => {
|
||||
const uri = requestInput instanceof Request ? requestInput.url : requestInput.toString();
|
||||
|
||||
if (needsProxying(uri)) {
|
||||
if (requestInput instanceof Request) {
|
||||
requestInput = new Request(corsProxyUrl + uri, {
|
||||
method: requestInput.method,
|
||||
headers: requestInput.headers,
|
||||
body: requestInput.body,
|
||||
mode: requestInput.mode,
|
||||
credentials: requestInput.credentials,
|
||||
cache: requestInput.cache,
|
||||
redirect: requestInput.redirect,
|
||||
referrer: requestInput.referrer,
|
||||
integrity: requestInput.integrity,
|
||||
keepalive: requestInput.keepalive,
|
||||
...(requestInput.body ? { duplex: "half" } : {})
|
||||
});
|
||||
} else {
|
||||
requestInput = corsProxyUrl + uri;
|
||||
}
|
||||
}
|
||||
|
||||
// Use the original fetch with the proxied URL and options
|
||||
return originalFetch(requestInput, requestOptions);
|
||||
};
|
||||
|
||||
const originalXhrOpen = XMLHttpRequest.prototype.open;
|
||||
|
||||
XMLHttpRequest.prototype.open = function (...args: any[]): void {
|
||||
if (needsProxying(args[1])) {
|
||||
args[1] = corsProxyUrl + args[1];
|
||||
}
|
||||
/* @ts-ignore */
|
||||
return originalXhrOpen.apply(this, args);
|
||||
};
|
||||
|
||||
setTimeout(() => goto('/', { replaceState: true }), 100);
|
||||
setTimeout(() => goto('/', { replaceState: true }), 100);
|
||||
}
|
||||
|
||||
@@ -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, 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,50 @@
|
||||
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 playerElementResizeObserver: ResizeObserver | undefined;
|
||||
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,9 +175,20 @@
|
||||
return;
|
||||
}
|
||||
|
||||
HttpFetchPlugin.cacheManager.clearCache();
|
||||
|
||||
player = new shaka.Player();
|
||||
playerElement = document.getElementById('player') as HTMLMediaElement;
|
||||
|
||||
playerElementResizeObserver = new ResizeObserver(() => {
|
||||
if (playerElement) {
|
||||
clientViewportHeight = playerElement.clientHeight;
|
||||
clientViewportWidth = playerElement.clientWidth;
|
||||
}
|
||||
});
|
||||
|
||||
playerElementResizeObserver.observe(playerElement);
|
||||
|
||||
// Change instantly to stop video from being loud for a second
|
||||
const savedVolume = localStorage.getItem(STORAGE_KEY_VOLUME);
|
||||
if (savedVolume) {
|
||||
@@ -166,35 +230,70 @@
|
||||
});
|
||||
|
||||
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
|
||||
}
|
||||
enabled: true
|
||||
},
|
||||
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;
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
);
|
||||
|
||||
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,125 +301,322 @@
|
||||
// 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 === 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: 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);
|
||||
}
|
||||
|
||||
console.log(videoPlaybackAbrRequest);
|
||||
|
||||
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 wrapped = {} as Record<string, any>;
|
||||
wrapped.context = data.video.ytjs?.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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -460,7 +756,11 @@
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await player.load(data.video.hlsUrl + '?local=true');
|
||||
if (data.video.fallbackPatch === 'youtubejs') {
|
||||
await player.load(data.video.dashUrl);
|
||||
} else {
|
||||
await player.load(data.video.hlsUrl + '?local=true');
|
||||
}
|
||||
}
|
||||
|
||||
if (data.video.fallbackPatch === 'youtubejs') {
|
||||
@@ -608,6 +908,12 @@
|
||||
}
|
||||
|
||||
onDestroy(async () => {
|
||||
HttpFetchPlugin.cacheManager.clearCache();
|
||||
|
||||
if (playerElementResizeObserver) {
|
||||
playerElementResizeObserver.disconnect();
|
||||
}
|
||||
|
||||
if (Capacitor.getPlatform() === 'android') {
|
||||
if (originalOrigination) {
|
||||
await StatusBar.setOverlaysWebView({ overlay: false });
|
||||
@@ -623,7 +929,6 @@
|
||||
try {
|
||||
savePlayerPos();
|
||||
} catch (error) {}
|
||||
await playerElement.pause();
|
||||
await player.destroy();
|
||||
await shakaUi.destroy();
|
||||
playerPosSet = false;
|
||||
|
||||
@@ -115,14 +115,16 @@
|
||||
.youtube-theme .shaka-overflow-menu,
|
||||
.youtube-theme .shaka-settings-menu {
|
||||
border-radius: 0.25rem;
|
||||
background-color: var(--surface-container-low);
|
||||
background: var(--surface-container-low);
|
||||
right: 0.625rem; /* 10px */
|
||||
bottom: 3.125rem; /* 50px */
|
||||
min-width: 12.5rem; /* 200px */
|
||||
transition:
|
||||
transform var(--speed3),
|
||||
border-radius var(--speed3),
|
||||
padding var(--speed3);
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
opacity: 1 !important;
|
||||
visibility: visible !important;
|
||||
z-index: 9999 !important;
|
||||
pointer-events: auto !important;
|
||||
}
|
||||
|
||||
.youtube-theme .shaka-settings-menu {
|
||||
|
||||
@@ -1,195 +1,231 @@
|
||||
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 { fromFormat } from '$lib/sabr/formatKeyUtils';
|
||||
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
|
||||
});
|
||||
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 | undefined;
|
||||
|
||||
let dashUri: string = '';
|
||||
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 || '';
|
||||
});
|
||||
|
||||
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) {
|
||||
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) {
|
||||
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(undefined, undefined, { captions_format: 'vtt' }))}`;
|
||||
}
|
||||
}
|
||||
|
||||
const descString = video.secondary_info.description?.toString() || '';
|
||||
if (!dashUri) throw Error('Unable to find suitable dash manifest');
|
||||
|
||||
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 descString = video.secondary_info.description?.toString() || '';
|
||||
|
||||
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 || ''
|
||||
});
|
||||
});
|
||||
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 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 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 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 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 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
|
||||
});
|
||||
});
|
||||
}
|
||||
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
|
||||
});
|
||||
});
|
||||
|
||||
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',
|
||||
};
|
||||
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
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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: rawPlayerResponse
|
||||
},
|
||||
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,102 @@
|
||||
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-ignore */
|
||||
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,281 @@
|
||||
import { GoogleVideo, Protos, concatenateChunks, PART } from 'googlevideo';
|
||||
import type { 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,266 @@
|
||||
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';
|
||||
import { androidFetch } from '$lib/android/http/androidRequests';
|
||||
|
||||
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_ = Capacitor.getPlatform() === 'android' ? androidFetch : 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 as string);
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,81 +1,93 @@
|
||||
import { decodeHtmlCharCodes } from "./misc";
|
||||
import { convertToSeconds } from "./numbers";
|
||||
import { decodeHtmlCharCodes } from './misc';
|
||||
import { convertToSeconds } from './numbers';
|
||||
|
||||
export interface PhasedDescription {
|
||||
description: string;
|
||||
timestamps: { title: string; time: number; timePretty: string; }[];
|
||||
description: string;
|
||||
timestamps: { title: string; time: number; timePretty: string }[];
|
||||
}
|
||||
|
||||
export function extractActualLink(url: string): string {
|
||||
const urlParams = new URLSearchParams(url.split('?')[1]);
|
||||
const actualLink = urlParams.get('q');
|
||||
if (actualLink) {
|
||||
return decodeURIComponent(actualLink);
|
||||
}
|
||||
return url;
|
||||
const urlParams = new URLSearchParams(url.split('?')[1]);
|
||||
const actualLink = urlParams.get('q');
|
||||
if (actualLink) {
|
||||
return decodeURIComponent(actualLink);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
export function processYoutubeLink(line: string): string {
|
||||
// Regex to match the <a> tag and extract the href (YouTube redirect link)
|
||||
const urlRegex = /<a href="https:\/\/www\.youtube\.com\/redirect\?([^"]+)"/;
|
||||
// Regex to match the <a> tag and extract the href (YouTube redirect link)
|
||||
const urlRegex = /<a href="https:\/\/www\.youtube\.com\/redirect\?([^"]+)"/;
|
||||
|
||||
const urlMatch = urlRegex.exec(line);
|
||||
const urlMatch = urlRegex.exec(line);
|
||||
|
||||
if (urlMatch) {
|
||||
// Extract the YouTube redirect URL and get the actual URL from the `q` parameter
|
||||
const redirectUrl = urlMatch[0]; // the full redirect URL with the `q` parameter
|
||||
const actualUrl = extractActualLink(redirectUrl);
|
||||
return line.replace(urlRegex, `<a href="${actualUrl}"`);
|
||||
} else {
|
||||
// If no match found, just return the original line
|
||||
return line;
|
||||
}
|
||||
if (urlMatch) {
|
||||
// Extract the YouTube redirect URL and get the actual URL from the `q` parameter
|
||||
const redirectUrl = urlMatch[0]; // the full redirect URL with the `q` parameter
|
||||
const actualUrl = extractActualLink(redirectUrl);
|
||||
return line.replace(urlRegex, `<a href="${actualUrl}"`);
|
||||
} else {
|
||||
// If no match found, just return the original line
|
||||
return line;
|
||||
}
|
||||
}
|
||||
|
||||
export function phaseDescription(videoId: string, content: string, fallbackPatch?: 'youtubejs' | 'piped'): PhasedDescription {
|
||||
const timestamps: { title: string; time: number; timePretty: string; }[] = [];
|
||||
const lines = content.split('\n');
|
||||
export function phaseDescription(
|
||||
videoId: string,
|
||||
content: string,
|
||||
fallbackPatch?: 'youtubejs' | 'piped'
|
||||
): PhasedDescription {
|
||||
const timestamps: { title: string; time: number; timePretty: string }[] = [];
|
||||
const lines = content.split('\n');
|
||||
|
||||
// Regular expressions for different timestamp formats
|
||||
const urlRegex = /<a href="([^"]+)"/;
|
||||
const timestampRegexInvidious = /<a href="([^"]+)" data-onclick="jump_to_time" data-jump-time="(\d+)">(\d+:\d+(?::\d+)?)<\/a>\s*(.+)/;
|
||||
const timestampRegexYtJs = new RegExp(
|
||||
`href="https://www\\.youtube\\.com/watch\\?v=${videoId}(?:&t=(\\d+)s)?"[^>]*>\\s*<span[^>]*>\\s*([^<]+)\\s*</span>\\s*</a>\\s*<span[^>]*>\\s*([^<]+)\\s*</span>`,
|
||||
'i'
|
||||
);
|
||||
// Regular expressions for different timestamp formats
|
||||
const urlRegex = /<a href="([^"]+)"/;
|
||||
const timestampRegexInvidious =
|
||||
/<a href="([^"]+)" data-onclick="jump_to_time" data-jump-time="(\d+)">(\d+:\d+(?::\d+)?)<\/a>\s*(.+)/;
|
||||
const timestampRegexYtJs = new RegExp(
|
||||
`href="https://www\\.youtube\\.com/watch\\?v=${videoId}(?:&t=(\\d+)s)?"[^>]*>\\s*<span[^>]*>\\s*([^<]+)\\s*</span>\\s*</a>\\s*<span[^>]*>\\s*([^<]+)\\s*</span>`,
|
||||
'i'
|
||||
);
|
||||
|
||||
let filteredLines: string[] = [];
|
||||
lines.forEach((line) => {
|
||||
const urlMatch = urlRegex.exec(line);
|
||||
// Use appropriate regex based on the `usingYoutubeJs` flag
|
||||
const timestampMatch = (fallbackPatch === 'youtubejs' ? timestampRegexYtJs : timestampRegexInvidious).exec(fallbackPatch === 'youtubejs' ? line + '</span>' : line);
|
||||
const filteredLines: string[] = [];
|
||||
lines.forEach((line) => {
|
||||
const urlMatch = urlRegex.exec(line);
|
||||
// Use appropriate regex based on the `usingYoutubeJs` flag
|
||||
const timestampMatch = (
|
||||
fallbackPatch === 'youtubejs' ? timestampRegexYtJs : timestampRegexInvidious
|
||||
).exec(fallbackPatch === 'youtubejs' ? line + '</span>' : line);
|
||||
|
||||
if (urlMatch !== null && timestampMatch === null) {
|
||||
// If line contains a URL but not a timestamp, modify the URL
|
||||
const modifiedLine = processYoutubeLink(line).replace(
|
||||
/<a href="([^"]+)"/,
|
||||
'<a href="$1" target="_blank" rel="noopener noreferrer" class="link"'
|
||||
);
|
||||
console.log(modifiedLine);
|
||||
filteredLines.push(modifiedLine);
|
||||
} else if (timestampMatch !== null) {
|
||||
// If line contains a timestamp, extract details and push into timestamps array
|
||||
const time = (fallbackPatch === 'youtubejs' ? timestampMatch[1] : timestampMatch[2]) || '0';
|
||||
const timestamp = fallbackPatch === 'youtubejs' ? timestampMatch[2] : timestampMatch[3];
|
||||
const title = fallbackPatch === 'youtubejs' ? timestampMatch[3] || '' : timestampMatch[4] || '';
|
||||
if (urlMatch !== null && timestampMatch === null) {
|
||||
// If line contains a URL but not a timestamp, modify the URL
|
||||
const modifiedLine = processYoutubeLink(line).replace(
|
||||
/<a href="([^"]+)"/,
|
||||
'<a href="$1" target="_blank" rel="noopener noreferrer" class="link"'
|
||||
);
|
||||
filteredLines.push(modifiedLine);
|
||||
} else if (timestampMatch !== null) {
|
||||
// If line contains a timestamp, extract details and push into timestamps array
|
||||
const time = (fallbackPatch === 'youtubejs' ? timestampMatch[1] : timestampMatch[2]) || '0';
|
||||
const timestamp = fallbackPatch === 'youtubejs' ? timestampMatch[2] : timestampMatch[3];
|
||||
const title =
|
||||
fallbackPatch === 'youtubejs' ? timestampMatch[3] || '' : timestampMatch[4] || '';
|
||||
|
||||
timestamps.push({
|
||||
time: convertToSeconds(time),
|
||||
// Remove any HTML in the timestamp title.
|
||||
title: decodeHtmlCharCodes(title.replace(/<[^>]+>/g, '').replace(/\n/g, '').trim()),
|
||||
timePretty: timestamp
|
||||
});
|
||||
} else {
|
||||
filteredLines.push(line);
|
||||
}
|
||||
});
|
||||
timestamps.push({
|
||||
time: convertToSeconds(time),
|
||||
// Remove any HTML in the timestamp title.
|
||||
title: decodeHtmlCharCodes(
|
||||
title
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.replace(/\n/g, '')
|
||||
.trim()
|
||||
),
|
||||
timePretty: timestamp
|
||||
});
|
||||
} else {
|
||||
filteredLines.push(line);
|
||||
}
|
||||
});
|
||||
|
||||
const filteredContent = filteredLines.join('\n');
|
||||
const filteredContent = filteredLines.join('\n');
|
||||
|
||||
return { description: filteredContent, timestamps: timestamps };
|
||||
}
|
||||
return { description: filteredContent, timestamps: timestamps };
|
||||
}
|
||||
|
||||
@@ -192,11 +192,9 @@
|
||||
|
||||
const link = (event.target as HTMLElement).closest('a');
|
||||
|
||||
if (link && link.href) {
|
||||
if (link.href && link.href.startsWith('http') && link.target === '_blank') {
|
||||
event.preventDefault();
|
||||
Browser.open({ url: link.href });
|
||||
}
|
||||
if (link && link.href && link.href.startsWith('http') && link.target === '_blank') {
|
||||
event.preventDefault();
|
||||
Browser.open({ url: link.href });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,129 +5,154 @@ const HOST = 'localhost';
|
||||
const PORT = 3000;
|
||||
const MAX_REDIRECTS = 10;
|
||||
|
||||
const CORS_HEADERS = 'Origin, X-Requested-With, Content-Type, Accept, Authorization, x-goog-visitor-id, x-goog-api-key, x-origin, x-youtube-client-version, x-youtube-client-name, x-goog-api-format-version, x-user-agent, Accept-Language, Range, Referer'
|
||||
const CORS_ORIGIN = 'https://www.youtube.com'
|
||||
const USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36(KHTML, like Gecko)'
|
||||
const CORS_HEADERS = [
|
||||
'Origin',
|
||||
'X-Requested-With',
|
||||
'Content-Type',
|
||||
'Accept',
|
||||
'Authorization',
|
||||
'x-goog-visitor-id',
|
||||
'x-goog-api-key',
|
||||
'x-origin',
|
||||
'x-youtube-client-version',
|
||||
'x-youtube-client-name',
|
||||
'x-goog-api-format-version',
|
||||
'x-goog-authuser',
|
||||
'x-user-agent',
|
||||
'Accept-Language',
|
||||
'X-Goog-FieldMask',
|
||||
'Range',
|
||||
'Referer',
|
||||
'Cookie'
|
||||
].join(', ');
|
||||
const CORS_ORIGIN = 'https://www.youtube.com';
|
||||
const USER_AGENT =
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36(KHTML, like Gecko)';
|
||||
|
||||
function setCorsHeaders(res) {
|
||||
res.setHeader('Access-Control-Allow-Origin', CORS_ORIGIN);
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', CORS_HEADERS);
|
||||
res.setHeader('Access-Control-Max-Age', '86400')
|
||||
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
||||
res.setHeader('Access-Control-Allow-Origin', CORS_ORIGIN);
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', CORS_HEADERS);
|
||||
res.setHeader('Access-Control-Max-Age', '86400');
|
||||
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
||||
}
|
||||
|
||||
function fetchWithRedirects(targetUrl, options, redirectCount = 0) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const httpClient = targetUrl.protocol.startsWith('https') ? https : http;
|
||||
function fetchWithRedirects(targetUrl, options, bodyChunks, redirectCount = 0) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const httpClient = targetUrl.protocol.startsWith('https') ? https : http;
|
||||
|
||||
const req = httpClient.request(targetUrl, options, (res) => {
|
||||
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
||||
if (redirectCount >= MAX_REDIRECTS) {
|
||||
req.end();
|
||||
return reject(new Error('Too many redirects'));
|
||||
}
|
||||
const req = httpClient.request(targetUrl, options, (res) => {
|
||||
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
||||
if (redirectCount >= MAX_REDIRECTS) {
|
||||
req.end();
|
||||
return reject(new Error('Too many redirects'));
|
||||
}
|
||||
|
||||
try {
|
||||
// Attempt to create the redirect URL
|
||||
const redirectUrl = new URL(res.headers.location, targetUrl);
|
||||
return resolve(fetchWithRedirects(redirectUrl, options, redirectCount + 1));
|
||||
} catch (error) {
|
||||
req.end();
|
||||
return reject(new Error(`Invalid URL in redirect: ${error.message}`));
|
||||
}
|
||||
}
|
||||
resolve(res); // Resolve with the final response if not a redirect
|
||||
});
|
||||
try {
|
||||
// Attempt to create the redirect URL
|
||||
const redirectUrl = new URL(res.headers.location, targetUrl);
|
||||
return resolve(fetchWithRedirects(redirectUrl, options, bodyChunks, redirectCount + 1));
|
||||
} catch (error) {
|
||||
req.end();
|
||||
return reject(new Error(`Invalid URL in redirect: ${error.message}`));
|
||||
}
|
||||
}
|
||||
resolve(res); // Resolve with the final response if not a redirect
|
||||
});
|
||||
|
||||
if (options.body && (req.method === 'POST' || req.method === 'PUT')) {
|
||||
req.write(options.body);
|
||||
}
|
||||
// For POST and PUT methods, pass the body to the outgoing request
|
||||
if (bodyChunks && (req.method === 'POST' || req.method === 'PUT')) {
|
||||
const buffer = Buffer.concat(bodyChunks);
|
||||
options.headers['Content-Length'] = buffer.length;
|
||||
|
||||
req.setTimeout(10000, () => reject(new Error('Request timeout')), req.end());
|
||||
req.on('error', (error) => reject(error), req.end());
|
||||
req.end();
|
||||
});
|
||||
req.write(buffer);
|
||||
}
|
||||
|
||||
req.setTimeout(10000, () => reject(new Error('Request timeout')), req.end());
|
||||
req.on('error', (error) => reject(error), req.end());
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
setCorsHeaders(res);
|
||||
setCorsHeaders(res);
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||
return res.end();
|
||||
}
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||
return res.end();
|
||||
}
|
||||
|
||||
if (!req.url || req.url === '/') {
|
||||
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
||||
return res.end('No URL provided to fetch.');
|
||||
}
|
||||
if (!req.url || req.url === '/') {
|
||||
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
||||
return res.end('No URL provided to fetch.');
|
||||
}
|
||||
|
||||
let targetUrl = req.url.slice(1); // Remove leading '/'
|
||||
let parsedTarget;
|
||||
try {
|
||||
// Ensure protocol (http) is added if missing
|
||||
if (!targetUrl.startsWith('http')) {
|
||||
targetUrl = 'http://' + targetUrl;
|
||||
}
|
||||
parsedTarget = new URL(targetUrl);
|
||||
} catch (error) {
|
||||
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
||||
return res.end(`Invalid URL: ${error.message}`);
|
||||
}
|
||||
let targetUrl = req.url.slice(1); // Remove leading '/'
|
||||
let parsedTarget;
|
||||
try {
|
||||
// Ensure protocol (http) is added if missing
|
||||
if (!targetUrl.startsWith('http')) {
|
||||
targetUrl = 'http://' + targetUrl;
|
||||
}
|
||||
parsedTarget = new URL(targetUrl);
|
||||
} catch (error) {
|
||||
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
||||
return res.end(`Invalid URL: ${error.message}`);
|
||||
}
|
||||
|
||||
let body = '';
|
||||
req.on('data', chunk => {
|
||||
body += chunk;
|
||||
});
|
||||
let chunks = [];
|
||||
req.on('data', (chunk) => {
|
||||
chunks.push(chunk);
|
||||
});
|
||||
|
||||
req.on('end', async () => {
|
||||
const options = {
|
||||
method: req.method,
|
||||
headers: Object.fromEntries(
|
||||
Object.entries(req.headers).filter(([key]) => ![
|
||||
'host', 'origin', 'referer', 'x-forwarded-for', 'x-requested-with'
|
||||
].includes(key.toLowerCase()))
|
||||
)
|
||||
};
|
||||
req.on('end', async () => {
|
||||
const options = {
|
||||
method: req.method,
|
||||
headers: Object.fromEntries(
|
||||
Object.entries(req.headers).filter(
|
||||
([key]) =>
|
||||
![
|
||||
'referer',
|
||||
'x-forwarded-for',
|
||||
'x-requested-with',
|
||||
'sec-ch-ua-mobile',
|
||||
'sec-ch-ua',
|
||||
'sec-ch-ua-platform'
|
||||
].includes(key.toLowerCase())
|
||||
)
|
||||
)
|
||||
};
|
||||
|
||||
options.headers.host = parsedTarget.host;
|
||||
options.headers.origin = parsedTarget.origin;
|
||||
options.headers['user-agent'] = USER_AGENT;
|
||||
options.headers.host = parsedTarget.host;
|
||||
options.headers.origin = parsedTarget.origin;
|
||||
options.headers['user-agent'] = USER_AGENT;
|
||||
|
||||
// For POST and PUT methods, pass the body to the outgoing request
|
||||
if (req.method === 'POST' || req.method === 'PUT') {
|
||||
options.headers['Content-Length'] = Buffer.byteLength(body);
|
||||
options.body = body;
|
||||
}
|
||||
try {
|
||||
const proxyRes = await fetchWithRedirects(parsedTarget, options, chunks);
|
||||
|
||||
try {
|
||||
const proxyRes = await fetchWithRedirects(parsedTarget, options);
|
||||
req.on('close', () => {
|
||||
console.log('Request canceled by the client.');
|
||||
proxyRes.destroy();
|
||||
});
|
||||
|
||||
req.on('close', () => {
|
||||
console.log('Request canceled by the client.');
|
||||
proxyRes.destroy();
|
||||
});
|
||||
res.writeHead(proxyRes.statusCode, {
|
||||
...proxyRes.headers,
|
||||
'Access-Control-Allow-Origin': CORS_ORIGIN,
|
||||
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
|
||||
'Access-Control-Allow-Headers': CORS_HEADERS,
|
||||
'Access-Control-Allow-Credentials': 'true'
|
||||
});
|
||||
|
||||
res.writeHead(proxyRes.statusCode, {
|
||||
...proxyRes.headers,
|
||||
'Access-Control-Allow-Origin': CORS_ORIGIN,
|
||||
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
|
||||
'Access-Control-Allow-Headers': CORS_HEADERS,
|
||||
'Access-Control-Allow-Credentials': 'true',
|
||||
});
|
||||
|
||||
// Pipe response data back to the client
|
||||
proxyRes.pipe(res);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Proxy error:', error);
|
||||
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
||||
res.end(`Error: ${error.message}`);
|
||||
}
|
||||
});
|
||||
// Pipe response data back to the client
|
||||
proxyRes.pipe(res);
|
||||
} catch (error) {
|
||||
console.error('Proxy error:', error);
|
||||
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
||||
res.end(`Error: ${error.message}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`Server is running on http://${HOST}:${PORT}`);
|
||||
console.log(`Server is running on http://${HOST}:${PORT}`);
|
||||
});
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ import os
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
LATEST_VERSION = "1.7.22"
|
||||
LATEST_VERSION = "1.8.0"
|
||||
RELEASE_DATE = datetime.now().strftime("%Y-%-m-%d") # Format: YYYY-M-D
|
||||
|
||||
WORKING_DIR = os.path.join(
|
||||
|
||||
Reference in New Issue
Block a user