diff --git a/package-lock.json b/package-lock.json index 55c4a0b..0d0793a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,16 @@ { "name": "play-dl", - "version": "1.4.5", + "version": "1.5.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "play-dl", - "version": "1.4.5", + "version": "1.5.0", "license": "GPL-3.0", + "dependencies": { + "play-audio": "^0.4.3" + }, "devDependencies": { "@types/node": "^16.9.4", "prettier": "^2.3.1", @@ -162,6 +165,11 @@ "node": ">=0.10.0" } }, + "node_modules/play-audio": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/play-audio/-/play-audio-0.4.3.tgz", + "integrity": "sha512-DOLTP1+cgXH0k1ZdZyXXRsAPnVrzV2xZV6EXpWRsMtk24oolS7mD3WUQltuCeuJXKGM1tIsXLr+EZo6Ky4aKRg==" + }, "node_modules/prettier": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.5.0.tgz", @@ -382,6 +390,11 @@ "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", "dev": true }, + "play-audio": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/play-audio/-/play-audio-0.4.3.tgz", + "integrity": "sha512-DOLTP1+cgXH0k1ZdZyXXRsAPnVrzV2xZV6EXpWRsMtk24oolS7mD3WUQltuCeuJXKGM1tIsXLr+EZo6Ky4aKRg==" + }, "prettier": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.5.0.tgz", diff --git a/package.json b/package.json index c743dc6..1eb95bc 100644 --- a/package.json +++ b/package.json @@ -44,8 +44,11 @@ "@types/node": "^16.9.4", "prettier": "^2.3.1", "typedoc": "^0.22.9", + "typedoc-plugin-extras": "^2.2.1", "typedoc-plugin-missing-exports": "^0.22.4", - "typescript": "^4.4.4", - "typedoc-plugin-extras": "^2.2.1" + "typescript": "^4.4.4" + }, + "dependencies": { + "play-audio": "^0.4.3" } } diff --git a/play-dl/YouTube/classes/SeekStream.ts b/play-dl/YouTube/classes/SeekStream.ts new file mode 100644 index 0000000..d4d094d --- /dev/null +++ b/play-dl/YouTube/classes/SeekStream.ts @@ -0,0 +1,217 @@ +import { IncomingMessage } from "http"; +import { Readable } from "stream"; +import { request_stream } from "../../Request"; +import { parseAudioFormats, StreamOptions, StreamType } from "../stream"; +import { video_info } from "../utils"; +import { Timer } from "./LiveStream"; +import { WebmSeeker, WebmSeekerState } from "./WebmSeeker"; + +/** + * YouTube Stream Class for playing audio from normal videos. + */ + export class SeekStream { + /** + * Readable Stream through which data passes + */ + stream: WebmSeeker; + /** + * Type of audio data that we recieved from normal youtube url. + */ + type: StreamType; + /** + * Audio Endpoint Format Url to get data from. + */ + private url: string; + /** + * Used to calculate no of bytes data that we have recieved + */ + private bytes_count: number; + /** + * Calculate per second bytes by using contentLength (Total bytes) / Duration (in seconds) + */ + private per_sec_bytes: number; + /** + * Total length of audio file in bytes + */ + private content_length: number; + /** + * YouTube video url. [ Used only for retrying purposes only. ] + */ + private video_url: string; + /** + * Timer for looping data every 265 seconds. + */ + private timer: Timer; + /** + * Quality given by user. [ Used only for retrying purposes only. ] + */ + private quality: number; + /** + * Incoming message that we recieve. + * + * Storing this is essential. + * This helps to destroy the TCP connection completely if you stopped player in between the stream + */ + private request: IncomingMessage | null; + /** + * YouTube Stream Class constructor + * @param url Audio Endpoint url. + * @param type Type of Stream + * @param duration Duration of audio playback [ in seconds ] + * @param contentLength Total length of Audio file in bytes. + * @param video_url YouTube video url. + * @param options Options provided to stream function. + */ + constructor( + url: string, + duration: number, + contentLength: number, + video_url: string, + options: StreamOptions + ) { + this.stream = new WebmSeeker({ highWaterMark: 5 * 1000 * 1000, readableObjectMode : true }); + this.url = url; + this.quality = options.quality as number; + this.type = StreamType.Opus; + this.bytes_count = 0; + this.video_url = video_url; + this.per_sec_bytes = Math.ceil(contentLength / duration); + this.content_length = contentLength; + this.request = null; + this.timer = new Timer(() => { + this.timer.reuse(); + this.loop(); + }, 265); + this.stream.on('close', () => { + this.timer.destroy(); + this.cleanup(); + }); + this.seek(options.seek!) + } + + private seek(ms : number){ + return new Promise(async(res) => { + const stream = await request_stream(this.url, { + headers: { + range: `bytes=0-1000` + } + }).catch((err: Error) => err); + + if (stream instanceof Error) { + this.stream.emit('error', stream); + this.bytes_count = 0; + this.per_sec_bytes = 0; + this.cleanup(); + return; + } + + this.request = stream + stream.pipe(this.stream, { end : false }) + + stream.once('end', () => { + this.stream.state = WebmSeekerState.READING_DATA + + const bytes = this.stream.seek(ms) + if (bytes instanceof Error) { + this.stream.emit('error', bytes); + this.bytes_count = 0; + this.per_sec_bytes = 0; + this.cleanup(); + return; + } + + this.bytes_count = bytes + this.timer.reuse() + this.loop() + res('') + }) + }) + } + /** + * Retry if we get 404 or 403 Errors. + */ + private async retry() { + const info = await video_info(this.video_url); + const audioFormat = parseAudioFormats(info.format); + this.url = audioFormat[this.quality].url; + } + /** + * This cleans every used variable in class. + * + * This is used to prevent re-use of this class and helping garbage collector to collect it. + */ + private cleanup() { + this.request?.destroy(); + this.request = null; + this.url = ''; + } + /** + * Getting data from audio endpoint url and passing it to stream. + * + * If 404 or 403 occurs, it will retry again. + */ + private async loop() { + if (this.stream.destroyed) { + this.timer.destroy(); + this.cleanup(); + return; + } + const end: number = this.bytes_count + this.per_sec_bytes * 300; + const stream = await request_stream(this.url, { + headers: { + range: `bytes=${this.bytes_count}-${end >= this.content_length ? '' : end}` + } + }).catch((err: Error) => err); + if (stream instanceof Error) { + this.stream.emit('error', stream); + this.bytes_count = 0; + this.per_sec_bytes = 0; + this.cleanup(); + return; + } + if (Number(stream.statusCode) >= 400) { + this.cleanup(); + await this.retry(); + this.timer.reuse(); + this.loop(); + return; + } + this.request = stream; + stream.pipe(this.stream) + + stream.once('error', async () => { + this.cleanup(); + await this.retry(); + this.timer.reuse(); + this.loop(); + }); + + stream.on('data', (chunk: any) => { + this.bytes_count += chunk.length; + }); + + stream.on('end', () => { + if (end >= this.content_length) { + this.timer.destroy(); + this.stream.push(null); + this.cleanup(); + } + }); + } + /** + * Pauses timer. + * Stops running of loop. + * + * Useful if you don't want to get excess data to be stored in stream. + */ + pause() { + this.timer.pause(); + } + /** + * Resumes timer. + * Starts running of loop. + */ + resume() { + this.timer.resume(); + } +} diff --git a/play-dl/YouTube/classes/WebmSeeker.ts b/play-dl/YouTube/classes/WebmSeeker.ts new file mode 100644 index 0000000..2324b5f --- /dev/null +++ b/play-dl/YouTube/classes/WebmSeeker.ts @@ -0,0 +1,207 @@ +import { WebmElements, WebmHeader } from 'play-audio' +import { Duplex, DuplexOptions } from 'stream' + +enum DataType { master, string, uint, binary, float } + +export enum WebmSeekerState{ + READING_HEAD = 'READING_HEAD', + READING_DATA = 'READING_DATA', +} + +export class WebmSeeker extends Duplex{ + remaining? : Buffer + state : WebmSeekerState + chunk? : Buffer + cursor : number + header : WebmHeader + headfound : boolean + seekfound : boolean + private data_size : number + private data_length : number + + constructor(options? : DuplexOptions){ + super(options) + this.state = WebmSeekerState.READING_HEAD + this.cursor = 0 + this.header = new WebmHeader() + this.headfound = false + this.seekfound = false + this.data_length = 0 + this.data_size = 0 + } + + private get vint_length(): number{ + let i = 0; + for (; i < 8; i++){ + if ((1 << (7 - i)) & this.chunk![this.cursor]) + break; + } + return ++i; + } + + private get vint_value(): boolean { + if (!this.chunk) return false + const length = this.vint_length + if(this.chunk.length < this.cursor + length) return false + let value = this.chunk[this.cursor] & ((1 << (8 - length)) - 1) + for (let i = this.cursor + 1; i < this.cursor + length; i++) value = (value << 8) + this.chunk[i]; + this.data_size = length + this.data_length = value + return true + } + + cleanup(){ + this.cursor = 0 + this.chunk = undefined + this.remaining = undefined + } + + _read() {} + + seek(ms : number): Error | number{ + let position = 0 + let time = (Math.floor(ms / 10) * 10) + if (!this.header.segment.cues) return new Error("Failed to Parse Cues") + + for(const data of this.header.segment.cues){ + if(Math.floor(data.time as number / 1000) === time) { + position = data.position as number + break; + } + else continue; + } + if(position === 0) return Error("Failed to find Cluster Position") + else return position + } + + _write(chunk: Buffer, _: BufferEncoding, callback: (error?: Error | null) => void): void { + if (this.remaining) { + this.chunk = Buffer.concat([this.remaining, chunk]) + this.remaining = undefined + } + else this.chunk = chunk + + let err : Error | undefined; + + if(this.state === WebmSeekerState.READING_HEAD) err = this.readHead() + else if(!this.seekfound) err = this.getClosetCluster() + else err = this.readTag() + + if(err) callback(err) + else callback() + } + + private readHead(): Error | undefined{ + if (!this.chunk) return new Error("Chunk is missing") + + while(this.chunk.length > this.cursor ){ + const oldCursor = this.cursor + const id = this.vint_length + if(this.chunk.length < this.cursor + id) break; + + const ebmlID = this.parseEbmlID(this.chunk.slice(this.cursor, this.cursor + id).toString('hex')) + this.cursor += id + const vint = this.vint_value + + if(!vint) { + this.cursor = oldCursor + break; + } + if(!ebmlID){ + this.cursor += this.data_size + this.data_length + continue; + } + + if(!this.headfound){ + if(ebmlID.name === "ebml") this.headfound = true + else return new Error("Failed to find EBML ID at start of stream.") + } + const data = this.chunk.slice(this.cursor + this.data_size, this.cursor + this.data_size + this.data_length) + const parse = this.header.parse(ebmlID, data) + if(parse instanceof Error) return parse + + if(ebmlID.type === DataType.master) { + this.cursor += this.data_size + continue; + } + + if(this.chunk.length < this.cursor + this.data_size + this.data_length) { + this.cursor = oldCursor; + break; + } + else this.cursor += this.data_size + this.data_length + } + this.remaining = this.chunk.slice(this.cursor) + this.cursor = 0 + } + + private readTag(): Error | undefined{ + if (!this.chunk) return new Error("Chunk is missing") + + while(this.chunk.length > this.cursor ){ + const oldCursor = this.cursor + const id = this.vint_length + if(this.chunk.length < this.cursor + id) break; + + const ebmlID = this.parseEbmlID(this.chunk.slice(this.cursor, this.cursor + id).toString('hex')) + this.cursor += id + const vint = this.vint_value + + if(!vint) { + this.cursor = oldCursor + break; + } + if(!ebmlID){ + this.cursor += this.data_size + this.data_length + continue; + } + + const data = this.chunk.slice(this.cursor + this.data_size, this.cursor + this.data_size + this.data_length) + const parse = this.header.parse(ebmlID, data) + if(parse instanceof Error) return parse + + if(ebmlID.type === DataType.master) { + this.cursor += this.data_size + continue; + } + + if(this.chunk.length < this.cursor + this.data_size + this.data_length) { + this.cursor = oldCursor; + break; + } + else this.cursor += this.data_size + this.data_length + + if(ebmlID.name === 'simpleBlock'){ + const track = this.header.segment.tracks![this.header.audioTrack] + if(!track || track.trackType !== 2) return new Error("No audio Track in this webm file.") + if((data[0] & 0xf) === track.trackNumber) this.push(data.slice(4)) + } + } + this.remaining = this.chunk.slice(this.cursor) + this.cursor = 0 + } + + private getClosetCluster(): Error | undefined{ + if(!this.chunk) return new Error("Chunk is missing") + const count = this.chunk.indexOf('1f43b675', 0, 'hex') + if(count === -1) throw new Error("Failed to find nearest Cluster.") + else this.chunk = this.chunk.slice(count) + this.seekfound = true + return this.readTag() + } + + private parseEbmlID(ebmlID : string){ + if(Object.keys(WebmElements).includes(ebmlID)) return WebmElements[ebmlID] + else return false + } + + _destroy(error : Error | null, callback : (error : Error | null) => void) : void { + this.cleanup() + callback(error); + } + + _final(callback: (error?: Error | null) => void): void { + this.cleanup(); + callback(); + } +} \ No newline at end of file diff --git a/play-dl/YouTube/stream.ts b/play-dl/YouTube/stream.ts index 363bed6..2aca551 100644 --- a/play-dl/YouTube/stream.ts +++ b/play-dl/YouTube/stream.ts @@ -1,4 +1,5 @@ import { LiveStream, Stream } from './classes/LiveStream'; +import { SeekStream } from './classes/SeekStream'; import { InfoData, StreamInfoData } from './utils/constants'; import { video_stream_info } from './utils/extractor'; @@ -11,6 +12,7 @@ export enum StreamType { } export interface StreamOptions { + seek? : number quality?: number; htmldata?: boolean; } @@ -35,7 +37,7 @@ export function parseAudioFormats(formats: any[]) { /** * Type for YouTube Stream */ -export type YouTubeStream = Stream | LiveStream; +export type YouTubeStream = Stream | LiveStream | SeekStream; /** * Stream command for YouTube * @param url YouTube URL @@ -77,7 +79,19 @@ export async function stream_from_info( else final.push(info.format[info.format.length - 1]); let type: StreamType = final[0].codec === 'opus' && final[0].container === 'webm' ? StreamType.WebmOpus : StreamType.Arbitrary; - return new Stream( + if(options.seek){ + if(type === StreamType.WebmOpus) { + return new SeekStream( + final[0].url, + info.video_details.durationInSec, + Number(final[0].contentLength), + info.video_details.url, + options + ) + } + else throw new Error("Seek is only supported in Webm Opus Files.") + } + else return new Stream( final[0].url, type, info.video_details.durationInSec,