mirror of
https://github.com/kirameki-cafe/Yumi.git
synced 2026-09-13 10:49:20 +00:00
Support Spotify track, playlist, and album
This commit is contained in:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user