diff --git a/package.json b/package.json index 7b922db..5f6176d 100644 --- a/package.json +++ b/package.json @@ -2,10 +2,10 @@ "name": "sunday.ts", "main": "dist/index.js", "types": "dist/index.d.ts", - "version": "1.0.18", + "version": "1.1.0", "description": "Sunday a lavalink wrapper", "license": "MIT", - "author": "FAYStarNext", + "author": "EwarinDev", "scripts": { "build:js": "npx babel src --out-dir dist --extensions \".ts,.tsx\" --source-maps inline", "lint": "npx eslint src/**/*.ts", @@ -13,14 +13,14 @@ "test:player": "bun test/player.ts" }, "devDependencies": { - "@eslint/js": "^9.9.0", + "@eslint/js": "^9.9.1", "@types/bun": "latest", - "eslint": "^9.9.0", + "eslint": "^9.9.1", "globals": "^15.9.0", - "typescript-eslint": "^8.0.1", - "@babel/cli": "^7.24.8", + "typescript-eslint": "^8.4.0", + "@babel/cli": "^7.25.6", "@babel/core": "^7.25.2", - "@babel/preset-env": "^7.25.3", + "@babel/preset-env": "^7.25.4", "@babel/preset-typescript": "^7.24.7" }, "publishConfig": { @@ -32,11 +32,12 @@ }, "homepage": "https://github.com/FAYStarNext/Sunday.ts#readme", "peerDependencies": { - "typescript": "^5.5.3" + "typescript": "^5.5.4" }, "dependencies": { - "@discordjs/collection": "^2.1.0", - "discord.js": "^14.15.3", + "@discordjs/collection": "^2.1.1", + "axios": "^1.7.7", + "discord.js": "^14.16.1", "tiny-typed-emitter": "^2.1.0", "ws": "^8.18.0" }, diff --git a/src/index.ts b/src/index.ts index dcd61d6..1a65971 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,4 +2,4 @@ export * from "./structures/Manager"; export * from "./structures/Node"; export * from "./structures/Player"; export * from "./structures/Queue"; -export * from "./structures/Utils"; +export * from "./structures/Utils"; \ No newline at end of file diff --git a/src/structures/Filters.ts b/src/structures/Filters.ts index 489d406..993cdab 100644 --- a/src/structures/Filters.ts +++ b/src/structures/Filters.ts @@ -2,13 +2,13 @@ import { Band, bassBoostEqualizer, softEqualizer, trebleBassEqualizer, tvEqualiz import { Player } from "./Player"; export class Filters { - public distortion: DistortionOptions | null; + public distortion: distortionOptions | null; public equalizer: Band[]; - public karaoke: KaraokeOptions | null; + public karaoke: karaokeOptions | null; public player: Player; - public rotation: RotationOptions | null; - public timescale: TimescaleOptions | null; - public vibrato: VibratoOptions | null; + public rotation: rotationOptions | null; + public timescale: timescaleOptions | null; + public vibrato: vibratoOptions | null; public volume: number; private filterStatus: { @@ -68,7 +68,7 @@ export class Filters { return this; } - private setFilterStatus(filter: keyof AvailableFilters, status: boolean): this { + private setFilterStatus(filter: keyof availableFilters, status: boolean): this { this.filterStatus[filter] = status; return this; } @@ -144,7 +144,7 @@ export class Filters { } /** Applies the karaoke options specified by the filter. */ - public setKaraoke(karaoke?: KaraokeOptions): this { + public setKaraoke(karaoke?: karaokeOptions): this { return this.applyFilter({ property: "karaoke", value: karaoke, @@ -152,22 +152,22 @@ export class Filters { } /** Applies the timescale options specified by the filter. */ - public setTimescale(timescale?: TimescaleOptions): this { + public setTimescale(timescale?: timescaleOptions): this { return this.applyFilter({ property: "timescale", value: timescale }); } /** Applies the vibrato options specified by the filter. */ - public setVibrato(vibrato?: VibratoOptions): this { + public setVibrato(vibrato?: vibratoOptions): this { return this.applyFilter({ property: "vibrato", value: vibrato }); } /** Applies the rotation options specified by the filter. */ - public setRotation(rotation?: RotationOptions): this { + public setRotation(rotation?: rotationOptions): this { return this.applyFilter({ property: "rotation", value: rotation }); } /** Applies the distortion options specified by the filter. */ - public setDistortion(distortion?: DistortionOptions): this { + public setDistortion(distortion?: distortionOptions): this { return this.applyFilter({ property: "distortion", value: distortion }); } @@ -199,13 +199,13 @@ export class Filters { } /** Returns the status of the specified filter . */ - public getFilterStatus(filter: keyof AvailableFilters): boolean { + public getFilterStatus(filter: keyof availableFilters): boolean { return this.filterStatus[filter]; } } /** Options for adjusting the timescale of audio. */ -interface TimescaleOptions { +interface timescaleOptions { /** The speed factor for the timescale. */ speed?: number; /** The pitch factor for the timescale. */ @@ -215,7 +215,7 @@ interface TimescaleOptions { } /** Options for applying vibrato effect to audio. */ -interface VibratoOptions { +interface vibratoOptions { /** The frequency of the vibrato effect. */ frequency: number; /** * The depth of the vibrato effect.*/ @@ -223,13 +223,13 @@ interface VibratoOptions { } /** Options for applying rotation effect to audio. */ -interface RotationOptions { +interface rotationOptions { /** The rotation speed in Hertz (Hz). */ rotationHz: number; } /** Options for applying karaoke effect to audio. */ -interface KaraokeOptions { +interface karaokeOptions { /** The level of karaoke effect. */ level?: number; /** The mono level of karaoke effect. */ @@ -240,7 +240,7 @@ interface KaraokeOptions { filterWidth?: number; } -interface DistortionOptions { +interface distortionOptions { sinOffset?: number; sinScale?: number; cosOffset?: number; @@ -251,7 +251,7 @@ interface DistortionOptions { scale?: number; } -interface AvailableFilters { +interface availableFilters { bassboost: boolean; distort: boolean; eightD: boolean; diff --git a/src/structures/Manager.ts b/src/structures/Manager.ts index f3fb71a..aac867f 100644 --- a/src/structures/Manager.ts +++ b/src/structures/Manager.ts @@ -14,6 +14,7 @@ import { WebSocketClosedEvent, } from "./Utils"; import { Collection } from "@discordjs/collection"; +import { EventEmitter } from "events"; import { Node, NodeOptions } from "./Node"; import { Player, PlayerOptions, Track, UnresolvedTrack } from "./Player"; import { VoiceState } from ".."; @@ -28,24 +29,23 @@ export class Manager extends TypedEmitter { public static readonly DEFAULT_SOURCES: Record = { "youtube music": "ytmsearch", youtube: "ytsearch", + spotify: "spsearch", + jiosaavn: "jssearch", soundcloud: "scsearch", deezer: "dzsearch", + tidal: "tdsearch", + applemusic: "amsearch", + bandcamp: "bcsearch", }; - YOUTUBE_REGEX = /https?:\/\/(www\.)?(youtu\.be|youtube\.com|music\.youtube\.com|m\.youtube\.com)/; - SOUNDCLOUD_REGEX = /^(https?:\/\/)?(www\.)?(soundcloud\.com)\/.+$/; - SPOTIFY_REGEX = /^(https?:\/\/)?(open\.spotify\.com)\/.+$/; - BILIBILITV_REGEX = /^(https?:\/\/)?(www\.)?(bilibili\.tv)\/.+$/; - JOOX_REGEX = /^(https?:\/\/)?(www\.)?(joox\.com)\/.+$/; /** The map of players. */ - public readonly players: Collection = new Collection(); + public readonly players = new Collection(); /** The map of nodes. */ - public readonly nodes: Collection = new Collection(); + public readonly nodes = new Collection(); /** The options that were set. */ public readonly options: ManagerOptions; private initiated = false; - /** The map of search. */ - public readonly search_cache: Map = new Map(); + public caches = new Collection(); /** Returns the nodes that has the least load. */ private get leastLoadNode(): Collection { @@ -87,15 +87,7 @@ export class Manager extends TypedEmitter { /** Returns the node to use. */ public get useableNodes(): Node { - let selectedNode: Node; - if (this.options.usePriority) { - selectedNode = this.priorityNode; - } else if (this.options.useNode === "leastLoad") { - selectedNode = this.leastLoadNode.first(); - } else { - selectedNode = this.leastPlayersNode.first(); - } - return selectedNode; + return this.options.usePriority ? this.priorityNode : this.options.useNode === "leastLoad" ? this.leastLoadNode.first() : this.leastPlayersNode.first(); } /** @@ -129,7 +121,7 @@ export class Manager extends TypedEmitter { shards: 1, autoPlay: true, usePriority: false, - clientName: "Sunday.ts", + clientName: "Sunday.ts (https://github.com/EwarinDev/Sunday.ts)", defaultSearchPlatform: "youtube", useNode: "leastPlayers", ...options, @@ -142,14 +134,9 @@ export class Manager extends TypedEmitter { } } - if (this.options.nodes) this.options.nodes.forEach((nodeOptions) => { return new (Structure.get("Node"))(nodeOptions); }); - if (this.options.caches.enabled) { - setInterval(() => { - if (this.search_cache.clear() === undefined) return; - this.emit("SearchCacheClear", this.search_cache.values().next().value); - this.search_cache.clear(); - }, this.options.caches?.time || 10000); - } else return; + if (this.options.nodes) { + for (const nodeOptions of this.options.nodes) new (Structure.get("Node"))(nodeOptions); + } } /** @@ -159,7 +146,9 @@ export class Manager extends TypedEmitter { public init(clientId?: string): this { if (this.initiated) return this; if (typeof clientId !== "undefined") this.options.clientId = clientId; + if (typeof this.options.clientId !== "string") throw new Error('"clientId" set is not type of "string"'); + if (!this.options.clientId) throw new Error('"clientId" is not set. Pass it in Manager#init() or as a option in the constructor.'); for (const node of this.nodes.values()) { @@ -180,141 +169,94 @@ export class Manager extends TypedEmitter { * @param requester * @returns The search result. */ - // TypeScript - public async search(options: SearchQuery): Promise { + public async search(query: string | SearchQuery, requester?: User | ClientUser): Promise { const node = this.useableNodes; - if (!node) throw new Error("No available nodes."); - const { search, code } = this.prepareQuery(options); - const cachedResult = this.getCachedResult(options, code); - if (cachedResult) return cachedResult; - - try { - const res = await this.fetchTracks(node, search); - const result = this.handleResponse(res, options); - if (options.cache !== false && this.options.caches.enabled !== false) { - this.cacheResult(res, code, result); - } - - return result; - } catch (err) { - throw new Error(err); + if (!node) { + throw new Error("No available nodes."); } - } - - private prepareQuery(options: SearchQuery): { search: string, code: string } { - const _query: SearchQuery = typeof options.query === "string" ? { query: options.query } : options.query; + if (this.options.caches.enabled && this.options.caches.time > 0 && typeof query === "string") { + let data = this.caches.get(query); + if (data) return data; + } + const _query: SearchQuery = typeof query === "string" ? { query } : query; const _source = Manager.DEFAULT_SOURCES[_query.source ?? this.options.defaultSearchPlatform] ?? _query.source; let search = _query.query; - const code = this.CheckURL(options.query); - if (!/^https?:\/\//.test(search)) search = `${_source}:${search}`; - return { search, code }; - } - private getCachedResult(options: SearchQuery, code: string): SearchResult | null { - if (options.cache !== false && this.options.caches.enabled !== false) { - const cachedResult = this.search_cache.get(code); - if (cachedResult) return cachedResult; + if (!/^https?:\/\//.test(search)) { + search = `${_source}:${search}`; } - return null; - } - private async fetchTracks(node: Node, search: string): Promise { - const res = await node.rest.get(`/v4/loadtracks?identifier=${encodeURIComponent(search)}`) as LavalinkResponse; - if (!res) throw new Error("Query not found."); - return res; - } + try { + const res = (await node.rest.get(`/v4/loadtracks?identifier=${encodeURIComponent(search)}`)) as LavalinkResponse; - private handleResponse(res: LavalinkResponse, options: SearchQuery): SearchResult { - let searchData = []; - let playlistData: PlaylistRawData | undefined; - - switch (res.loadType) { - case "search": - searchData = res.data as TrackData[]; - break; - case "track": - searchData = [res.data as TrackData[]]; - break; - case "playlist": - playlistData = res.data as PlaylistRawData; - break; - } + if (!res) { + throw new Error("Query not found."); + } - const tracks = searchData.map((track) => TrackUtils.build(track, options.requester)); - let playlist = null; + let searchData = []; + let playlistData: PlaylistRawData | undefined; - if (res.loadType === "playlist") { - playlist = { - name: playlistData.info.name, - tracks: playlistData.tracks.map((track) => TrackUtils.build(track, options.requester)), - duration: playlistData.tracks.reduce((acc, cur) => acc + (cur.info.length || 0), 0), - }; - } + switch (res.loadType) { + case "search": + searchData = res.data as TrackData[]; + break; - const result: SearchResult = { - loadType: res.loadType, - tracks, - playlist, - }; + case "track": + searchData = [res.data as TrackData[]]; + break; - this.replaceYouTubeCredentials(result); + case "playlist": + playlistData = res.data as PlaylistRawData; + break; + } - return result; - } + const tracks = searchData.map((track) => TrackUtils.build(track, requester)); + let playlist = null; - private replaceYouTubeCredentials(result: SearchResult): void { - if (this.options.replaceYouTubeCredentials) { - let tracksToReplace: Track[] = []; - if (result.loadType === "playlist") { - tracksToReplace = result.playlist.tracks; - } else { - tracksToReplace = result.tracks; + if (res.loadType === "playlist") { + playlist = { + name: playlistData!.info.name, + tracks: playlistData!.tracks.map((track) => TrackUtils.build(track, requester)), + duration: playlistData!.tracks.reduce((acc, cur) => acc + (cur.info.length || 0), 0), + }; } - for (const track of tracksToReplace) { - if (this.YOUTUBE_REGEX.test(track.uri)) { - track.author = track.author.replace("- Topic", ""); - track.title = track.title.replace("Topic -", ""); + const result: SearchResult = { + loadType: res.loadType, + tracks, + playlist, + }; + + if (this.options.replaceYouTubeCredentials) { + let tracksToReplace: Track[] = []; + if (result.loadType === "playlist") { + tracksToReplace = result.playlist.tracks; + } else { + tracksToReplace = result.tracks; } - if (track.title.includes("-")) { - const [author, title] = track.title.split("-").map((str: string) => str.trim()); - track.author = author; - track.title = title; + + for (const track of tracksToReplace) { + if (isYouTubeURL(track.uri)) { + track.author = track.author.replace("- Topic", ""); + track.title = track.title.replace("Topic -", ""); + } + if (track.title.includes("-")) { + const [author, title] = track.title.split("-").map((str: string) => str.trim()); + track.author = author; + track.title = title; + } } } + if (this.options.caches.enabled && this.options.caches.time > 0) this.caches.set(search, result); + return result; + } catch (err) { + throw new Error(err); } - } - private cacheResult(res: LavalinkResponse, code: string, result: SearchResult): void { - if (res.loadType === "search" || res.loadType === "track") { - this.search_cache.set(code, result); - } - } - private CheckURL(uri: string): string { - const data = this.regex_link(uri); - if (!data) return uri; - if (data === "yt") { - const videoCode = uri.match(/v=([^&]+)/)?.[1]; - const playlistCode = uri.match(/list=([^&]+)/)?.[1]; - if (playlistCode) { - return `yt:playlist:${playlistCode}`; - } - return "yt:" + (videoCode ?? ""); + function isYouTubeURL(uri: string): boolean { + return uri.includes("youtube.com") || uri.includes("youtu.be"); } - return uri; - } - - private isLink(link: string) { - return /^(https?:\/\/)?(www\.)?([a-zA-Z0-9]+)\.([a-zA-Z0-9]+)\/.+$/.test(link); - } - - private regex_link(link: string) { - if (this.YOUTUBE_REGEX.test(link)) return "yt"; - if (this.SOUNDCLOUD_REGEX.test(link)) return "sc"; - if (this.SPOTIFY_REGEX.test(link)) return "sp"; - if (this.isLink(link)) return "http"; - return null; } /** @@ -322,11 +264,16 @@ export class Manager extends TypedEmitter { * @param tracks */ public decodeTracks(tracks: string[]): Promise { - return new Promise(async (resolve, reject) => { + return new Promise(async (resolve, reject) => { const node = this.nodes.first(); if (!node) throw new Error("No available nodes."); + const res = (await node.rest.post("/v4/decodetracks", JSON.stringify(tracks)).catch((err) => reject(err))) as TrackData[]; - if (!res) return reject(new Error("No data returned from query.")); + + if (!res) { + return reject(new Error("No data returned from query.")); + } + return resolve(res); }); } @@ -434,17 +381,9 @@ export class Manager extends TypedEmitter { this.emit("PlayerDisconnect", player, player.voiceChannel); player.voiceChannel = null; - player.voiceState = { - channel_id: null, - guildId: undefined, - sessionId: null, - event: null, - op: "voiceUpdate", - session_id: null, - user_id: null, - guild_id: null, - } - return player.destroy(); + player.voiceState = Object.assign({}); + player.destroy(); + return; } } @@ -482,10 +421,10 @@ export interface ManagerOptions { defaultSearchPlatform?: SearchPlatform; /** Whether the YouTube video titles should be replaced if the Author does not exactly match. */ replaceYouTubeCredentials?: boolean; - caches: { - /** Whether to enable cache. */ + caches?: { + /** Whether to cache the search results. */ enabled: boolean; - /** Clear cache every second */ + /** The time to cache the search results. */ time: number; } /** @@ -496,18 +435,13 @@ export interface ManagerOptions { send(id: string, payload: Payload): void; } -export type SearchPlatform = "deezer" | "soundcloud" | "youtube music" | "youtube"; -/* The above code is defining a TypeScript type that represents a union of -string literals. The type can only have one of the specified values: -"deezer", "soundcloud", "youtube music", or "youtube". */ +export type SearchPlatform = "deezer" | "soundcloud" | "youtube music" | "youtube" | "spotify" | "jiosaavn" | "tidal" | "applemusic" | "bandcamp"; export interface SearchQuery { /** The source to search from. */ - source?: SearchPlatform; + source?: SearchPlatform | string; /** The query to search for. */ query: string; - requester?: User | ClientUser; - cache?: boolean } export interface LavalinkResponse { @@ -545,7 +479,6 @@ export interface PlaylistData { } export interface ManagerEvents { - SearchCacheClear: (data: string) => void; NodeCreate: (node: Node) => void; NodeDestroy: (node: Node) => void; NodeConnect: (node: Node) => void; diff --git a/src/structures/Node.ts b/src/structures/Node.ts index c5cc0e1..78fa3cf 100644 --- a/src/structures/Node.ts +++ b/src/structures/Node.ts @@ -1,5 +1,15 @@ -import { PlayerEvent, PlayerEvents, Structure, TrackEndEvent, TrackExceptionEvent, TrackStartEvent, TrackStuckEvent, WebSocketClosedEvent } from "./Utils"; -import { Manager } from "./Manager"; +import { + PlayerEvent, + PlayerEvents, + Structure, + TrackEndEvent, + TrackExceptionEvent, + TrackStartEvent, + TrackStuckEvent, + TrackUtils, + WebSocketClosedEvent, +} from "./Utils"; +import { LavalinkResponse, Manager, PlaylistRawData } from "./Manager"; import { Player, Track, UnresolvedTrack } from "./Player"; import { Rest } from "./Rest"; import nodeCheck from "../utils/nodeCheck"; @@ -44,9 +54,12 @@ export class Node { if (!this.manager) this.manager = Structure.get("Node")._manager; if (!this.manager) throw new RangeError("Manager has not been initiated."); - if (this.manager.nodes.has(options.identifier || options.host)) return this.manager.nodes.get(options.identifier || options.host); + if (this.manager.nodes.has(options.identifier || options.host)) { + return this.manager.nodes.get(options.identifier || options.host); + } nodeCheck(options); + this.options = { port: 2333, password: "youshallnotpass", @@ -93,12 +106,12 @@ export class Node { public connect(): void { if (this.connected) return; - const headers = { + const headers = Object.assign({ Authorization: this.options.password, "Num-Shards": String(this.manager.options.shards), "User-Id": this.manager.options.clientId, "Client-Name": this.manager.options.clientName, - }; + }); this.socket = new WebSocket(`ws${this.options.secure ? "s" : ""}://${this.address}/v4/websocket`, { headers }); this.socket.on("open", this.open.bind(this)); @@ -115,7 +128,8 @@ export class Node { if (players.size) players.forEach((p) => p.destroy()); this.socket.close(1000, "destroy"); - this.socket.removeListener("close", this.close.bind(this)); + // @ts-ignore + this.socket.removeAllListeners(); this.socket = null; this.reconnectAttempts = 1; @@ -133,12 +147,13 @@ export class Node { this.manager.emit("NodeError", this, error); return this.destroy(); } - this.socket.removeListener("close", this.close.bind(this)); + // @ts-ignore + this.socket?.removeAllListeners(); this.socket = null; this.manager.emit("NodeReconnect", this); this.connect(); this.reconnectAttempts++; - }, this.options.retryDelay) as NodeJS.Timeout; + }, this.options.retryDelay) as unknown as NodeJS.Timeout; } protected open(): void { @@ -270,32 +285,106 @@ export class Node { } } + public extractSpotifyTrackID(url: string): string | null { + const regex = /https:\/\/open\.spotify\.com\/track\/([a-zA-Z0-9]+)/; + const match = url.match(regex); + return match ? match[1] : null; + } + + public extractSpotifyArtistID(url: string): string | null { + const regex = /https:\/\/open\.spotify\.com\/artist\/([a-zA-Z0-9]+)/; + const match = url.match(regex); + return match ? match[1] : null; + } + // Handle autoplay private async handleAutoplay(player: Player, track: Track) { const previousTrack = player.queue.previous; + if (!player.isAutoplay || !previousTrack) return; + + const hasSpotifyURL = ["spotify.com", "open.spotify.com"].some((url) => previousTrack.uri.includes(url)); + + if (hasSpotifyURL) { + const node = this.manager.useableNodes; + + const res = await node.rest.get(`/v4/info`); + const info = res as LavalinkInfo; + + const isSpotifyPluginEnabled = info.plugins.some((plugin: { name: string }) => plugin.name === "lavasrc-plugin"); + const isSpotifySourceManagerEnabled = info.sourceManagers.includes("spotify"); + + if (isSpotifyPluginEnabled && isSpotifySourceManagerEnabled) { + const trackID = this.extractSpotifyTrackID(previousTrack.uri); + const artistID = this.extractSpotifyArtistID(previousTrack.pluginInfo.artistUrl); + + let identifier = ""; + if (trackID && artistID) { + identifier = `sprec:seed_artists=${artistID}&seed_tracks=${trackID}`; + } else if (trackID) { + identifier = `sprec:seed_tracks=${trackID}`; + } else if (artistID) { + identifier = `sprec:seed_artists=${artistID}`; + } + + if (identifier) { + const recommendedResult = (await node.rest.get(`/v4/loadtracks?identifier=${encodeURIComponent(identifier)}`)) as LavalinkResponse; + + if (recommendedResult.loadType === "playlist") { + const playlistData = recommendedResult.data as PlaylistRawData; + const recommendedTrack = playlistData.tracks[0]; + + if (recommendedTrack) { + player.queue.add(TrackUtils.build(recommendedTrack, player.get("Internal_BotUser"))); + player.play(); + return; + } + } + } + } + } + const hasYouTubeURL = ["youtube.com", "youtu.be"].some((url) => previousTrack.uri.includes(url)); + let videoID = previousTrack.uri.substring(previousTrack.uri.indexOf("=") + 1); if (!hasYouTubeURL) { - const res = await player.search(`${previousTrack.author} - ${previousTrack.title}`); + const res = await player.search(`${previousTrack.author} - ${previousTrack.title}`, player.get("Internal_BotUser")); + videoID = res.tracks[0].uri.substring(res.tracks[0].uri.indexOf("=") + 1); } + let randomIndex: number; let searchURI: string; + do { randomIndex = Math.floor(Math.random() * 23) + 2; searchURI = `https://www.youtube.com/watch?v=${videoID}&list=RD${videoID}&index=${randomIndex}`; } while (track.uri.includes(searchURI)); const res = await player.search(searchURI, player.get("Internal_BotUser")); + if (res.loadType === "empty" || res.loadType === "error") return; + let tracks = res.tracks; - if (res.loadType === "playlist") tracks = res.playlist.tracks; - const sortedTracks = tracks.toSorted(() => Math.random() - 0.5); - const foundTrack = sortedTracks.find((shuffledTrack) => shuffledTrack.uri !== track.uri); + + if (res.loadType === "playlist") { + tracks = res.playlist.tracks; + } + + const foundTrack = tracks.sort(() => Math.random() - 0.5).find((shuffledTrack) => shuffledTrack.uri !== track.uri); if (foundTrack) { + if (this.manager.options.replaceYouTubeCredentials) { + foundTrack.author = foundTrack.author.replace("- Topic", ""); + foundTrack.title = foundTrack.title.replace("Topic -", ""); + + if (foundTrack.title.includes("-")) { + const [author, title] = foundTrack.title.split("-").map((str: string) => str.trim()); + foundTrack.author = author; + foundTrack.title = title; + } + } player.queue.add(foundTrack); player.play(); } @@ -325,11 +414,13 @@ export class Node { } else if (queueRepeat) { queue.add(queue.current); } + queue.previous = queue.current; queue.current = queue.shift(); + this.manager.emit("TrackEnd", player, track, payload); - if (payload.reason === "stopped") { + if (payload.reason === "stopped" && !(queue.current = queue.shift())) { this.queueEnd(player, track, payload); return; } @@ -444,3 +535,14 @@ export interface FrameStats { /** The amount of deficit frames. */ deficit?: number; } + +export interface LavalinkInfo { + version: { semver: string; major: number; minor: number; patch: number; preRelease: string }; + buildTime: number; + git: { branch: string; commit: string; commitTime: number }; + jvm: string; + lavaplayer: string; + sourceManagers: string[]; + filters: string[]; + plugins: { name: string; version: string }[]; +} diff --git a/src/structures/Player.ts b/src/structures/Player.ts index 46fcfd0..64fa633 100644 --- a/src/structures/Player.ts +++ b/src/structures/Player.ts @@ -1,14 +1,15 @@ import { Filters } from "./Filters"; -import { Manager, SearchQuery, SearchResult } from "./Manager"; -import { Node } from "./Node"; +import { LavalinkResponse, Manager, PlaylistRawData, SearchQuery, SearchResult } from "./Manager"; +import { LavalinkInfo, Node } from "./Node"; import { Queue } from "./Queue"; import { Sizes, State, Structure, TrackSourceName, TrackUtils, VoiceState } from "./Utils"; +import * as _ from "lodash"; import playerCheck from "../utils/playerCheck"; import { ClientUser, Message, User } from "discord.js"; export class Player { /** The Queue for the Player. */ - public readonly queue: Queue = new (Structure.get("Queue"))(); + public readonly queue = new (Structure.get("Queue"))() as Queue; /** The filters applied to the audio. */ public filters: Filters; /** Whether the queue repeats the track. */ @@ -48,7 +49,7 @@ export class Player { private static _manager: Manager; private readonly data: Record = {}; - private dynamicLoopInterval: NodeJS.Timeout | null = null; + private dynamicLoopInterval: NodeJS.Timeout; /** * Set custom data. @@ -80,7 +81,9 @@ export class Player { if (!this.manager) this.manager = Structure.get("Player")._manager; if (!this.manager) throw new RangeError("Manager has not been initiated."); - if (this.manager.players.has(options.guild)) return this.manager.players.get(options.guild); + if (this.manager.players.has(options.guild)) { + return this.manager.players.get(options.guild); + } playerCheck(options); @@ -95,7 +98,9 @@ export class Player { const node = this.manager.nodes.get(options.node); this.node = node || this.manager.useableNodes; + if (!this.node) throw new RangeError("No available nodes."); + this.manager.players.set(options.guild, this); this.manager.emit("PlayerCreate", this); this.setVolume(options.volume ?? 100); @@ -108,10 +113,7 @@ export class Player { * @param requester */ public search(query: string | SearchQuery, requester?: User | ClientUser): Promise { - return this.manager.search({ - query: query as string, - requester: requester, - }); + return this.manager.search(query, requester); } /** Connect to the voice channel. */ @@ -192,9 +194,10 @@ export class Player { /** Sets the now playing message. */ public setNowPlayingMessage(message: Message): Message { - if (!message) throw new TypeError("You must provide the message of the now playing message."); - this.nowPlayingMessage = message; - return message; + if (!message) { + throw new TypeError("You must provide the message of the now playing message."); + } + return (this.nowPlayingMessage = message); } /** Plays the next track. */ @@ -229,8 +232,8 @@ export class Player { const finalOptions = playOptions ? playOptions : ["startTime", "endTime", "noReplace"].every((v) => Object.keys(optionsOrTrack || {}).includes(v)) - ? (optionsOrTrack as PlayOptions) - : {}; + ? (optionsOrTrack as PlayOptions) + : {}; if (TrackUtils.isUnresolvedTrack(this.queue.current)) { try { @@ -273,6 +276,96 @@ export class Player { return this; } + /** + * Gets recommended tracks and returns an array of tracks. + * @param track + * @param requester + */ + public async getRecommended(track: Track, requester?: User | ClientUser) { + const node = this.manager.useableNodes; + + if (!node) { + throw new Error("No available nodes."); + } + + const hasSpotifyURL = ["spotify.com", "open.spotify.com"].some((url) => track.uri.includes(url)); + const hasYouTubeURL = ["youtube.com", "youtu.be"].some((url) => track.uri.includes(url)); + + if (hasSpotifyURL) { + const res = await node.rest.get(`/v4/info`); + const info = res as LavalinkInfo; + + const isSpotifyPluginEnabled = info.plugins.some((plugin: { name: string }) => plugin.name === "lavasrc-plugin"); + const isSpotifySourceManagerEnabled = info.sourceManagers.includes("spotify"); + + if (isSpotifyPluginEnabled && isSpotifySourceManagerEnabled) { + const trackID = node.extractSpotifyTrackID(track.uri); + const artistID = node.extractSpotifyArtistID(track.pluginInfo.artistUrl); + + let identifier = ""; + if (trackID && artistID) { + identifier = `sprec:seed_artists=${artistID}&seed_tracks=${trackID}`; + } else if (trackID) { + identifier = `sprec:seed_tracks=${trackID}`; + } else if (artistID) { + identifier = `sprec:seed_artists=${artistID}`; + } + + if (identifier) { + const recommendedResult = (await node.rest.get(`/v4/loadtracks?identifier=${encodeURIComponent(identifier)}`)) as LavalinkResponse; + + if (recommendedResult.loadType === "playlist") { + const playlistData = recommendedResult.data as PlaylistRawData; + const recommendedTracks = playlistData.tracks; + + if (recommendedTracks) { + const tracks = recommendedTracks.map((track) => TrackUtils.build(track, requester)); + + return tracks; + } + } + } + } + } + + let videoID = track.uri.substring(track.uri.indexOf("=") + 1); + + if (!hasYouTubeURL) { + const res = await this.manager.search(`${track.author} - ${track.title}`); + + videoID = res.tracks[0].uri.substring(res.tracks[0].uri.indexOf("=") + 1); + } + + const searchURI = `https://www.youtube.com/watch?v=${videoID}&list=RD${videoID}`; + + const res = await this.manager.search(searchURI); + + if (res.loadType === "empty" || res.loadType === "error") return; + + let tracks = res.tracks; + + if (res.loadType === "playlist") { + tracks = res.playlist.tracks; + } + + const filteredTracks = tracks.filter((track) => track.uri !== `https://www.youtube.com/watch?v=${videoID}`); + + if (this.manager.options.replaceYouTubeCredentials) { + for (const track of filteredTracks) { + track.author = track.author.replace("- Topic", ""); + track.title = track.title.replace("Topic -", ""); + + if (track.title.includes("-")) { + const [author, title] = track.title.split("-").map((str: string) => str.trim()); + track.author = author; + track.title = title; + } + } + } + + return filteredTracks; + } + /** * Sets the player volume. * @param volume @@ -337,6 +430,7 @@ export class Player { this.manager.emit("PlayerStateUpdate", oldPlayer, this); return this; } + /** * Sets the queue to repeat and shuffles the queue after each song. * @param repeat "true" or "false". @@ -357,17 +451,10 @@ export class Player { this.trackRepeat = false; this.queueRepeat = false; this.dynamicRepeat = true; - function shuffleArray(array: Queue): Queue[] { - // Shuffle the array - for (let i = array.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - [array[i], array[j]] = [array[j], array[i]]; - } - return [array]; - } + this.dynamicLoopInterval = setInterval(() => { if (!this.dynamicRepeat) return; - const shuffled = shuffleArray(this.queue); + const shuffled = _.shuffle(this.queue); this.queue.clear(); shuffled.forEach((track) => { this.queue.add(track); diff --git a/src/structures/Queue.ts b/src/structures/Queue.ts index c5110f1..44ce948 100644 --- a/src/structures/Queue.ts +++ b/src/structures/Queue.ts @@ -36,6 +36,7 @@ export class Queue extends Array { if (!TrackUtils.validate(track)) { throw new RangeError('Track must be a "Track" or "Track[]".'); } + if (!this.current) { if (Array.isArray(track)) { this.current = track.shift() || null; @@ -59,8 +60,7 @@ export class Queue extends Array { this.splice(offset, 0, track); } } else { - const isArray = Array.isArray(track); - if (isArray) { + if (Array.isArray(track)) { this.push(...track); } else { this.push(track); @@ -110,4 +110,33 @@ export class Queue extends Array { [this[i], this[j]] = [this[j], this[i]]; } } + + public equalizedShuffle() { + const userTracks = new Map>(); + + this.forEach((track) => { + const user = track.requester.id; + + if (!userTracks.has(user)) { + userTracks.set(user, []); + } + + userTracks.get(user).push(track); + }); + + const shuffledQueue: Array = []; + + while (shuffledQueue.length < this.length) { + userTracks.forEach((tracks) => { + const track = tracks.shift(); + if (track) { + shuffledQueue.push(track); + } + }); + } + + this.clear(); + this.add(shuffledQueue); + console.log(this); + } } diff --git a/src/structures/Rest.ts b/src/structures/Rest.ts index f0c0df8..77ea149 100644 --- a/src/structures/Rest.ts +++ b/src/structures/Rest.ts @@ -1,4 +1,5 @@ import { Node } from "./Node"; +import axios, { AxiosRequestConfig, AxiosResponse } from "axios"; /** Handles the requests sent to the Lavalink REST API. */ export class Rest { @@ -33,7 +34,7 @@ export class Rest { } /** Sends a PATCH request to update player related data. */ - public async updatePlayer(options: PlayOptions): Promise { + public async updatePlayer(options: playOptions): Promise { return await this.patch(`/v4/sessions/${this.sessionId}/players/${options.guildId}?noReplace=false`, options.data); } @@ -43,18 +44,21 @@ export class Rest { } /* Sends a GET request to the specified endpoint and returns the response data. */ - private async request(method: "GET" | "POST" | "PATCH" | "DELETE", endpoint: string, body?: unknown): Promise { + private async request(method: string, endpoint: string, body?: unknown): Promise { + const config: AxiosRequestConfig = { + method, + url: this.url + endpoint, + headers: { + "Content-Type": "application/json", + Authorization: this.password, + }, + data: body, + }; + try { - const response = await fetch(this.url + endpoint, { - method, - headers: { - "Content-Type": "application/json", - Authorization: this.password, - }, - body: JSON.stringify(body), - }); - return await response.json(); - } catch(error) { + const response = await axios(config) as AxiosResponse + return response.data; + } catch (error) { if (error?.response?.status === 404) { this.node.destroy(); this.node.manager.createNode(this.node.options).connect(); @@ -85,7 +89,7 @@ export class Rest { } } -interface PlayOptions { +interface playOptions { guildId: string; data: { /** The base64 encoded track. */ diff --git a/src/structures/Utils.ts b/src/structures/Utils.ts index 00fa1b8..a23dbe1 100644 --- a/src/structures/Utils.ts +++ b/src/structures/Utils.ts @@ -92,14 +92,7 @@ export abstract class TrackUtils { return this.uri.includes("youtube") ? `https://img.youtube.com/vi/${data.info.identifier}/${finalSize}.jpg` : null; }, requester, - pluginInfo: { - albumName: data.pluginInfo?.albumName, - albumUrl: data.pluginInfo?.albumUrl, - artistArtworkUrl: data.pluginInfo?.artistArtworkUrl, - artistUrl: data.pluginInfo?.artistUrl, - isPreview: data.pluginInfo?.isPreview, - previewUrl: data.pluginInfo?.previewUrl, - }, + pluginInfo: data.pluginInfo, customData: {}, }; @@ -157,10 +150,7 @@ export abstract class TrackUtils { if (!TrackUtils.isUnresolvedTrack(unresolvedTrack)) throw new RangeError("Provided track is not a UnresolvedTrack."); const query = unresolvedTrack.uri ? unresolvedTrack.uri : [unresolvedTrack.author, unresolvedTrack.title].filter(Boolean).join(" - "); - const res = await TrackUtils.manager.search({ - query, - requester: unresolvedTrack.requester, - }); + const res = await TrackUtils.manager.search(query, unresolvedTrack.requester); if (unresolvedTrack.author) { const channelNames = [unresolvedTrack.author, `${unresolvedTrack.author} - Topic`]; @@ -214,14 +204,13 @@ export abstract class Structure { export class Plugin { public load(manager: Manager): void {} - public unload(manager: Manager): void {} } const structures = { - Player: Player, - Queue: Queue, - Node: Node, + Player: (await import("./Player")).Player, + Queue: (await import("./Queue")).Queue, + Node: (await import("./Node")).Node, }; export interface UnresolvedQuery {