Skip to content

Commit 250c7d3

Browse files
authored
Merge pull request #1137 from museofficial/develop
Develop
2 parents b2565a3 + 07bfd32 commit 250c7d3

File tree

9 files changed

+203
-168
lines changed

9 files changed

+203
-168
lines changed

package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737
"@types/debug": "^4.1.5",
3838
"@types/fluent-ffmpeg": "^2.1.17",
3939
"@types/fs-capacitor": "^2.0.0",
40-
"@types/ms": "0.7.31",
40+
"@types/ms": "0.7.34",
4141
"@types/node": "^17.0.0",
4242
"@types/node-emoji": "^1.8.1",
4343
"@types/spotify-web-api-node": "^5.0.2",
@@ -106,14 +106,14 @@
106106
"got": "^12.0.2",
107107
"hasha": "^5.2.2",
108108
"inversify": "^6.0.1",
109-
"iso8601-duration": "^1.3.0",
109+
"iso8601-duration": "^2.1.2",
110110
"libsodium-wrappers": "^0.7.9",
111111
"make-dir": "^3.1.0",
112112
"node-emoji": "^1.10.0",
113113
"nodesplash": "^0.1.1",
114114
"ora": "^8.1.0",
115115
"p-event": "^5.0.1",
116-
"p-limit": "^4.0.0",
116+
"p-limit": "^6.1.0",
117117
"p-queue": "^7.2.0",
118118
"p-retry": "6.2.0",
119119
"pagination.djs": "^4.0.10",

src/commands/play.ts

Lines changed: 31 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import {AutocompleteInteraction, ChatInputCommandInteraction} from 'discord.js';
22
import {URL} from 'url';
3-
import {SlashCommandBuilder} from '@discordjs/builders';
4-
import {inject, injectable} from 'inversify';
3+
import {SlashCommandBuilder, SlashCommandSubcommandsOnlyBuilder} from '@discordjs/builders';
4+
import {inject, injectable, optional} from 'inversify';
55
import Spotify from 'spotify-web-api-node';
66
import Command from './index.js';
77
import {TYPES} from '../types.js';
@@ -13,37 +13,43 @@ import AddQueryToQueue from '../services/add-query-to-queue.js';
1313

