diff --git a/.gitignore b/.gitignore index 04c01ba..4cabf51 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ node_modules/ -dist/ \ No newline at end of file +dist/ +play-dl/Stream/stream_final.ts \ No newline at end of file diff --git a/node-youtube-dl/Spotify/index.ts b/node-youtube-dl/Spotify/index.ts deleted file mode 100644 index e69de29..0000000 diff --git a/node-youtube-dl/YouTube/index.ts b/node-youtube-dl/YouTube/index.ts deleted file mode 100644 index ea8738c..0000000 --- a/node-youtube-dl/YouTube/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { search } from './search' - -export * from './utils' \ No newline at end of file diff --git a/node-youtube-dl/index.ts b/node-youtube-dl/index.ts deleted file mode 100644 index c2176cf..0000000 --- a/node-youtube-dl/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -//This File is in testing stage, everything will change in this -import { playlist_info, video_basic_info, video_info, search } from "./YouTube"; - -let main = async() => { - let time_start = Date.now() - let result = await search('Rick Roll') - console.log(result[0].url) - let time_end = Date.now() - console.log(`Time Taken : ${(time_end - time_start)/1000} seconds`) -} - -main() \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 81a5c9b..f0f3168 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,13 @@ { - "name": "killer", - "version": "1.0.0", + "name": "play-dl", + "version": "0.0.2", "lockfileVersion": 2, "requires": true, "packages": { "": { - "name": "killer", - "version": "1.0.0", - "license": "ISC", + "name": "play-dl", + "version": "0.0.2", + "license": "MIT", "dependencies": { "node-fetch": "^2.6.1" }, diff --git a/package.json b/package.json index 27a6b00..80e2090 100644 --- a/package.json +++ b/package.json @@ -1,22 +1,36 @@ { - "name": "killer", - "version": "1.0.0", - "description": "", + "name": "play-dl", + "version": "0.0.2", + "description": "YouTube, SoundCloud, Spotify downloader", "main": "dist/index.js", + "typings": "dist/index.d.ts", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "build" : "tsc", + "build:check": "tsc --noEmit --incremental false" }, "repository": { "type": "git", - "url": "git+https://github.com/killer069/node-youtube-dl.git" + "url": "git+https://github.com/play-dl/play-dl" }, - "keywords": [], - "author": "", - "license": "ISC", + "keywords": [ + "node-youtube-dl", + "youtube-dl", + "yt-dl", + "ytdl", + "youtube", + "spotify-dl", + "spotify", + "soundcloud" + ], + "author": "Killer069", + "license": "MIT", "bugs": { - "url": "https://github.com/killer069/node-youtube-dl/issues" + "url": "https://github.com/play-dl/play-dl/issues" }, - "homepage": "https://github.com/killer069/node-youtube-dl#readme", + "homepage": "https://github.com/play-dl/play-dl#readme", + "files": [ + "dist/*" + ], "dependencies": { "node-fetch": "^2.6.1" }, diff --git a/play-dl/Stream/stream.ts b/play-dl/Stream/stream.ts new file mode 100644 index 0000000..39d17b6 --- /dev/null +++ b/play-dl/Stream/stream.ts @@ -0,0 +1,225 @@ +import { IncomingMessage, ClientRequest } from 'http'; +import { Socket } from 'net' +import { EventEmitter } from 'node:events' +import { RequestOptions, default as https } from 'https' +import { URL } from 'url' +import { Transform, PassThrough } from 'stream' + +interface StreamOptions extends RequestOptions { + maxRetries?: number; + maxReconnects?: number; + highWaterMark?: number; + backoff?: { inc: number; max: number }; +} + +class StreamError extends Error{ + public statusCode? : number; + constructor(message:string, statusCode? : number){ + super(message) + this.statusCode = statusCode + } +} + +export interface Stream extends PassThrough { + abort: (err?: Error) => void; + aborted: boolean; + destroy: (err?: Error) => void; + destroyed: boolean; + text: () => Promise; + on(event: 'reconnect', listener: (attempt: number, err?: Error) => void): this; + on(event: 'retry', listener: (attempt: number, err?: Error) => void): this; + on(event: 'redirect', listener: (streaming_url: string) => void): this; + on(event: string | symbol, listener: (...args: any) => void): this; +} + +var default_opts : StreamOptions = { + maxReconnects : 2, + maxRetries : 5, + highWaterMark : 15 * 1000 * 1000, + backoff : { + inc : 100, + max : 1000 + } +} + +interface RetryOptions { + err?: Error; + retryAfter?: number; +} + +class Streaming { + private opts : StreamOptions; + private stream : Stream; + private streaming_url : URL; + private request? : ClientRequest; + private response? : IncomingMessage; + private actual_stream : Transform | null; + private retries : number; + private retryTime? : NodeJS.Timer; + private reconnects : number; + private contentLength : number; + private downloaded : number; + private retryStatusCodes = new Set([429, 503]); + constructor(stream_url : string | URL, options : StreamOptions = {}){ + this.streaming_url = (stream_url instanceof URL) ? stream_url : new URL(stream_url) + this.opts = Object.assign({}, default_opts, options) + this.stream = new PassThrough({ highWaterMark: this.opts.highWaterMark }) as Stream + this.stream.destroyed = this.stream.aborted = false + this.retries = 0 + this.reconnects = 0 + this.contentLength = 0 + this.downloaded = 0 + this.actual_stream = null + } + + creation = () => { + process.nextTick(() => this.download()) + return this.stream + } + + private downloadStarted = () => { return Boolean(this.actual_stream && this.downloaded > 0) } + private downloadCompleted = () => { return Boolean(this.downloaded === this.contentLength) } + + private reconnect = (err? : StreamError) => { + this.actual_stream = null + this.retries = 0 + let ms = Math.min(this.opts.backoff?.inc as number, this.opts.backoff?.max as number) + this.retryTime = setTimeout(this.download , ms) + this.stream.emit('reconnect', this.reconnects, err) + } + + private Earlyreconnect = (err? : StreamError) => { + if(this.opts.method !== 'HEAD' && !this.downloadCompleted() && this.reconnects++ < (this.opts.maxReconnects as number)){ + this.reconnect(err) + return true + } + else return false + } + + private retryrequest = (retryOptions : RetryOptions) => { + if(!this.opts.backoff?.inc || this.stream.destroyed) return false + if(this.downloadStarted()){ + return this.Earlyreconnect(retryOptions.err) + } + else if((!retryOptions.err || retryOptions.err.message === 'ENOTFOUND') && this.reconnects++ < (this.opts.maxReconnects as number)){ + let ms = retryOptions.retryAfter || + Math.min(this.retries * this.opts.backoff.inc, this.opts.backoff.max); + this.retryTime = setTimeout(this.download, ms) + return true + } + return false + } + + private OnError = (err? : StreamError) => { + if (this.stream.destroyed || this.stream.readableEnded) { return; } + this.cleanup(); + if (!this.retryrequest({ err })) { + this.stream.emit('error', err); + } else { + this.request?.removeListener('close', this.onRequestClose); + } + } + + private onRequestClose = () => { + this.cleanup(); + this.retryrequest({}) + } + + private cleanup = () => { + this.request?.removeListener('close', this.onRequestClose) + this.response?.removeListener('data', this.OnData) + this.stream.removeListener('end', this.OnEnd) + } + + private OnData(chunk : Buffer){ + this.downloaded += chunk.length + } + + private OnEnd = () => { + this.cleanup() + if(!this.Earlyreconnect()){ + this.stream.end() + } + } + + private forwardEvents = (ee: EventEmitter, events: string[]) => { + for (let event of events) { + ee.on(event, this.stream.emit.bind(this.stream, event)); + } + }; + + private download = () => { + let parsed : RequestOptions = {} + parsed = { + host : this.streaming_url.host, + hostname : this.streaming_url.hostname, + path : this.streaming_url.pathname + this.streaming_url.search + this.streaming_url.hash, + port : this.streaming_url.port, + protocol : this.streaming_url.protocol + } + if(this.streaming_url.username){ + parsed.auth = `${this.streaming_url.username}:${this.streaming_url.password}` + } + + Object.assign(parsed, this.opts) + this.request = https.request(parsed, (res : IncomingMessage) => this.OnResponse(res)) + this.request?.on('error', this.OnError) + this.request.on('close', this.onRequestClose) + this.forwardEvents(this.request, ['connect', 'continue', 'information', 'socket', 'timeout', 'upgrade']) + if (this.stream.destroyed) { + this.Destroy(); + } + this.stream.emit('request', this.request); + this.request.end(); + } + + private Destroy = () => { + this.request?.destroy(new Error('Some Error Occured')) + this.actual_stream?.unpipe() + this.actual_stream?.destroy() + clearTimeout(this.retryTime as NodeJS.Timer) + } + + private OnResponse = (res : IncomingMessage) => { + if(this.stream.destroyed) return + + if(this.retryStatusCodes.has(res.statusCode as number)){ + if(!this.retryrequest({ retryAfter: parseInt(res.headers['retry-after'] || '0', 10) })){ + let err = new StreamError(`Status code: ${res.statusCode}`, res.statusCode) + this.stream.emit('error', err) + } + this.cleanup(); + return; + } + else if(res.statusCode && (res.statusCode < 200 || res.statusCode >= 400)){ + let err = new StreamError(`Status code: ${res.statusCode}`, res.statusCode); + if (res.statusCode >= 500) { + this.OnError(err); + } else { + this.stream.emit('error', err); + } + this.cleanup(); + return; + } + + if(!this.contentLength){ + this.contentLength = parseInt(`${res.headers['content-length']}`, 10) + } + + this.actual_stream = res as unknown as Transform + res.on('data', this.OnData) + this.actual_stream.on('end', this.OnEnd) + this.actual_stream.pipe(this.stream) + this.response = res + this.stream.emit('response', res) + res.on('error', this.OnError) + this.forwardEvents(res, ['aborted']) + } + + +} + +export function stream(stream_url : string | URL, options? : StreamOptions){ + let new_stream = new Streaming(stream_url, options) + return new_stream.creation() +} \ No newline at end of file diff --git a/node-youtube-dl/YouTube/README.md b/play-dl/YouTube/README.md similarity index 100% rename from node-youtube-dl/YouTube/README.md rename to play-dl/YouTube/README.md diff --git a/node-youtube-dl/YouTube/classes/Channel.ts b/play-dl/YouTube/classes/Channel.ts similarity index 100% rename from node-youtube-dl/YouTube/classes/Channel.ts rename to play-dl/YouTube/classes/Channel.ts diff --git a/node-youtube-dl/YouTube/classes/Playlist.ts b/play-dl/YouTube/classes/Playlist.ts similarity index 100% rename from node-youtube-dl/YouTube/classes/Playlist.ts rename to play-dl/YouTube/classes/Playlist.ts diff --git a/node-youtube-dl/YouTube/classes/Thumbnail.ts b/play-dl/YouTube/classes/Thumbnail.ts similarity index 100% rename from node-youtube-dl/YouTube/classes/Thumbnail.ts rename to play-dl/YouTube/classes/Thumbnail.ts diff --git a/node-youtube-dl/YouTube/classes/Video.ts b/play-dl/YouTube/classes/Video.ts similarity index 100% rename from node-youtube-dl/YouTube/classes/Video.ts rename to play-dl/YouTube/classes/Video.ts diff --git a/play-dl/YouTube/index.ts b/play-dl/YouTube/index.ts new file mode 100644 index 0000000..9459d6a --- /dev/null +++ b/play-dl/YouTube/index.ts @@ -0,0 +1,5 @@ +import { stream } from './stream' + +export { search } from './search' +export { stream } +export * from './utils' \ No newline at end of file diff --git a/node-youtube-dl/YouTube/search.ts b/play-dl/YouTube/search.ts similarity index 100% rename from node-youtube-dl/YouTube/search.ts rename to play-dl/YouTube/search.ts diff --git a/play-dl/YouTube/stream.ts b/play-dl/YouTube/stream.ts new file mode 100644 index 0000000..03c21c6 --- /dev/null +++ b/play-dl/YouTube/stream.ts @@ -0,0 +1,87 @@ +import { video_info } from "." +import { Stream, stream as yt_stream } from "../Stream/stream" + + +interface FilterOptions { + averagebitrate? : number; + videoQuality? : "144p" | "240p" | "360p" | "480p" | "720p" | "1080p"; + audioQuality? : "AUDIO_QUALITY_LOW" | "AUDIO_QUALITY_MEDIUM"; + audioSampleRate? : number; + audioChannels? : number; + audioCodec? : string; + audioContainer? : string; + hasAudio? : boolean; + hasVideo? : boolean; + isLive? : boolean; +} + +interface StreamOptions { + filter : "bestaudio" | "bestvideo" +} + +function parseFormats(formats : any[]): { audio: any[], video:any[] } { + let audio: any[] = [] + let video: any[] = [] + formats.forEach((format) => { + let type = format.mimeType as string + if(type.startsWith('audio')){ + format.audioCodec = type.split('codecs="')[1].split('"')[0] + format.audioContainer = type.split('audio/')[1].split(';')[0] + format.hasAudio = true + format.hasVideo = false + audio.push(format) + } + else if(type.startsWith('video')){ + format.videoQuality = format.qualityLabel + format.hasAudio = false + format.hasVideo = true + video.push(format) + } + }) + return { audio, video } +} + +function filter_songs(formats : any[], options : FilterOptions) { +} + +export async function stream(url : string, options? : StreamOptions): Promise{ + let info = await video_info(url) + let final: any[] = []; + + if(options?.filter === 'bestaudio'){ + info.format.forEach((format) => { + let type = format.mimeType as string + if(type.startsWith('audio/webm')){ + return final.push(format) + } + else return + }) + + if(final.length === 0){ + info.format.forEach((format) => { + let type = format.mimeType as string + if(type.startsWith('audio/')){ + return final.push(format) + } + else return + }) + } + } + else if(options?.filter === 'bestvideo'){ + info.format.forEach((format) => { + let type = format.mimeType as string + if(type.startsWith('video/')){ + if(parseInt(format.qualityLabel) > 480) final.push(format) + else return + } + else return + }) + + if(final.length === 0) throw new Error("Video Format > 480p is not found") + } + else{ + final.push(info.format[info.format.length - 1]) + } + + return yt_stream(final[0].url) +} \ No newline at end of file diff --git a/node-youtube-dl/YouTube/utils/cipher.ts b/play-dl/YouTube/utils/cipher.ts similarity index 100% rename from node-youtube-dl/YouTube/utils/cipher.ts rename to play-dl/YouTube/utils/cipher.ts diff --git a/node-youtube-dl/YouTube/utils/extractor.ts b/play-dl/YouTube/utils/extractor.ts similarity index 100% rename from node-youtube-dl/YouTube/utils/extractor.ts rename to play-dl/YouTube/utils/extractor.ts diff --git a/node-youtube-dl/YouTube/utils/index.ts b/play-dl/YouTube/utils/index.ts similarity index 100% rename from node-youtube-dl/YouTube/utils/index.ts rename to play-dl/YouTube/utils/index.ts diff --git a/node-youtube-dl/YouTube/utils/parser.ts b/play-dl/YouTube/utils/parser.ts similarity index 100% rename from node-youtube-dl/YouTube/utils/parser.ts rename to play-dl/YouTube/utils/parser.ts diff --git a/node-youtube-dl/YouTube/utils/request.ts b/play-dl/YouTube/utils/request.ts similarity index 100% rename from node-youtube-dl/YouTube/utils/request.ts rename to play-dl/YouTube/utils/request.ts diff --git a/play-dl/index.ts b/play-dl/index.ts new file mode 100644 index 0000000..77d27e5 --- /dev/null +++ b/play-dl/index.ts @@ -0,0 +1,5 @@ +//This File is in testing stage, everything will change in this +import { playlist_info, video_basic_info, video_info, search, stream } from "./YouTube"; + + +export let youtube = { playlist_info, video_basic_info, video_info, search, stream } diff --git a/tsconfig.json b/tsconfig.json index 9ef05e0..e8aeba8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,6 +18,6 @@ "skipLibCheck": true, "resolveJsonModule": true }, - "include": ["node-youtube-dl/**/**/*.ts"], - "exclude": ["node-youtube-dl/**/__tests__"] + "include": ["play-dl/**/**/*.ts"], + "exclude": ["play-dl/**/__tests__"] } \ No newline at end of file