add option to use non-sabr streams
This commit is contained in:
parent
13a34db2ae
commit
fa086e7bec
|
@ -1,4 +1,5 @@
|
||||||
{
|
{
|
||||||
"whitelist": [],
|
"whitelist": [],
|
||||||
"player_id": "0004de42"
|
"player_id": "0004de42",
|
||||||
|
"useCompanion": true
|
||||||
}
|
}
|
100
index.ts
100
index.ts
|
@ -8,8 +8,10 @@ import {
|
||||||
createOutputStream,
|
createOutputStream,
|
||||||
createStreamSink,
|
createStreamSink,
|
||||||
createSabrStream,
|
createSabrStream,
|
||||||
|
type DownloadOutput
|
||||||
} from './utils/sabr-stream-factory.js';
|
} from './utils/sabr-stream-factory.js';
|
||||||
import type { SabrPlaybackOptions } from 'googlevideo/sabr-stream';
|
import type { SabrPlaybackOptions } from 'googlevideo/sabr-stream';
|
||||||
|
import { getVideoStreams, downloadStream } from './utils/companion';
|
||||||
|
|
||||||
const ffmpeg = require('fluent-ffmpeg')
|
const ffmpeg = require('fluent-ffmpeg')
|
||||||
const ffmpegStatic = require('ffmpeg-static')
|
const ffmpegStatic = require('ffmpeg-static')
|
||||||
|
@ -132,11 +134,12 @@ app.get('/videos/:id', async (req, res) => {
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
app.ws('/download/:id', async (ws, req) => {
|
app.ws('/download/:id', async (ws, req) => {
|
||||||
|
const config = await Bun.file('config.json').json()
|
||||||
const yt = await Innertube.create();
|
const yt = await Innertube.create();
|
||||||
let quality = '480p'
|
let quality = '480p'
|
||||||
|
|
||||||
const info = await yt.getInfo(req.params.id, {
|
const info = await yt.getInfo(req.params.id, {
|
||||||
client: 'IOS'
|
client: 'TV_SIMPLY'
|
||||||
});
|
});
|
||||||
if (!info || info.basic_info.duration == undefined) {
|
if (!info || info.basic_info.duration == undefined) {
|
||||||
ws.send('Unable to retrieve video info from YouTube. Please try again later.');
|
ws.send('Unable to retrieve video info from YouTube. Please try again later.');
|
||||||
|
@ -157,35 +160,78 @@ app.ws('/download/:id', async (ws, req) => {
|
||||||
return ws.close()
|
return ws.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
const streamOptions: SabrPlaybackOptions = {
|
let audioOutputStream: DownloadOutput | undefined;
|
||||||
videoQuality: quality,
|
let videoOutputStream: DownloadOutput | undefined;
|
||||||
audioQuality: 'AUDIO_QUALITY_LOW',
|
|
||||||
enabledTrackTypes: EnabledTrackTypes.VIDEO_AND_AUDIO
|
|
||||||
};
|
|
||||||
const { streamResults } = await createSabrStream(req.params.id, streamOptions);
|
|
||||||
const { videoStream, audioStream, selectedFormats } = streamResults;
|
|
||||||
|
|
||||||
const config = await Bun.file('config.json').json()
|
if (config.useCompanion) {
|
||||||
const videoSizeTotal = (selectedFormats.audioFormat.contentLength || 0)
|
const streamOptions = {
|
||||||
+ (selectedFormats.videoFormat.contentLength || 0)
|
videoQuality: quality,
|
||||||
|
audioQuality: 'AUDIO_QUALITY_LOW'
|
||||||
|
};
|
||||||
|
const { streamResults, error } = await getVideoStreams(req.params.id, info.streaming_data!.adaptive_formats, streamOptions);
|
||||||
|
if (streamResults == false) {
|
||||||
|
ws.send(error)
|
||||||
|
return ws.close()
|
||||||
|
}
|
||||||
|
|
||||||
if (videoSizeTotal > (1_048_576 * 150) && !config.whitelist.includes(req.params.id)) {
|
const { videoStreamUrl, audioStreamUrl, selectedFormats } = streamResults;
|
||||||
ws.send('Is this content considered high risk? If so, please email me at admin@preservetube.com.');
|
|
||||||
ws.send('This video is too large, and unfortunately, Preservetube does not have unlimited storage.');
|
const videoSizeTotal = (selectedFormats.audioFormat.content_length || 0)
|
||||||
return ws.close()
|
+ (selectedFormats.videoFormat.content_length || 0)
|
||||||
} else if (!selectedFormats.videoFormat.contentLength) {
|
|
||||||
ws.send('Youtube isn\'t giving us enough information to be able to tell if we can process this video.')
|
if (videoSizeTotal > (1_048_576 * 150) && !config.whitelist.includes(req.params.id)) {
|
||||||
ws.send('Please try again later.')
|
ws.send('Is this content considered high risk? If so, please email me at admin@preservetube.com.');
|
||||||
return ws.close()
|
ws.send('This video is too large, and unfortunately, Preservetube does not have unlimited storage.');
|
||||||
|
return ws.close()
|
||||||
|
} else if (!selectedFormats.videoFormat.content_length) {
|
||||||
|
ws.send('Youtube isn\'t giving us enough information to be able to tell if we can process this video.')
|
||||||
|
ws.send('Please try again later.')
|
||||||
|
return ws.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
const audioOutputStream = createOutputStream(req.params.id, selectedFormats.audioFormat.mime_type!);
|
||||||
|
const videoOutputStream = createOutputStream(req.params.id, selectedFormats.videoFormat.mime_type!);
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
downloadStream(videoStreamUrl, selectedFormats.videoFormat, videoOutputStream.stream, ws, 'video'),
|
||||||
|
downloadStream(audioStreamUrl, selectedFormats.audioFormat, audioOutputStream.stream, ws, 'audio')
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
const streamOptions: SabrPlaybackOptions = {
|
||||||
|
videoQuality: quality,
|
||||||
|
audioQuality: 'AUDIO_QUALITY_LOW',
|
||||||
|
enabledTrackTypes: EnabledTrackTypes.VIDEO_AND_AUDIO
|
||||||
|
};
|
||||||
|
const { streamResults } = await createSabrStream(req.params.id, streamOptions);
|
||||||
|
const { videoStream, audioStream, selectedFormats } = streamResults;
|
||||||
|
|
||||||
|
const config = await Bun.file('config.json').json()
|
||||||
|
const videoSizeTotal = (selectedFormats.audioFormat.contentLength || 0)
|
||||||
|
+ (selectedFormats.videoFormat.contentLength || 0)
|
||||||
|
|
||||||
|
if (videoSizeTotal > (1_048_576 * 150) && !config.whitelist.includes(req.params.id)) {
|
||||||
|
ws.send('Is this content considered high risk? If so, please email me at admin@preservetube.com.');
|
||||||
|
ws.send('This video is too large, and unfortunately, Preservetube does not have unlimited storage.');
|
||||||
|
return ws.close()
|
||||||
|
} else if (!selectedFormats.videoFormat.contentLength) {
|
||||||
|
ws.send('Youtube isn\'t giving us enough information to be able to tell if we can process this video.')
|
||||||
|
ws.send('Please try again later.')
|
||||||
|
return ws.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
const audioOutputStream = createOutputStream(req.params.id, selectedFormats.audioFormat.mimeType!);
|
||||||
|
const videoOutputStream = createOutputStream(req.params.id, selectedFormats.videoFormat.mimeType!);
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
videoStream.pipeTo(createStreamSink(selectedFormats.videoFormat, videoOutputStream.stream, ws, 'video')),
|
||||||
|
audioStream.pipeTo(createStreamSink(selectedFormats.audioFormat, audioOutputStream.stream, ws, 'audio'))
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const audioOutputStream = createOutputStream(req.params.id, selectedFormats.audioFormat.mimeType!);
|
if (audioOutputStream == undefined || videoOutputStream == undefined) {
|
||||||
const videoOutputStream = createOutputStream(req.params.id, selectedFormats.videoFormat.mimeType!);
|
ws.send('This should not happen. Please report it via admin@preservetube.com.')
|
||||||
|
return ws.close()
|
||||||
await Promise.all([
|
}
|
||||||
videoStream.pipeTo(createStreamSink(selectedFormats.videoFormat, videoOutputStream.stream, ws, 'video')),
|
|
||||||
audioStream.pipeTo(createStreamSink(selectedFormats.audioFormat, audioOutputStream.stream, ws, 'audio'))
|
|
||||||
]);
|
|
||||||
|
|
||||||
ws.send('Downloaded video and audio. Merging them together.')
|
ws.send('Downloaded video and audio. Merging them together.')
|
||||||
|
|
||||||
|
@ -325,5 +371,5 @@ setInterval(switchIps, 30 * 60000) // 30 minutes
|
||||||
|
|
||||||
app.listen(8008, () => {
|
app.listen(8008, () => {
|
||||||
console.log('the metadata server is up.')
|
console.log('the metadata server is up.')
|
||||||
switchIps()
|
// switchIps()
|
||||||
})
|
})
|
|
@ -0,0 +1,163 @@
|
||||||
|
import { type WriteStream } from 'node:fs';
|
||||||
|
import * as hr from '@tsmx/human-readable'
|
||||||
|
|
||||||
|
interface StreamResults {
|
||||||
|
videoStreamUrl: string;
|
||||||
|
audioStreamUrl: string;
|
||||||
|
selectedFormats: {
|
||||||
|
videoFormat: any;
|
||||||
|
audioFormat: any;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getVideoStreams(
|
||||||
|
videoId: string,
|
||||||
|
adaptiveFormats: any[],
|
||||||
|
options: { videoQuality: string; audioQuality: string }
|
||||||
|
): Promise<{
|
||||||
|
streamResults: StreamResults | false;
|
||||||
|
error?: string
|
||||||
|
}> {
|
||||||
|
const lowestStorageVideo = adaptiveFormats
|
||||||
|
.filter((format) => !!format.quality_label?.toLowerCase().includes(options.videoQuality?.toLowerCase() || ''))
|
||||||
|
.sort((a, b) => (a.contentLength || 0) - (b.contentLength || 0))?.[0]
|
||||||
|
const lowestStorageAudio = adaptiveFormats
|
||||||
|
.filter((format) => !!format.audio_quality?.toLowerCase().includes(options.audioQuality?.toLowerCase() || ''))
|
||||||
|
.sort((a, b) => (a.contentLength || 0) - (b.contentLength || 0))?.[0]
|
||||||
|
const lowestOptions = {
|
||||||
|
videoFormat: lowestStorageVideo?.itag,
|
||||||
|
audioFormat: lowestStorageAudio?.itag
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!lowestOptions.videoFormat || !lowestOptions.audioFormat) {
|
||||||
|
return { streamResults: false, error: 'Couldn\'t find any suitable download formats.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
videoStreamUrl,
|
||||||
|
audioStreamUrl
|
||||||
|
} = {
|
||||||
|
videoStreamUrl: await getStreamUrl(videoId, lowestOptions.videoFormat),
|
||||||
|
audioStreamUrl: await getStreamUrl(videoId, lowestOptions.audioFormat)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!videoStreamUrl || !audioStreamUrl) return { streamResults: false, error: 'Failed to fetch streaming URLs from Youtube.' }
|
||||||
|
|
||||||
|
return {
|
||||||
|
streamResults: {
|
||||||
|
videoStreamUrl,
|
||||||
|
audioStreamUrl,
|
||||||
|
selectedFormats: {
|
||||||
|
videoFormat: lowestStorageVideo,
|
||||||
|
audioFormat: lowestStorageAudio
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadStream(streamUrl: string, format: any, stream: WriteStream, ws: any, type: string) {
|
||||||
|
// get the final url of the stream, since it redirects
|
||||||
|
let location = streamUrl
|
||||||
|
let headResponse: Response | undefined;
|
||||||
|
const headersToSend: HeadersInit = {
|
||||||
|
"accept": "*/*",
|
||||||
|
"accept-encoding": "gzip, deflate, br, zstd",
|
||||||
|
"accept-language": "en-us,en;q=0.5",
|
||||||
|
"origin": "https://www.youtube.com",
|
||||||
|
"referer": "https://www.youtube.com",
|
||||||
|
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36'
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const googlevideoResponse: Response = await fetch(location, {
|
||||||
|
method: "HEAD",
|
||||||
|
headers: headersToSend,
|
||||||
|
redirect: "manual",
|
||||||
|
});
|
||||||
|
if (googlevideoResponse.status == 403) {
|
||||||
|
throw new Error(`403 from google - ${await googlevideoResponse.text()}`)
|
||||||
|
}
|
||||||
|
if (googlevideoResponse.headers.has("Location")) {
|
||||||
|
location = googlevideoResponse.headers.get("Location") as string;
|
||||||
|
continue;
|
||||||
|
} else {
|
||||||
|
headResponse = googlevideoResponse;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (headResponse === undefined) {
|
||||||
|
throw new Error('google redirected too many times')
|
||||||
|
}
|
||||||
|
|
||||||
|
// setup the chunking setup.
|
||||||
|
const googleVideoUrl = new URL(location);
|
||||||
|
let size = 0;
|
||||||
|
const totalSize = Number(headResponse.headers.get("Content-Length") || format.content_length || "0")
|
||||||
|
const videoStartTime = Date.now();
|
||||||
|
const videoPrecentages: string[] = []
|
||||||
|
|
||||||
|
const getChunk = async (start: number, end: number) => {
|
||||||
|
googleVideoUrl.searchParams.set(
|
||||||
|
"range",
|
||||||
|
`${start}-${end}`,
|
||||||
|
);
|
||||||
|
const postResponse = await fetch(googleVideoUrl, {
|
||||||
|
method: "POST",
|
||||||
|
body: new Uint8Array([0x78, 0]), // protobuf: { 15: 0 } (no idea what it means but this is what YouTube uses),
|
||||||
|
headers: headersToSend,
|
||||||
|
});
|
||||||
|
if (postResponse.status !== 200) {
|
||||||
|
throw new Error("Non-200 response from google servers");
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunk = Buffer.from(await postResponse.arrayBuffer())
|
||||||
|
stream.write(chunk);
|
||||||
|
|
||||||
|
size += chunk.length;
|
||||||
|
|
||||||
|
if (totalSize > 0) {
|
||||||
|
let elapsedTime = (Date.now() - videoStartTime) / 1000;
|
||||||
|
let progress = size / totalSize;
|
||||||
|
let speedInMBps = (size / (1024 * 1024)) / elapsedTime;
|
||||||
|
let remainingTime = (totalSize - size) / (speedInMBps * 1024 * 1024);
|
||||||
|
|
||||||
|
if (!videoPrecentages.includes((progress * 100).toFixed(0))) {
|
||||||
|
videoPrecentages.push((progress * 100).toFixed(0))
|
||||||
|
ws.send(`[${type}] ${(progress * 100).toFixed(2)}% of ${hr.fromBytes(totalSize, {})} at ${speedInMBps.toFixed(2)} MB/s ETA ${secondsToTime(parseInt(remainingTime.toFixed(0)))}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const chunkSize = 5 * 1_000_000 // 5mb
|
||||||
|
const wholeRequestEndByte = Number(totalSize) - 1;
|
||||||
|
|
||||||
|
for (let startByte = 0; startByte < wholeRequestEndByte; startByte += chunkSize) {
|
||||||
|
let endByte = startByte + chunkSize - 1;
|
||||||
|
if (endByte > wholeRequestEndByte) {
|
||||||
|
endByte = wholeRequestEndByte;
|
||||||
|
}
|
||||||
|
await getChunk(startByte, endByte)
|
||||||
|
}
|
||||||
|
|
||||||
|
stream.end()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getStreamUrl(videoId: string, itag: number): Promise<string|false> {
|
||||||
|
const req = await fetch(`http://127.0.0.1:8282/companion/latest_version?id=${videoId}&itag=${itag}`, {
|
||||||
|
redirect: 'manual'
|
||||||
|
})
|
||||||
|
|
||||||
|
if (req.status == 302) {
|
||||||
|
return req.headers.get('Location')!
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function secondsToTime(seconds: number) {
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const remainingSeconds = seconds % 60;
|
||||||
|
const formattedSeconds = remainingSeconds < 10 ? '0' + remainingSeconds : remainingSeconds;
|
||||||
|
return `${minutes}:${formattedSeconds}`;
|
||||||
|
}
|
Loading…
Reference in New Issue