Merge pull request #895 from Materialious/update/1.8.0

Update/1.8.0
This commit is contained in:
Ward
2025-05-01 12:47:31 +12:00
committed by GitHub
20 changed files with 2117 additions and 838 deletions
+1 -1
View File
@@ -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>
+2 -2
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "Materialious",
"version": "1.7.22",
"version": "1.8.0",
"description": "Modern material design for Invidious.",
"author": {
"name": "Ward Pearce",
+322 -301
View File
File diff suppressed because it is too large Load Diff
+4 -5
View File
@@ -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);
}
+7 -3
View File
@@ -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';
}
+439 -134
View File
@@ -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 {
+203 -167
View File
@@ -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'
};
}
+74
View File
@@ -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;
}
+147
View File
@@ -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;
}
}
+102
View File
@@ -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('-');
}
+281
View File
@@ -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;
}
}
+75 -63
View File
@@ -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 };
}
+3 -5
View File
@@ -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 });
}
}
+126 -101
View File
@@ -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
View File
@@ -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(