diff --git a/docs/README.md b/docs/README.md index 1ea6ed9..1fa5b92 100644 --- a/docs/README.md +++ b/docs/README.md @@ -77,6 +77,7 @@ let data = await search('Rick Roll', { limit: 1, source : { soundcloud : "tracks - quality : `number` :- Sets quality of stream [ 0 = Lowest, 1 = Medium ]. Leave this empty to get highest audio quality. - cookie : `string` :- **[Cookies](https://github.com/play-dl/play-dl/discussions/34)** are optional and are required for playing age restricted videos. +- proxy : `Proxy` :- Optional parameter to add support of proxies. As of now, HTTPS proxies are only supported. So make sure to get HTTPS proxies only. #### stream(url : `string`, options? : [`StreamOptions`](https://github.com/play-dl/play-dl/tree/main/docs#streamoptions-)) @@ -91,6 +92,14 @@ let source = await stream("url", { quality : 1 }) // Next to Lowest quality. let source = await stream("url", { cookie: COOKIE }) //This will create a stream Class and also give cookies. +let source = await stream(url, { proxy : ['url'] }) // Accepts a url which has port in in it. + +let source = await stream(url. {proxy : [{ + host : "IP or hostname", + port : 8080 + }] +}) // Or add a json containing hostname and port. + let resource = createAudioResource(source.stream, { inputType : source.type }) // This creates resource for playing @@ -104,7 +113,7 @@ _This is basic to create a stream from a info [ from [video_info](https://github ```js let source = await stream_from_info(info) // This will create a stream Class from video_info or SoundCoudTrack Class. Highest Quality - + let source = await stream_from_info(info, { quality : 0 }) // Lowest quality let source = await stream_from_info(info, { quality : 1 }) // Next to Lowest quality. @@ -112,7 +121,15 @@ let source = await stream_from_info(info, { quality : 1 }) // Next to Lowest qua let source = await stream_from_info(info, { cookie: COOKIE }) //This will create a stream Class and also give cookies if retrying. - let resource = createAudioResource(source.stream, { +let source = await stream_from_info(url, { proxy : ['url'] }) // Accepts a url which has port in in it. + +let source = await stream_from_info(url. {proxy : [{ + host : "IP or hostname", + port : 8080 + }] +}) // Or add a json containing hostname and port. + +let resource = createAudioResource(source.stream, { inputType : source.type }) // This creates resource for playing ``` diff --git a/docs/YouTube/README.md b/docs/YouTube/README.md index d054fd3..0267d44 100644 --- a/docs/YouTube/README.md +++ b/docs/YouTube/README.md @@ -43,22 +43,37 @@ let id = extractID(url) ## Video -### video_basic_info(url : `string`, cookie? : `string`) +### InfoOptions + +_This are the info options that can be passed as a parameter in `video_info` and `video_basic_info`_ + +- cookie : **[Cookies](https://github.com/play-dl/play-dl/discussions/34) are optional and are required for playing age restricted videos.** +- proxy : Optional parameter to add support of proxies. As of now, HTTPS proxies are only supported. So make sure to get HTTPS proxies only. + +```js +const video = await video_basic_info(url, { cookie : "cookies" }) // Gives cookies support + +const video = await video_basic_info(url, { proxy : ['url'] }) // Accepts a url which has port in in it. + +const video = await video_basic_info(url. {proxy : [{ + host : "IP or hostname", + port : 8080 + }] +}) // Or add a json containing hostname and port. +``` + +### video_basic_info(url : `string`, options? : [`InfoOptions`]()) _The basic video details `play-dl` fetches at first from url or videoID._ -**[Cookies](https://github.com/play-dl/play-dl/discussions/34) are optional and are required for playing age restricted videos.** - ```js const video = await video_basic_info(url) ``` -### video_info(url : `string`, cookie? : `string`) +### video_info(url : `string`, , options? : [`InfoOptions`]()) _This contains everything with deciphered formats along with `video_details`. It can fetech data from url or videoID._ -**[Cookies](https://github.com/play-dl/play-dl/discussions/34) are optional and are required for playing age restricted videos.** - ```js const video = await video_info(url) ``` @@ -74,18 +89,18 @@ const video = await video_info(url) ## Playlist -### playlist_info(url : `string`, parseIncomplete : `boolean`) +### playlist_info(url : `string`, options : `PlaylistOptions`) _This fetches all details about a playlist from a url or playlistID._ -**parseIncomplete** is optional parameter if you want to parse playlist with hidden videos. - ```js const playlist = await playlist_info(url) //This only fetches first 100 videos from a playlist -const playlist = await playlist_info(url, true) +const playlist = await playlist_info(url, { incomplete : true }) //This only fetches first 100 videos from a playlist and also parses playlist with hidden videos + +const playlist = await playlist_info(url, { proxy : [''] }) // Same 2 options as mentioned in InfoOptions ``` - #### fetch() `method` diff --git a/play-dl/SoundCloud/classes.ts b/play-dl/SoundCloud/classes.ts index 4296021..9674af3 100644 --- a/play-dl/SoundCloud/classes.ts +++ b/play-dl/SoundCloud/classes.ts @@ -32,7 +32,9 @@ export interface SoundCloudTrackFormat { }; quality: string; } - +/** + * SoundCloud Track + */ export class SoundCloudTrack { name: string; id: number; @@ -100,7 +102,9 @@ export class SoundCloudTrack { }; } } - +/** + * SoundCloud Playlist + */ export class SoundCloudPlaylist { name: string; id: number; @@ -193,7 +197,9 @@ export class SoundCloudPlaylist { }; } } - +/** + * SoundCloud Stream class + */ export class Stream { stream: PassThrough; type: StreamType; diff --git a/play-dl/SoundCloud/index.ts b/play-dl/SoundCloud/index.ts index 18de69b..7c59028 100644 --- a/play-dl/SoundCloud/index.ts +++ b/play-dl/SoundCloud/index.ts @@ -12,9 +12,13 @@ interface SoundDataOptions { client_id: string; } -const pattern = /^(?:(https?):\/\/)?(?:(?:www|m)\.)?(soundcloud\.com|snd\.sc)\/(.*)$/; - -export async function soundcloud(url: string): Promise { +const pattern = /^(?:(https?):\/\/)?(?:(?:www|m)\.)?(api\.soundcloud\.com|soundcloud\.com|snd\.sc)\/(.*)$/; +/** + * Function to get info from a soundcloud url + * @param url soundcloud url + * @returns SoundCloud Track or SoundCloud Playlist + */ +export async function soundcloud(url: string): Promise { if (!soundData) throw new Error('SoundCloud Data is missing\nDid you forgot to do authorization ?'); if (!url.match(pattern)) throw new Error('This is not a SoundCloud URL'); @@ -32,12 +36,22 @@ export async function soundcloud(url: string): Promise { +): Promise { const response = await request( `https://api-v2.soundcloud.com/search/${type}?q=${query}&client_id=${soundData.client_id}&limit=${limit}` ); @@ -49,7 +63,12 @@ export async function so_search( }); return results; } - +/** + * Main Function for creating a Stream of soundcloud + * @param url soundcloud url + * @param quality Quality to select from + * @returns SoundCloud Stream + */ export async function stream(url: string, quality?: number): Promise { const data = await soundcloud(url); @@ -66,8 +85,17 @@ export async function stream(url: string, quality?: number): Promise { : StreamType.Arbitrary; return new Stream(s_data.url, type); } - -export async function stream_from_info(data: SoundCloudTrack, quality?: number): Promise { +/** + * Type for SoundCloud Stream + */ +export type SoundCloudStream = Stream; +/** + * Function for creating a Stream of soundcloud using a SoundCloud Track Class + * @param data SoundCloud Track Class + * @param quality Quality to select from + * @returns SoundCloud Stream + */ +export async function stream_from_info(data: SoundCloudTrack, quality?: number): Promise { const HLSformats = parseHlsFormats(data.formats); if (typeof quality !== 'number') quality = HLSformats.length - 1; else if (quality <= 0) quality = 0; @@ -79,7 +107,11 @@ export async function stream_from_info(data: SoundCloudTrack, quality?: number): : StreamType.Arbitrary; return new Stream(s_data.url, type); } - +/** + * Function to check client ID + * @param id Client ID + * @returns boolean + */ export async function check_id(id: string): Promise { const response = await request(`https://api-v2.soundcloud.com/search?client_id=${id}&q=Rick+Roll&limit=0`).catch( (err: Error) => { @@ -89,7 +121,11 @@ export async function check_id(id: string): Promise { if (response instanceof Error) return false; else return true; } - +/** + * Function to validate for a soundcloud url + * @param url soundcloud url + * @returns "false" | 'track' | 'playlist' + */ export async function so_validate(url: string): Promise { const data = await request( `https://api-v2.soundcloud.com/resolve?url=${url}&client_id=${soundData.client_id}` @@ -102,7 +138,11 @@ export async function so_validate(url: string): Promise { diff --git a/play-dl/Spotify/classes.ts b/play-dl/Spotify/classes.ts index 52540a8..807497a 100644 --- a/play-dl/Spotify/classes.ts +++ b/play-dl/Spotify/classes.ts @@ -26,8 +26,10 @@ interface SpotifyCopyright { text: string; type: string; } - -export class SpotifyVideo { +/** + * Class for Spotify Track + */ +export class SpotifyTrack { name: string; type: 'track' | 'playlist' | 'album'; id: string; @@ -36,8 +38,8 @@ export class SpotifyVideo { durationInSec: number; durationInMs: number; artists: SpotifyArtists[]; - album: SpotifyTrackAlbum; - thumbnail: SpotifyThumbnail; + album: SpotifyTrackAlbum | undefined; + thumbnail: SpotifyThumbnail | undefined; constructor(data: any) { this.name = data.name; this.id = data.id; @@ -55,15 +57,19 @@ export class SpotifyVideo { }); }); this.artists = artists; - this.album = { - name: data.album.name, - url: data.external_urls.spotify, - id: data.album.id, - release_date: data.album.release_date, - release_date_precision: data.album.release_date_precision, - total_tracks: data.album.total_tracks - }; - this.thumbnail = data.album.images[0]; + if (!data.album?.name) this.album = undefined; + else { + this.album = { + name: data.album.name, + url: data.external_urls.spotify, + id: data.album.id, + release_date: data.album.release_date, + release_date_precision: data.album.release_date_precision, + total_tracks: data.album.total_tracks + }; + } + if (!data.album?.images?.[0]) this.thumbnail = undefined; + else this.thumbnail = data.album.images[0]; } toJSON() { @@ -81,7 +87,9 @@ export class SpotifyVideo { }; } } - +/** + * Class for Spotify Playlist + */ export class SpotifyPlaylist { name: string; type: 'track' | 'playlist' | 'album'; @@ -93,7 +101,7 @@ export class SpotifyPlaylist { owner: SpotifyArtists; tracksCount: number; private spotifyData: SpotifyDataOptions; - private fetched_tracks: Map; + private fetched_tracks: Map; constructor(data: any, spotifyData: SpotifyDataOptions) { this.name = data.name; this.type = 'playlist'; @@ -108,9 +116,9 @@ export class SpotifyPlaylist { id: data.owner.id }; this.tracksCount = Number(data.tracks.total); - const videos: SpotifyVideo[] = []; + const videos: SpotifyTrack[] = []; data.tracks.items.forEach((v: any) => { - videos.push(new SpotifyVideo(v.track)); + videos.push(new SpotifyTrack(v.track)); }); this.fetched_tracks = new Map(); this.fetched_tracks.set('1', videos); @@ -136,11 +144,11 @@ export class SpotifyPlaylist { } } ).catch((err) => reject(`Response Error : \n${err}`)); - const videos: SpotifyVideo[] = []; + const videos: SpotifyTrack[] = []; if (typeof response !== 'string') return; const json_data = JSON.parse(response); json_data.items.forEach((v: any) => { - videos.push(new SpotifyVideo(v.track)); + videos.push(new SpotifyTrack(v.track)); }); this.fetched_tracks.set(`${i}`, videos); resolve('Success'); @@ -163,7 +171,7 @@ export class SpotifyPlaylist { get total_tracks() { const page_number: number = this.total_pages; - return (page_number - 1) * 100 + (this.fetched_tracks.get(`${page_number}`) as SpotifyVideo[]).length; + return (page_number - 1) * 100 + (this.fetched_tracks.get(`${page_number}`) as SpotifyTrack[]).length; } toJSON() { @@ -179,7 +187,9 @@ export class SpotifyPlaylist { }; } } - +/** + * Class for Spotify Album + */ export class SpotifyAlbum { name: string; type: 'track' | 'playlist' | 'album'; @@ -192,7 +202,7 @@ export class SpotifyAlbum { release_date_precision: string; trackCount: number; private spotifyData: SpotifyDataOptions; - private fetched_tracks: Map; + private fetched_tracks: Map; constructor(data: any, spotifyData: SpotifyDataOptions) { this.name = data.name; this.type = 'album'; @@ -212,9 +222,9 @@ export class SpotifyAlbum { this.release_date = data.release_date; this.release_date_precision = data.release_date_precision; this.trackCount = data.total_tracks; - const videos: SpotifyTracks[] = []; + const videos: SpotifyTrack[] = []; data.tracks.items.forEach((v: any) => { - videos.push(new SpotifyTracks(v)); + videos.push(new SpotifyTrack(v)); }); this.fetched_tracks = new Map(); this.fetched_tracks.set('1', videos); @@ -240,11 +250,11 @@ export class SpotifyAlbum { } } ).catch((err) => reject(`Response Error : \n${err}`)); - const videos: SpotifyTracks[] = []; + const videos: SpotifyTrack[] = []; if (typeof response !== 'string') return; const json_data = JSON.parse(response); json_data.items.forEach((v: any) => { - videos.push(new SpotifyTracks(v)); + videos.push(new SpotifyTrack(v)); }); this.fetched_tracks.set(`${i}`, videos); resolve('Success'); @@ -267,7 +277,7 @@ export class SpotifyAlbum { get total_tracks() { const page_number: number = this.total_pages; - return (page_number - 1) * 100 + (this.fetched_tracks.get(`${page_number}`) as SpotifyVideo[]).length; + return (page_number - 1) * 100 + (this.fetched_tracks.get(`${page_number}`) as SpotifyTrack[]).length; } toJSON() { @@ -284,45 +294,3 @@ export class SpotifyAlbum { }; } } - -class SpotifyTracks { - name: string; - type: 'track' | 'playlist' | 'album'; - id: string; - url: string; - explicit: boolean; - durationInSec: number; - durationInMs: number; - artists: SpotifyArtists[]; - constructor(data: any) { - this.name = data.name; - this.id = data.id; - this.type = 'track'; - this.url = data.external_urls.spotify; - this.explicit = data.explicit; - this.durationInMs = data.duration_ms; - this.durationInSec = Math.round(this.durationInMs / 1000); - const artists: SpotifyArtists[] = []; - data.artists.forEach((v: any) => { - artists.push({ - name: v.name, - id: v.id, - url: v.external_urls.spotify - }); - }); - this.artists = artists; - } - - toJSON() { - return { - name: this.name, - id: this.id, - type: this.type, - url: this.url, - explicit: this.explicit, - durationInMs: this.durationInMs, - durationInSec: this.durationInSec, - artists: this.artists - }; - } -} diff --git a/play-dl/Spotify/index.ts b/play-dl/Spotify/index.ts index df5b02a..cd2f7d5 100644 --- a/play-dl/Spotify/index.ts +++ b/play-dl/Spotify/index.ts @@ -1,12 +1,14 @@ import { request } from '../YouTube/utils/request'; -import { SpotifyAlbum, SpotifyPlaylist, SpotifyVideo } from './classes'; +import { SpotifyAlbum, SpotifyPlaylist, SpotifyTrack } from './classes'; import fs from 'fs'; let spotifyData: SpotifyDataOptions; if (fs.existsSync('.data/spotify.data')) { spotifyData = JSON.parse(fs.readFileSync('.data/spotify.data').toString()); } - +/** + * Spotify Data options that are stored in spotify.data file. + */ export interface SpotifyDataOptions { client_id: string; client_secret: string; @@ -21,8 +23,12 @@ export interface SpotifyDataOptions { } const pattern = /^((https:)?\/\/)?open.spotify.com\/(track|album|playlist)\//; - -export async function spotify(url: string): Promise { +/** + * Function to get Playlist | Album | Track + * @param url url of spotify from which you want info + * @returns Spotify type. + */ +export async function spotify(url: string): Promise { if (!spotifyData) throw new Error('Spotify Data is missing\nDid you forgot to do authorization ?'); if (!url.match(pattern)) throw new Error('This is not a Spotify URL'); if (url.indexOf('track/') !== -1) { @@ -35,7 +41,7 @@ export async function spotify(url: string): Promise { const response = await request(`https://accounts.spotify.com/api/token`, { headers: { @@ -104,18 +118,31 @@ export async function SpotifyAuthorize(data: SpotifyDataOptions): Promise= (spotifyData.expiry as number)) return true; else return false; } - +/** + * type for Spotify Class + */ +export type Spotify = SpotifyAlbum | SpotifyPlaylist | SpotifyTrack; +/** + * Function for searching songs on Spotify + * @param query searching query + * @param type "album" | "playlist" | "track" + * @param limit max no of results + * @returns Spotify type. + */ export async function sp_search( query: string, type: 'album' | 'playlist' | 'track', limit: number = 10 -): Promise<(SpotifyAlbum | SpotifyPlaylist | SpotifyVideo)[]> { - const results: (SpotifyAlbum | SpotifyPlaylist | SpotifyVideo)[] = []; +): Promise { + const results: Spotify[] = []; if (!spotifyData) throw new Error('Spotify Data is missing\nDid you forgot to do authorization ?'); if (query.length === 0) throw new Error('Pass some query to search.'); if (limit > 50 || limit < 0) throw new Error(`You crossed limit range of Spotify [ 0 - 50 ]`); @@ -135,7 +162,7 @@ export async function sp_search( const json_data = JSON.parse(response); if (type === 'track') { json_data.tracks.items.forEach((track: any) => { - results.push(new SpotifyVideo(track)); + results.push(new SpotifyTrack(track)); }); } else if (type === 'album') { json_data.albums.items.forEach((album: any) => { @@ -148,7 +175,10 @@ export async function sp_search( } return results; } - +/** + * Function to refresh Token + * @returns boolean to check whether token is refreshed or not + */ export async function refreshToken(): Promise { const response = await request(`https://accounts.spotify.com/api/token`, { headers: { diff --git a/play-dl/YouTube/classes/Channel.ts b/play-dl/YouTube/classes/Channel.ts index f1d4653..5891a69 100644 --- a/play-dl/YouTube/classes/Channel.ts +++ b/play-dl/YouTube/classes/Channel.ts @@ -3,18 +3,21 @@ export interface ChannelIconInterface { width: number; height: number; } - -export class Channel { +/** + * Class for YouTube Channel url + */ +export class YouTubeChannel { name?: string; verified?: boolean; id?: string; + type: 'video' | 'playlist' | 'channel'; url?: string; icon?: ChannelIconInterface; subscribers?: string; constructor(data: any) { if (!data) throw new Error(`Cannot instantiate the ${this.constructor.name} class without data!`); - + this.type = 'channel'; this._patch(data); } @@ -41,10 +44,6 @@ export class Channel { return this.icon.url.replace(`=s${def}-c`, `=s${options.size}-c`); } - get type(): 'channel' { - return 'channel'; - } - toString(): string { return this.name || ''; } diff --git a/play-dl/YouTube/classes/LiveStream.ts b/play-dl/YouTube/classes/LiveStream.ts index 0c32f9a..805613f 100644 --- a/play-dl/YouTube/classes/LiveStream.ts +++ b/play-dl/YouTube/classes/LiveStream.ts @@ -1,7 +1,7 @@ import { PassThrough } from 'stream'; import { IncomingMessage } from 'http'; -import { StreamType } from '../stream'; -import { request, request_stream } from '../utils/request'; +import { parseAudioFormats, StreamOptions, StreamType } from '../stream'; +import { Proxy, request, request_stream } from '../utils/request'; import { video_info } from '..'; export interface FormatInterface { @@ -47,7 +47,7 @@ export class LiveStreaming { if ( info.LiveStreamData.isLive === true && info.LiveStreamData.hlsManifestUrl !== null && - info.video_details.durationInSec === '0' + info.video_details.durationInSec === 0 ) { this.url = info.LiveStreamData.dashManifestUrl; } @@ -119,7 +119,9 @@ export class LiveStreaming { }, this.interval); } } - +/** + * Class for YouTube Stream + */ export class Stream { stream: PassThrough; type: StreamType; @@ -128,10 +130,11 @@ export class Stream { private per_sec_bytes: number; private content_length: number; private video_url: string; - private timer: NodeJS.Timer | null; private cookie: string; private data_ended: boolean; private playing_count: number; + private quality: number; + private proxy: Proxy[]; private request: IncomingMessage | null; constructor( url: string, @@ -139,17 +142,17 @@ export class Stream { duration: number, contentLength: number, video_url: string, - cookie: string + cookie: string, + options: StreamOptions ) { this.stream = new PassThrough({ highWaterMark: 10 * 1000 * 1000 }); this.url = url; + this.quality = options.quality as number; + this.proxy = options.proxy || []; this.type = type; this.bytes_count = 0; this.video_url = video_url; this.cookie = cookie; - this.timer = setInterval(() => { - this.retry(); - }, 7200 * 1000); this.per_sec_bytes = Math.ceil(contentLength / duration); this.content_length = contentLength; this.request = null; @@ -174,15 +177,14 @@ export class Stream { } private async retry() { - const info = await video_info(this.video_url, this.cookie); - this.url = info.format[info.format.length - 1].url; + const info = await video_info(this.video_url, { cookie: this.cookie, proxy: this.proxy }); + const audioFormat = parseAudioFormats(info.format); + this.url = audioFormat[this.quality].url; } private cleanup() { - clearInterval(this.timer as NodeJS.Timer); this.request?.unpipe(this.stream); this.request?.destroy(); - this.timer = null; this.request = null; this.url = ''; } @@ -210,18 +212,15 @@ export class Stream { this.cleanup(); await this.retry(); this.loop(); - if (!this.timer) { - this.timer = setInterval(() => { - this.retry(); - }, 7200 * 1000); - } return; } this.request = stream; stream.pipe(this.stream, { end: false }); - stream.once('error', (err) => { - this.stream.emit('error', err); + stream.once('error', async (err) => { + this.cleanup(); + await this.retry(); + this.loop(); }); stream.on('data', (chunk: any) => { diff --git a/play-dl/YouTube/classes/Playlist.ts b/play-dl/YouTube/classes/Playlist.ts index 3254c8c..ee07455 100644 --- a/play-dl/YouTube/classes/Playlist.ts +++ b/play-dl/YouTube/classes/Playlist.ts @@ -1,22 +1,29 @@ import { getPlaylistVideos, getContinuationToken } from '../utils/extractor'; import { request } from '../utils/request'; -import { Thumbnail } from './Thumbnail'; -import { Channel } from './Channel'; -import { Video } from './Video'; +import { YouTubeChannel } from './Channel'; +import { YouTubeVideo } from './Video'; const BASE_API = 'https://www.youtube.com/youtubei/v1/browse?key='; - -export class PlayList { +/** + * Class for YouTube Playlist url + */ +export class YouTubePlayList { id?: string; title?: string; + type: 'video' | 'playlist' | 'channel'; videoCount?: number; lastUpdate?: string; views?: number; url?: string; link?: string; - channel?: Channel; - thumbnail?: Thumbnail; + channel?: YouTubeChannel; + thumbnail?: { + id: string | undefined; + width: number | undefined; + height: number | undefined; + url: string | undefined; + }; private videos?: []; - private fetched_videos: Map; + private fetched_videos: Map; private _continuation: { api?: string; token?: string; @@ -28,6 +35,7 @@ export class PlayList { if (!data) throw new Error(`Cannot instantiate the ${this.constructor.name} class without data!`); this.__count = 0; this.fetched_videos = new Map(); + this.type = 'playlist'; if (searchResult) this.__patchSearch(data); else this.__patch(data); } @@ -44,7 +52,7 @@ export class PlayList { this.thumbnail = data.thumbnail || undefined; this.videos = data.videos || []; this.__count++; - this.fetched_videos.set(`${this.__count}`, this.videos as Video[]); + this.fetched_videos.set(`${this.__count}`, this.videos as YouTubeVideo[]); this._continuation.api = data.continuation?.api ?? undefined; this._continuation.token = data.continuation?.token ?? undefined; this._continuation.clientVersion = data.continuation?.clientVersion ?? ''; @@ -63,7 +71,7 @@ export class PlayList { this.views = 0; } - async next(limit = Infinity): Promise { + async next(limit = Infinity): Promise { if (!this._continuation || !this._continuation.token) return []; const nextPage = await request(`${BASE_API}${this._continuation.api}`, { @@ -109,14 +117,10 @@ export class PlayList { return this; } - get type(): 'playlist' { - return 'playlist'; - } - - page(number: number): Video[] { + page(number: number): YouTubeVideo[] { if (!number) throw new Error('Page number is not provided'); if (!this.fetched_videos.has(`${number}`)) throw new Error('Given Page number is invalid'); - return this.fetched_videos.get(`${number}`) as Video[]; + return this.fetched_videos.get(`${number}`) as YouTubeVideo[]; } get total_pages() { @@ -125,7 +129,7 @@ export class PlayList { get total_videos() { const page_number: number = this.total_pages; - return (page_number - 1) * 100 + (this.fetched_videos.get(`${page_number}`) as Video[]).length; + return (page_number - 1) * 100 + (this.fetched_videos.get(`${page_number}`) as YouTubeVideo[]).length; } toJSON() { diff --git a/play-dl/YouTube/classes/Thumbnail.ts b/play-dl/YouTube/classes/Thumbnail.ts deleted file mode 100644 index fce1644..0000000 --- a/play-dl/YouTube/classes/Thumbnail.ts +++ /dev/null @@ -1,49 +0,0 @@ -type ThumbnailType = 'default' | 'hqdefault' | 'mqdefault' | 'sddefault' | 'maxresdefault' | 'ultrares'; - -export class Thumbnail { - id?: string; - width?: number; - height?: number; - url?: string; - - constructor(data: any) { - if (!data) throw new Error(`Cannot instantiate the ${this.constructor.name} class without data!`); - - this._patch(data); - } - - private _patch(data: any) { - if (!data) data = {}; - - this.id = data.id || undefined; - this.width = data.width || 0; - this.height = data.height || 0; - this.url = data.url || undefined; - } - - displayThumbnailURL(thumbnailType: ThumbnailType = 'maxresdefault'): string { - if (!['default', 'hqdefault', 'mqdefault', 'sddefault', 'maxresdefault', 'ultrares'].includes(thumbnailType)) - throw new Error(`Invalid thumbnail type "${thumbnailType}"!`); - if (thumbnailType === 'ultrares') return this.url as string; - return `https://i3.ytimg.com/vi/${this.id}/${thumbnailType}.jpg`; - } - - defaultThumbnailURL(id: '0' | '1' | '2' | '3' | '4'): string { - if (!id) id = '0'; - if (!['0', '1', '2', '3', '4'].includes(id)) throw new Error(`Invalid thumbnail id "${id}"!`); - return `https://i3.ytimg.com/vi/${this.id}/${id}.jpg`; - } - - toString(): string { - return this.url ? `${this.url}` : ''; - } - - toJSON() { - return { - id: this.id, - width: this.width, - height: this.height, - url: this.url - }; - } -} diff --git a/play-dl/YouTube/classes/Video.ts b/play-dl/YouTube/classes/Video.ts index eaf0dc0..d52b9f5 100644 --- a/play-dl/YouTube/classes/Video.ts +++ b/play-dl/YouTube/classes/Video.ts @@ -1,5 +1,4 @@ -import { Channel } from './Channel'; -import { Thumbnail } from './Thumbnail'; +import { YouTubeChannel } from './Channel'; interface VideoOptions { id?: string; @@ -21,7 +20,6 @@ interface VideoOptions { id: string; icon: string; }; - videos?: Video[]; type: string; ratings: { likes: number; @@ -31,10 +29,13 @@ interface VideoOptions { private: boolean; tags: string[]; } - -export class Video { +/** + * Class for YouTube Video url + */ +export class YouTubeVideo { id?: string; - url?: string; + url: string; + type: 'video' | 'playlist' | 'channel'; title?: string; description?: string; durationRaw: string; @@ -47,8 +48,7 @@ export class Video { height: number | undefined; url: string | undefined; }; - channel?: Channel; - videos?: Video[]; + channel?: YouTubeChannel; likes: number; dislikes: number; live: boolean; @@ -60,6 +60,7 @@ export class Video { this.id = data.id || undefined; this.url = `https://www.youtube.com/watch?v=${this.id}`; + this.type = 'video'; this.title = data.title || undefined; this.description = data.description || undefined; this.durationRaw = data.duration_raw || '0:00'; @@ -75,10 +76,6 @@ export class Video { this.tags = data.tags || []; } - get type(): 'video' { - return 'video'; - } - get toString(): string { return this.url || ''; } diff --git a/play-dl/YouTube/index.ts b/play-dl/YouTube/index.ts index 8ca7478..a5b2754 100644 --- a/play-dl/YouTube/index.ts +++ b/play-dl/YouTube/index.ts @@ -1,2 +1,3 @@ -export { stream, stream_from_info } from './stream'; +export { stream, stream_from_info, YouTubeStream } from './stream'; export * from './utils'; +export { YouTube } from './search'; diff --git a/play-dl/YouTube/search.ts b/play-dl/YouTube/search.ts index 45cc9c4..7446ba9 100644 --- a/play-dl/YouTube/search.ts +++ b/play-dl/YouTube/search.ts @@ -1,8 +1,8 @@ import { request } from './utils/request'; import { ParseSearchInterface, ParseSearchResult } from './utils/parser'; -import { Video } from './classes/Video'; -import { Channel } from './classes/Channel'; -import { PlayList } from './classes/Playlist'; +import { YouTubeVideo } from './classes/Video'; +import { YouTubeChannel } from './classes/Channel'; +import { YouTubePlayList } from './classes/Playlist'; enum SearchType { Video = 'EgIQAQ%253D%253D', @@ -10,10 +10,17 @@ enum SearchType { Channel = 'EgIQAg%253D%253D' } -export async function yt_search( - search: string, - options: ParseSearchInterface = {} -): Promise<(Video | Channel | PlayList)[]> { +/** + * Type for YouTube returns + */ +export type YouTube = YouTubeVideo | YouTubeChannel | YouTubePlayList; +/** + * Command to search from YouTube + * @param search The query to search + * @param options limit & type of YouTube search you want. + * @returns YouTube type. + */ +export async function yt_search(search: string, options: ParseSearchInterface = {}): Promise { let url = 'https://www.youtube.com/results?search_query=' + search.replaceAll(' ', '+'); options.type ??= 'video'; if (!url.match('&sp=')) { diff --git a/play-dl/YouTube/stream.ts b/play-dl/YouTube/stream.ts index 02332d0..c87f683 100644 --- a/play-dl/YouTube/stream.ts +++ b/play-dl/YouTube/stream.ts @@ -1,5 +1,6 @@ import { video_info } from '.'; import { LiveStreaming, Stream } from './classes/LiveStream'; +import { Proxy } from './utils/request'; export enum StreamType { Arbitrary = 'arbitrary', @@ -12,6 +13,7 @@ export enum StreamType { export interface StreamOptions { quality?: number; cookie?: string; + proxy?: Proxy[]; } export interface InfoData { @@ -24,8 +26,12 @@ export interface InfoData { format: any[]; video_details: any; } - -function parseAudioFormats(formats: any[]) { +/** + * Command to find audio formats from given format array + * @param formats Formats to search from + * @returns Audio Formats array + */ +export function parseAudioFormats(formats: any[]) { const result: any[] = []; formats.forEach((format) => { const type = format.mimeType as string; @@ -37,9 +43,57 @@ function parseAudioFormats(formats: any[]) { }); return result; } +/** + * Type for YouTube Stream + */ +export type YouTubeStream = Stream | LiveStreaming; +/** + * Stream command for YouTube + * @param url YouTube URL + * @param options lets you add quality, cookie, proxy support for stream + * @returns Stream class with type and stream for playing. + */ +export async function stream(url: string, options: StreamOptions = {}): Promise { + const info = await video_info(url, { cookie: options.cookie, proxy: options.proxy }); + const final: any[] = []; + if ( + info.LiveStreamData.isLive === true && + info.LiveStreamData.hlsManifestUrl !== null && + info.video_details.durationInSec === 0 + ) { + return new LiveStreaming( + info.LiveStreamData.dashManifestUrl, + info.format[info.format.length - 1].targetDurationSec, + info.video_details.url + ); + } -export async function stream(url: string, options: StreamOptions = {}): Promise { - const info = await video_info(url, options.cookie); + const audioFormat = parseAudioFormats(info.format); + if (typeof options.quality !== 'number') options.quality = audioFormat.length - 1; + else if (options.quality <= 0) options.quality = 0; + else if (options.quality >= audioFormat.length) options.quality = audioFormat.length - 1; + final.push(audioFormat[options.quality]); + let type: StreamType = + audioFormat[options.quality].codec === 'opus' && audioFormat[options.quality].container === 'webm' + ? StreamType.WebmOpus + : StreamType.Arbitrary; + return new Stream( + final[0].url, + type, + info.video_details.durationInSec, + Number(final[0].contentLength), + info.video_details.url, + options.cookie as string, + options + ); +} +/** + * Stream command for YouTube using info from video_info function. + * @param info video_info data + * @param options lets you add quality, cookie, proxy support for stream + * @returns Stream class with type and stream for playing. + */ +export async function stream_from_info(info: InfoData, options: StreamOptions = {}): Promise { const final: any[] = []; if ( info.LiveStreamData.isLive === true && @@ -68,39 +122,7 @@ export async function stream(url: string, options: StreamOptions = {}): Promise< info.video_details.durationInSec, Number(final[0].contentLength), info.video_details.url, - options.cookie as string - ); -} - -export async function stream_from_info(info: InfoData, options: StreamOptions = {}): Promise { - const final: any[] = []; - if ( - info.LiveStreamData.isLive === true && - info.LiveStreamData.hlsManifestUrl !== null && - info.video_details.durationInSec === '0' - ) { - return new LiveStreaming( - info.LiveStreamData.dashManifestUrl, - info.format[info.format.length - 1].targetDurationSec, - info.video_details.url - ); - } - - const audioFormat = parseAudioFormats(info.format); - if (typeof options.quality !== 'number') options.quality = audioFormat.length - 1; - else if (options.quality <= 0) options.quality = 0; - else if (options.quality >= audioFormat.length) options.quality = audioFormat.length - 1; - final.push(audioFormat[options.quality]); - let type: StreamType = - audioFormat[options.quality].codec === 'opus' && audioFormat[options.quality].container === 'webm' - ? StreamType.WebmOpus - : StreamType.Arbitrary; - return new Stream( - final[0].url, - type, - info.video_details.durationInSec, - Number(final[0].contentLength), - info.video_details.url, - options.cookie as string + options.cookie as string, + options ); } diff --git a/play-dl/YouTube/utils/cipher.ts b/play-dl/YouTube/utils/cipher.ts index b9f63cb..dcb6880 100644 --- a/play-dl/YouTube/utils/cipher.ts +++ b/play-dl/YouTube/utils/cipher.ts @@ -9,6 +9,7 @@ interface formatOptions { cipher?: string; s?: string; } +// RegExp for various js functions const var_js = '[a-zA-Z_\\$][a-zA-Z_0-9]*'; const singlequote_js = `'[^'\\\\]*(:?\\\\[\\s\\S][^'\\\\]*)*'`; const duoblequote_js = `"[^"\\\\]*(:?\\\\[\\s\\S][^"\\\\]*)*"`; @@ -37,7 +38,11 @@ const reverse_regexp = new RegExp(`(?:^|,)(${key_js})${reverse_function}`, 'm'); const slice_regexp = new RegExp(`(?:^|,)(${key_js})${slice_function}`, 'm'); const splice_regexp = new RegExp(`(?:^|,)(${key_js})${splice_function}`, 'm'); const swap_regexp = new RegExp(`(?:^|,)(${key_js})${swap_function}`, 'm'); - +/** + * Function to get tokens from html5player body data. + * @param body body data of html5player. + * @returns Array of tokens. + */ export function js_tokens(body: string) { const function_action = function_regexp.exec(body); const object_action = obj_regexp.exec(body); @@ -82,7 +87,12 @@ export function js_tokens(body: string) { } return tokens; } - +/** + * Function to decipher signature + * @param tokens Tokens from js_tokens function + * @param signature Signatured format url + * @returns deciphered signature + */ function deciper_signature(tokens: string[], signature: string) { let sig = signature.split(''); const len = tokens.length; @@ -109,14 +119,24 @@ function deciper_signature(tokens: string[], signature: string) { } return sig.join(''); } - +/** + * Function to swap positions in a array + * @param array array + * @param position position to switch with first element + * @returns new array with swapped positions. + */ function swappositions(array: string[], position: number) { const first = array[0]; array[0] = array[position]; array[position] = first; return array; } - +/** + * Sets Download url with some extra parameter + * @param format video fomat + * @param sig deciphered signature + * @returns void + */ function download_url(format: formatOptions, sig: string) { let decoded_url; if (!format.url) return; @@ -132,8 +152,13 @@ function download_url(format: formatOptions, sig: string) { } format.url = parsed_url.toString(); } - -export async function format_decipher(formats: formatOptions[], html5player: string) { +/** + * Main function which handles all queries related to video format deciphering + * @param formats video formats + * @param html5player url of html5player + * @returns array of format. + */ +export async function format_decipher(formats: formatOptions[], html5player: string): Promise { const body = await request(html5player); const tokens = js_tokens(body); formats.forEach((format) => { diff --git a/play-dl/YouTube/utils/extractor.ts b/play-dl/YouTube/utils/extractor.ts index e16d307..cda92e7 100644 --- a/play-dl/YouTube/utils/extractor.ts +++ b/play-dl/YouTube/utils/extractor.ts @@ -1,13 +1,27 @@ -import { request } from './request'; +import { Proxy, request } from './request'; import { format_decipher } from './cipher'; -import { Video } from '../classes/Video'; -import { PlayList } from '../classes/Playlist'; +import { YouTubeVideo } from '../classes/Video'; +import { YouTubePlayList } from '../classes/Playlist'; + +interface InfoOptions { + cookie?: string; + proxy?: Proxy[]; +} + +interface PlaylistOptions { + incomplete?: boolean; + proxy?: Proxy[]; +} const DEFAULT_API_KEY = 'AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8'; const video_pattern = /^((?:https?:)?\/\/)?(?:(?:www|m)\.)?((?:youtube\.com|youtu.be))(\/(?:[\w\-]+\?v=|embed\/|v\/)?)([\w\-]+)(\S+)?$/; const playlist_pattern = /^((?:https?:)?\/\/)?(?:(?:www|m)\.)?(youtube\.com)\/(?:(playlist|watch))(.*)?((\?|\&)list=)/; - +/** + * Command to validate a YouTube url + * @param url Url for validation + * @returns type of url or false. + */ export function yt_validate(url: string): 'playlist' | 'video' | false { if (url.indexOf('list=') === -1) { if (!url.match(video_pattern)) return false; @@ -21,7 +35,11 @@ export function yt_validate(url: string): 'playlist' | 'video' | false { return 'playlist'; } } - +/** + * Function to extract ID of YouTube url. + * @param url ID or url of YouTube + * @returns ID of video or playlist. + */ export function extractID(url: string): string { if (url.startsWith('https')) { if (url.indexOf('list=') === -1) { @@ -35,18 +53,24 @@ export function extractID(url: string): string { } } else return url; } - -export async function video_basic_info(url: string, cookie?: string) { +/** + * Basic function to get data from a YouTube url or ID. + * @param url YouTube url or ID + * @param options cookie and proxy parameters to add + * @returns Data containing video_details, LiveStreamData and formats of video url. + */ +export async function video_basic_info(url: string, options: InfoOptions = {}) { let video_id: string; if (url.startsWith('https')) { if (yt_validate(url) !== 'video') throw new Error('This is not a YouTube Watch URL'); video_id = extractID(url); } else video_id = url; - const new_url = `https://www.youtube.com/watch?v=${video_id}`; + const new_url = `https://www.youtube.com/watch?v=${video_id}&has_verified=1`; const body = await request(new_url, { - headers: cookie + proxies: options.proxy ?? [], + headers: options.cookie ? { - 'cookie': cookie, + 'cookie': options.cookie, 'accept-language': 'en-US,en-IN;q=0.9,en;q=0.8,hi;q=0.7' } : { 'accept-language': 'en-US,en-IN;q=0.9,en;q=0.8,hi;q=0.7' } @@ -66,7 +90,7 @@ export async function video_basic_info(url: string, cookie?: string) { initial_response.contents.twoColumnWatchNextResults.results.results.contents[1]?.videoSecondaryInfoRenderer ?.owner?.videoOwnerRenderer?.badges[0]; const html5player = `https://www.youtube.com${body.split('"jsUrl":"')[1].split('"')[0]}`; - const related: any[] = []; + const related: string[] = []; initial_response.contents.twoColumnWatchNextResults.secondaryResults.secondaryResults.results.forEach( (res: any) => { if (res.compactVideoRenderer) @@ -76,7 +100,7 @@ export async function video_basic_info(url: string, cookie?: string) { const format = []; const vid = player_response.videoDetails; const microformat = player_response.microformat.playerMicroformatRenderer; - const video_details = { + const video_details = new YouTubeVideo({ id: vid.videoId, url: `https://www.youtube.com/watch?v=${vid.videoId}`, title: vid.title, @@ -96,7 +120,7 @@ export async function video_basic_info(url: string, cookie?: string) { averageRating: vid.averageRating, live: vid.isLiveContent, private: vid.isPrivate - }; + }); format.push(...(player_response.streamingData.formats ?? [])); format.push(...(player_response.streamingData.adaptiveFormats ?? [])); const LiveStreamData = { @@ -112,7 +136,11 @@ export async function video_basic_info(url: string, cookie?: string) { related_videos: related }; } - +/** + * Function to convert seconds to [hour : minutes : seconds] format + * @param seconds seconds to convert + * @returns [hour : minutes : seconds] format + */ function parseSeconds(seconds: number): string { const d = Number(seconds); const h = Math.floor(d / 3600); @@ -124,9 +152,14 @@ function parseSeconds(seconds: number): string { const sDisplay = s > 0 ? (s < 10 ? `0${s}` : s) : '00'; return hDisplay + mDisplay + sDisplay; } - -export async function video_info(url: string, cookie?: string) { - const data = await video_basic_info(url, cookie); +/** + * Function which gets data from video_basic_info and deciphers it if it contains signatures. + * @param url YouTube Video URL + * @param options cookie and proxy parameters to add + * @returns Data containing video_details, LiveStreamData and formats of video url. + */ +export async function video_info(url: string, options: InfoOptions = {}) { + const data = await video_basic_info(url, options); if (data.LiveStreamData.isLive === true && data.LiveStreamData.hlsManifestUrl !== null) { return data; } else if (data.format[0].signatureCipher || data.format[0].cipher) { @@ -136,8 +169,13 @@ export async function video_info(url: string, cookie?: string) { return data; } } - -export async function playlist_info(url: string, parseIncomplete = false) { +/** + * Function to get YouTube playlist info from a playlist url. + * @param url Playlist URL + * @param options incomplete and proxy to add. + * @returns YouTube Playlist + */ +export async function playlist_info(url: string, options: PlaylistOptions = {}): Promise { if (!url || typeof url !== 'string') throw new Error(`Expected playlist url, received ${typeof url}!`); let Playlist_id: string; if (url.startsWith('https')) { @@ -147,12 +185,13 @@ export async function playlist_info(url: string, parseIncomplete = false) { const new_url = `https://www.youtube.com/playlist?list=${Playlist_id}`; const body = await request(new_url, { + proxies: options.proxy ?? [], headers: { 'accept-language': 'en-US,en-IN;q=0.9,en;q=0.8,hi;q=0.7' } }); const response = JSON.parse(body.split('var ytInitialData = ')[1].split(';')[0]); if (response.alerts) { if (response.alerts[0].alertWithButtonRenderer?.type === 'INFO') { - if (!parseIncomplete) + if (!options.incomplete) throw new Error( `While parsing playlist url\n${response.alerts[0].alertWithButtonRenderer.text.simpleText}` ); @@ -172,7 +211,7 @@ export async function playlist_info(url: string, parseIncomplete = false) { const videos = getPlaylistVideos(parsed, 100); const data = playlistDetails[0].playlistSidebarPrimaryInfoRenderer; - if (!data.title.runs || !data.title.runs.length) return undefined; + if (!data.title.runs || !data.title.runs.length) throw new Error('Failed to Parse Playlist info.'); const author = playlistDetails[1]?.playlistSidebarSecondaryInfoRenderer.videoOwner; const views = data.stats.length === 3 ? data.stats[1].simpleText.replace(/[^0-9]/g, '') : 0; @@ -182,7 +221,7 @@ export async function playlist_info(url: string, parseIncomplete = false) { ?.runs.pop()?.text ?? null; const videosCount = data.stats[0].runs[0].text.replace(/[^0-9]/g, '') || 0; - const res = new PlayList({ + const res = new YouTubePlayList({ continuation: { api: API_KEY, token: getContinuationToken(parsed), @@ -217,13 +256,18 @@ export async function playlist_info(url: string, parseIncomplete = false) { thumbnail: data.thumbnailRenderer.playlistVideoThumbnailRenderer?.thumbnail.thumbnails.length ? data.thumbnailRenderer.playlistVideoThumbnailRenderer.thumbnail.thumbnails[ data.thumbnailRenderer.playlistVideoThumbnailRenderer.thumbnail.thumbnails.length - 1 - ].url + ] : null }); return res; } - -export function getPlaylistVideos(data: any, limit = Infinity): Video[] { +/** + * Function to parse Playlist from YouTube search + * @param data html data of that request + * @param limit No. of videos to parse + * @returns Array of YouTubeVideo. + */ +export function getPlaylistVideos(data: any, limit = Infinity): YouTubeVideo[] { const videos = []; for (let i = 0; i < data.length; i++) { @@ -232,7 +276,7 @@ export function getPlaylistVideos(data: any, limit = Infinity): Video[] { if (!info || !info.shortBylineText) continue; videos.push( - new Video({ + new YouTubeVideo({ id: info.videoId, index: parseInt(info.index?.simpleText) || 0, duration: parseDuration(info.lengthText?.simpleText) || 0, @@ -258,7 +302,11 @@ export function getPlaylistVideos(data: any, limit = Infinity): Video[] { } return videos; } - +/** + * Function to convert [hour : minutes : seconds] format to seconds + * @param duration hour : minutes : seconds format + * @returns seconds + */ function parseDuration(duration: string): number { duration ??= '0:00'; const args = duration.split(':'); @@ -277,7 +325,11 @@ function parseDuration(duration: string): number { return dur; } - +/** + * Function to get Continuation Token + * @param data html data of playlist url + * @returns token + */ export function getContinuationToken(data: any): string { const continuationToken = data.find((x: any) => Object.keys(x)[0] === 'continuationItemRenderer') ?.continuationItemRenderer.continuationEndpoint?.continuationCommand?.token; diff --git a/play-dl/YouTube/utils/parser.ts b/play-dl/YouTube/utils/parser.ts index bd8d619..39e0b17 100644 --- a/play-dl/YouTube/utils/parser.ts +++ b/play-dl/YouTube/utils/parser.ts @@ -1,6 +1,7 @@ -import { Video } from '../classes/Video'; -import { PlayList } from '../classes/Playlist'; -import { Channel } from '../classes/Channel'; +import { YouTubeVideo } from '../classes/Video'; +import { YouTubePlayList } from '../classes/Playlist'; +import { YouTubeChannel } from '../classes/Channel'; +import { YouTube } from '..'; export interface ParseSearchInterface { type?: 'video' | 'playlist' | 'channel'; @@ -12,8 +13,13 @@ export interface thumbnail { height: string; url: string; } - -export function ParseSearchResult(html: string, options?: ParseSearchInterface): (Video | PlayList | Channel)[] { +/** + * Main command which converts html body data and returns the type of data requested. + * @param html body of that request + * @param options limit & type of YouTube search you want. + * @returns Array of one of YouTube type. + */ +export function ParseSearchResult(html: string, options?: ParseSearchInterface): YouTube[] { if (!html) throw new Error("Can't parse Search result without data"); if (!options) options = { type: 'video', limit: 0 }; if (!options.type) options.type = 'video'; @@ -42,7 +48,11 @@ export function ParseSearchResult(html: string, options?: ParseSearchInterface): } return results; } - +/** + * Function to convert [hour : minutes : seconds] format to seconds + * @param duration hour : minutes : seconds format + * @returns seconds + */ function parseDuration(duration: string): number { duration ??= '0:00'; const args = duration.split(':'); @@ -61,15 +71,19 @@ function parseDuration(duration: string): number { return dur; } - -export function parseChannel(data?: any): Channel | void { - if (!data || !data.channelRenderer) return; +/** + * Function to parse Channel searches + * @param data body of that channel request. + * @returns YouTubeChannel class + */ +export function parseChannel(data?: any): YouTubeChannel { + if (!data || !data.channelRenderer) throw new Error('Failed to Parse YouTube Channel'); const badge = data.channelRenderer.ownerBadges && data.channelRenderer.ownerBadges[0]; const url = `https://www.youtube.com${ data.channelRenderer.navigationEndpoint.browseEndpoint.canonicalBaseUrl || data.channelRenderer.navigationEndpoint.commandMetadata.webCommandMetadata.url }`; - const res = new Channel({ + const res = new YouTubeChannel({ id: data.channelRenderer.channelId, name: data.channelRenderer.title.simpleText, icon: { @@ -90,12 +104,16 @@ export function parseChannel(data?: any): Channel | void { return res; } - -export function parseVideo(data?: any): Video | void { - if (!data || !data.videoRenderer) return; +/** + * Function to parse Video searches + * @param data body of that video request. + * @returns YouTubeVideo class + */ +export function parseVideo(data?: any): YouTubeVideo { + if (!data || !data.videoRenderer) throw new Error('Failed to Parse YouTube Video'); const badge = data.videoRenderer.ownerBadges && data.videoRenderer.ownerBadges[0]; - const res = new Video({ + const res = new YouTubeVideo({ id: data.videoRenderer.videoId, url: `https://www.youtube.com/watch?v=${data.videoRenderer.videoId}`, title: data.videoRenderer.title.runs[0].text, @@ -130,11 +148,15 @@ export function parseVideo(data?: any): Video | void { return res; } +/** + * Function to parse Playlist searches + * @param data body of that playlist request. + * @returns YouTubePlaylist class + */ +export function parsePlaylist(data?: any): YouTubePlayList { + if (!data.playlistRenderer) throw new Error('Failed to Parse YouTube Playlist'); -export function parsePlaylist(data?: any): PlayList | void { - if (!data.playlistRenderer) return; - - const res = new PlayList( + const res = new YouTubePlayList( { id: data.playlistRenderer.playlistId, title: data.playlistRenderer.title.simpleText, @@ -151,9 +173,9 @@ export function parsePlaylist(data?: any): PlayList | void { ].width }, channel: { - id: data.playlistRenderer.shortBylineText.runs[0].navigationEndpoint.browseEndpoint.browseId, - name: data.playlistRenderer.shortBylineText.runs[0].text, - url: `https://www.youtube.com${data.playlistRenderer.shortBylineText.runs[0].navigationEndpoint.commandMetadata.webCommandMetadata.url}` + id: data.playlistRenderer.shortBylineText.runs?.[0].navigationEndpoint.browseEndpoint.browseId, + name: data.playlistRenderer.shortBylineText.runs?.[0].text, + url: `https://www.youtube.com${data.playlistRenderer.shortBylineText.runs?.[0].navigationEndpoint.commandMetadata.webCommandMetadata.url}` }, videos: parseInt(data.playlistRenderer.videoCount.replace(/[^0-9]/g, '')) }, diff --git a/play-dl/YouTube/utils/request.ts b/play-dl/YouTube/utils/request.ts index c502b73..f2735f2 100644 --- a/play-dl/YouTube/utils/request.ts +++ b/play-dl/YouTube/utils/request.ts @@ -1,12 +1,38 @@ import https, { RequestOptions } from 'https'; -import { IncomingMessage } from 'http'; +import tls from 'tls'; +import http, { ClientRequest, IncomingMessage } from 'http'; import { URL } from 'url'; +/** + * Types for Proxy + */ +export type Proxy = ProxyOpts | string; + +interface ProxyOpts { + host: string; + port: number; + authentication?: { + username: string; + password: string; + }; +} + +interface ProxyOutput { + statusCode: number; + head: string; + body: string; +} interface RequestOpts extends RequestOptions { body?: string; method?: 'GET' | 'POST'; + proxies?: Proxy[]; } - +/** + * Main module that play-dl uses for making a https request + * @param req_url URL to make https request to + * @param options Request options for https request + * @returns Incoming Message from the https request + */ function https_getter(req_url: string, options: RequestOpts = {}): Promise { return new Promise((resolve, reject) => { const s = new URL(req_url); @@ -26,26 +52,142 @@ function https_getter(req_url: string, options: RequestOpts = {}): Promise { + return new Promise((resolve, reject) => { + const proxy: string | ProxyOpts = req_proxy[randomIntFromInterval(0, req_proxy.length)]; + const parsed_url = new URL(req_url); + let opts: ProxyOpts; + if (typeof proxy === 'string') { + const parsed = new URL(proxy); + opts = { + host: parsed.hostname, + port: Number(parsed.port), + authentication: { + username: parsed.username, + password: parsed.password + } + }; + } else opts = proxy; + let req: ClientRequest; + if (opts.authentication?.username.length === 0) { + req = http.request({ + host: opts.host, + port: opts.port, + method: 'CONNECT', + path: `${parsed_url.host}:443` + }); + } else { + req = http.request({ + host: opts.host, + port: opts.port, + method: 'CONNECT', + path: `${parsed_url.host}:443`, + headers: { + 'Proxy-Authorization': `Basic ${Buffer.from( + `${opts.authentication?.username}:${opts.authentication?.password}` + ).toString('base64')}` + } + }); + } -export async function request(url: string, options?: RequestOpts): Promise { - return new Promise(async (resolve, reject) => { - let data = ''; - let res = await https_getter(url, options).catch((err: Error) => err); - if (res instanceof Error) { - reject(res); - return; - } - if (Number(res.statusCode) >= 300 && Number(res.statusCode) < 400) { - res = await https_getter(res.headers.location as string, options); - } else if (Number(res.statusCode) > 400) { - reject(new Error(`Got ${res.statusCode} from the request`)); - } - res.setEncoding('utf-8'); - res.on('data', (c) => (data += c)); - res.on('end', () => resolve(data)); + req.on('connect', function (res, socket, head) { + console.log('Connected'); + const tlsConnection = tls.connect( + { + host: parsed_url.hostname, + port: 443, + socket: socket, + rejectUnauthorized: false + }, + function () { + tlsConnection.write( + `GET ${parsed_url.pathname}${parsed_url.search} HTTP/1.1\r\n` + + `Host : ${parsed_url.hostname}\r\n` + + 'Connection: close\r\n' + + '\r\n' + ); + } + ); + + tlsConnection.setEncoding('utf-8'); + let data = ''; + tlsConnection.once('error', (e) => reject(e)); + tlsConnection.on('data', (c) => (data += c)); + tlsConnection.on('end', () => { + const y = data.split('\r\n\r\n'); + const head = y.shift() as string; + resolve({ + statusCode: Number(head.split('\n')[0].split(' ')[1]), + head: head, + body: y.join('\n') + }); + }); + }); + req.on('error', (e: Error) => reject(e)); + req.end(); }); } - +/** + * Main module which play-dl uses to make a proxy or normal request + * @param url URL to make https request to + * @param options Request options for https request + * @returns body of that request + */ +export async function request(url: string, options?: RequestOpts): Promise { + return new Promise(async (resolve, reject) => { + if (!options?.proxies) { + let data = ''; + let res = await https_getter(url, options).catch((err: Error) => err); + if (res instanceof Error) { + reject(res); + return; + } + if (Number(res.statusCode) >= 300 && Number(res.statusCode) < 400) { + res = await https_getter(res.headers.location as string, options); + } else if (Number(res.statusCode) > 400) { + reject(new Error(`Got ${res.statusCode} from the request`)); + } + res.setEncoding('utf-8'); + res.on('data', (c) => (data += c)); + res.on('end', () => resolve(data)); + } else { + let res = await proxy_getter(url, options.proxies).catch((e: Error) => e); + if (res instanceof Error) { + reject(res); + return; + } + if (res.statusCode >= 300 && res.statusCode < 400) { + res = await proxy_getter(res.head.split('Location: ')[1].split('\n')[0], options.proxies); + } else if (res.statusCode > 400) { + reject(new Error(`GOT ${res.statusCode} from proxy request`)); + } + resolve(res.body); + } + }); +} +/** + * Main module which play-dl uses to make a request to stream url. + * @param url URL to make https request to + * @param options Request options for https request + * @returns IncomingMessage from the request + */ export async function request_stream(url: string, options?: RequestOpts): Promise { return new Promise(async (resolve, reject) => { let res = await https_getter(url, options).catch((err: Error) => err); diff --git a/play-dl/index.ts b/play-dl/index.ts index d446b14..b4ae6a5 100644 --- a/play-dl/index.ts +++ b/play-dl/index.ts @@ -1,6 +1,6 @@ -export { playlist_info, video_basic_info, video_info, yt_validate, extractID } from './YouTube'; -export { spotify, sp_validate, refreshToken, is_expired } from './Spotify'; -export { soundcloud, so_validate } from './SoundCloud'; +export { playlist_info, video_basic_info, video_info, yt_validate, extractID, YouTube, YouTubeStream } from './YouTube'; +export { spotify, sp_validate, refreshToken, is_expired, Spotify } from './Spotify'; +export { soundcloud, so_validate, SoundCloud, SoundCloudStream } from './SoundCloud'; interface SearchOptions { limit?: number; @@ -13,36 +13,72 @@ interface SearchOptions { import readline from 'readline'; import fs from 'fs'; -import { sp_validate, yt_validate, so_validate } from '.'; +import { + sp_validate, + yt_validate, + so_validate, + YouTubeStream, + SoundCloudStream, + YouTube, + SoundCloud, + Spotify +} from '.'; import { SpotifyAuthorize, sp_search } from './Spotify'; import { check_id, so_search, stream as so_stream, stream_from_info as so_stream_info } from './SoundCloud'; import { InfoData, stream as yt_stream, StreamOptions, stream_from_info as yt_stream_info } from './YouTube/stream'; -import { SoundCloudTrack, Stream as SoStream } from './SoundCloud/classes'; -import { LiveStreaming, Stream as YTStream } from './YouTube/classes/LiveStream'; +import { SoundCloudTrack } from './SoundCloud/classes'; import { yt_search } from './YouTube/search'; -export async function stream(url: string, options: StreamOptions = {}): Promise { +/** + * Main stream Command for streaming through various sources + * @param url The video / track url to make stream of + * @param options contains quality, cookie and proxy to set for stream + * @returns YouTube / SoundCloud Stream to play + */ + +export async function stream(url: string, options: StreamOptions = {}): Promise { if (url.length === 0) throw new Error('Stream URL has a length of 0. Check your url again.'); if (url.indexOf('soundcloud') !== -1) return await so_stream(url, options.quality); - else return await yt_stream(url, { cookie: options.cookie }); + else return await yt_stream(url, options); } -export async function search(query: string, options: SearchOptions = {}) { +/** + * Main Search Command for searching through various sources + * @param query string to search. + * @param options contains limit and source to choose. + * @returns Array of YouTube or Spotify or SoundCloud + */ +export async function search( + query: string, + options: SearchOptions = {} +): Promise { if (!options.source) options.source = { youtube: 'video' }; if (options.source.youtube) return await yt_search(query, { limit: options.limit, type: options.source.youtube }); else if (options.source.spotify) return await sp_search(query, options.source.spotify, options.limit); else if (options.source.soundcloud) return await so_search(query, options.source.soundcloud, options.limit); + else throw new Error('Not possible to reach Here LOL. Easter Egg of play-dl if someone get this.'); } +/** + * stream Command for streaming through various sources using data from video_info or soundcloud + * SoundCloud Track is only supported + * @param info video_info data or SoundCloud Track data. + * @param options contains quality, cookie and proxy to set for stream + * @returns YouTube / SoundCloud Stream to play + */ export async function stream_from_info( info: InfoData | SoundCloudTrack, options: StreamOptions = {} -): Promise { - if (info instanceof SoundCloudTrack) return await so_stream_info(info); - else return await yt_stream_info(info, { cookie: options.cookie }); +): Promise { + if (info instanceof SoundCloudTrack) return await so_stream_info(info, options.quality); + else return await yt_stream_info(info, options); } - +/** + * Command to validate the provided url. It checks whether it supports play-dl or not. + * @param url url to validate + * @returns On failure, returns false else type of url. + */ export async function validate( url: string ): Promise<'so_playlist' | 'so_track' | 'sp_track' | 'sp_album' | 'sp_playlist' | 'yt_video' | 'yt_playlist' | false> { @@ -58,7 +94,9 @@ export async function validate( return check !== false ? (('yt_' + check) as 'yt_video' | 'yt_playlist') : false; } } - +/** + * Authorization interface for Spotify and SoundCloud. + */ export function authorization(): void { const ask = readline.createInterface({ input: process.stdin,