1414
@injectable()
1515
export default class implements Command {
16-
public readonly slashCommand = new SlashCommandBuilder()
17-
.setName('play')
18-
.setDescription('play a song')
19-
.addStringOption(option => option
20-
.setName('query')
21-
.setDescription('YouTube URL, Spotify URL, or search query')
22-
.setAutocomplete(true)
23-
.setRequired(true))
24-
.addBooleanOption(option => option
25-
.setName('immediate')
26-
.setDescription('add track to the front of the queue'))
27-
.addBooleanOption(option => option
28-
.setName('shuffle')
29-
.setDescription('shuffle the input if you\'re adding multiple tracks'))
30-
.addBooleanOption(option => option
31-
.setName('split')
32-
.setDescription('if a track has chapters, split it'))
33-
.addBooleanOption(option => option
34-
.setName('skip')
35-
.setDescription('skip the currently playing track'));
16+
public readonly slashCommand: Partial<SlashCommandBuilder | SlashCommandSubcommandsOnlyBuilder> & Pick<SlashCommandBuilder, 'toJSON'>;
3617

3718
public requiresVC = true;
3819

39-
private readonly spotify: Spotify;
20+
private readonly spotify?: Spotify;
4021
private readonly cache: KeyValueCacheProvider;
4122
private readonly addQueryToQueue: AddQueryToQueue;
4223

43-
constructor(@inject(TYPES.ThirdParty) thirdParty: ThirdParty, @inject(TYPES.KeyValueCache) cache: KeyValueCacheProvider, @inject(TYPES.Services.AddQueryToQueue) addQueryToQueue: AddQueryToQueue) {
44-
this.spotify = thirdParty.spotify;
24+
constructor(@inject(TYPES.ThirdParty) @optional() thirdParty: ThirdParty, @inject(TYPES.KeyValueCache) cache: KeyValueCacheProvider, @inject(TYPES.Services.AddQueryToQueue) addQueryToQueue: AddQueryToQueue) {
25+
this.spotify = thirdParty?.spotify;
4526
this.cache = cache;
4627
this.addQueryToQueue = addQueryToQueue;
28+
29+
const queryDescription = thirdParty === undefined
30+
? 'YouTube URL or search query'
31+
: 'YouTube URL, Spotify URL, or search query';
32+
33+
this.slashCommand = new SlashCommandBuilder()
34+
.setName('play')
35+
.setDescription('play a song')
36+
.addStringOption(option => option
37+
.setName('query')
38+
.setDescription(queryDescription)
39+
.setAutocomplete(true)
40+
.setRequired(true))
41+
.addBooleanOption(option => option
42+
.setName('immediate')
43+
.setDescription('add track to the front of the queue'))
44+
.addBooleanOption(option => option
45+
.setName('shuffle')
46+
.setDescription('shuffle the input if you\'re adding multiple tracks'))
47+
.addBooleanOption(option => option
48+
.setName('split')
49+
.setDescription('if a track has chapters, split it'))
50+
.addBooleanOption(option => option
51+
.setName('skip')
52+
.setDescription('skip the currently playing track'));
4753
}
4854

4955
public async execute(interaction: ChatInputCommandInteraction): Promise<void> {

src/inversify.config.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -57,11 +57,20 @@ container.bind<Client>(TYPES.Client).toConstantValue(new Client({intents}));
5757
// Managers
5858
container.bind<PlayerManager>(TYPES.Managers.Player).to(PlayerManager).inSingletonScope();
5959

60+
// Config values
61+
container.bind(TYPES.Config).toConstantValue(new ConfigProvider());
62+
6063
// Services
6164
container.bind<GetSongs>(TYPES.Services.GetSongs).to(GetSongs).inSingletonScope();
6265
container.bind<AddQueryToQueue>(TYPES.Services.AddQueryToQueue).to(AddQueryToQueue).inSingletonScope();
6366
container.bind<YoutubeAPI>(TYPES.Services.YoutubeAPI).to(YoutubeAPI).inSingletonScope();
64-
container.bind<SpotifyAPI>(TYPES.Services.SpotifyAPI).to(SpotifyAPI).inSingletonScope();
67+
68+
// Only instanciate spotify dependencies if the Spotify client ID and secret are set
69+
const config = container.get<ConfigProvider>(TYPES.Config);
70+
if (config.SPOTIFY_CLIENT_ID !== '' && config.SPOTIFY_CLIENT_SECRET !== '') {
71+
container.bind<SpotifyAPI>(TYPES.Services.SpotifyAPI).to(SpotifyAPI).inSingletonScope();
72+
container.bind(TYPES.ThirdParty).to(ThirdParty);
73+
}
6574

6675
// Commands
6776
[
@@ -91,12 +100,7 @@ container.bind<SpotifyAPI>(TYPES.Services.SpotifyAPI).to(SpotifyAPI).inSingleton
91100
container.bind<Command>(TYPES.Command).to(command).inSingletonScope();
92101
});
93102

94-
// Config values
95-
container.bind(TYPES.Config).toConstantValue(new ConfigProvider());
96-
97103
// Static libraries
98-
container.bind(TYPES.ThirdParty).to(ThirdParty);
99-
100104
container.bind(TYPES.FileCache).to(FileCacheProvider);
101105
container.bind(TYPES.KeyValueCache).to(KeyValueCacheProvider);
102106

src/services/add-query-to-queue.ts

Lines changed: 1 addition & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
/* eslint-disable complexity */
22
import {ChatInputCommandInteraction, GuildMember} from 'discord.js';
3-
import {URL} from 'node:url';
43
import {inject, injectable} from 'inversify';
54
import shuffle from 'array-shuffle';
65
import {TYPES} from '../types.js';
@@ -60,74 +59,7 @@ export default class AddQueryToQueue {
6059

6160
await interaction.deferReply({ephemeral: queueAddResponseEphemeral});
6261

63-
let newSongs: SongMetadata[] = [];
64-
let extraMsg = '';
65-
66-
// Test if it's a complete URL
67-
try {
68-
const url = new URL(query);
69-
70-
const YOUTUBE_HOSTS = [
71-
'www.youtube.com',
72-
'youtu.be',
73-
'youtube.com',
74-
'music.youtube.com',
75-
'www.music.youtube.com',
76-
];
77-
78-
if (YOUTUBE_HOSTS.includes(url.host)) {
79-
// YouTube source
80-
if (url.searchParams.get('list')) {
81-
// YouTube playlist
82-
newSongs.push(...await this.getSongs.youtubePlaylist(url.searchParams.get('list')!, shouldSplitChapters));
83-
} else {
84-
const songs = await this.getSongs.youtubeVideo(url.href, shouldSplitChapters);
85-
86-
if (songs) {
87-
newSongs.push(...songs);
88-
} else {
89-
throw new Error('that doesn\'t exist');
90-
}
91-
}
92-
} else if (url.protocol === 'spotify:' || url.host === 'open.spotify.com') {
93-
const [convertedSongs, nSongsNotFound, totalSongs] = await this.getSongs.spotifySource(query, playlistLimit, shouldSplitChapters);
94-
95-
if (totalSongs > playlistLimit) {
96-
extraMsg = `a random sample of ${playlistLimit} songs was taken`;
97-
}
98-
99-
if (totalSongs > playlistLimit && nSongsNotFound !== 0) {
100-
extraMsg += ' and ';
101-
}
102-
103-
if (nSongsNotFound !== 0) {
104-
if (nSongsNotFound === 1) {
105-
extraMsg += '1 song was not found';
106-
} else {
107-
extraMsg += `${nSongsNotFound.toString()} songs were not found`;
108-
}
109-
}
110-
111-
newSongs.push(...convertedSongs);
112-
} else {
113-
const song = await this.getSongs.httpLiveStream(query);
114-
115-
if (song) {
116-
newSongs.push(song);
117-
} else {
118-
throw new Error('that doesn\'t exist');
119-
}
120-
}
121-
} catch (_: unknown) {
122-
// Not a URL, must search YouTube
123-
const songs = await this.getSongs.youtubeVideoSearch(query, shouldSplitChapters);
124-
125-
if (songs) {
126-
newSongs.push(...songs);
127-
} else {
128-
throw new Error('that doesn\'t exist');
129-
}
130-
}
62+
let [newSongs, extraMsg] = await this.getSongs.getSongs(query, playlistLimit, shouldSplitChapters);
13163

13264
if (newSongs.length === 0) {
13365
throw new Error('no songs found');

src/services/config.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ export const DATA_DIR = path.resolve(process.env.DATA_DIR ? process.env.DATA_DIR
1212
const CONFIG_MAP = {
1313
DISCORD_TOKEN: process.env.DISCORD_TOKEN,
1414
YOUTUBE_API_KEY: process.env.YOUTUBE_API_KEY,
15-
SPOTIFY_CLIENT_ID: process.env.SPOTIFY_CLIENT_ID,
16-
SPOTIFY_CLIENT_SECRET: process.env.SPOTIFY_CLIENT_SECRET,
15+
SPOTIFY_CLIENT_ID: process.env.SPOTIFY_CLIENT_ID ?? '',
16+
SPOTIFY_CLIENT_SECRET: process.env.SPOTIFY_CLIENT_SECRET ?? '',
1717
REGISTER_COMMANDS_ON_BOT: process.env.REGISTER_COMMANDS_ON_BOT === 'true',
1818
DATA_DIR,
1919
CACHE_DIR: path.join(DATA_DIR, 'cache'),

src/services/get-songs.ts

Lines changed: 94 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,120 @@
1-
import {inject, injectable} from 'inversify';
1+
import {inject, injectable, optional} from 'inversify';
22
import * as spotifyURI from 'spotify-uri';
33
import {SongMetadata, QueuedPlaylist, MediaSource} from './player.js';
44
import {TYPES} from '../types.js';
55
import ffmpeg from 'fluent-ffmpeg';
66
import YoutubeAPI from './youtube-api.js';
77
import SpotifyAPI, {SpotifyTrack} from './spotify-api.js';
8+
import {URL} from 'node:url';
89

910
@injectable()
1011
export default class {
1112
private readonly youtubeAPI: YoutubeAPI;
12-
private readonly spotifyAPI: SpotifyAPI;
13+
private readonly spotifyAPI?: SpotifyAPI;
1314

14-
constructor(@inject(TYPES.Services.YoutubeAPI) youtubeAPI: YoutubeAPI, @inject(TYPES.Services.SpotifyAPI) spotifyAPI: SpotifyAPI) {
15+
constructor(@inject(TYPES.Services.YoutubeAPI) youtubeAPI: YoutubeAPI, @inject(TYPES.Services.SpotifyAPI) @optional() spotifyAPI?: SpotifyAPI) {
1516
this.youtubeAPI = youtubeAPI;
1617
this.spotifyAPI = spotifyAPI;
1718
}
1819

19-
async youtubeVideoSearch(query: string, shouldSplitChapters: boolean): Promise<SongMetadata[]> {
20+
async getSongs(query: string, playlistLimit: number, shouldSplitChapters: boolean): Promise<[SongMetadata[], string]> {
21+
const newSongs: SongMetadata[] = [];
22+
let extraMsg = '';
23+
24+
// Test if it's a complete URL
25+
try {
26+
const url = new URL(query);
27+
28+
const YOUTUBE_HOSTS = [
29+
'www.youtube.com',
30+
'youtu.be',
31+
'youtube.com',
32+
'music.youtube.com',
33+
'www.music.youtube.com',
34+
];
35+
36+
if (YOUTUBE_HOSTS.includes(url.host)) {
37+
// YouTube source
38+
if (url.searchParams.get('list')) {
39+
// YouTube playlist
40+
newSongs.push(...await this.youtubePlaylist(url.searchParams.get('list')!, shouldSplitChapters));
41+
} else {
42+
const songs = await this.youtubeVideo(url.href, shouldSplitChapters);
43+
44+
if (songs) {
45+
newSongs.push(...songs);
46+
} else {
47+
throw new Error('that doesn\'t exist');
48+
}
49+
}
50+
} else if (url.protocol === 'spotify:' || url.host === 'open.spotify.com') {
51+
if (this.spotifyAPI === undefined) {
52+
throw new Error('Spotify is not enabled!');
53+
}
54+
55+
const [convertedSongs, nSongsNotFound, totalSongs] = await this.spotifySource(query, playlistLimit, shouldSplitChapters);
56+
57+
if (totalSongs > playlistLimit) {
58+
extraMsg = `a random sample of ${playlistLimit} songs was taken`;
59+
}
60+
61+
if (totalSongs > playlistLimit && nSongsNotFound !== 0) {
62+
extraMsg += ' and ';
63+
}
64+
65+
if (nSongsNotFound !== 0) {
66+
if (nSongsNotFound === 1) {
67+
extraMsg += '1 song was not found';
68+
} else {
69+
extraMsg += `${nSongsNotFound.toString()} songs were not found`;
70+
}
71+
}
72+
73+
newSongs.push(...convertedSongs);
74+
} else {
75+
const song = await this.httpLiveStream(query);
76+
77+
if (song) {
78+
newSongs.push(song);
79+
} else {
80+
throw new Error('that doesn\'t exist');
81+
}
82+
}
83+
} catch (err: any) {
84+
if (err instanceof Error && err.message === 'Spotify is not enabled!') {
85+
throw err;
86+
}
87+
88+
// Not a URL, must search YouTube
89+
const songs = await this.youtubeVideoSearch(query, shouldSplitChapters);
90+
91+
if (songs) {
92+
newSongs.push(...songs);
93+
} else {
94+
throw new Error('that doesn\'t exist');
95+
}
96+
}
97+
98+
return [newSongs, extraMsg];
99+
}
100+
101+
private async youtubeVideoSearch(query: string, shouldSplitChapters: boolean): Promise<SongMetadata[]> {
20102
return this.youtubeAPI.search(query, shouldSplitChapters);
21103
}
22104

23-
async youtubeVideo(url: string, shouldSplitChapters: boolean): Promise<SongMetadata[]> {
105+
private async youtubeVideo(url: string, shouldSplitChapters: boolean): Promise<SongMetadata[]> {
24106
return this.youtubeAPI.getVideo(url, shouldSplitChapters);
25107
}
26108

27-
async youtubePlaylist(listId: string, shouldSplitChapters: boolean): Promise<SongMetadata[]> {
109+
private async youtubePlaylist(listId: string, shouldSplitChapters: boolean): Promise<SongMetadata[]> {
28110
return this.youtubeAPI.getPlaylist(listId, shouldSplitChapters);
29111
}
30112

31-
async spotifySource(url: string, playlistLimit: number, shouldSplitChapters: boolean): Promise<[SongMetadata[], number, number]> {
113+
private async spotifySource(url: string, playlistLimit: number, shouldSplitChapters: boolean): Promise<[SongMetadata[], number, number]> {
114+
if (this.spotifyAPI === undefined) {
115+
return [[], 0, 0];
116+
}
117+
32118
const parsed = spotifyURI.parse(url);
33119

34120
switch (parsed.type) {
@@ -58,7 +144,7 @@ export default class {
58144
}
59145
}
60146

61-
async httpLiveStream(url: string): Promise<SongMetadata> {
147+
private async httpLiveStream(url: string): Promise<SongMetadata> {
62148
return new Promise((resolve, reject) => {
63149
ffmpeg(url).ffprobe((err, _) => {
64150
if (err) {

src/services/youtube-api.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ export default class {
9595
}
9696

9797
if (!firstVideo) {
98-
throw new Error('No video found.');
98+
return [];
9999
}
100100

101101
return this.getVideo(firstVideo.url, shouldSplitChapters);

0 commit comments

Comments
 (0)