Skip to content

Commit

Permalink
Merge pull request #1092 from sofushn/master
Browse files Browse the repository at this point in the history
feat: allow running without spotify
  • Loading branch information
Codixer authored Nov 4, 2024
2 parents 418a7ec + 66e0224 commit 07bfd32
Show file tree
Hide file tree
Showing 7 changed files with 182 additions and 147 deletions.
56 changes: 31 additions & 25 deletions src/commands/play.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {AutocompleteInteraction, ChatInputCommandInteraction} from 'discord.js';
import {URL} from 'url';
import {SlashCommandBuilder} from '@discordjs/builders';
import {inject, injectable} from 'inversify';
import {SlashCommandBuilder, SlashCommandSubcommandsOnlyBuilder} from '@discordjs/builders';
import {inject, injectable, optional} from 'inversify';
import Spotify from 'spotify-web-api-node';
import Command from './index.js';
import {TYPES} from '../types.js';
Expand All @@ -13,37 +13,43 @@ import AddQueryToQueue from '../services/add-query-to-queue.js';

@injectable()
export default class implements Command {
public readonly slashCommand = new SlashCommandBuilder()
.setName('play')
.setDescription('play a song')
.addStringOption(option => option
.setName('query')
.setDescription('YouTube URL, Spotify URL, or search query')
.setAutocomplete(true)
.setRequired(true))
.addBooleanOption(option => option
.setName('immediate')
.setDescription('add track to the front of the queue'))
.addBooleanOption(option => option
.setName('shuffle')
.setDescription('shuffle the input if you\'re adding multiple tracks'))
.addBooleanOption(option => option
.setName('split')
.setDescription('if a track has chapters, split it'))
.addBooleanOption(option => option
.setName('skip')
.setDescription('skip the currently playing track'));
public readonly slashCommand: Partial<SlashCommandBuilder | SlashCommandSubcommandsOnlyBuilder> & Pick<SlashCommandBuilder, 'toJSON'>;

public requiresVC = true;

private readonly spotify: Spotify;
private readonly spotify?: Spotify;
private readonly cache: KeyValueCacheProvider;
private readonly addQueryToQueue: AddQueryToQueue;

constructor(@inject(TYPES.ThirdParty) thirdParty: ThirdParty, @inject(TYPES.KeyValueCache) cache: KeyValueCacheProvider, @inject(TYPES.Services.AddQueryToQueue) addQueryToQueue: AddQueryToQueue) {
this.spotify = thirdParty.spotify;
constructor(@inject(TYPES.ThirdParty) @optional() thirdParty: ThirdParty, @inject(TYPES.KeyValueCache) cache: KeyValueCacheProvider, @inject(TYPES.Services.AddQueryToQueue) addQueryToQueue: AddQueryToQueue) {
this.spotify = thirdParty?.spotify;
this.cache = cache;
this.addQueryToQueue = addQueryToQueue;

const queryDescription = thirdParty === undefined
? 'YouTube URL or search query'
: 'YouTube URL, Spotify URL, or search query';

this.slashCommand = new SlashCommandBuilder()
.setName('play')
.setDescription('play a song')
.addStringOption(option => option
.setName('query')
.setDescription(queryDescription)
.setAutocomplete(true)
.setRequired(true))
.addBooleanOption(option => option
.setName('immediate')
.setDescription('add track to the front of the queue'))
.addBooleanOption(option => option
.setName('shuffle')
.setDescription('shuffle the input if you\'re adding multiple tracks'))
.addBooleanOption(option => option
.setName('split')
.setDescription('if a track has chapters, split it'))
.addBooleanOption(option => option
.setName('skip')
.setDescription('skip the currently playing track'));
}

public async execute(interaction: ChatInputCommandInteraction): Promise<void> {
Expand Down
16 changes: 10 additions & 6 deletions src/inversify.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,11 +57,20 @@ container.bind<Client>(TYPES.Client).toConstantValue(new Client({intents}));
// Managers
container.bind<PlayerManager>(TYPES.Managers.Player).to(PlayerManager).inSingletonScope();

// Config values
container.bind(TYPES.Config).toConstantValue(new ConfigProvider());

// Services
container.bind<GetSongs>(TYPES.Services.GetSongs).to(GetSongs).inSingletonScope();
container.bind<AddQueryToQueue>(TYPES.Services.AddQueryToQueue).to(AddQueryToQueue).inSingletonScope();
container.bind<YoutubeAPI>(TYPES.Services.YoutubeAPI).to(YoutubeAPI).inSingletonScope();
container.bind<SpotifyAPI>(TYPES.Services.SpotifyAPI).to(SpotifyAPI).inSingletonScope();

// Only instanciate spotify dependencies if the Spotify client ID and secret are set
const config = container.get<ConfigProvider>(TYPES.Config);
if (config.SPOTIFY_CLIENT_ID !== '' && config.SPOTIFY_CLIENT_SECRET !== '') {
container.bind<SpotifyAPI>(TYPES.Services.SpotifyAPI).to(SpotifyAPI).inSingletonScope();
container.bind(TYPES.ThirdParty).to(ThirdParty);
}

// Commands
[
Expand Down Expand Up @@ -91,12 +100,7 @@ container.bind<SpotifyAPI>(TYPES.Services.SpotifyAPI).to(SpotifyAPI).inSingleton
container.bind<Command>(TYPES.Command).to(command).inSingletonScope();
});

// Config values
container.bind(TYPES.Config).toConstantValue(new ConfigProvider());

// Static libraries
container.bind(TYPES.ThirdParty).to(ThirdParty);

container.bind(TYPES.FileCache).to(FileCacheProvider);
container.bind(TYPES.KeyValueCache).to(KeyValueCacheProvider);

Expand Down
70 changes: 1 addition & 69 deletions src/services/add-query-to-queue.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
/* eslint-disable complexity */
import {ChatInputCommandInteraction, GuildMember} from 'discord.js';
import {URL} from 'node:url';
import {inject, injectable} from 'inversify';
import shuffle from 'array-shuffle';
import {TYPES} from '../types.js';
Expand Down Expand Up @@ -60,74 +59,7 @@ export default class AddQueryToQueue {

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

let newSongs: SongMetadata[] = [];
let extraMsg = '';

// Test if it's a complete URL
try {
const url = new URL(query);

const YOUTUBE_HOSTS = [
'www.youtube.com',
'youtu.be',
'youtube.com',
'music.youtube.com',
'www.music.youtube.com',
];

if (YOUTUBE_HOSTS.includes(url.host)) {
// YouTube source
if (url.searchParams.get('list')) {
// YouTube playlist
newSongs.push(...await this.getSongs.youtubePlaylist(url.searchParams.get('list')!, shouldSplitChapters));
} else {
const songs = await this.getSongs.youtubeVideo(url.href, shouldSplitChapters);

if (songs) {
newSongs.push(...songs);
} else {
throw new Error('that doesn\'t exist');
}
}
} else if (url.protocol === 'spotify:' || url.host === 'open.spotify.com') {
const [convertedSongs, nSongsNotFound, totalSongs] = await this.getSongs.spotifySource(query, playlistLimit, shouldSplitChapters);

if (totalSongs > playlistLimit) {
extraMsg = `a random sample of ${playlistLimit} songs was taken`;
}

if (totalSongs > playlistLimit && nSongsNotFound !== 0) {
extraMsg += ' and ';
}

if (nSongsNotFound !== 0) {
if (nSongsNotFound === 1) {
extraMsg += '1 song was not found';
} else {
extraMsg += `${nSongsNotFound.toString()} songs were not found`;
}
}

newSongs.push(...convertedSongs);
} else {
const song = await this.getSongs.httpLiveStream(query);

if (song) {
newSongs.push(song);
} else {
throw new Error('that doesn\'t exist');
}
}
} catch (_: unknown) {
// Not a URL, must search YouTube
const songs = await this.getSongs.youtubeVideoSearch(query, shouldSplitChapters);

if (songs) {
newSongs.push(...songs);
} else {
throw new Error('that doesn\'t exist');
}
}
let [newSongs, extraMsg] = await this.getSongs.getSongs(query, playlistLimit, shouldSplitChapters);

if (newSongs.length === 0) {
throw new Error('no songs found');
Expand Down
4 changes: 2 additions & 2 deletions src/services/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ export const DATA_DIR = path.resolve(process.env.DATA_DIR ? process.env.DATA_DIR
const CONFIG_MAP = {
DISCORD_TOKEN: process.env.DISCORD_TOKEN,
YOUTUBE_API_KEY: process.env.YOUTUBE_API_KEY,
SPOTIFY_CLIENT_ID: process.env.SPOTIFY_CLIENT_ID,
SPOTIFY_CLIENT_SECRET: process.env.SPOTIFY_CLIENT_SECRET,
SPOTIFY_CLIENT_ID: process.env.SPOTIFY_CLIENT_ID ?? '',
SPOTIFY_CLIENT_SECRET: process.env.SPOTIFY_CLIENT_SECRET ?? '',
REGISTER_COMMANDS_ON_BOT: process.env.REGISTER_COMMANDS_ON_BOT === 'true',
DATA_DIR,
CACHE_DIR: path.join(DATA_DIR, 'cache'),
Expand Down
102 changes: 94 additions & 8 deletions src/services/get-songs.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,120 @@
import {inject, injectable} from 'inversify';
import {inject, injectable, optional} from 'inversify';
import * as spotifyURI from 'spotify-uri';
import {SongMetadata, QueuedPlaylist, MediaSource} from './player.js';
import {TYPES} from '../types.js';
import ffmpeg from 'fluent-ffmpeg';
import YoutubeAPI from './youtube-api.js';
import SpotifyAPI, {SpotifyTrack} from './spotify-api.js';
import {URL} from 'node:url';

@injectable()
export default class {
private readonly youtubeAPI: YoutubeAPI;
private readonly spotifyAPI: SpotifyAPI;
private readonly spotifyAPI?: SpotifyAPI;

constructor(@inject(TYPES.Services.YoutubeAPI) youtubeAPI: YoutubeAPI, @inject(TYPES.Services.SpotifyAPI) spotifyAPI: SpotifyAPI) {
constructor(@inject(TYPES.Services.YoutubeAPI) youtubeAPI: YoutubeAPI, @inject(TYPES.Services.SpotifyAPI) @optional() spotifyAPI?: SpotifyAPI) {
this.youtubeAPI = youtubeAPI;
this.spotifyAPI = spotifyAPI;
}

async youtubeVideoSearch(query: string, shouldSplitChapters: boolean): Promise<SongMetadata[]> {
async getSongs(query: string, playlistLimit: number, shouldSplitChapters: boolean): Promise<[SongMetadata[], string]> {
const newSongs: SongMetadata[] = [];
let extraMsg = '';

// Test if it's a complete URL
try {
const url = new URL(query);

const YOUTUBE_HOSTS = [
'www.youtube.com',
'youtu.be',
'youtube.com',
'music.youtube.com',
'www.music.youtube.com',
];

if (YOUTUBE_HOSTS.includes(url.host)) {
// YouTube source
if (url.searchParams.get('list')) {
// YouTube playlist
newSongs.push(...await this.youtubePlaylist(url.searchParams.get('list')!, shouldSplitChapters));
} else {
const songs = await this.youtubeVideo(url.href, shouldSplitChapters);

if (songs) {
newSongs.push(...songs);
} else {
throw new Error('that doesn\'t exist');
}
}
} else if (url.protocol === 'spotify:' || url.host === 'open.spotify.com') {
if (this.spotifyAPI === undefined) {
throw new Error('Spotify is not enabled!');
}

const [convertedSongs, nSongsNotFound, totalSongs] = await this.spotifySource(query, playlistLimit, shouldSplitChapters);

if (totalSongs > playlistLimit) {
extraMsg = `a random sample of ${playlistLimit} songs was taken`;
}

if (totalSongs > playlistLimit && nSongsNotFound !== 0) {
extraMsg += ' and ';
}

if (nSongsNotFound !== 0) {
if (nSongsNotFound === 1) {
extraMsg += '1 song was not found';
} else {
extraMsg += `${nSongsNotFound.toString()} songs were not found`;
}
}

newSongs.push(...convertedSongs);
} else {
const song = await this.httpLiveStream(query);

if (song) {
newSongs.push(song);
} else {
throw new Error('that doesn\'t exist');
}
}
} catch (err: any) {
if (err instanceof Error && err.message === 'Spotify is not enabled!') {
throw err;
}

// Not a URL, must search YouTube
const songs = await this.youtubeVideoSearch(query, shouldSplitChapters);

if (songs) {
newSongs.push(...songs);
} else {
throw new Error('that doesn\'t exist');
}
}

return [newSongs, extraMsg];
}

private async youtubeVideoSearch(query: string, shouldSplitChapters: boolean): Promise<SongMetadata[]> {
return this.youtubeAPI.search(query, shouldSplitChapters);
}

async youtubeVideo(url: string, shouldSplitChapters: boolean): Promise<SongMetadata[]> {
private async youtubeVideo(url: string, shouldSplitChapters: boolean): Promise<SongMetadata[]> {
return this.youtubeAPI.getVideo(url, shouldSplitChapters);
}

async youtubePlaylist(listId: string, shouldSplitChapters: boolean): Promise<SongMetadata[]> {
private async youtubePlaylist(listId: string, shouldSplitChapters: boolean): Promise<SongMetadata[]> {
return this.youtubeAPI.getPlaylist(listId, shouldSplitChapters);
}

async spotifySource(url: string, playlistLimit: number, shouldSplitChapters: boolean): Promise<[SongMetadata[], number, number]> {
private async spotifySource(url: string, playlistLimit: number, shouldSplitChapters: boolean): Promise<[SongMetadata[], number, number]> {
if (this.spotifyAPI === undefined) {
return [[], 0, 0];
}

const parsed = spotifyURI.parse(url);

switch (parsed.type) {
Expand Down Expand Up @@ -58,7 +144,7 @@ export default class {
}
}

async httpLiveStream(url: string): Promise<SongMetadata> {
private async httpLiveStream(url: string): Promise<SongMetadata> {
return new Promise((resolve, reject) => {
ffmpeg(url).ffprobe((err, _) => {
if (err) {
Expand Down
2 changes: 1 addition & 1 deletion src/services/youtube-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ export default class {
}

if (!firstVideo) {
throw new Error('No video found.');
return [];
}

return this.getVideo(firstVideo.url, shouldSplitChapters);
Expand Down
Loading

0 comments on commit 07bfd32

Please sign in to comment.