diff options
| author | Thongrapee Panyapatiphan <[email protected]> | 2021-12-09 17:55:29 +0700 |
|---|---|---|
| committer | Thongrapee Panyapatiphan <[email protected]> | 2021-12-09 17:55:29 +0700 |
| commit | d8086be5cf1ab7152a6dc27ada78b3d186d85220 (patch) | |
| tree | 1d9fa4aa618cd0266e1266818a80e3b636d8a8e0 /src | |
| parent | 33b0ffa244eaa33a75f16790678390aad8f3d3ff (diff) | |
| parent | 9afca25866830dacc668b1861bab5c12260de639 (diff) | |
| download | muse-d8086be5cf1ab7152a6dc27ada78b3d186d85220.tar.xz muse-d8086be5cf1ab7152a6dc27ada78b3d186d85220.zip | |
Merge branch 'master' into playlist-limit-config
Diffstat (limited to 'src')
| -rw-r--r-- | src/bot.ts | 7 | ||||
| -rw-r--r-- | src/commands/config.ts | 9 | ||||
| -rw-r--r-- | src/commands/help.ts | 12 | ||||
| -rw-r--r-- | src/commands/play.ts | 2 | ||||
| -rw-r--r-- | src/commands/queue.ts | 2 | ||||
| -rw-r--r-- | src/commands/remove.ts | 76 | ||||
| -rw-r--r-- | src/commands/shortcuts.ts | 4 | ||||
| -rw-r--r-- | src/events/guild-create.ts | 6 | ||||
| -rw-r--r-- | src/events/voice-state-update.ts | 5 | ||||
| -rw-r--r-- | src/index.ts | 3 | ||||
| -rw-r--r-- | src/inversify.config.ts | 24 | ||||
| -rw-r--r-- | src/managers/player.ts | 10 | ||||
| -rw-r--r-- | src/models/file-cache.ts | 14 | ||||
| -rw-r--r-- | src/models/index.ts | 6 | ||||
| -rw-r--r-- | src/models/key-value-cache.ts (renamed from src/models/cache.ts) | 2 | ||||
| -rw-r--r-- | src/models/settings.ts | 2 | ||||
| -rw-r--r-- | src/models/shortcut.ts | 2 | ||||
| -rw-r--r-- | src/services/config.ts | 12 | ||||
| -rw-r--r-- | src/services/file-cache.ts | 117 | ||||
| -rw-r--r-- | src/services/get-songs.ts | 6 | ||||
| -rw-r--r-- | src/services/key-value-cache.ts (renamed from src/services/cache.ts) | 8 | ||||
| -rw-r--r-- | src/services/player.ts | 150 | ||||
| -rw-r--r-- | src/types.ts | 3 | ||||
| -rw-r--r-- | src/utils/channels.ts | 10 | ||||
| -rw-r--r-- | src/utils/db.ts | 4 |
25 files changed, 360 insertions, 136 deletions
@@ -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, }); |
