aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorThongrapee Panyapatiphan <[email protected]>2021-12-09 17:55:29 +0700
committerThongrapee Panyapatiphan <[email protected]>2021-12-09 17:55:29 +0700
commitd8086be5cf1ab7152a6dc27ada78b3d186d85220 (patch)
tree1d9fa4aa618cd0266e1266818a80e3b636d8a8e0 /src
parent33b0ffa244eaa33a75f16790678390aad8f3d3ff (diff)
parent9afca25866830dacc668b1861bab5c12260de639 (diff)
downloadmuse-d8086be5cf1ab7152a6dc27ada78b3d186d85220.tar.xz
muse-d8086be5cf1ab7152a6dc27ada78b3d186d85220.zip
Merge branch 'master' into playlist-limit-config
Diffstat (limited to 'src')
-rw-r--r--src/bot.ts7
-rw-r--r--src/commands/config.ts9
-rw-r--r--src/commands/help.ts12
-rw-r--r--src/commands/play.ts2
-rw-r--r--src/commands/queue.ts2
-rw-r--r--src/commands/remove.ts76
-rw-r--r--src/commands/shortcuts.ts4
-rw-r--r--src/events/guild-create.ts6
-rw-r--r--src/events/voice-state-update.ts5
-rw-r--r--src/index.ts3
-rw-r--r--src/inversify.config.ts24
-rw-r--r--src/managers/player.ts10
-rw-r--r--src/models/file-cache.ts14
-rw-r--r--src/models/index.ts6
-rw-r--r--src/models/key-value-cache.ts (renamed from src/models/cache.ts)2
-rw-r--r--src/models/settings.ts2
-rw-r--r--src/models/shortcut.ts2
-rw-r--r--src/services/config.ts12
-rw-r--r--src/services/file-cache.ts117
-rw-r--r--src/services/get-songs.ts6
-rw-r--r--src/services/key-value-cache.ts (renamed from src/services/cache.ts)8
-rw-r--r--src/services/player.ts150
-rw-r--r--src/types.ts3
-rw-r--r--src/utils/channels.ts10
-rw-r--r--src/utils/db.ts4
25 files changed, 360 insertions, 136 deletions
diff --git a/src/bot.ts b/src/bot.ts
index 5d2e97e..b49c1f6 100644
--- a/src/bot.ts
+++ b/src/bot.ts
@@ -11,6 +11,7 @@ import handleVoiceStateUpdate from './events/voice-state-update.js';
import errorMsg from './utils/error-msg.js';
import {isUserInVoice} from './utils/channels.js';
import Config from './services/config.js';
+import {generateDependencyReport} from '@discordjs/voice';
@injectable()
export default class {
@@ -34,7 +35,7 @@ export default class {
commandNames.forEach(commandName => this.commands.set(commandName, command));
});
- this.client.on('message', async (msg: Message) => {
+ this.client.on('messageCreate', async (msg: Message) => {
// Get guild settings
if (!msg.guild) {
return;
@@ -44,7 +45,8 @@ export default class {
if (!settings) {
// Got into a bad state, send owner welcome message
- return this.client.emit('guildCreate', msg.guild);
+ this.client.emit('guildCreate', msg.guild);
+ return;
}
const {prefix, channel} = settings;
@@ -95,6 +97,7 @@ export default class {
});
this.client.on('ready', async () => {
+ debug(generateDependencyReport());
console.log(`Ready! Invite the bot with https://discordapp.com/oauth2/authorize?client_id=${this.client.user?.id ?? ''}&scope=bot&permissions=36752448`);
});
diff --git a/src/commands/config.ts b/src/commands/config.ts
index cb3fc74..dbe0949 100644
--- a/src/commands/config.ts
+++ b/src/commands/config.ts
@@ -1,4 +1,4 @@
-import {TextChannel, Message, GuildChannel} from 'discord.js';
+import {TextChannel, Message, GuildChannel, ThreadChannel} from 'discord.js';
import {injectable} from 'inversify';
import {Settings} from '../models/index.js';
import errorMsg from '../utils/error-msg.js';
@@ -21,6 +21,7 @@ export default class implements Command {
if (settings) {
let response = `prefix: \`${settings.prefix}\`\n`;
+ // eslint-disable-next-line @typescript-eslint/no-base-to-string
response += `channel: ${msg.guild!.channels.cache.get(settings.channel)!.toString()}\n`;
response += `playlist-limit: ${settings.playlistLimit}`;
@@ -37,7 +38,7 @@ export default class implements Command {
return;
}
- if (msg.author.id !== msg.guild!.owner!.id) {
+ if (msg.author.id !== msg.guild!.ownerId) {
await msg.channel.send(errorMsg('not authorized'));
return;
}
@@ -53,7 +54,7 @@ export default class implements Command {
}
case 'channel': {
- let channel: GuildChannel | undefined;
+ let channel: GuildChannel | ThreadChannel | undefined;
if (args[1].includes('<#') && args[1].includes('>')) {
channel = msg.guild!.channels.cache.find(c => c.id === args[1].slice(2, args[1].indexOf('>')));
@@ -61,7 +62,7 @@ export default class implements Command {
channel = msg.guild!.channels.cache.find(c => c.name === args[1]);
}
- if (channel && channel.type === 'text') {
+ if (channel && channel.type === 'GUILD_TEXT') {
await Settings.update({channel: channel.id}, {where: {guildId: msg.guild!.id}});
await Promise.all([
diff --git a/src/commands/help.ts b/src/commands/help.ts
index f150c54..7058efd 100644
--- a/src/commands/help.ts
+++ b/src/commands/help.ts
@@ -1,4 +1,4 @@
-import {Message} from 'discord.js';
+import {Message, Util} from 'discord.js';
import {injectable} from 'inversify';
import Command from '.';
import {TYPES} from '../types.js';
@@ -29,7 +29,7 @@ export default class implements Command {
const {prefix} = settings;
- const res = this.commands.sort((a, b) => a.name.localeCompare(b.name)).reduce((content, command) => {
+ const res = Util.splitMessage(this.commands.sort((a, b) => a.name.localeCompare(b.name)).reduce((content, command) => {
const aliases = command.aliases.reduce((str, alias, i) => {
str += alias;
@@ -53,9 +53,13 @@ export default class implements Command {
content += '\n';
return content;
- }, '');
+ }, ''));
+
+ for (const r of res) {
+ // eslint-disable-next-line no-await-in-loop
+ await msg.author.send(r);
+ }
- await msg.author.send(res, {split: true});
await msg.react('🇩');
await msg.react('🇲');
}
diff --git a/src/commands/play.ts b/src/commands/play.ts
index 7f02d5e..ddaa6bd 100644
--- a/src/commands/play.ts
+++ b/src/commands/play.ts
@@ -38,6 +38,7 @@ export default class implements Command {
this.getSongs = getSongs;
}
+ // eslint-disable-next-line complexity
public async execute(msg: Message, args: string[]): Promise<void> {
const [targetVoiceChannel] = getMemberVoiceChannel(msg.member!) ?? getMostPopularVoiceChannel(msg.guild!);
const settings = await Settings.findByPk(msg.guild!.id);
@@ -134,7 +135,6 @@ export default class implements Command {
if (song) {
newSongs.push(song);
} else {
- console.log(_);
await res.stop(errorMsg('that doesn\'t exist'));
return;
}
diff --git a/src/commands/queue.ts b/src/commands/queue.ts
index 85420e8..1c0735c 100644
--- a/src/commands/queue.ts
+++ b/src/commands/queue.ts
@@ -74,7 +74,7 @@ export default class implements Command {
embed.addField('Page', `${queuePage} out of ${maxQueuePage}`, false);
- await msg.channel.send(embed);
+ await msg.channel.send({embeds: [embed]});
} else {
await msg.channel.send('queue empty');
}
diff --git a/src/commands/remove.ts b/src/commands/remove.ts
new file mode 100644
index 0000000..9c40a71
--- /dev/null
+++ b/src/commands/remove.ts
@@ -0,0 +1,76 @@
+import {Message} from 'discord.js';
+import {inject, injectable} from 'inversify';
+import {TYPES} from '../types.js';
+import PlayerManager from '../managers/player.js';
+import Command from '.';
+import errorMsg from '../utils/error-msg.js';
+
+@injectable()
+export default class implements Command {
+ public name = 'remove';
+ public aliases = ['rm'];
+ public examples = [
+ ['remove 1', 'removes the next song in the queue'],
+ ['rm 5-7', 'remove every song in range 5 - 7 (inclusive) from the queue'],
+ ];
+
+ private readonly playerManager: PlayerManager;
+
+ constructor(@inject(TYPES.Managers.Player) playerManager: PlayerManager) {
+ this.playerManager = playerManager;
+ }
+
+ public async execute(msg: Message, args: string []): Promise<void> {
+ const player = this.playerManager.get(msg.guild!.id);
+
+ if (args.length === 0) {
+ await msg.channel.send(errorMsg('missing song position or range'));
+ return;
+ }
+
+ const reg = /^(\d+)-(\d+)$|^(\d+)$/g; // Expression has 3 groups: x-y or z. x-y is range, z is a single digit.
+ const match = reg.exec(args[0]);
+
+ if (match === null) {
+ await msg.channel.send(errorMsg('incorrect format'));
+ return;
+ }
+
+ if (match[3] === undefined) { // 3rd group (z) doesn't exist -> a range
+ const range = [parseInt(match[1], 10), parseInt(match[2], 10)];
+
+ if (range[0] < 1) {
+ await msg.channel.send(errorMsg('position must be greater than 0'));
+ return;
+ }
+
+ if (range[1] > player.queueSize()) {
+ await msg.channel.send(errorMsg('position is outside of the queue\'s range'));
+ return;
+ }
+
+ if (range[0] < range[1]) {
+ player.removeFromQueue(range[0], range[1] - range[0] + 1);
+ } else {
+ await msg.channel.send(errorMsg('range is backwards'));
+ return;
+ }
+ } else { // 3rd group exists -> just one song
+ const index = parseInt(match[3], 10);
+
+ if (index < 1) {
+ await msg.channel.send(errorMsg('position must be greater than 0'));
+ return;
+ }
+
+ if (index > player.queueSize()) {
+ await msg.channel.send(errorMsg('position is outside of the queue\'s range'));
+ return;
+ }
+
+ player.removeFromQueue(index, 1);
+ }
+
+ await msg.channel.send(':wastebasket: removed');
+ }
+}
diff --git a/src/commands/shortcuts.ts b/src/commands/shortcuts.ts
index 109d5fc..929a5c1 100644
--- a/src/commands/shortcuts.ts
+++ b/src/commands/shortcuts.ts
@@ -55,7 +55,7 @@ export default class implements Command {
const newShortcut = {shortcut: shortcutName, command, guildId: msg.guild!.id, authorId: msg.author.id};
if (shortcut) {
- if (shortcut.authorId !== msg.author.id && msg.author.id !== msg.guild!.owner!.id) {
+ if (shortcut.authorId !== msg.author.id && msg.author.id !== msg.guild!.ownerId) {
await msg.channel.send(errorMsg('you do\'nt have permission to do that'));
return;
}
@@ -80,7 +80,7 @@ export default class implements Command {
}
// Check permissions
- if (shortcut.authorId !== msg.author.id && msg.author.id !== msg.guild!.owner!.id) {
+ if (shortcut.authorId !== msg.author.id && msg.author.id !== msg.guild!.ownerId) {
await msg.channel.send(errorMsg('you don\'t have permission to do that'));
return;
}
diff --git a/src/events/guild-create.ts b/src/events/guild-create.ts
index 3bcf281..01f0910 100644
--- a/src/events/guild-create.ts
+++ b/src/events/guild-create.ts
@@ -9,7 +9,7 @@ const DEFAULT_PREFIX = '!';
export default async (guild: Guild): Promise<void> => {
await Settings.upsert({guildId: guild.id, prefix: DEFAULT_PREFIX});
- const owner = await guild.client.users.fetch(guild.ownerID);
+ const owner = await guild.client.users.fetch(guild.ownerId);
let firstStep = '👋 Hi!\n';
firstStep += 'I just need to ask a few questions before you start listening to music.\n\n';
@@ -27,7 +27,7 @@ export default async (guild: Guild): Promise<void> => {
const emojiChannels: EmojiChannel[] = [];
for (const [channelId, channel] of guild.channels.cache) {
- if (channel.type === 'text') {
+ if (channel.type === 'GUILD_TEXT') {
emojiChannels.push({
name: channel.name,
id: channelId,
@@ -65,7 +65,7 @@ export default async (guild: Guild): Promise<void> => {
await owner.send(secondStep);
- const prefixResponses = await firstStepMsg.channel.awaitMessages((r: Message) => r.content.length === 1, {max: 1});
+ const prefixResponses = await firstStepMsg.channel.awaitMessages({filter: (r: Message) => r.content.length === 1, max: 1});
const prefixCharacter = prefixResponses.first()!.content;
diff --git a/src/events/voice-state-update.ts b/src/events/voice-state-update.ts
index 6440949..69b4504 100644
--- a/src/events/voice-state-update.ts
+++ b/src/events/voice-state-update.ts
@@ -1,4 +1,4 @@
-import {VoiceState} from 'discord.js';
+import {VoiceChannel, VoiceState} from 'discord.js';
import container from '../inversify.config.js';
import {TYPES} from '../types.js';
import PlayerManager from '../managers/player.js';
@@ -10,7 +10,8 @@ export default (oldState: VoiceState, _: VoiceState): void => {
const player = playerManager.get(oldState.guild.id);
if (player.voiceConnection) {
- if (getSizeWithoutBots(player.voiceConnection.channel) === 0) {
+ const voiceChannel: VoiceChannel = oldState.guild.channels.cache.get(player.voiceConnection.joinConfig.channelId!) as VoiceChannel;
+ if (!voiceChannel || getSizeWithoutBots(voiceChannel) === 0) {
player.disconnect();
}
}
diff --git a/src/index.ts b/src/index.ts
index 5bf5bab..383faef 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -5,6 +5,7 @@ import {TYPES} from './types.js';
import Bot from './bot.js';
import {sequelize} from './utils/db.js';
import Config from './services/config.js';
+import FileCacheProvider from './services/file-cache.js';
const bot = container.get<Bot>(TYPES.Bot);
@@ -18,5 +19,7 @@ const bot = container.get<Bot>(TYPES.Bot);
await sequelize.sync({alter: true});
+ await container.get<FileCacheProvider>(TYPES.FileCache).cleanup();
+
await bot.listen();
})();
diff --git a/src/inversify.config.ts b/src/inversify.config.ts
index 00a2507..6ec1ba2 100644
--- a/src/inversify.config.ts
+++ b/src/inversify.config.ts
@@ -2,7 +2,7 @@ import 'reflect-metadata';
import {Container} from 'inversify';
import {TYPES} from './types.js';
import Bot from './bot.js';
-import {Client} from 'discord.js';
+import {Client, Intents} from 'discord.js';
import ConfigProvider from './services/config.js';
// Managers
@@ -21,20 +21,30 @@ import ForwardSeek from './commands/fseek.js';
import Help from './commands/help.js';
import Pause from './commands/pause.js';
import Play from './commands/play.js';
-import QueueCommad from './commands/queue.js';
+import QueueCommand from './commands/queue.js';
+import Remove from './commands/remove.js';
import Seek from './commands/seek.js';
import Shortcuts from './commands/shortcuts.js';
import Shuffle from './commands/shuffle.js';
import Skip from './commands/skip.js';
import Unskip from './commands/unskip.js';
import ThirdParty from './services/third-party.js';
-import CacheProvider from './services/cache.js';
+import FileCacheProvider from './services/file-cache.js';
+import KeyValueCacheProvider from './services/key-value-cache.js';
const container = new Container();
+// Intents
+const intents = new Intents();
+intents.add(Intents.FLAGS.GUILDS); // To listen for guildCreate event
+intents.add(Intents.FLAGS.GUILD_MESSAGES); // To listen for messages (messageCreate event)
+intents.add(Intents.FLAGS.DIRECT_MESSAGE_REACTIONS); // To listen for message reactions (messageReactionAdd event)
+intents.add(Intents.FLAGS.DIRECT_MESSAGES); // To receive the prefix message
+intents.add(Intents.FLAGS.GUILD_VOICE_STATES); // To listen for voice state changes (voiceStateUpdate event)
+
// Bot
container.bind<Bot>(TYPES.Bot).to(Bot).inSingletonScope();
-container.bind<Client>(TYPES.Client).toConstantValue(new Client());
+container.bind<Client>(TYPES.Client).toConstantValue(new Client({intents}));
// Managers
container.bind<PlayerManager>(TYPES.Managers.Player).to(PlayerManager).inSingletonScope();
@@ -52,7 +62,8 @@ container.bind<NaturalLanguage>(TYPES.Services.NaturalLanguage).to(NaturalLangua
Help,
Pause,
Play,
- QueueCommad,
+ QueueCommand,
+ Remove,
Seek,
Shortcuts,
Shuffle,
@@ -68,6 +79,7 @@ container.bind(TYPES.Config).toConstantValue(new ConfigProvider());
// Static libraries
container.bind(TYPES.ThirdParty).to(ThirdParty);
-container.bind(TYPES.Cache).to(CacheProvider);
+container.bind(TYPES.FileCache).to(FileCacheProvider);
+container.bind(TYPES.KeyValueCache).to(KeyValueCacheProvider);
export default container;
diff --git a/src/managers/player.ts b/src/managers/player.ts
index 02e4ba0..5d816b8 100644
--- a/src/managers/player.ts
+++ b/src/managers/player.ts
@@ -2,25 +2,25 @@ import {inject, injectable} from 'inversify';
import {Client} from 'discord.js';
import {TYPES} from '../types.js';
import Player from '../services/player.js';
-import Config from '../services/config.js';
+import FileCacheProvider from '../services/file-cache.js';
@injectable()
export default class {
private readonly guildPlayers: Map<string, Player>;
- private readonly cacheDir: string;
private readonly discordClient: Client;
+ private readonly fileCache: FileCacheProvider;
- constructor(@inject(TYPES.Config) config: Config, @inject(TYPES.Client) client: Client) {
+ constructor(@inject(TYPES.FileCache) fileCache: FileCacheProvider, @inject(TYPES.Client) client: Client) {
this.guildPlayers = new Map();
- this.cacheDir = config.CACHE_DIR;
this.discordClient = client;
+ this.fileCache = fileCache;
}
get(guildId: string): Player {
let player = this.guildPlayers.get(guildId);
if (!player) {
- player = new Player(this.cacheDir, this.discordClient);
+ player = new Player(this.discordClient, this.fileCache);
this.guildPlayers.set(guildId, player);
}
diff --git a/src/models/file-cache.ts b/src/models/file-cache.ts
new file mode 100644
index 0000000..dbac6d3
--- /dev/null
+++ b/src/models/file-cache.ts
@@ -0,0 +1,14 @@
+import {Table, Column, PrimaryKey, Model} from 'sequelize-typescript';
+
+@Table
+export default class FileCache extends Model {
+ @PrimaryKey
+ @Column
+ hash!: string;
+
+ @Column
+ bytes!: number;
+
+ @Column
+ accessedAt!: Date;
+}
diff --git a/src/models/index.ts b/src/models/index.ts
index fec0c8e..e3f7c0a 100644
--- a/src/models/index.ts
+++ b/src/models/index.ts
@@ -1,9 +1,11 @@
-import Cache from './cache.js';
+import FileCache from './file-cache.js';
+import KeyValueCache from './key-value-cache.js';
import Settings from './settings.js';
import Shortcut from './shortcut.js';
export {
- Cache,
+ FileCache,
+ KeyValueCache,
Settings,
Shortcut,
};
diff --git a/src/models/cache.ts b/src/models/key-value-cache.ts
index ebf8dad..5072538 100644
--- a/src/models/cache.ts
+++ b/src/models/key-value-cache.ts
@@ -2,7 +2,7 @@ import {Table, Column, PrimaryKey, Model} from 'sequelize-typescript';
import sequelize from 'sequelize';
@Table
-export default class Cache extends Model<Cache> {
+export default class KeyValueCache extends Model {
@PrimaryKey
@Column
key!: string;
diff --git a/src/models/settings.ts b/src/models/settings.ts
index a2c05c3..f9756d3 100644
--- a/src/models/settings.ts
+++ b/src/models/settings.ts
@@ -1,7 +1,7 @@
import {Table, Column, PrimaryKey, Model, Default} from 'sequelize-typescript';
@Table
-export default class Settings extends Model<Settings> {
+export default class Settings extends Model {
@PrimaryKey
@Column
guildId!: string;
diff --git a/src/models/shortcut.ts b/src/models/shortcut.ts
index 7ce1177..4ec88ed 100644
--- a/src/models/shortcut.ts
+++ b/src/models/shortcut.ts
@@ -1,7 +1,7 @@
import {Table, Column, PrimaryKey, Model, AutoIncrement, Index} from 'sequelize-typescript';
@Table
-export default class Shortcut extends Model<Shortcut> {
+export default class Shortcut extends Model {
@PrimaryKey
@AutoIncrement
@Column
diff --git a/src/services/config.ts b/src/services/config.ts
index 759a27a3..96b161c 100644
--- a/src/services/config.ts
+++ b/src/services/config.ts
@@ -1,6 +1,8 @@
import dotenv from 'dotenv';
import {injectable} from 'inversify';
import path from 'path';
+import xbytes from 'xbytes';
+import {ConditionalKeys} from 'type-fest';
dotenv.config();
export const DATA_DIR = path.resolve(process.env.DATA_DIR ? process.env.DATA_DIR : './data');
@@ -12,6 +14,7 @@ const CONFIG_MAP = {
SPOTIFY_CLIENT_SECRET: process.env.SPOTIFY_CLIENT_SECRET,
DATA_DIR,
CACHE_DIR: path.join(DATA_DIR, 'cache'),
+ CACHE_LIMIT_IN_BYTES: xbytes.parseSize(process.env.CACHE_LIMIT ?? '2GB'),
} as const;
@injectable()
@@ -22,6 +25,7 @@ export default class Config {
readonly SPOTIFY_CLIENT_SECRET!: string;
readonly DATA_DIR!: string;
readonly CACHE_DIR!: string;
+ readonly CACHE_LIMIT_IN_BYTES!: number;
constructor() {
for (const [key, value] of Object.entries(CONFIG_MAP)) {
@@ -30,7 +34,13 @@ export default class Config {
process.exit(1);
}
- this[key as keyof typeof CONFIG_MAP] = value;
+ if (typeof value === 'number') {
+ this[key as ConditionalKeys<typeof CONFIG_MAP, number>] = value;
+ } else if (typeof value === 'string') {
+ this[key as ConditionalKeys<typeof CONFIG_MAP, string>] = value;
+ } else {
+ throw new Error(`Unsupported type for ${key}`);
+ }
}
}
}
diff --git a/src/services/file-cache.ts b/src/services/file-cache.ts
new file mode 100644
index 0000000..fa4f1f7
--- /dev/null
+++ b/src/services/file-cache.ts
@@ -0,0 +1,117 @@
+import {promises as fs, createWriteStream} from 'fs';
+import path from 'path';
+import {inject, injectable} from 'inversify';
+import sequelize from 'sequelize';
+import {FileCache} from '../models/index.js';
+import {TYPES} from '../types.js';
+import Config from './config.js';
+
+@injectable()
+export default class FileCacheProvider {
+ private readonly config: Config;
+
+ constructor(@inject(TYPES.Config) config: Config) {
+ this.config = config;
+ }
+
+ /**
+ * Returns path to cached file if it exists, otherwise throws an error.
+ * Updates the `accessedAt` property of the cached file.
+ * @param hash lookup key
+ */
+ async getPathFor(hash: string): Promise<string> {
+ const model = await FileCache.findByPk(hash);
+
+ if (!model) {
+ throw new Error('File is not cached');
+ }
+
+ const resolvedPath = path.join(this.config.CACHE_DIR, hash);
+
+ try {
+ await fs.access(resolvedPath);
+ } catch (_: unknown) {
+ await FileCache.destroy({where: {hash}});
+
+ throw new Error('File is not cached');
+ }
+
+ await model.update({accessedAt: new Date()});
+
+ return resolvedPath;
+ }
+
+ /**
+ * Returns a write stream for the given hash key.
+ * The stream handles saving a new file and will
+ * update the database after the stream is closed.
+ * @param hash lookup key
+ */
+ createWriteStream(hash: string) {
+ const tmpPath = path.join(this.config.CACHE_DIR, 'tmp', hash);
+ const finalPath = path.join(this.config.CACHE_DIR, hash);
+
+ const stream = createWriteStream(tmpPath);
+
+ stream.on('close', async () => {
+ // Only move if size is non-zero (may have errored out)
+ const stats = await fs.stat(tmpPath);
+
+ if (stats.size !== 0) {
+ await fs.rename(tmpPath, finalPath);
+ }
+
+ await FileCache.create({hash, bytes: stats.size, accessedAt: new Date()});
+
+ await this.evictOldestIfNecessary();
+ });
+
+ return stream;
+ }
+
+ /**
+ * Deletes orphaned cache files and evicts files if
+ * necessary. Should be run on program startup so files
+ * will be evicted if the cache limit has changed.
+ */
+ async cleanup() {
+ await this.removeOrphans();
+ await this.evictOldestIfNecessary();
+ }
+
+ private async evictOldestIfNecessary() {
+ const [{dataValues: {totalSizeBytes}}] = await FileCache.findAll({
+ attributes: [
+ [sequelize.fn('sum', sequelize.col('bytes')), 'totalSizeBytes'],
+ ],
+ }) as unknown as [{dataValues: {totalSizeBytes: number}}];
+
+ if (totalSizeBytes > this.config.CACHE_LIMIT_IN_BYTES) {
+ const oldest = await FileCache.findOne({
+ order: [
+ ['accessedAt', 'ASC'],
+ ],
+ });
+
+ if (oldest) {
+ await oldest.destroy();
+ await fs.unlink(path.join(this.config.CACHE_DIR, oldest.hash));
+ }
+
+ // Continue to evict until we're under the limit
+ await this.evictOldestIfNecessary();
+ }
+ }
+
+ private async removeOrphans() {
+ for await (const dirent of await fs.opendir(this.config.CACHE_DIR)) {
+ if (dirent.isFile()) {
+ const model = await FileCache.findByPk(dirent.name);
+
+ if (!model) {
+ await fs.unlink(path.join(this.config.CACHE_DIR, dirent.name));
+ }
+ }
+ }
+ }
+}
diff --git a/src/services/get-songs.ts b/src/services/get-songs.ts
index 49628e8..38a2b61 100644
--- a/src/services/get-songs.ts
+++ b/src/services/get-songs.ts
@@ -14,7 +14,7 @@ import {TYPES} from '../types.js';
import {cleanUrl} from '../utils/url.js';
import ThirdParty from './third-party.js';
import Config from './config.js';
-import CacheProvider from './cache.js';
+import KeyValueCacheProvider from './key-value-cache.js';
type QueuedSongWithoutChannel = Except<QueuedSong, 'addedInChannelId'>;
@@ -26,14 +26,14 @@ export default class {
private readonly youtube: YouTube;
private readonly youtubeKey: string;
private readonly spotify: Spotify;
- private readonly cache: CacheProvider;
+ private readonly cache: KeyValueCacheProvider;
private readonly ytsrQueue: PQueue;
constructor(
@inject(TYPES.ThirdParty) thirdParty: ThirdParty,
@inject(TYPES.Config) config: Config,
- @inject(TYPES.Cache) cache: CacheProvider) {
+ @inject(TYPES.KeyValueCache) cache: KeyValueCacheProvider) {
this.youtube = thirdParty.youtube;
this.youtubeKey = config.YOUTUBE_API_KEY;
this.spotify = thirdParty.spotify;
diff --git a/src/services/cache.ts b/src/services/key-value-cache.ts
index e877c56..7f1164d 100644
--- a/src/services/cache.ts
+++ b/src/services/key-value-cache.ts
@@ -1,5 +1,5 @@
import {injectable} from 'inversify';
-import {Cache} from '../models/index.js';
+import {KeyValueCache} from '../models/index.js';
import debug from '../utils/debug.js';
type Seconds = number;
@@ -12,7 +12,7 @@ type Options = {
const futureTimeToDate = (time: Seconds) => new Date(new Date().getTime() + (time * 1000));
@injectable()
-export default class CacheProvider {
+export default class KeyValueCacheProvider {
async wrap<T extends [...any[], Options], F>(func: (...options: any) => Promise<F>, ...options: T): Promise<F> {
if (options.length === 0) {
throw new Error('Missing cache options');
@@ -29,7 +29,7 @@ export default class CacheProvider {
throw new Error(`Cache key ${key} is too short.`);
}
- const cachedResult = await Cache.findByPk(key);
+ const cachedResult = await KeyValueCache.findByPk(key);
if (cachedResult) {
if (new Date() < cachedResult.expiresAt) {
@@ -45,7 +45,7 @@ export default class CacheProvider {
const result = await func(...options as any[]);
// Save result
- await Cache.upsert({
+ await KeyValueCache.upsert({
key,
value: JSON.stringify(result),
expiresAt: futureTimeToDate(expiresIn),
diff --git a/src/services/player.ts b/src/services/player.ts
index a74cb37..67b8b38 100644
--- a/src/services/player.ts
+++ b/src/services/player.ts
@@ -1,13 +1,13 @@
-import {VoiceConnection, VoiceChannel, StreamDispatcher, Snowflake, Client, TextChannel} from 'discord.js';
-import {promises as fs, createWriteStream} from 'fs';
-import {Readable, PassThrough} from 'stream';
-import path from 'path';
+import {VoiceChannel, Snowflake, Client, TextChannel} from 'discord.js';
+import {Readable} from 'stream';
import hasha from 'hasha';
import ytdl from 'ytdl-core';
import {WriteStream} from 'fs-capacitor';
import ffmpeg from 'fluent-ffmpeg';
import shuffle from 'array-shuffle';
import errorMsg from '../utils/error-msg.js';
+import {AudioPlayer, AudioPlayerStatus, createAudioPlayer, createAudioResource, joinVoiceChannel, StreamType, VoiceConnection, VoiceConnectionStatus} from '@discordjs/voice';
+import FileCacheProvider from './file-cache.js';
export interface QueuedPlaylist {
title: string;
@@ -34,8 +34,7 @@ export default class {
public voiceConnection: VoiceConnection | null = null;
private queue: QueuedSong[] = [];
private queuePosition = 0;
- private readonly cacheDir: string;
- private dispatcher: StreamDispatcher | null = null;
+ private audioPlayer: AudioPlayer | null = null;
private nowPlaying: QueuedSong | null = null;
private playPositionInterval: NodeJS.Timeout | undefined;
private lastSongURL = '';
@@ -43,30 +42,34 @@ export default class {
private positionInSeconds = 0;
private readonly discordClient: Client;
+ private readonly fileCache: FileCacheProvider;
- constructor(cacheDir: string, client: Client) {
- this.cacheDir = cacheDir;
+ constructor(client: Client, fileCache: FileCacheProvider) {
this.discordClient = client;
+ this.fileCache = fileCache;
}
async connect(channel: VoiceChannel): Promise<void> {
- const conn = await channel.join();
+ const conn = joinVoiceChannel({
+ channelId: channel.id,
+ guildId: channel.guild.id,
+ adapterCreator: channel.guild.voiceAdapterCreator,
+ });
this.voiceConnection = conn;
}
- disconnect(breakConnection = true): void {
+ disconnect(): void {
if (this.voiceConnection) {
if (this.status === STATUS.PLAYING) {
this.pause();
}
- if (breakConnection) {
- this.voiceConnection.disconnect();
- }
+ this.voiceConnection.destroy();
+ this.audioPlayer?.stop();
this.voiceConnection = null;
- this.dispatcher = null;
+ this.audioPlayer = null;
}
}
@@ -88,8 +91,11 @@ export default class {
}
const stream = await this.getStream(currentSong.url, {seek: positionSeconds});
- this.dispatcher = this.voiceConnection.play(stream, {type: 'webm/opus', bitrate: 'auto'});
-
+ this.audioPlayer = createAudioPlayer();
+ this.voiceConnection.subscribe(this.audioPlayer);
+ this.audioPlayer.play(createAudioResource(stream, {
+ inputType: StreamType.WebmOpus,
+ }));
this.attachListeners();
this.startTrackingPosition(positionSeconds);
@@ -117,8 +123,8 @@ export default class {
// Resume from paused state
if (this.status === STATUS.PAUSED && currentSong.url === this.nowPlaying?.url) {
- if (this.dispatcher) {
- this.dispatcher.resume();
+ if (this.audioPlayer) {
+ this.audioPlayer.unpause();
this.status = STATUS.PLAYING;
this.startTrackingPosition();
return;
@@ -132,7 +138,11 @@ export default class {
try {
const stream = await this.getStream(currentSong.url);
- this.dispatcher = this.voiceConnection.play(stream, {type: 'webm/opus'});
+ this.audioPlayer = createAudioPlayer();
+ this.voiceConnection.subscribe(this.audioPlayer);
+ this.audioPlayer.play(createAudioResource(stream, {
+ inputType: StreamType.WebmOpus,
+ }));
this.attachListeners();
@@ -170,8 +180,8 @@ export default class {
this.status = STATUS.PAUSED;
- if (this.dispatcher) {
- this.dispatcher.pause();
+ if (this.audioPlayer) {
+ this.audioPlayer.pause();
}
this.stopTrackingPosition();
@@ -230,25 +240,12 @@ export default class {
}
add(song: QueuedSong, {immediate = false} = {}): void {
- if (song.playlist) {
+ if (song.playlist || !immediate) {
// Add to end of queue
this.queue.push(song);
} else {
- // Not from playlist, add immediately
- let insertAt = this.queuePosition + 1;
-
- if (!immediate) {
- // Loop until playlist song
- this.queue.some(song => {
- if (song.playlist) {
- return true;
- }
-
- insertAt++;
- return false;
- });
- }
-
+ // Add as the next song to be played
+ const insertAt = this.queuePosition + 1;
this.queue = [...this.queue.slice(0, insertAt), song, ...this.queue.slice(insertAt)];
}
}
@@ -273,6 +270,10 @@ export default class {
this.queue = newQueue;
}
+ removeFromQueue(index: number, amount = 1): void {
+ this.queue.splice(this.queuePosition + index, amount);
+ }
+
removeCurrent(): void {
this.queue = [...this.queue.slice(0, this.queuePosition), ...this.queue.slice(this.queuePosition + 1)];
}
@@ -285,40 +286,24 @@ export default class {
return this.queueSize() === 0;
}
- private getCachedPath(url: string): string {
- return path.join(this.cacheDir, hasha(url));
- }
-
- private getCachedPathTemp(url: string): string {
- return path.join(this.cacheDir, 'tmp', hasha(url));
- }
-
- private async isCached(url: string): Promise<boolean> {
- try {
- await fs.access(this.getCachedPath(url));
-
- return true;
- } catch (_: unknown) {
- return false;
- }
+ private getHashForCache(url: string): string {
+ return hasha(url);
}
private async getStream(url: string, options: {seek?: number} = {}): Promise<Readable> {
- const cachedPath = this.getCachedPath(url);
-
let ffmpegInput = '';
const ffmpegInputOptions: string[] = [];
let shouldCacheVideo = false;
let format: ytdl.videoFormat | undefined;
- if (await this.isCached(url)) {
- ffmpegInput = cachedPath;
+ try {
+ ffmpegInput = await this.fileCache.getPathFor(this.getHashForCache(url));
if (options.seek) {
ffmpegInputOptions.push('-ss', options.seek.toString());
}
- } else {
+ } catch {
// Not yet cached, must download
const info = await ytdl.getInfo(url);
@@ -379,6 +364,17 @@ export default class {
// Create stream and pipe to capacitor
return new Promise((resolve, reject) => {
+ const capacitor = new WriteStream();
+
+ // Cache video if necessary
+ if (shouldCacheVideo) {
+ const cacheStream = this.fileCache.createWriteStream(this.getHashForCache(url));
+
+ capacitor.createReadStream().pipe(cacheStream);
+ } else {
+ ffmpegInputOptions.push('-re');
+ }
+
const youtubeStream = ffmpeg(ffmpegInput)
.inputOptions(ffmpegInputOptions)
.noVideo()
@@ -387,29 +383,9 @@ export default class {
.on('error', error => {
console.error(error);
reject(error);
- })
- .pipe() as PassThrough;
-
- const capacitor = new WriteStream();
-
- youtubeStream.pipe(capacitor);
-
- // Cache video if necessary
- if (shouldCacheVideo) {
- const cacheTempPath = this.getCachedPathTemp(url);
- const cacheStream = createWriteStream(cacheTempPath);
-
- cacheStream.on('finish', async () => {
- // Only move if size is non-zero (may have errored out)
- const stats = await fs.stat(cacheTempPath);
-
- if (stats.size !== 0) {
- await fs.rename(cacheTempPath, cachedPath);
- }
});
- capacitor.createReadStream().pipe(cacheStream);
- }
+ youtubeStream.pipe(capacitor);
resolve(capacitor.createReadStream());
});
@@ -440,22 +416,26 @@ export default class {
return;
}
- this.voiceConnection.on('disconnect', this.onVoiceConnectionDisconnect.bind(this));
+ if (this.voiceConnection.listeners(VoiceConnectionStatus.Disconnected).length === 0) {
+ this.voiceConnection.on(VoiceConnectionStatus.Disconnected, this.onVoiceConnectionDisconnect.bind(this));
+ }
- if (!this.dispatcher) {
+ if (!this.audioPlayer) {
return;
}
- this.dispatcher.on('speaking', this.onVoiceConnectionSpeaking.bind(this));
+ if (this.audioPlayer.listeners('stateChange').length === 0) {
+ this.audioPlayer.on('stateChange', this.onAudioPlayerStateChange.bind(this));
+ }
}
private onVoiceConnectionDisconnect(): void {
- this.disconnect(false);
+ this.disconnect();
}
- private async onVoiceConnectionSpeaking(isSpeaking: boolean): Promise<void> {
+ private async onAudioPlayerStateChange(_oldState: {status: AudioPlayerStatus}, newState: {status: AudioPlayerStatus}): Promise<void> {
// Automatically advance queued song at end
- if (!isSpeaking && this.status === STATUS.PLAYING) {
+ if (newState.status === AudioPlayerStatus.Idle && this.status === STATUS.PLAYING) {
await this.forward(1);
}
}
diff --git a/src/types.ts b/src/types.ts
index 8bcd684..e6edd14 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -1,6 +1,7 @@
export const TYPES = {
Bot: Symbol('Bot'),
- Cache: Symbol('Cache'),
+ KeyValueCache: Symbol('KeyValueCache'),
+ FileCache: Symbol('FileCache'),
Client: Symbol('Client'),
Config: Symbol('Config'),
Command: Symbol('Command'),
diff --git a/src/utils/channels.ts b/src/utils/channels.ts
index 4c5b6b0..2d074b4 100644
--- a/src/utils/channels.ts
+++ b/src/utils/channels.ts
@@ -3,8 +3,8 @@ import {Guild, VoiceChannel, User, GuildMember} from 'discord.js';
export const isUserInVoice = (guild: Guild, user: User): boolean => {
let inVoice = false;
- guild.channels.cache.filter(channel => channel.type === 'voice').forEach(channel => {
- if (channel.members.array().find(member => member.id === user.id)) {
+ guild.channels.cache.filter(channel => channel.type === 'GUILD_VOICE').forEach(channel => {
+ if ((channel as VoiceChannel).members.find(member => member.id === user.id)) {
inVoice = true;
}
});
@@ -12,7 +12,7 @@ export const isUserInVoice = (guild: Guild, user: User): boolean => {
return inVoice;
};
-export const getSizeWithoutBots = (channel: VoiceChannel): number => channel.members.array().reduce((s, member) => {
+export const getSizeWithoutBots = (channel: VoiceChannel): number => channel.members.reduce((s, member) => {
if (!member.user.bot) {
s++;
}
@@ -22,7 +22,7 @@ export const getSizeWithoutBots = (channel: VoiceChannel): number => channel.mem
export const getMemberVoiceChannel = (member?: GuildMember): [VoiceChannel, number] | null => {
const channel = member?.voice?.channel;
- if (channel && channel.type === 'voice') {
+ if (channel && channel.type === 'GUILD_VOICE') {
return [
channel,
getSizeWithoutBots(channel),
@@ -41,7 +41,7 @@ export const getMostPopularVoiceChannel = (guild: Guild): [VoiceChannel, number]
const voiceChannels: PopularResult[] = [];
for (const [_, channel] of guild.channels.cache) {
- if (channel.type === 'voice') {
+ if (channel.type === 'GUILD_VOICE') {
const size = getSizeWithoutBots(channel as VoiceChannel);
voiceChannels.push({
diff --git a/src/utils/db.ts b/src/utils/db.ts
index be42013..15a2d79 100644
--- a/src/utils/db.ts
+++ b/src/utils/db.ts
@@ -1,12 +1,12 @@
import {Sequelize} from 'sequelize-typescript';
import path from 'path';
import {DATA_DIR} from '../services/config.js';
-import {Cache, Settings, Shortcut} from '../models/index.js';
+import {FileCache, KeyValueCache, Settings, Shortcut} from '../models/index.js';
export const sequelize = new Sequelize({
dialect: 'sqlite',
database: 'muse',
storage: path.join(DATA_DIR, 'db.sqlite'),
- models: [Cache, Settings, Shortcut],
+ models: [FileCache, KeyValueCache, Settings, Shortcut],
logging: false,
});