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 {
|
import {
|
||||||
VoiceChannel,
|
VoiceChannel,
|
||||||
Snowflake,
|
Snowflake,
|
||||||
TextChannel,
|
|
||||||
StageChannel,
|
StageChannel,
|
||||||
Guild,
|
Guild,
|
||||||
PermissionsBitField,
|
PermissionsBitField,
|
||||||
@@ -16,27 +15,91 @@ import {
|
|||||||
createAudioResource,
|
createAudioResource,
|
||||||
VoiceConnectionStatus,
|
VoiceConnectionStatus,
|
||||||
AudioPlayerStatus,
|
AudioPlayerStatus,
|
||||||
AudioPlayerState,
|
|
||||||
NoSubscriberBehavior,
|
NoSubscriberBehavior,
|
||||||
VoiceConnectionState,
|
VoiceConnectionState,
|
||||||
AudioPlayerError,
|
AudioPlayerError,
|
||||||
DiscordGatewayAdapterCreator
|
DiscordGatewayAdapterCreator
|
||||||
} from '@discordjs/voice';
|
} from '@discordjs/voice';
|
||||||
import playdl, { YouTubeVideo } from 'play-dl';
|
import playdl, { Spotify, SpotifyPlaylist, SpotifyTrack, YouTubeVideo } from 'play-dl';
|
||||||
import { EventEmitter } from 'stream';
|
import { EventEmitter } from 'stream';
|
||||||
|
|
||||||
import DiscordProvider from './Discord';
|
import DiscordProvider from './Discord';
|
||||||
import Environment from './Environment';
|
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) {
|
if (Environment.get().YOUTUBE_COOKIE_BASE64) {
|
||||||
playdl.setToken({
|
playdl.setToken({
|
||||||
youtube: {
|
youtube: {
|
||||||
cookie: Buffer.from(Environment.get().YOUTUBE_COOKIE_BASE64, 'base64').toString()
|
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 {
|
export class Queue {
|
||||||
public track: ValidTracks[] = [];
|
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 {
|
export class PlayerPlayingEvent {
|
||||||
public instance: DiscordMusicPlayerInstance;
|
public instance: DiscordMusicPlayerInstance;
|
||||||
|
|
||||||
@@ -92,6 +167,8 @@ export class DiscordMusicPlayerInstance {
|
|||||||
public paused: boolean = false;
|
public paused: boolean = false;
|
||||||
public loopMode: DiscordMusicPlayerLoopMode = DiscordMusicPlayerLoopMode.None;
|
public loopMode: DiscordMusicPlayerLoopMode = DiscordMusicPlayerLoopMode.None;
|
||||||
|
|
||||||
|
public actualPlaybackURL?: string;
|
||||||
|
|
||||||
public readonly events: EventEmitter;
|
public readonly events: EventEmitter;
|
||||||
|
|
||||||
constructor({ voiceChannel }: { voiceChannel: VoiceChannel | StageChannel }) {
|
constructor({ voiceChannel }: { voiceChannel: VoiceChannel | StageChannel }) {
|
||||||
@@ -229,11 +306,26 @@ export class DiscordMusicPlayerInstance {
|
|||||||
if (!this.voiceConnection) throw new Error('No voice connection');
|
if (!this.voiceConnection) throw new Error('No voice connection');
|
||||||
|
|
||||||
try {
|
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.player.play(resource);
|
||||||
this.voiceConnection.subscribe(this.player);
|
this.voiceConnection.subscribe(this.player);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -268,6 +360,10 @@ export class DiscordMusicPlayerInstance {
|
|||||||
return this.previousTrack;
|
return this.previousTrack;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getActualPlaybackURL() {
|
||||||
|
return this.actualPlaybackURL;
|
||||||
|
}
|
||||||
|
|
||||||
public isPaused() {
|
public isPaused() {
|
||||||
return this.paused;
|
return this.paused;
|
||||||
}
|
}
|
||||||
@@ -331,6 +427,41 @@ class DiscordMusicPlayer {
|
|||||||
return searched;
|
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) {
|
public async searchYouTubeByYouTubeLink(youtubeLink: YouTubeLink) {
|
||||||
// Search the url
|
// Search the url
|
||||||
const searched: YouTubeVideo[] = await playdl.search('https://www.youtube.com/watch?v=' + youtubeLink.videoId, {
|
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 {
|
public isYouTubeLink(link: string): boolean {
|
||||||
try {
|
try {
|
||||||
this.parseYouTubeLink(link);
|
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 {
|
public parseYouTubeLink(query: string): YouTubeLink {
|
||||||
if (
|
if (
|
||||||
query.startsWith('https://www.youtube.com/watch?v=') ||
|
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) {
|
private parseURLQuery(query: string) {
|
||||||
let queryObject: any = {};
|
let queryObject: any = {};
|
||||||
if (query.indexOf('?') >= 0) {
|
if (query.indexOf('?') >= 0) {
|
||||||
|
|||||||
Reference in New Issue
Block a user