diff --git a/src/providers/DiscordMusicPlayer.ts b/src/providers/DiscordMusicPlayer.ts index f834aa6..6c937f7 100644 --- a/src/providers/DiscordMusicPlayer.ts +++ b/src/providers/DiscordMusicPlayer.ts @@ -1,7 +1,6 @@ import { VoiceChannel, Snowflake, - TextChannel, StageChannel, Guild, PermissionsBitField, @@ -16,27 +15,91 @@ import { createAudioResource, VoiceConnectionStatus, AudioPlayerStatus, - AudioPlayerState, NoSubscriberBehavior, VoiceConnectionState, AudioPlayerError, DiscordGatewayAdapterCreator } from '@discordjs/voice'; -import playdl, { YouTubeVideo } from 'play-dl'; +import playdl, { Spotify, SpotifyPlaylist, SpotifyTrack, YouTubeVideo } from 'play-dl'; import { EventEmitter } from 'stream'; import DiscordProvider from './Discord'; import Environment from './Environment'; -export type ValidTracks = YouTubeVideo; +export type ValidTracks = YouTubeVideo | SpotifyTrack; +declare class YouTubeThumbnail { + url: string; + width: number; + height: number; + constructor(data: any); + toJSON(): { + url: string; + width: number; + height: number; + }; +} + +interface SpotifyThumbnail { + height: number; + width: number; + url: string; +} if (Environment.get().YOUTUBE_COOKIE_BASE64) { playdl.setToken({ youtube: { cookie: Buffer.from(Environment.get().YOUTUBE_COOKIE_BASE64, 'base64').toString() + }, + spotify: { + client_id: Environment.get().SPOTIFY_CLIENT_ID, + client_secret: Environment.get().SPOTIFY_CLIENT_SECRET, + refresh_token: Environment.get().SPOTIFY_REFRESH_TOKEN, + market: Environment.get().SPOTIFY_CLIENT_MARKET, } }); } + +export class TrackUtils { + public static getTitle(track: ValidTracks) { + if (track instanceof YouTubeVideo) { + return track.title; + } else if (track instanceof SpotifyTrack) { + let artistNames = ""; + for(let artist of track.artists) + artistNames += artist.name + " "; + + return `${artistNames} - ${track.name}`;; + } else { + throw new Error('Invalid track type'); + } + } + public static async getThumbnails(track: ValidTracks) { + if (track instanceof YouTubeVideo) { + return track.thumbnails; + } else if (track instanceof SpotifyTrack) { + if (track.thumbnail) + return [track.thumbnail]; + else { + // Try to find the thumbnail again + const result = await DiscordMusicPlayer_Instance.searchSpotifyBySpotifyLink(track); + if(!result) return null; + if(result.thumbnail) + return [result.thumbnail]; + return null; + } + } else { + throw new Error('Invalid track type'); + } + } + public static getHighestResolutionThumbnail(thumbnails: (YouTubeThumbnail[] | SpotifyThumbnail[] | null)) { + if(!thumbnails) return null; + + return (thumbnails as any[]).reduce((prev: any, current: any) => + prev.height * prev.width > current.height * current.width ? prev : current + ); + } +} + export class Queue { public track: ValidTracks[] = []; } @@ -51,6 +114,18 @@ export class YouTubeLink { } } +export class SpotifyLink { + public id: string; + public type: 'track' | 'playlist' | 'album'; + public url: string; + + constructor(id: string, type: ('track' | 'playlist' | 'album'), url: string) { + this.id = id; + this.type = type; + this.url = url; + } +} + export class PlayerPlayingEvent { public instance: DiscordMusicPlayerInstance; @@ -92,6 +167,8 @@ export class DiscordMusicPlayerInstance { public paused: boolean = false; public loopMode: DiscordMusicPlayerLoopMode = DiscordMusicPlayerLoopMode.None; + public actualPlaybackURL?: string; + public readonly events: EventEmitter; constructor({ voiceChannel }: { voiceChannel: VoiceChannel | StageChannel }) { @@ -229,11 +306,26 @@ export class DiscordMusicPlayerInstance { if (!this.voiceConnection) throw new Error('No voice connection'); try { - const stream = await playdl.stream(track.url); - const resource = createAudioResource(stream.stream, { - inputType: stream.type - }); + let resource; + if(track instanceof YouTubeVideo) { + const stream = await playdl.stream(track.url); + resource = createAudioResource(stream.stream, { + inputType: stream.type + }); + this.actualPlaybackURL = track.url; + } else { + const search = await DiscordMusicPlayer_Instance.searchYouTubeBySpotifyLink(track); + if(!search) + throw new Error('Unable to find Spotify track on YouTube'); + + const stream = await playdl.stream(search.url); + resource = createAudioResource(stream.stream, { + inputType: stream.type + }); + this.actualPlaybackURL = search.url; + } + this.player.play(resource); this.voiceConnection.subscribe(this.player); } catch (error: any) { @@ -268,6 +360,10 @@ export class DiscordMusicPlayerInstance { return this.previousTrack; } + public getActualPlaybackURL() { + return this.actualPlaybackURL; + } + public isPaused() { return this.paused; } @@ -331,6 +427,41 @@ class DiscordMusicPlayer { return searched; } + public async searchYouTubeBySpotifyLink(spotifyLink: SpotifyLink) { + + const track = await this.searchSpotifyBySpotifyLink(spotifyLink); + if(!track) return; + + let artistNames = ""; + for(let artist of track.artists) + artistNames += artist.name + " "; + + const ytSearchResult = await this.searchYouTubeByQuery(`${artistNames} ${track.name}`); + if(!ytSearchResult) return null; + + return ytSearchResult[0]; + } + + public async searchSpotifyBySpotifyLink(spotifyLink: SpotifyLink) { + if(playdl.is_expired()) + await playdl.refreshToken(); + + if(spotifyLink.type != 'track') return; + + if(playdl.is_expired()) + await playdl.refreshToken(); + + if(spotifyLink.type != 'track') return; + + // Fetch data from spotify + const searched: Spotify = await playdl.spotify("https://open.spotify.com/track/" + spotifyLink.id); + if(!(searched instanceof SpotifyTrack)) return; + + if (!searched) return null; + const track = searched as unknown as SpotifyTrack; + return track; + } + public async searchYouTubeByYouTubeLink(youtubeLink: YouTubeLink) { // Search the url const searched: YouTubeVideo[] = await playdl.search('https://www.youtube.com/watch?v=' + youtubeLink.videoId, { @@ -393,6 +524,15 @@ class DiscordMusicPlayer { }); } + public async getSpotifySongsInPlayList(spotifyLink: string) { + const result = await playdl.spotify(spotifyLink); + + if(!(result.type == 'playlist' || result.type == 'album')) + throw new Error("Not a spotify playlist"); + + return result as unknown as SpotifyPlaylist; + } + public isYouTubeLink(link: string): boolean { try { this.parseYouTubeLink(link); @@ -402,6 +542,15 @@ class DiscordMusicPlayer { } } + public isSpotifyLink(link: string): boolean { + try { + this.parseSpotifyLink(link); + return true; + } catch (err) { + return false; + } + } + public parseYouTubeLink(query: string): YouTubeLink { if ( query.startsWith('https://www.youtube.com/watch?v=') || @@ -437,6 +586,36 @@ class DiscordMusicPlayer { } } + public parseSpotifyLink(query: string): SpotifyLink { + if (query.startsWith('https://open.spotify.com/track/')) { + let id = query.split('/')[4].split(/[?#]/)[0]; + return { + id: id, + type: 'track', + url: query.split(/[?#]/)[0] + }; + } + else if (query.startsWith('https://open.spotify.com/album/')) { + let id = query.split('/')[4].split(/[?#]/)[0]; + return { + id: id, + type: 'album', + url: query.split(/[?#]/)[0] + }; + } + else if (query.startsWith('https://open.spotify.com/playlist/')) { + let id = query.split('/')[4].split(/[?#]/)[0]; + return { + id: id, + type: 'playlist', + url: query.split(/[?#]/)[0] + }; + } + else { + throw new Error('Spotify link is invalid'); + } + } + private parseURLQuery(query: string) { let queryObject: any = {}; if (query.indexOf('?') >= 0) {