diff --git a/.npmignore b/.npmignore index e37a996..7dac574 100644 --- a/.npmignore +++ b/.npmignore @@ -7,4 +7,5 @@ tsup.config.ts CHANGELOG.md Rotator.md docs -examples/ \ No newline at end of file +examples/ +.prettierignore \ No newline at end of file diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..40239d0 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,5 @@ +yarn/ +docs/ +.github/ +dist/ +bin/ \ No newline at end of file diff --git a/.yarn/install-state.gz b/.yarn/install-state.gz index 9a8fcca..c36f78e 100644 Binary files a/.yarn/install-state.gz and b/.yarn/install-state.gz differ diff --git a/examples/trustedToken/potoken.js b/examples/trustedToken/potoken.js index 9e693ce..3ec89d4 100644 --- a/examples/trustedToken/potoken.js +++ b/examples/trustedToken/potoken.js @@ -1,23 +1,24 @@ -const { Worker } = require("node:worker_threads") -const { YoutubeiExtractor } = require("../../dist/index.js") // replace "../../dist/index.js" with "discord-player-youtubei" +const { Worker } = require("node:worker_threads"); +const { YoutubeiExtractor } = require("../../dist/index.js"); // replace "../../dist/index.js" with "discord-player-youtubei" -const generateToken = () => new Promise((resolve, reject) => { - const worker = new Worker(`${__dirname}/potoken.worker.js`) +const generateToken = () => + new Promise((resolve, reject) => { + const worker = new Worker(`${__dirname}/potoken.worker.js`); worker.once("message", (v) => { - resolve(v) - }) + resolve(v); + }); worker.once("error", (v) => { - reject(v) - }) -}); + reject(v); + }); + }); // Register YoutubeiExtractor somewhere around here // Then, set the tokens to the youtubei extractor generateToken().then((tokens) => { - const instance = YoutubeiExtractor.getInstance() + const instance = YoutubeiExtractor.getInstance(); - if(instance) instance.setTrustedTokens(tokens) -}) \ No newline at end of file + if (instance) instance.setTrustedTokens(tokens); +}); diff --git a/examples/trustedToken/potoken.worker.js b/examples/trustedToken/potoken.worker.js index aa710f5..2e13d6f 100644 --- a/examples/trustedToken/potoken.worker.js +++ b/examples/trustedToken/potoken.worker.js @@ -1,6 +1,6 @@ -const { generateTrustedToken } = require("../../dist/index.js") -const { parentPort } = require("node:worker_threads") +const { generateTrustedToken } = require("../../dist/index.js"); +const { parentPort } = require("node:worker_threads"); generateTrustedToken().then((v) => { - parentPort.postMessage(v) -}) \ No newline at end of file + parentPort.postMessage(v); +}); diff --git a/lib/Extractor/Youtube.ts b/lib/Extractor/Youtube.ts index 6a7cd9b..b3002ae 100644 --- a/lib/Extractor/Youtube.ts +++ b/lib/Extractor/Youtube.ts @@ -1,29 +1,31 @@ import type { - ExtractorStreamable, - SearchQueryType, - ExtractorInfo, - ExtractorSearchContext, - GuildQueueHistory, + ExtractorStreamable, + SearchQueryType, + ExtractorInfo, + ExtractorSearchContext, + GuildQueueHistory, } from "discord-player"; import { - Util, - Track, - Playlist, - QueryType, - BaseExtractor -} from "discord-player" - -import Innertube, { - Session -} from "youtubei.js"; -import { type DownloadOptions, InnerTubeConfig, InnerTubeClient } from "youtubei.js/dist/src/types"; + Util, + Track, + Playlist, + QueryType, + BaseExtractor, +} from "discord-player"; + +import Innertube, { Session } from "youtubei.js"; +import { + type DownloadOptions, + InnerTubeConfig, + InnerTubeClient, +} from "youtubei.js/dist/src/types"; import { Readable } from "node:stream"; import { YouTubeExtractor } from "@discord-player/extractor"; import type { - PlaylistVideo, - CompactVideo, - Video + PlaylistVideo, + CompactVideo, + Video, } from "youtubei.js/dist/src/parser/nodes"; import { streamFromYT } from "../common/generateYTStream"; import { AsyncLocalStorage } from "node:async_hooks"; @@ -32,147 +34,177 @@ import { createReadableFromWeb } from "../common/webToReadable"; import { type GeneratorReturnData } from "../utils"; export interface StreamOptions { - useClient?: InnerTubeClient - highWaterMark?: number + useClient?: InnerTubeClient; + highWaterMark?: number; } export interface RefreshInnertubeOptions { - filePath: string; - interval?: number; + filePath: string; + interval?: number; } -export type TrustedTokenConfig = GeneratorReturnData +export type TrustedTokenConfig = GeneratorReturnData; export interface YoutubeiOptions { - authentication?: string; - overrideDownloadOptions?: DownloadOptions; - createStream?: (q: Track, extractor: BaseExtractor) => Promise; - signOutOnDeactive?: boolean; - streamOptions?: StreamOptions; - overrideBridgeMode?: "ytmusic" | "yt"; - disablePlayer?: boolean; - ignoreSignInErrors?: boolean; - innertubeConfigRaw?: InnerTubeConfig; - trustedTokens?: TrustedTokenConfig; - cookie?: string; + authentication?: string; + overrideDownloadOptions?: DownloadOptions; + createStream?: ( + q: Track, + extractor: BaseExtractor, + ) => Promise; + signOutOnDeactive?: boolean; + streamOptions?: StreamOptions; + overrideBridgeMode?: "ytmusic" | "yt"; + disablePlayer?: boolean; + ignoreSignInErrors?: boolean; + innertubeConfigRaw?: InnerTubeConfig; + trustedTokens?: TrustedTokenConfig; + cookie?: string; } export interface AsyncTrackingContext { - useClient: InnerTubeClient - highWaterMark?: number + useClient: InnerTubeClient; + highWaterMark?: number; } export class YoutubeiExtractor extends BaseExtractor { - public static identifier: string = "com.retrouser955.discord-player.discord-player-youtubei"; - public innerTube!: Innertube; - public _stream!: (q: Track, extractor: BaseExtractor) => Promise; - public static instance?: YoutubeiExtractor; - public priority = 2; - static ytContext = new AsyncLocalStorage() - - setTrustedTokens(tokens: TrustedTokenConfig) { - if(!this.options.streamOptions?.useClient || (["ANDROID", "IOS"] as InnerTubeClient[]).includes(this.options.streamOptions.useClient)) process.emitWarning("Warning: Using poTokens and default \"ANDROID\" client which are not compatible") - - this.innerTube.session.context.client.visitorData = tokens.visitorData - - const clonedInnertube = new Session( - this.innerTube.session.context, - this.innerTube.session.key, - this.innerTube.session.api_version, - this.innerTube.session.account_index, - this.innerTube.session.player, - undefined, - this.innerTube.session.http.fetch, - this.innerTube.session.cache, - tokens.poToken - ) - - this.innerTube = new Innertube(clonedInnertube) - } - - setInnertube(tube: Innertube) { - this.innerTube = tube - } - - static getInstance() { - return this.instance - } - - setClientMode(client: InnerTubeClient) { - if(!this.options.streamOptions) this.options.streamOptions = {} - - this.options.streamOptions.useClient = client - } - - static getStreamingContext() { - const ctx = YoutubeiExtractor.ytContext.getStore() - if (!ctx) throw new Error("INVALID INVOKCATION") - return ctx - } - - async activate(): Promise { - this.protocols = ["ytsearch", "youtube"]; - - if(this.options.trustedTokens && !this.options.streamOptions?.useClient) process.emitWarning("Warning: Using poTokens and default \"ANDROID\" client which are not compatible") - - const INNERTUBE_OPTIONS: InnerTubeConfig = { - retrieve_player: this.options.disablePlayer === true ? false : true, - ...(this.options.innertubeConfigRaw), - cookie: this.options.cookie - } - - if(this.options.trustedTokens) { - INNERTUBE_OPTIONS.po_token = this.options.trustedTokens.poToken - INNERTUBE_OPTIONS.visitor_data = this.options.trustedTokens.visitorData - } - - this.innerTube = await Innertube.create(INNERTUBE_OPTIONS) - - if (typeof this.options.createStream === "function") { - this._stream = this.options.createStream; - } else { - this._stream = (q, _) => { - return YoutubeiExtractor.ytContext.run({ - useClient: this.options.streamOptions?.useClient ?? "IOS", - highWaterMark: this.options.streamOptions?.highWaterMark - }, async () => { - return streamFromYT(q, this.innerTube, { - overrideDownloadOptions: this.options.overrideDownloadOptions, - }); - }) - }; - } - - YoutubeiExtractor.instance = this; - - if (this.options.authentication) { - try { - await this.signIn(this.options.authentication) - - const info = await this.innerTube.account.getInfo() - - this.context.player.debug(info.contents?.contents ? `Signed into YouTube using the name: ${info.contents.contents[0]?.account_name?.text ?? "UNKNOWN ACCOUNT"}` : `Signed into YouTube using the client name: ${this.innerTube.session.client_name}@${this.innerTube.session.client_version}`) - } catch (error) { - if(this.options.ignoreSignInErrors) process.emitWarning(`Unable to sign into YouTube\n\n${error}`); - else throw error - } - } - } - - async signIn(tokens: string) { - const tkn = tokenToObject(tokens) - await this.innerTube.session.signIn(tkn) - } - - async deactivate(): Promise { - this.protocols = []; - if (this.options.signOutOnDeactive && this.innerTube.session.logged_in) await this.innerTube.session.signOut(); - } - - async validate(query: string, type?: SearchQueryType | null | undefined): Promise { - if (typeof query !== "string") return false; - // prettier-ignore - return ([ + public static identifier: string = + "com.retrouser955.discord-player.discord-player-youtubei"; + public innerTube!: Innertube; + public _stream!: ( + q: Track, + extractor: BaseExtractor, + ) => Promise; + public static instance?: YoutubeiExtractor; + public priority = 2; + static ytContext = new AsyncLocalStorage(); + + setTrustedTokens(tokens: TrustedTokenConfig) { + if ( + !this.options.streamOptions?.useClient || + (["ANDROID", "IOS"] as InnerTubeClient[]).includes( + this.options.streamOptions.useClient, + ) + ) + process.emitWarning( + 'Warning: Using poTokens and default "ANDROID" client which are not compatible', + ); + + this.innerTube.session.context.client.visitorData = tokens.visitorData; + + const clonedInnertube = new Session( + this.innerTube.session.context, + this.innerTube.session.key, + this.innerTube.session.api_version, + this.innerTube.session.account_index, + this.innerTube.session.player, + undefined, + this.innerTube.session.http.fetch, + this.innerTube.session.cache, + tokens.poToken, + ); + + this.innerTube = new Innertube(clonedInnertube); + } + + setInnertube(tube: Innertube) { + this.innerTube = tube; + } + + static getInstance() { + return this.instance; + } + + setClientMode(client: InnerTubeClient) { + if (!this.options.streamOptions) this.options.streamOptions = {}; + + this.options.streamOptions.useClient = client; + } + + static getStreamingContext() { + const ctx = YoutubeiExtractor.ytContext.getStore(); + if (!ctx) throw new Error("INVALID INVOKCATION"); + return ctx; + } + + async activate(): Promise { + this.protocols = ["ytsearch", "youtube"]; + + if (this.options.trustedTokens && !this.options.streamOptions?.useClient) + process.emitWarning( + 'Warning: Using poTokens and default "ANDROID" client which are not compatible', + ); + + const INNERTUBE_OPTIONS: InnerTubeConfig = { + retrieve_player: this.options.disablePlayer === true ? false : true, + ...this.options.innertubeConfigRaw, + cookie: this.options.cookie, + }; + + if (this.options.trustedTokens) { + INNERTUBE_OPTIONS.po_token = this.options.trustedTokens.poToken; + INNERTUBE_OPTIONS.visitor_data = this.options.trustedTokens.visitorData; + } + + this.innerTube = await Innertube.create(INNERTUBE_OPTIONS); + + if (typeof this.options.createStream === "function") { + this._stream = this.options.createStream; + } else { + this._stream = (q, _) => { + return YoutubeiExtractor.ytContext.run( + { + useClient: this.options.streamOptions?.useClient ?? "IOS", + highWaterMark: this.options.streamOptions?.highWaterMark, + }, + async () => { + return streamFromYT(q, this.innerTube, { + overrideDownloadOptions: this.options.overrideDownloadOptions, + }); + }, + ); + }; + } + + YoutubeiExtractor.instance = this; + + if (this.options.authentication) { + try { + await this.signIn(this.options.authentication); + + const info = await this.innerTube.account.getInfo(); + + this.context.player.debug( + info.contents?.contents + ? `Signed into YouTube using the name: ${info.contents.contents[0]?.account_name?.text ?? "UNKNOWN ACCOUNT"}` + : `Signed into YouTube using the client name: ${this.innerTube.session.client_name}@${this.innerTube.session.client_version}`, + ); + } catch (error) { + if (this.options.ignoreSignInErrors) + process.emitWarning(`Unable to sign into YouTube\n\n${error}`); + else throw error; + } + } + } + + async signIn(tokens: string) { + const tkn = tokenToObject(tokens); + await this.innerTube.session.signIn(tkn); + } + + async deactivate(): Promise { + this.protocols = []; + if (this.options.signOutOnDeactive && this.innerTube.session.logged_in) + await this.innerTube.session.signOut(); + } + + async validate( + query: string, + type?: SearchQueryType | null | undefined, + ): Promise { + if (typeof query !== "string") return false; + // prettier-ignore + return ([ QueryType.YOUTUBE, QueryType.YOUTUBE_PLAYLIST, QueryType.YOUTUBE_SEARCH, @@ -180,348 +212,410 @@ export class YoutubeiExtractor extends BaseExtractor { QueryType.AUTO, QueryType.AUTO_SEARCH ] as SearchQueryType[]).some((r) => r === type); - } - - async bridge(track: Track, ext: BaseExtractor | null): Promise { - if(ext?.identifier === this.identifier) return this.stream(track) - - let protocol: YoutubeiOptions['overrideBridgeMode'] - - if (this.options.overrideBridgeMode) { - protocol = this.options.overrideBridgeMode - } else { - if (this.innerTube.session.logged_in) protocol = "ytmusic" - else protocol = "yt" - } - - const query = ext?.createBridgeQuery(track) || `${track.author} - ${track.title}${protocol === "yt" ? " (official audio)" : ""}` - - switch (protocol) { - case "ytmusic": { - try { - let stream = await this.bridgeFromYTMusic(query, track) - - if(!stream) { - this.context.player.debug("Unable to bridge from Youtube music. Falling back to default behavior") - stream = await this.bridgeFromYT(query, track) - } - - return stream - } catch (error) { - this.context.player.debug("Unable to bridge from youtube music due to an error. Falling back to default behavior\n\n" + error) - const stream = await this.bridgeFromYT(query, track) - return stream - } - } - default: { - const stream = await this.bridgeFromYT(query, track) - - return stream - } - } - } - - async bridgeFromYTMusic(query: string, track: Track): Promise { - const musicSearch = await this.innerTube.music.search(query, { - type: "song", - }) - - if (!musicSearch.songs) return null - if (!musicSearch.songs.contents || musicSearch.songs.contents.length === 0) return null - if (!musicSearch.songs.contents[0].id) return null - - const info = await this.innerTube.music.getInfo(musicSearch.songs.contents[0].id) - - const metadata = new Track(this.context.player, { - title: info.basic_info.title ?? "UNKNOWN TITLE", - duration: Util.buildTimeCode(Util.parseMS((info.basic_info.duration || 0) * 1000)), - author: info.basic_info.author ?? "UNKNOWN AUTHOR", - views: info.basic_info.view_count, - thumbnail: info.basic_info.thumbnail?.at(0)?.url, - url: `https://youtube.com/watch?v=${info.basic_info.id}&dpymeta=ytmusic`, - source: "youtube", - queryType: "youtubeVideo", - live: false - }) - - track.setMetadata(metadata) - - const webStream = await info.download({ - type: "audio", - quality: "best", - format: "mp4" - }) - - return createReadableFromWeb(webStream, this.options.streamOptions?.highWaterMark) - } - - async bridgeFromYT(query: string, track: Track): Promise { - const youtubeTrack = await this.handle(query, { - type: QueryType.YOUTUBE_SEARCH, - requestedBy: track.requestedBy - }) - - if (youtubeTrack.tracks.length === 0) return null - - track.setMetadata({ - bridge: youtubeTrack.tracks[0] - }) - - return this.stream(youtubeTrack.tracks[0]) - } - - async handle(query: string, context: ExtractorSearchContext): Promise { - if (context.protocol === "ytsearch") context.type = QueryType.YOUTUBE_SEARCH; - query = query.includes("youtube.com") ? query.replace(/(m(usic)?|gaming)\./, "") : query; - if (!query.includes("list=RD") && YouTubeExtractor.validateURL(query)) context.type = QueryType.YOUTUBE_VIDEO; - - if (context.type === QueryType.YOUTUBE_PLAYLIST) { - const url = new URL(query) - - if (url.searchParams.has("v") && url.searchParams.has("list")) context.type = QueryType.YOUTUBE_VIDEO - } - - switch (context.type) { - case QueryType.YOUTUBE_PLAYLIST: { - const playlistUrl = new URL(query); - const plId = playlistUrl.searchParams.get("list")!; - let playlist = await this.innerTube.getPlaylist(plId); - - const pl = new Playlist(this.context.player, { - title: playlist.info.title ?? "UNKNOWN PLAYLIST", - thumbnail: playlist.info.thumbnails[0].url, - description: playlist.info.description ?? playlist.info.title ?? "UNKNOWN DESCRIPTION", - type: "playlist", - author: { - name: playlist?.channels[0]?.author?.name ?? playlist.info.author.name ?? "UNKNOWN AUTHOR", - url: playlist?.channels[0]?.author?.url ?? playlist.info.author.url ?? "UNKNOWN AUTHOR", - }, - tracks: [], - id: plId, - url: query, - source: "youtube", - }); - - pl.tracks = [] - - let plTracks = (playlist.videos.filter((v) => v.type === "PlaylistVideo") as PlaylistVideo[]).map( - (v) => { - const duration = Util.buildTimeCode(Util.parseMS(v.duration.seconds * 1000)) - const raw = { - duration_ms: v.duration.seconds * 1000, - live: v.is_live, - duration - } - - return new Track(this.context.player, { - title: v.title.text ?? "UNKNOWN TITLE", - duration: duration, - thumbnail: v.thumbnails[0]?.url, - author: v.author.name, - requestedBy: context.requestedBy, - url: `https://youtube.com/watch?v=${v.id}`, - raw, - playlist: pl, - source: "youtube", - queryType: "youtubeVideo", - async requestMetadata() { - return this.raw; - }, - metadata: raw, - live: v.is_live - }) - } - ) - - while (playlist.has_continuation) { - playlist = await playlist.getContinuation() - - plTracks.push(...(playlist.videos.filter((v) => v.type === "PlaylistVideo") as PlaylistVideo[]).map( - (v) => { - const duration = Util.buildTimeCode(Util.parseMS(v.duration.seconds * 1000)) - const raw = { - duration_ms: v.duration.seconds * 1000, - live: v.is_live, - duration - } - - return new Track(this.context.player, { - title: v.title.text ?? "UNKNOWN TITLE", - duration, - thumbnail: v.thumbnails[0]?.url, - author: v.author.name, - requestedBy: context.requestedBy, - url: `https://youtube.com/watch?v=${v.id}`, - raw, - playlist: pl, - source: "youtube", - queryType: "youtubeVideo", - async requestMetadata() { - return this.raw; - }, - metadata: raw, - live: v.is_live - }) - } - )) - } - - pl.tracks = plTracks - - return { - playlist: pl, - tracks: pl.tracks, - }; - } - case QueryType.YOUTUBE_VIDEO: { - let videoId = new URL(query).searchParams.get("v"); - - // detected as yt shorts or youtu.be link - if (!videoId) videoId = query.split("/").at(-1)!.split("?")[0] - - const vid = await this.innerTube.getBasicInfo(videoId); - const duration = Util.buildTimeCode(Util.parseMS((vid.basic_info.duration ?? 0) * 1000)) - - const uploadTime = vid.basic_info.start_timestamp - - const raw = { - duration_ms: vid.basic_info.duration as number * 1000, - live: vid.basic_info.is_live, - duration, - startTime: uploadTime - } - - return { - playlist: null, - tracks: [ - new Track(this.context.player, { - title: vid.basic_info.title ?? "UNKNOWN TITLE", - thumbnail: vid.basic_info.thumbnail?.at(0)?.url, - description: vid.basic_info.short_description, - author: vid.basic_info.channel?.name, - requestedBy: context.requestedBy, - url: `https://youtube.com/watch?v=${vid.basic_info.id}`, - views: vid.basic_info.view_count, - duration, - raw, - source: "youtube", - queryType: "youtubeVideo", - async requestMetadata() { - return this.raw; - }, - metadata: raw, - live: vid.basic_info.is_live - }), - ], - }; - } - default: { - const search = await this.innerTube.search(query); - const videos = search.videos.filter((v) => v.type === "Video") as Video[]; - - return { - playlist: null, - tracks: videos.map((v) => this.buildTrack(v, context)), - }; - } - } - } - - buildTrack(vid: Video, context: ExtractorSearchContext, pl?: Playlist) { - const duration = Util.buildTimeCode(Util.parseMS(vid.duration.seconds * 1000)) - - const raw = { - duration_ms: vid.duration.seconds * 1000, - live: vid.is_live - } - - const track = new Track(this.context.player, { - title: vid.title.text ?? "UNKNOWN YOUTUBE VIDEO", - thumbnail: vid.best_thumbnail?.url ?? vid.thumbnails[0]?.url ?? "", - description: vid.description ?? vid.title ?? "UNKNOWN DESCRIPTION", - author: vid.author?.name ?? "UNKNOWN AUTHOR", - requestedBy: context.requestedBy, - url: `https://youtube.com/watch?v=${vid.id}`, - views: parseInt((vid.view_count?.text ?? "0").replaceAll(",", "")), - duration, - raw, - playlist: pl, - source: "youtube", - queryType: "youtubeVideo", - async requestMetadata() { - return this.raw; - }, - metadata: raw, - live: vid.is_live - }); - - track.extractor = this; - - return track; - } - - stream(info: Track): Promise { - return this._stream(info, this); - } - - async getRelatedTracks( - track: Track<{ duration_ms: number, live: boolean }>, - history: GuildQueueHistory - ): Promise { - let id = new URL(track.url).searchParams.get("v") - // VIDEO DETECTED AS YT SHORTS OR youtu.be link - if (!id) id = track.url.split("/").at(-1)?.split("?").at(0)! - - const videoInfo = await this.innerTube.getInfo(id) - - const next = videoInfo.watch_next_feed! - - const recommended = (next as unknown as CompactVideo[]).filter( - (v) => !history.tracks.some((x) => x.url === `https://youtube.com/watch?v=${v.id}`) && v.type === "CompactVideo" - ) - - if (!recommended) { - this.context.player.debug("Unable to fetch recommendations"); - return this.#emptyResponse(); - } - - const trackConstruct = recommended.map((v) => { - const duration = Util.buildTimeCode(Util.parseMS(v.duration.seconds * 1000)) - const raw = { - live: v.is_live, - duration_ms: v.duration.seconds * 1000, - duration, - } - - return new Track(this.context.player, { - title: v.title?.text ?? "UNKNOWN TITLE", - thumbnail: v.best_thumbnail?.url ?? v.thumbnails[0]?.url, - author: v.author?.name ?? "UNKNOWN AUTHOR", - requestedBy: track.requestedBy, - url: `https://youtube.com/watch?v=${v.id}`, - views: parseInt((v.view_count?.text ?? "0").replaceAll(",", "")), - duration, - raw, - source: "youtube", - queryType: "youtubeVideo", - metadata: raw, - async requestMetadata() { - return this.raw; - }, - live: v.is_live - }); - }); - - return { - playlist: null, - tracks: trackConstruct - } - } - - #emptyResponse() { - return { - playlist: null, - tracks: [], - }; - } + } + + async bridge( + track: Track, + ext: BaseExtractor | null, + ): Promise { + if (ext?.identifier === this.identifier) return this.stream(track); + + let protocol: YoutubeiOptions["overrideBridgeMode"]; + + if (this.options.overrideBridgeMode) { + protocol = this.options.overrideBridgeMode; + } else { + if (this.innerTube.session.logged_in) protocol = "ytmusic"; + else protocol = "yt"; + } + + const query = + ext?.createBridgeQuery(track) || + `${track.author} - ${track.title}${protocol === "yt" ? " (official audio)" : ""}`; + + switch (protocol) { + case "ytmusic": { + try { + let stream = await this.bridgeFromYTMusic(query, track); + + if (!stream) { + this.context.player.debug( + "Unable to bridge from Youtube music. Falling back to default behavior", + ); + stream = await this.bridgeFromYT(query, track); + } + + return stream; + } catch (error) { + this.context.player.debug( + "Unable to bridge from youtube music due to an error. Falling back to default behavior\n\n" + + error, + ); + const stream = await this.bridgeFromYT(query, track); + return stream; + } + } + default: { + const stream = await this.bridgeFromYT(query, track); + + return stream; + } + } + } + + async bridgeFromYTMusic( + query: string, + track: Track, + ): Promise { + const musicSearch = await this.innerTube.music.search(query, { + type: "song", + }); + + if (!musicSearch.songs) return null; + if (!musicSearch.songs.contents || musicSearch.songs.contents.length === 0) + return null; + if (!musicSearch.songs.contents[0].id) return null; + + const info = await this.innerTube.music.getInfo( + musicSearch.songs.contents[0].id, + ); + + const metadata = new Track(this.context.player, { + title: info.basic_info.title ?? "UNKNOWN TITLE", + duration: Util.buildTimeCode( + Util.parseMS((info.basic_info.duration || 0) * 1000), + ), + author: info.basic_info.author ?? "UNKNOWN AUTHOR", + views: info.basic_info.view_count, + thumbnail: info.basic_info.thumbnail?.at(0)?.url, + url: `https://youtube.com/watch?v=${info.basic_info.id}&dpymeta=ytmusic`, + source: "youtube", + queryType: "youtubeVideo", + live: false, + }); + + track.setMetadata(metadata); + + const webStream = await info.download({ + type: "audio", + quality: "best", + format: "mp4", + }); + + return createReadableFromWeb( + webStream, + this.options.streamOptions?.highWaterMark, + ); + } + + async bridgeFromYT( + query: string, + track: Track, + ): Promise { + const youtubeTrack = await this.handle(query, { + type: QueryType.YOUTUBE_SEARCH, + requestedBy: track.requestedBy, + }); + + if (youtubeTrack.tracks.length === 0) return null; + + track.setMetadata({ + bridge: youtubeTrack.tracks[0], + }); + + return this.stream(youtubeTrack.tracks[0]); + } + + async handle( + query: string, + context: ExtractorSearchContext, + ): Promise { + if (context.protocol === "ytsearch") + context.type = QueryType.YOUTUBE_SEARCH; + query = query.includes("youtube.com") + ? query.replace(/(m(usic)?|gaming)\./, "") + : query; + if (!query.includes("list=RD") && YouTubeExtractor.validateURL(query)) + context.type = QueryType.YOUTUBE_VIDEO; + + if (context.type === QueryType.YOUTUBE_PLAYLIST) { + const url = new URL(query); + + if (url.searchParams.has("v") && url.searchParams.has("list")) + context.type = QueryType.YOUTUBE_VIDEO; + } + + switch (context.type) { + case QueryType.YOUTUBE_PLAYLIST: { + const playlistUrl = new URL(query); + const plId = playlistUrl.searchParams.get("list")!; + let playlist = await this.innerTube.getPlaylist(plId); + + const pl = new Playlist(this.context.player, { + title: playlist.info.title ?? "UNKNOWN PLAYLIST", + thumbnail: playlist.info.thumbnails[0].url, + description: + playlist.info.description ?? + playlist.info.title ?? + "UNKNOWN DESCRIPTION", + type: "playlist", + author: { + name: + playlist?.channels[0]?.author?.name ?? + playlist.info.author.name ?? + "UNKNOWN AUTHOR", + url: + playlist?.channels[0]?.author?.url ?? + playlist.info.author.url ?? + "UNKNOWN AUTHOR", + }, + tracks: [], + id: plId, + url: query, + source: "youtube", + }); + + pl.tracks = []; + + let plTracks = ( + playlist.videos.filter( + (v) => v.type === "PlaylistVideo", + ) as PlaylistVideo[] + ).map((v) => { + const duration = Util.buildTimeCode( + Util.parseMS(v.duration.seconds * 1000), + ); + const raw = { + duration_ms: v.duration.seconds * 1000, + live: v.is_live, + duration, + }; + + return new Track(this.context.player, { + title: v.title.text ?? "UNKNOWN TITLE", + duration: duration, + thumbnail: v.thumbnails[0]?.url, + author: v.author.name, + requestedBy: context.requestedBy, + url: `https://youtube.com/watch?v=${v.id}`, + raw, + playlist: pl, + source: "youtube", + queryType: "youtubeVideo", + async requestMetadata() { + return this.raw; + }, + metadata: raw, + live: v.is_live, + }); + }); + + while (playlist.has_continuation) { + playlist = await playlist.getContinuation(); + + plTracks.push( + ...( + playlist.videos.filter( + (v) => v.type === "PlaylistVideo", + ) as PlaylistVideo[] + ).map((v) => { + const duration = Util.buildTimeCode( + Util.parseMS(v.duration.seconds * 1000), + ); + const raw = { + duration_ms: v.duration.seconds * 1000, + live: v.is_live, + duration, + }; + + return new Track(this.context.player, { + title: v.title.text ?? "UNKNOWN TITLE", + duration, + thumbnail: v.thumbnails[0]?.url, + author: v.author.name, + requestedBy: context.requestedBy, + url: `https://youtube.com/watch?v=${v.id}`, + raw, + playlist: pl, + source: "youtube", + queryType: "youtubeVideo", + async requestMetadata() { + return this.raw; + }, + metadata: raw, + live: v.is_live, + }); + }), + ); + } + + pl.tracks = plTracks; + + return { + playlist: pl, + tracks: pl.tracks, + }; + } + case QueryType.YOUTUBE_VIDEO: { + let videoId = new URL(query).searchParams.get("v"); + + // detected as yt shorts or youtu.be link + if (!videoId) videoId = query.split("/").at(-1)!.split("?")[0]; + + const vid = await this.innerTube.getBasicInfo(videoId); + const duration = Util.buildTimeCode( + Util.parseMS((vid.basic_info.duration ?? 0) * 1000), + ); + + const uploadTime = vid.basic_info.start_timestamp; + + const raw = { + duration_ms: (vid.basic_info.duration as number) * 1000, + live: vid.basic_info.is_live, + duration, + startTime: uploadTime, + }; + + return { + playlist: null, + tracks: [ + new Track(this.context.player, { + title: vid.basic_info.title ?? "UNKNOWN TITLE", + thumbnail: vid.basic_info.thumbnail?.at(0)?.url, + description: vid.basic_info.short_description, + author: vid.basic_info.channel?.name, + requestedBy: context.requestedBy, + url: `https://youtube.com/watch?v=${vid.basic_info.id}`, + views: vid.basic_info.view_count, + duration, + raw, + source: "youtube", + queryType: "youtubeVideo", + async requestMetadata() { + return this.raw; + }, + metadata: raw, + live: vid.basic_info.is_live, + }), + ], + }; + } + default: { + const search = await this.innerTube.search(query); + const videos = search.videos.filter( + (v) => v.type === "Video", + ) as Video[]; + + return { + playlist: null, + tracks: videos.map((v) => this.buildTrack(v, context)), + }; + } + } + } + + buildTrack(vid: Video, context: ExtractorSearchContext, pl?: Playlist) { + const duration = Util.buildTimeCode( + Util.parseMS(vid.duration.seconds * 1000), + ); + + const raw = { + duration_ms: vid.duration.seconds * 1000, + live: vid.is_live, + }; + + const track = new Track(this.context.player, { + title: vid.title.text ?? "UNKNOWN YOUTUBE VIDEO", + thumbnail: vid.best_thumbnail?.url ?? vid.thumbnails[0]?.url ?? "", + description: vid.description ?? vid.title ?? "UNKNOWN DESCRIPTION", + author: vid.author?.name ?? "UNKNOWN AUTHOR", + requestedBy: context.requestedBy, + url: `https://youtube.com/watch?v=${vid.id}`, + views: parseInt((vid.view_count?.text ?? "0").replaceAll(",", "")), + duration, + raw, + playlist: pl, + source: "youtube", + queryType: "youtubeVideo", + async requestMetadata() { + return this.raw; + }, + metadata: raw, + live: vid.is_live, + }); + + track.extractor = this; + + return track; + } + + stream(info: Track): Promise { + return this._stream(info, this); + } + + async getRelatedTracks( + track: Track<{ duration_ms: number; live: boolean }>, + history: GuildQueueHistory, + ): Promise { + let id = new URL(track.url).searchParams.get("v"); + // VIDEO DETECTED AS YT SHORTS OR youtu.be link + if (!id) id = track.url.split("/").at(-1)?.split("?").at(0)!; + + const videoInfo = await this.innerTube.getInfo(id); + + const next = videoInfo.watch_next_feed!; + + const recommended = (next as unknown as CompactVideo[]).filter( + (v) => + !history.tracks.some( + (x) => x.url === `https://youtube.com/watch?v=${v.id}`, + ) && v.type === "CompactVideo", + ); + + if (!recommended) { + this.context.player.debug("Unable to fetch recommendations"); + return this.#emptyResponse(); + } + + const trackConstruct = recommended.map((v) => { + const duration = Util.buildTimeCode( + Util.parseMS(v.duration.seconds * 1000), + ); + const raw = { + live: v.is_live, + duration_ms: v.duration.seconds * 1000, + duration, + }; + + return new Track(this.context.player, { + title: v.title?.text ?? "UNKNOWN TITLE", + thumbnail: v.best_thumbnail?.url ?? v.thumbnails[0]?.url, + author: v.author?.name ?? "UNKNOWN AUTHOR", + requestedBy: track.requestedBy, + url: `https://youtube.com/watch?v=${v.id}`, + views: parseInt((v.view_count?.text ?? "0").replaceAll(",", "")), + duration, + raw, + source: "youtube", + queryType: "youtubeVideo", + metadata: raw, + async requestMetadata() { + return this.raw; + }, + live: v.is_live, + }); + }); + + return { + playlist: null, + tracks: trackConstruct, + }; + } + + #emptyResponse() { + return { + playlist: null, + tracks: [], + }; + } } diff --git a/lib/common/generateYTStream.ts b/lib/common/generateYTStream.ts index 1cc950e..ef8323e 100644 --- a/lib/common/generateYTStream.ts +++ b/lib/common/generateYTStream.ts @@ -1,49 +1,136 @@ import type Innertube from "youtubei.js/agnostic"; -import { Constants as YoutubeiConsts } from "youtubei.js"; +import { + Constants, + Platform, + Utils, + Constants as YoutubeiConsts, +} from "youtubei.js"; import type { BaseExtractor, Track } from "discord-player"; import type { OAuth2Tokens } from "youtubei.js/agnostic"; -import type { DownloadOptions, InnerTubeClient } from "youtubei.js/dist/src/types"; +import type { + DownloadOptions, + InnerTubeClient, +} from "youtubei.js/dist/src/types"; import { YoutubeiExtractor } from "../Extractor/Youtube"; import type { ExtractorStreamable } from "discord-player"; import { createReadableFromWeb } from "./webToReadable"; export interface YTStreamingOptions { - extractor?: BaseExtractor; - authentication?: OAuth2Tokens; - overrideDownloadOptions?: DownloadOptions; + extractor?: BaseExtractor; + authentication?: OAuth2Tokens; + overrideDownloadOptions?: DownloadOptions; } const DEFAULT_DOWNLOAD_OPTIONS: DownloadOptions = { - quality: "best", - format: "mp4", - type: "audio" + quality: "best", + format: "mp4", + type: "audio", +}; + +export function createWebReadableStream( + url: string, + size: number, + innertube: Innertube, +) { + let [start, end] = [0, 1048576 * 10]; + let isEnded = false; + + let abort: AbortController; + + // all credits go to [LuanRT](https://github.com/LuanRT/YouTube.js/blob/main/src/utils/FormatUtils.ts) + return new Platform.shim.ReadableStream( + { + start() {}, + pull(controller) { + if (isEnded) { + controller.close(); + return; + } + + if (end >= size) { + isEnded = true; + } + + return new Promise(async (resolve, reject) => { + abort = new AbortController(); + try { + const chunks = await innertube.session.http.fetch_function(url, { + headers: { + ...Constants.STREAM_HEADERS, + }, + signal: abort.signal, + }); + + const readable = chunks.body; + + if (!readable || chunks.ok) + throw new Error(`Downloading of ${url} failed.`); + + for await (const chunk of Utils.streamToIterable(readable)) { + controller.enqueue(chunk); + } + + start = end + 1; + end += size; + + resolve(); + } catch (error) { + reject(error); + } + }); + }, + async cancel() { + abort.abort(); + }, + }, + { + highWaterMark: 1, + size(ch) { + return ch.byteLength; + }, + }, + ); } -export async function streamFromYT(query: Track, innerTube: Innertube, options: YTStreamingOptions = { overrideDownloadOptions: DEFAULT_DOWNLOAD_OPTIONS }): Promise { - const context = YoutubeiExtractor.getStreamingContext() +export async function streamFromYT( + query: Track, + innerTube: Innertube, + options: YTStreamingOptions = { + overrideDownloadOptions: DEFAULT_DOWNLOAD_OPTIONS, + }, +): Promise { + const context = YoutubeiExtractor.getStreamingContext(); + + let id = new URL(query.url).searchParams.get("v"); + // VIDEO DETECTED AS YT SHORTS OR youtu.be link + if (!id) id = query.url.split("/").at(-1)?.split("?").at(0)!; + const videoInfo = await innerTube.getBasicInfo(id, context.useClient); - let id = new URL(query.url).searchParams.get("v") - // VIDEO DETECTED AS YT SHORTS OR youtu.be link - if(!id) id = query.url.split("/").at(-1)?.split("?").at(0)! - const videoInfo = await innerTube.getBasicInfo(id, context.useClient) + if (videoInfo.basic_info.is_live) + return videoInfo.streaming_data?.hls_manifest_url!; - if(videoInfo.basic_info.is_live) return videoInfo.streaming_data?.hls_manifest_url! + if ( + (["IOS", "ANDROID", "TV_EMBEDDED"] as InnerTubeClient[]).includes( + context.useClient, + ) + ) { + const downloadURL = videoInfo.chooseFormat( + options.overrideDownloadOptions ?? DEFAULT_DOWNLOAD_OPTIONS, + ); + const download = createWebReadableStream( + downloadURL.url!, + downloadURL.content_length!, + innerTube, + ); - if((["IOS", "ANDROID", "TV_EMBEDDED"] as InnerTubeClient[]).includes(context.useClient)) { - const downloadURL = videoInfo.chooseFormat(options.overrideDownloadOptions ?? DEFAULT_DOWNLOAD_OPTIONS) - const controller = new AbortController() - const download = await innerTube.session.http.fetch_function(`${downloadURL.url!}&cpn=${videoInfo.cpn}`, { - method: "GET", - headers: YoutubeiConsts.STREAM_HEADERS, - signal: controller.signal - }) + return createReadableFromWeb(download, context.highWaterMark); + } - return createReadableFromWeb(download.body!, context.highWaterMark) - } + const download = await videoInfo.download( + options.overrideDownloadOptions ?? DEFAULT_DOWNLOAD_OPTIONS, + ); - const download = await videoInfo.download(options.overrideDownloadOptions ?? DEFAULT_DOWNLOAD_OPTIONS) - - const stream = createReadableFromWeb(download, context.highWaterMark) + const stream = createReadableFromWeb(download, context.highWaterMark); - return stream -} \ No newline at end of file + return stream; +} diff --git a/lib/common/getInstance.ts b/lib/common/getInstance.ts index e30f93d..f237e9d 100644 --- a/lib/common/getInstance.ts +++ b/lib/common/getInstance.ts @@ -1,5 +1,5 @@ -import { YoutubeiExtractor } from "../Extractor/Youtube" +import { YoutubeiExtractor } from "../Extractor/Youtube"; export function getYoutubeiInstance() { - return YoutubeiExtractor.instance?.innerTube -} \ No newline at end of file + return YoutubeiExtractor.instance?.innerTube; +} diff --git a/lib/common/randomAuthToken.ts b/lib/common/randomAuthToken.ts index 280f7a9..5fee079 100644 --- a/lib/common/randomAuthToken.ts +++ b/lib/common/randomAuthToken.ts @@ -1,7 +1,7 @@ -import { type OAuth2Tokens } from "youtubei.js" +import { type OAuth2Tokens } from "youtubei.js"; export function getRandomOauthToken(tokens: OAuth2Tokens[]) { - const randomInt = Math.round(Math.random() * (tokens.length - 1)) + const randomInt = Math.round(Math.random() * (tokens.length - 1)); - return tokens[randomInt] -} \ No newline at end of file + return tokens[randomInt]; +} diff --git a/lib/common/tokenUtils.ts b/lib/common/tokenUtils.ts index fd1b641..a6272a7 100644 --- a/lib/common/tokenUtils.ts +++ b/lib/common/tokenUtils.ts @@ -1,30 +1,48 @@ import { type OAuth2Tokens } from "youtubei.js"; export function objectToToken(tokens: OAuth2Tokens) { - return Object.entries(tokens).map(([k, v]) => `${k}=${v instanceof Date ? v.toISOString() : v}`).join("; ") + return Object.entries(tokens) + .map(([k, v]) => `${k}=${v instanceof Date ? v.toISOString() : v}`) + .join("; "); } export function tokenToObject(token: string): OAuth2Tokens { - if(!token.includes("; ") || !token.includes("=")) throw new Error("Error: this is not a valid authentication token. Make sure you are putting the entire string instead of just what's behind access_token=") + if (!token.includes("; ") || !token.includes("=")) + throw new Error( + "Error: this is not a valid authentication token. Make sure you are putting the entire string instead of just what's behind access_token=", + ); - const kvPair = token.split("; ") + const kvPair = token.split("; "); - const validKeys = ["access_token", 'expiry_date', 'expires_in', 'refresh_token', 'scope', 'token_type', 'client'] - // @ts-ignore - let finalObject: OAuth2Tokens = {} - for (let kv of kvPair) { - const [key, value] = kv.split("=") - if (!validKeys.includes(key)) continue; - // @ts-expect-error - finalObject[key as keyof OAuth2Tokens] = Number.isNaN(Number(value)) ? value : Number(value) - } + const validKeys = [ + "access_token", + "expiry_date", + "expires_in", + "refresh_token", + "scope", + "token_type", + "client", + ]; + // @ts-ignore + let finalObject: OAuth2Tokens = {}; + for (let kv of kvPair) { + const [key, value] = kv.split("="); + if (!validKeys.includes(key)) continue; + // @ts-expect-error + finalObject[key as keyof OAuth2Tokens] = Number.isNaN(Number(value)) + ? value + : Number(value); + } - // perform final checks - const requiredKeys = ['access_token', 'expiry_date', 'refresh_token'] + // perform final checks + const requiredKeys = ["access_token", "expiry_date", "refresh_token"]; - for(const key of requiredKeys) { - if(!(key in finalObject)) throw new Error(`Error: Invalid authentication keys. Missing the required key ${key}. Make sure you are putting the entire string instead of just what's behind access_token=`) - } + for (const key of requiredKeys) { + if (!(key in finalObject)) + throw new Error( + `Error: Invalid authentication keys. Missing the required key ${key}. Make sure you are putting the entire string instead of just what's behind access_token=`, + ); + } - return finalObject -} \ No newline at end of file + return finalObject; +} diff --git a/lib/common/webToReadable.ts b/lib/common/webToReadable.ts index 98e5761..0618074 100644 --- a/lib/common/webToReadable.ts +++ b/lib/common/webToReadable.ts @@ -1,37 +1,40 @@ -import { PassThrough } from "stream" -import { Utils } from "youtubei.js" +import { PassThrough } from "stream"; +import { Utils } from "youtubei.js"; -export async function createReadableFromWeb(readStream: ReadableStream, highWaterMark = 1024 * 512) { - const readable = new PassThrough({ - highWaterMark, - }); +export async function createReadableFromWeb( + readStream: ReadableStream, + highWaterMark = 1024 * 512, +) { + const readable = new PassThrough({ + highWaterMark, + }); - // run out of order - (async () => { - let shouldListen = true + // run out of order + (async () => { + let shouldListen = true; - for await (const chunk of Utils.streamToIterable(readStream)) { - if(readable.destroyed) continue; + for await (const chunk of Utils.streamToIterable(readStream)) { + if (readable.destroyed) continue; - const shouldWrite = readable.write(chunk) - - if(!shouldWrite && shouldListen) { - shouldListen = false - await new Promise(res => { - readable.once("drain", () => { - shouldListen = true - res() - }) - }) - } - } - })() + const shouldWrite = readable.write(chunk); - readable._destroy = () => { - readStream.cancel() - readable.destroyed = true - readable.destroy() - }; + if (!shouldWrite && shouldListen) { + shouldListen = false; + await new Promise((res) => { + readable.once("drain", () => { + shouldListen = true; + res(); + }); + }); + } + } + })(); - return readable -} \ No newline at end of file + readable._destroy = () => { + readStream.cancel(); + readable.destroyed = true; + readable.destroy(); + }; + + return readable; +} diff --git a/lib/index.ts b/lib/index.ts index 95dc661..7db731f 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -1,42 +1,44 @@ -import Innertube from "youtubei.js" -import { objectToToken } from "./common/tokenUtils" +import Innertube from "youtubei.js"; +import { objectToToken } from "./common/tokenUtils"; const exit = (message: any, clean: boolean) => { - if(clean) { - console.log(message) - process.exit(0) - } + if (clean) { + console.log(message); + process.exit(0); + } - throw new Error(message) -} + throw new Error(message); +}; export async function generateOauthTokens() { - const youtube = await Innertube.create({ - retrieve_player: false - }) + const youtube = await Innertube.create({ + retrieve_player: false, + }); + + youtube.session.on("auth-pending", (data) => { + const { verification_url: verify, user_code } = data; - youtube.session.on("auth-pending", (data) => { - const { verification_url: verify, user_code } = data + console.log( + `Follow this URL: ${verify} and enter this code: ${user_code}\nMake sure you are using a throwaway account to login. Using your main account may result in ban or suspension`, + ); + }); - console.log(`Follow this URL: ${verify} and enter this code: ${user_code}\nMake sure you are using a throwaway account to login. Using your main account may result in ban or suspension`) - }) + youtube.session.on("auth-error", (err) => { + exit(err.message, false); + }); - youtube.session.on("auth-error", (err) => { - exit(err.message, false) - }) + youtube.session.on("auth", (data) => { + if (!data.credentials) exit("Something went wrong", false); - youtube.session.on('auth', (data) => { - if(!data.credentials) exit("Something went wrong", false) - - console.log('Your cookies are printed down below') - console.log(objectToToken(data.credentials)) - exit("Done Getting the credentials", true) - }) + console.log("Your cookies are printed down below"); + console.log(objectToToken(data.credentials)); + exit("Done Getting the credentials", true); + }); - await youtube.session.signIn() + await youtube.session.signIn(); } -export * from "./Extractor/Youtube" -export * from "./common/tokenUtils" -export * from "./common/getInstance" -export * from "./utils/index" \ No newline at end of file +export * from "./Extractor/Youtube"; +export * from "./common/tokenUtils"; +export * from "./common/getInstance"; +export * from "./utils/index"; diff --git a/lib/utils/index.ts b/lib/utils/index.ts index c14411c..af2d952 100644 --- a/lib/utils/index.ts +++ b/lib/utils/index.ts @@ -1,5 +1,5 @@ -export * from "./live/getLiveChat" -export { LiveChatEvents } from "./live/LiveChat" -export { ChatMessageType } from "./live/LiveChatMessage" -export * from "./poTokenGenerator/generatePoToken" -export * from "./poTokenGenerator/generatePoTokenInterval" \ No newline at end of file +export * from "./live/getLiveChat"; +export { LiveChatEvents } from "./live/LiveChat"; +export { ChatMessageType } from "./live/LiveChatMessage"; +export * from "./poTokenGenerator/generatePoToken"; +export * from "./poTokenGenerator/generatePoTokenInterval"; diff --git a/lib/utils/live/LiveChat.ts b/lib/utils/live/LiveChat.ts index 9eec233..56e7492 100644 --- a/lib/utils/live/LiveChat.ts +++ b/lib/utils/live/LiveChat.ts @@ -5,83 +5,97 @@ import { YTNodes } from "youtubei.js"; import type { ChatAction } from "youtubei.js/dist/src/parser/youtube/LiveChat"; export const LiveChatEvents = { - MessageCreate: "messageCreate", - StreamEnd: "streamEnd" -} as const + MessageCreate: "messageCreate", + StreamEnd: "streamEnd", +} as const; -type LiveChatReturnType = ReturnType +type LiveChatReturnType = ReturnType< + (typeof VideoInfo)["prototype"]["getLiveChat"] +>; -export type LiveChatEvents = typeof LiveChatEvents[keyof typeof LiveChatEvents] +export type LiveChatEvents = + (typeof LiveChatEvents)[keyof typeof LiveChatEvents]; export interface LiveChatEventsData { - [LiveChatEvents.MessageCreate]: (message: LiveChatMessage) => any - [LiveChatEvents.StreamEnd]: () => any + [LiveChatEvents.MessageCreate]: (message: LiveChatMessage) => any; + [LiveChatEvents.StreamEnd]: () => any; } export class LiveChat { - chat: ReturnType - #eventEmitter = new TypedEmitter() + chat: ReturnType<(typeof VideoInfo)["prototype"]["getLiveChat"]>; + #eventEmitter = new TypedEmitter(); - #hasListener: Record = { - [LiveChatEvents.MessageCreate]: false, - [LiveChatEvents.StreamEnd]: false - } + #hasListener: Record = { + [LiveChatEvents.MessageCreate]: false, + [LiveChatEvents.StreamEnd]: false, + }; - // this is scuffed but i cant access 'this' inside other non-arrow functions - chatUpdateHandler = (action: ChatAction) => { - if(action.is(YTNodes.AddChatItemAction)) { - const { item } = action.as(YTNodes.AddChatItemAction) + // this is scuffed but i cant access 'this' inside other non-arrow functions + chatUpdateHandler = (action: ChatAction) => { + if (action.is(YTNodes.AddChatItemAction)) { + const { item } = action.as(YTNodes.AddChatItemAction); - switch(item.type) { - case "LiveChatTextMessage": { - this.#eventEmitter.emit(LiveChatEvents.MessageCreate, new LiveChatMessage(item, ChatMessageType.Regular)) - break; - } - case "LiveChatPaidMessage": { - this.#eventEmitter.emit(LiveChatEvents.MessageCreate, new LiveChatMessage(item, ChatMessageType.Premium)) - break; - } - case "LiveChatPaidSticker": { - this.#eventEmitter.emit(LiveChatEvents.MessageCreate, new LiveChatMessage(item, ChatMessageType.PremiumSticker)) - break; - } - default: { - // noop - break; - } - } + switch (item.type) { + case "LiveChatTextMessage": { + this.#eventEmitter.emit( + LiveChatEvents.MessageCreate, + new LiveChatMessage(item, ChatMessageType.Regular), + ); + break; + } + case "LiveChatPaidMessage": { + this.#eventEmitter.emit( + LiveChatEvents.MessageCreate, + new LiveChatMessage(item, ChatMessageType.Premium), + ); + break; + } + case "LiveChatPaidSticker": { + this.#eventEmitter.emit( + LiveChatEvents.MessageCreate, + new LiveChatMessage(item, ChatMessageType.PremiumSticker), + ); + break; } + default: { + // noop + break; + } + } } + }; - #chatEndHandler = () => { - this.#eventEmitter.emit(LiveChatEvents.StreamEnd) - } + #chatEndHandler = () => { + this.#eventEmitter.emit(LiveChatEvents.StreamEnd); + }; - constructor(chat: LiveChatReturnType) { - this.chat = chat - } + constructor(chat: LiveChatReturnType) { + this.chat = chat; + } - on(event: T, handler: LiveChatEventsData[T]) { - switch(event) { - case LiveChatEvents.MessageCreate: { - if(!this.#hasListener[LiveChatEvents.MessageCreate]) { - this.chat.on("chat-update", this.chatUpdateHandler) - this.#hasListener[LiveChatEvents.MessageCreate] = true - } - this.#eventEmitter.on(event, handler) - } - case LiveChatEvents.StreamEnd: { - if(!this.#hasListener[LiveChatEvents.StreamEnd]) { - this.chat.on("end", this.#chatEndHandler) - } - } + on(event: T, handler: LiveChatEventsData[T]) { + switch (event) { + case LiveChatEvents.MessageCreate: { + if (!this.#hasListener[LiveChatEvents.MessageCreate]) { + this.chat.on("chat-update", this.chatUpdateHandler); + this.#hasListener[LiveChatEvents.MessageCreate] = true; + } + this.#eventEmitter.on(event, handler); + } + case LiveChatEvents.StreamEnd: { + if (!this.#hasListener[LiveChatEvents.StreamEnd]) { + this.chat.on("end", this.#chatEndHandler); } + } } + } - destroy() { - if(this.#hasListener[LiveChatEvents.MessageCreate]) this.chat.off("chat-update", this.chatUpdateHandler) - if(this.#hasListener[LiveChatEvents.StreamEnd]) this.chat.off("end", this.#chatEndHandler) + destroy() { + if (this.#hasListener[LiveChatEvents.MessageCreate]) + this.chat.off("chat-update", this.chatUpdateHandler); + if (this.#hasListener[LiveChatEvents.StreamEnd]) + this.chat.off("end", this.#chatEndHandler); - this.chat.stop() - } -} \ No newline at end of file + this.chat.stop(); + } +} diff --git a/lib/utils/live/LiveChatAuthor.ts b/lib/utils/live/LiveChatAuthor.ts index db5f4dc..82ef8f4 100644 --- a/lib/utils/live/LiveChatAuthor.ts +++ b/lib/utils/live/LiveChatAuthor.ts @@ -1,25 +1,25 @@ import { type Author } from "youtubei.js/dist/src/parser/misc"; export class LiveChatAuthor { - username: string - url: string - thumbnail: string - verifiedChannel: boolean - verifiedArtist: boolean - isMod: boolean - id: string + username: string; + url: string; + thumbnail: string; + verifiedChannel: boolean; + verifiedArtist: boolean; + isMod: boolean; + id: string; - raw: Author - - constructor(author: Author) { - this.username = author.name - this.url = author.url - this.thumbnail = author.best_thumbnail?.url ?? author.thumbnails[0].url - this.verifiedChannel = author.is_verified || false - this.verifiedArtist = author.is_verified_artist || false - this.isMod = author.is_moderator || false - this.id = author.id + raw: Author; - this.raw = author - } -} \ No newline at end of file + constructor(author: Author) { + this.username = author.name; + this.url = author.url; + this.thumbnail = author.best_thumbnail?.url ?? author.thumbnails[0].url; + this.verifiedChannel = author.is_verified || false; + this.verifiedArtist = author.is_verified_artist || false; + this.isMod = author.is_moderator || false; + this.id = author.id; + + this.raw = author; + } +} diff --git a/lib/utils/live/LiveChatMessage.ts b/lib/utils/live/LiveChatMessage.ts index adc1cc0..05aba86 100644 --- a/lib/utils/live/LiveChatMessage.ts +++ b/lib/utils/live/LiveChatMessage.ts @@ -1,26 +1,48 @@ import { type YTNode } from "youtubei.js/dist/src/parser/helpers"; import { LiveChatAuthor } from "./LiveChatAuthor"; -import type { LiveChatPaidMessage, LiveChatPaidSticker, LiveChatTextMessage } from "youtubei.js/dist/src/parser/nodes"; +import type { + LiveChatPaidMessage, + LiveChatPaidSticker, + LiveChatTextMessage, +} from "youtubei.js/dist/src/parser/nodes"; export enum ChatMessageType { - Regular = 1, - Premium = 2, - PremiumSticker = 3 + Regular = 1, + Premium = 2, + PremiumSticker = 3, } export class LiveChatMessage { - author: LiveChatAuthor - type: ChatMessageType - content?: string; - timestamp: number - - constructor(chatUpdate: YTNode, type: ChatMessageType) { - this.author = new LiveChatAuthor((chatUpdate as LiveChatTextMessage | LiveChatPaidMessage | LiveChatPaidSticker).author) - this.type = type - this.timestamp = (chatUpdate as LiveChatTextMessage | LiveChatPaidMessage | LiveChatPaidSticker).timestamp || Date.now() + author: LiveChatAuthor; + type: ChatMessageType; + content?: string; + timestamp: number; - if(chatUpdate.type === "LiveChatTextMessage" || chatUpdate.type === "LiveChatPaidMessage") { - this.content = (chatUpdate as LiveChatTextMessage | LiveChatPaidMessage).message.toString(); - } + constructor(chatUpdate: YTNode, type: ChatMessageType) { + this.author = new LiveChatAuthor( + ( + chatUpdate as + | LiveChatTextMessage + | LiveChatPaidMessage + | LiveChatPaidSticker + ).author, + ); + this.type = type; + this.timestamp = + ( + chatUpdate as + | LiveChatTextMessage + | LiveChatPaidMessage + | LiveChatPaidSticker + ).timestamp || Date.now(); + + if ( + chatUpdate.type === "LiveChatTextMessage" || + chatUpdate.type === "LiveChatPaidMessage" + ) { + this.content = ( + chatUpdate as LiveChatTextMessage | LiveChatPaidMessage + ).message.toString(); } + } } diff --git a/lib/utils/live/getLiveChat.ts b/lib/utils/live/getLiveChat.ts index 5d45b5f..8d5cccc 100644 --- a/lib/utils/live/getLiveChat.ts +++ b/lib/utils/live/getLiveChat.ts @@ -1,36 +1,39 @@ import { YoutubeiExtractor } from "../../Extractor/Youtube"; import { LiveChat } from "./LiveChat"; -const YOUTUBE_URL_REGEX = /^((?:https?:)?\/\/)?((?:www|m)\.)?((?:youtube(?:-nocookie)?\.com|youtu.be))(\/(?:[\w\-]+\?v=|embed\/|live\/|v\/)?)([\w\-]+)(\S+)?$/ +const YOUTUBE_URL_REGEX = + /^((?:https?:)?\/\/)?((?:www|m)\.)?((?:youtube(?:-nocookie)?\.com|youtu.be))(\/(?:[\w\-]+\?v=|embed\/|live\/|v\/)?)([\w\-]+)(\S+)?$/; function parseYoutubeVideo(videoUrl: string) { - if(!YOUTUBE_URL_REGEX.test(videoUrl)) throw new Error("This is not a valid video URL") + if (!YOUTUBE_URL_REGEX.test(videoUrl)) + throw new Error("This is not a valid video URL"); - const idExtractor = new URL(videoUrl) + const idExtractor = new URL(videoUrl); - let id = idExtractor.searchParams.get("v") - - if(!id) id = videoUrl.split("/").at(-1)?.split("?").at(0)! + let id = idExtractor.searchParams.get("v"); - return id + if (!id) id = videoUrl.split("/").at(-1)?.split("?").at(0)!; + + return id; } export async function getLiveChat(videoUrl: string, ext?: YoutubeiExtractor) { - const instance = YoutubeiExtractor.instance ?? ext + const instance = YoutubeiExtractor.instance ?? ext; - if(!instance) throw new Error("Invoked getLiveChat before player.extractors.register") + if (!instance) + throw new Error("Invoked getLiveChat before player.extractors.register"); - const innertube = instance.innerTube + const innertube = instance.innerTube; - const videoId = parseYoutubeVideo(videoUrl) + const videoId = parseYoutubeVideo(videoUrl); - const info = await innertube.getInfo(videoId) + const info = await innertube.getInfo(videoId); - if(!info.basic_info.is_live) return null + if (!info.basic_info.is_live) return null; - const chat = info.getLiveChat() + const chat = info.getLiveChat(); - chat.start() + chat.start(); - return new LiveChat(chat) -} \ No newline at end of file + return new LiveChat(chat); +} diff --git a/lib/utils/poTokenGenerator/generatePoToken.ts b/lib/utils/poTokenGenerator/generatePoToken.ts index 8b18823..df3b8f4 100644 --- a/lib/utils/poTokenGenerator/generatePoToken.ts +++ b/lib/utils/poTokenGenerator/generatePoToken.ts @@ -1,76 +1,90 @@ -import type { PuppeteerLaunchOptions } from "puppeteer" +import type { PuppeteerLaunchOptions } from "puppeteer"; async function hasPuppeteer() { - try { - await import("puppeteer") - return true - } catch { - return false - } + try { + await import("puppeteer"); + return true; + } catch { + return false; + } } export interface GeneratorReturnData { - visitorData: string; - poToken: string; + visitorData: string; + poToken: string; } export interface GeneratorOptions { - puppeteerOptions?: Omit - timeout?: number - embeddedVideoUrl?: string - skipPuppeteerCheck?: boolean + puppeteerOptions?: Omit; + timeout?: number; + embeddedVideoUrl?: string; + skipPuppeteerCheck?: boolean; } export async function generateTrustedToken(options: GeneratorOptions = {}) { - if(!options.skipPuppeteerCheck && !await hasPuppeteer()) throw new Error("ERR_NO_DEP: Puppeteer not found") - if(options.embeddedVideoUrl && !options.embeddedVideoUrl.startsWith("https://www.youtube.com/embed/")) throw new Error("ERR_INVALID_YT_EMBED: That is not a valid youtube embed") - - return new Promise(async (resolve, reject) => { - const puppet = await import("puppeteer") - - const browser = await puppet.launch({ - ...(options.puppeteerOptions), - headless: false - }) - - const page = await browser.newPage() - - const client = await page.createCDPSession(); - await client.send('Debugger.enable'); - await client.send('Debugger.setAsyncCallStackDepth', { maxDepth: 32 }); - await client.send('Network.enable'); - - const timeout = setTimeout(() => { - client.removeAllListeners() - reject("ERR_PUPPETEER_TIMEOUT: Timeout exceeded. Use GeneratorOptions.timeout to increase it") - }, options.timeout ?? 10_000).unref() - - client.on("Network.requestWillBeSent", (e) => { - if(e.request.url.includes("/youtubei/v1/player")) { - const jsonData = JSON.parse(e.request.postData!) - - // cleanup - browser.close() - client.removeAllListeners() - - clearTimeout(timeout) - - if(!jsonData["serviceIntegrityDimensions"]["poToken"] || !jsonData["context"]["client"]["visitorData"]) reject("Unable to get poToken or visitorData") - - resolve({ - poToken: jsonData["serviceIntegrityDimensions"]["poToken"], - visitorData: jsonData["context"]["client"]["visitorData"] - }) - } - }) - - await page.goto(options.embeddedVideoUrl ?? "https://www.youtube.com/embed/jNQXAC9IVRw", { - waitUntil: "networkidle2" - }) - - // Start playing the video - const playButton = (await page.$("#movie_player"))! - - await playButton.click() - }) -} \ No newline at end of file + if (!options.skipPuppeteerCheck && !(await hasPuppeteer())) + throw new Error("ERR_NO_DEP: Puppeteer not found"); + if ( + options.embeddedVideoUrl && + !options.embeddedVideoUrl.startsWith("https://www.youtube.com/embed/") + ) + throw new Error("ERR_INVALID_YT_EMBED: That is not a valid youtube embed"); + + return new Promise(async (resolve, reject) => { + const puppet = await import("puppeteer"); + + const browser = await puppet.launch({ + ...options.puppeteerOptions, + headless: false, + }); + + const page = await browser.newPage(); + + const client = await page.createCDPSession(); + await client.send("Debugger.enable"); + await client.send("Debugger.setAsyncCallStackDepth", { maxDepth: 32 }); + await client.send("Network.enable"); + + const timeout = setTimeout(() => { + client.removeAllListeners(); + reject( + "ERR_PUPPETEER_TIMEOUT: Timeout exceeded. Use GeneratorOptions.timeout to increase it", + ); + }, options.timeout ?? 10_000).unref(); + + client.on("Network.requestWillBeSent", (e) => { + if (e.request.url.includes("/youtubei/v1/player")) { + const jsonData = JSON.parse(e.request.postData!); + + // cleanup + browser.close(); + client.removeAllListeners(); + + clearTimeout(timeout); + + if ( + !jsonData["serviceIntegrityDimensions"]["poToken"] || + !jsonData["context"]["client"]["visitorData"] + ) + reject("Unable to get poToken or visitorData"); + + resolve({ + poToken: jsonData["serviceIntegrityDimensions"]["poToken"], + visitorData: jsonData["context"]["client"]["visitorData"], + }); + } + }); + + await page.goto( + options.embeddedVideoUrl ?? "https://www.youtube.com/embed/jNQXAC9IVRw", + { + waitUntil: "networkidle2", + }, + ); + + // Start playing the video + const playButton = (await page.$("#movie_player"))!; + + await playButton.click(); + }); +} diff --git a/lib/utils/poTokenGenerator/generatePoTokenInterval.ts b/lib/utils/poTokenGenerator/generatePoTokenInterval.ts index f40bbe0..a238093 100644 --- a/lib/utils/poTokenGenerator/generatePoTokenInterval.ts +++ b/lib/utils/poTokenGenerator/generatePoTokenInterval.ts @@ -1,53 +1,60 @@ -import { type GeneratorOptions, type GeneratorReturnData, generateTrustedToken } from "./generatePoToken"; +import { + type GeneratorOptions, + type GeneratorReturnData, + generateTrustedToken, +} from "./generatePoToken"; export interface IGeneratorOptions { - interval?: number; - onGenerate: (data: GeneratorReturnData) => any; - generateInstant?: boolean; - onError: (e: any) => any + interval?: number; + onGenerate: (data: GeneratorReturnData) => any; + generateInstant?: boolean; + onError: (e: any) => any; } -export type IntervalGeneratorOptions = IGeneratorOptions & Omit - -export async function generateTrustedTokenInterval(options: IntervalGeneratorOptions) { - if(!options.interval) options.interval = 6.048e+8 // 1 week in ms - - if(options.generateInstant) { - const tokens = await generateTrustedToken({ - ...options, - skipPuppeteerCheck: false - }).catch(e => e) - - if(tokens instanceof Error) { - if(!options.onError) throw tokens - else options.onError(tokens) - } else { - options.onGenerate(tokens) - } +export type IntervalGeneratorOptions = IGeneratorOptions & + Omit; + +export async function generateTrustedTokenInterval( + options: IntervalGeneratorOptions, +) { + if (!options.interval) options.interval = 6.048e8; // 1 week in ms + + if (options.generateInstant) { + const tokens = await generateTrustedToken({ + ...options, + skipPuppeteerCheck: false, + }).catch((e) => e); + + if (tokens instanceof Error) { + if (!options.onError) throw tokens; + else options.onError(tokens); + } else { + options.onGenerate(tokens); } - - // non blocking interval - const interval = setInterval(async () => { - const tokens = await generateTrustedToken({ - ...options, - skipPuppeteerCheck: true - }).catch(e => e) - - if(tokens instanceof Error) { - if(!options.onError) throw tokens - else options.onError(tokens) - } else { - options.onGenerate(tokens) - } - }, options.interval) - - const background = () => interval.unref() - const foreground = () => interval.ref() - const stop = () => clearInterval(interval) - - return { - background, - foreground, - stop + } + + // non blocking interval + const interval = setInterval(async () => { + const tokens = await generateTrustedToken({ + ...options, + skipPuppeteerCheck: true, + }).catch((e) => e); + + if (tokens instanceof Error) { + if (!options.onError) throw tokens; + else options.onError(tokens); + } else { + options.onGenerate(tokens); } -} \ No newline at end of file + }, options.interval); + + const background = () => interval.unref(); + const foreground = () => interval.ref(); + const stop = () => clearInterval(interval); + + return { + background, + foreground, + stop, + }; +} diff --git a/package.json b/package.json index 605c7d5..e22906a 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "@types/node": "^22.0.3", "discord-player": "^6.7.0", "discord.js": "^14.15.3", + "prettier": "^3.3.3", "puppeteer": "^23.0.2", "tsup": "^8.2.4", "typescript": "^5.5.2" @@ -21,7 +22,8 @@ }, "scripts": { "build": "tsup", - "prepare": "npm run build" + "prepare": "npm run build", + "format": "prettier --write \"{lib,examples,bin}/**/*.{js,ts}\"" }, "bin": "./bin/index.js", "packageManager": "yarn@4.3.1" diff --git a/yarn.lock b/yarn.lock index 5f8730f..4bddcb6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1150,6 +1150,7 @@ __metadata: "@types/node": "npm:^22.0.3" discord-player: "npm:^6.7.0" discord.js: "npm:^14.15.3" + prettier: "npm:^3.3.3" puppeteer: "npm:^23.0.2" tiny-typed-emitter: "npm:^2.1.0" tsup: "npm:^8.2.4" @@ -2501,6 +2502,15 @@ __metadata: languageName: node linkType: hard +"prettier@npm:^3.3.3": + version: 3.3.3 + resolution: "prettier@npm:3.3.3" + bin: + prettier: bin/prettier.cjs + checksum: 10c0/b85828b08e7505716324e4245549b9205c0cacb25342a030ba8885aba2039a115dbcf75a0b7ca3b37bc9d101ee61fab8113fc69ca3359f2a226f1ecc07ad2e26 + languageName: node + linkType: hard + "prism-media@npm:^1.3.5": version: 1.3.5 resolution: "prism-media@npm:1.3.5"