mirror of
https://github.com/BluemediaGER/muse.git
synced 2024-11-23 09:15:29 +01:00
Merge branch 'master' into playlist-limit-config
This commit is contained in:
commit
d8086be5cf
|
@ -1,5 +1,8 @@
|
||||||
DISCORD_TOKEN=
|
DISCORD_TOKEN=
|
||||||
DATA_DIR=
|
DATA_DIR=./data
|
||||||
YOUTUBE_API_KEY=
|
YOUTUBE_API_KEY=
|
||||||
SPOTIFY_CLIENT_ID=
|
SPOTIFY_CLIENT_ID=
|
||||||
SPOTIFY_CLIENT_SECRET=
|
SPOTIFY_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Optional
|
||||||
|
# CACHE_LIMIT=2GB
|
||||||
|
|
3
.github/workflows/build.yml
vendored
3
.github/workflows/build.yml
vendored
|
@ -8,6 +8,9 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v1
|
- uses: actions/checkout@v1
|
||||||
|
- uses: actions/setup-node@v2
|
||||||
|
with:
|
||||||
|
node-version: '16'
|
||||||
- name: Cache dependencies
|
- name: Cache dependencies
|
||||||
uses: actions/cache@v1
|
uses: actions/cache@v1
|
||||||
with:
|
with:
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
FROM node:14.15.4-alpine AS base
|
FROM node:16.13.0-alpine AS base
|
||||||
|
|
||||||
# Install ffmpeg and build dependencies
|
# Install ffmpeg and build dependencies
|
||||||
RUN apk add --no-cache ffmpeg python make g++
|
RUN apk add --no-cache ffmpeg python2 make g++
|
||||||
|
|
||||||
WORKDIR /usr/app
|
WORKDIR /usr/app
|
||||||
|
|
||||||
|
|
|
@ -69,3 +69,7 @@ services:
|
||||||
5. `yarn start` (or `npm run start`)
|
5. `yarn start` (or `npm run start`)
|
||||||
|
|
||||||
**Note**: if you're on Windows, you may need to manually set the ffmpeg path. See [#345](https://github.com/codetheweb/muse/issues/345) for details.
|
**Note**: if you're on Windows, you may need to manually set the ffmpeg path. See [#345](https://github.com/codetheweb/muse/issues/345) for details.
|
||||||
|
|
||||||
|
#### Advanced
|
||||||
|
|
||||||
|
By default, Muse limits the total cache size to around 2 GB. If you want to change this, set the environment variable `CACHE_LIMIT`. For example, `CACHE_LIMIT=512MB` or `CACHE_LIMIT=10GB`.
|
||||||
|
|
26
package.json
26
package.json
|
@ -10,7 +10,7 @@
|
||||||
"types": "dts/types",
|
"types": "dts/types",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
"node": ">=16.0.0"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"dist",
|
"dist",
|
||||||
|
@ -25,7 +25,7 @@
|
||||||
"watch": "tsc --watch",
|
"watch": "tsc --watch",
|
||||||
"prepack": "npm run clean && npm run build",
|
"prepack": "npm run clean && npm run build",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"dev": "nodemon",
|
"dev": "concurrently nodemon 'tsc --watch'",
|
||||||
"docker-publish": "docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 -t codetheweb/muse:latest --push ."
|
"docker-publish": "docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 -t codetheweb/muse:latest --push ."
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
@ -33,19 +33,21 @@
|
||||||
"@types/debug": "^4.1.5",
|
"@types/debug": "^4.1.5",
|
||||||
"@types/fluent-ffmpeg": "^2.1.17",
|
"@types/fluent-ffmpeg": "^2.1.17",
|
||||||
"@types/fs-capacitor": "^2.0.0",
|
"@types/fs-capacitor": "^2.0.0",
|
||||||
"@types/node": "^14.14.41",
|
"@types/node": "^16.11.6",
|
||||||
"@types/node-emoji": "^1.8.1",
|
"@types/node-emoji": "^1.8.1",
|
||||||
"@types/spotify-web-api-node": "^5.0.2",
|
"@types/spotify-web-api-node": "^5.0.2",
|
||||||
"@types/validator": "^13.1.4",
|
"@types/validator": "^13.1.4",
|
||||||
"@types/ws": "^7.4.4",
|
"@types/ws": "^8.2.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^4.31.1",
|
"@typescript-eslint/eslint-plugin": "^4.31.1",
|
||||||
"@typescript-eslint/parser": "^4.31.1",
|
"@typescript-eslint/parser": "^4.31.1",
|
||||||
|
"concurrently": "^6.4.0",
|
||||||
"eslint": "^7.32.0",
|
"eslint": "^7.32.0",
|
||||||
"eslint-config-xo": "^0.38.0",
|
"eslint-config-xo": "^0.38.0",
|
||||||
"eslint-config-xo-typescript": "^0.44.0",
|
"eslint-config-xo-typescript": "^0.44.0",
|
||||||
"husky": "^4.3.8",
|
"husky": "^4.3.8",
|
||||||
"nodemon": "^2.0.7",
|
"nodemon": "^2.0.7",
|
||||||
"ts-node": "^9.1.1",
|
"ts-node": "^10.4.0",
|
||||||
|
"type-fest": "^2.5.4",
|
||||||
"typescript": "^4.4.3"
|
"typescript": "^4.4.3"
|
||||||
},
|
},
|
||||||
"eslintConfig": {
|
"eslintConfig": {
|
||||||
|
@ -70,30 +72,34 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@discordjs/opus": "^0.5.3",
|
"@discordjs/opus": "^0.7.0",
|
||||||
|
"@discordjs/voice": "^0.7.5",
|
||||||
|
"@types/libsodium-wrappers": "^0.7.9",
|
||||||
"array-shuffle": "^2.0.0",
|
"array-shuffle": "^2.0.0",
|
||||||
"debug": "^4.3.1",
|
"debug": "^4.3.1",
|
||||||
"delay": "^5.0.0",
|
"delay": "^5.0.0",
|
||||||
"discord.js": "^12.5.3",
|
"discord.js": "^13.3.0",
|
||||||
"dotenv": "^8.5.1",
|
"dotenv": "^8.5.1",
|
||||||
"fluent-ffmpeg": "^2.1.2",
|
"fluent-ffmpeg": "^2.1.2",
|
||||||
"fs-capacitor": "^6.2.0",
|
"fs-capacitor": "^7.0.1",
|
||||||
"get-youtube-id": "^1.0.1",
|
"get-youtube-id": "^1.0.1",
|
||||||
"got": "^11.8.2",
|
"got": "^11.8.2",
|
||||||
"hasha": "^5.2.2",
|
"hasha": "^5.2.2",
|
||||||
"inversify": "^5.1.1",
|
"inversify": "^5.1.1",
|
||||||
"iso8601-duration": "^1.3.0",
|
"iso8601-duration": "^1.3.0",
|
||||||
|
"libsodium-wrappers": "^0.7.9",
|
||||||
"make-dir": "^3.1.0",
|
"make-dir": "^3.1.0",
|
||||||
"node-emoji": "^1.10.0",
|
"node-emoji": "^1.10.0",
|
||||||
"p-event": "^4.2.0",
|
"p-event": "^4.2.0",
|
||||||
"p-limit": "^3.1.0",
|
"p-limit": "^3.1.0",
|
||||||
"p-queue": "^7.1.0",
|
"p-queue": "^7.1.0",
|
||||||
"reflect-metadata": "^0.1.13",
|
"reflect-metadata": "^0.1.13",
|
||||||
"sequelize": "^5.22.4",
|
"sequelize": "^6.11.0",
|
||||||
"sequelize-typescript": "^1.1.0",
|
"sequelize-typescript": "^2.1.1",
|
||||||
"spotify-uri": "^2.2.0",
|
"spotify-uri": "^2.2.0",
|
||||||
"spotify-web-api-node": "^5.0.2",
|
"spotify-web-api-node": "^5.0.2",
|
||||||
"sqlite3": "^5.0.2",
|
"sqlite3": "^5.0.2",
|
||||||
|
"xbytes": "^1.7.0",
|
||||||
"youtube.ts": "^0.2.2",
|
"youtube.ts": "^0.2.2",
|
||||||
"ytdl-core": "^4.9.1",
|
"ytdl-core": "^4.9.1",
|
||||||
"ytsr": "^3.5.3"
|
"ytsr": "^3.5.3"
|
||||||
|
|
|
@ -11,6 +11,7 @@ import handleVoiceStateUpdate from './events/voice-state-update.js';
|
||||||
import errorMsg from './utils/error-msg.js';
|
import errorMsg from './utils/error-msg.js';
|
||||||
import {isUserInVoice} from './utils/channels.js';
|
import {isUserInVoice} from './utils/channels.js';
|
||||||
import Config from './services/config.js';
|
import Config from './services/config.js';
|
||||||
|
import {generateDependencyReport} from '@discordjs/voice';
|
||||||
|
|
||||||
@injectable()
|
@injectable()
|
||||||
export default class {
|
export default class {
|
||||||
|
@ -34,7 +35,7 @@ export default class {
|
||||||
commandNames.forEach(commandName => this.commands.set(commandName, command));
|
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
|
// Get guild settings
|
||||||
if (!msg.guild) {
|
if (!msg.guild) {
|
||||||
return;
|
return;
|
||||||
|
@ -44,7 +45,8 @@ export default class {
|
||||||
|
|
||||||
if (!settings) {
|
if (!settings) {
|
||||||
// Got into a bad state, send owner welcome message
|
// 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;
|
const {prefix, channel} = settings;
|
||||||
|
@ -95,6 +97,7 @@ export default class {
|
||||||
});
|
});
|
||||||
|
|
||||||
this.client.on('ready', async () => {
|
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`);
|
console.log(`Ready! Invite the bot with https://discordapp.com/oauth2/authorize?client_id=${this.client.user?.id ?? ''}&scope=bot&permissions=36752448`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import {TextChannel, Message, GuildChannel} from 'discord.js';
|
import {TextChannel, Message, GuildChannel, ThreadChannel} from 'discord.js';
|
||||||
import {injectable} from 'inversify';
|
import {injectable} from 'inversify';
|
||||||
import {Settings} from '../models/index.js';
|
import {Settings} from '../models/index.js';
|
||||||
import errorMsg from '../utils/error-msg.js';
|
import errorMsg from '../utils/error-msg.js';
|
||||||
|
@ -21,6 +21,7 @@ export default class implements Command {
|
||||||
|
|
||||||
if (settings) {
|
if (settings) {
|
||||||
let response = `prefix: \`${settings.prefix}\`\n`;
|
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 += `channel: ${msg.guild!.channels.cache.get(settings.channel)!.toString()}\n`;
|
||||||
response += `playlist-limit: ${settings.playlistLimit}`;
|
response += `playlist-limit: ${settings.playlistLimit}`;
|
||||||
|
|
||||||
|
@ -37,7 +38,7 @@ export default class implements Command {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (msg.author.id !== msg.guild!.owner!.id) {
|
if (msg.author.id !== msg.guild!.ownerId) {
|
||||||
await msg.channel.send(errorMsg('not authorized'));
|
await msg.channel.send(errorMsg('not authorized'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -53,7 +54,7 @@ export default class implements Command {
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'channel': {
|
case 'channel': {
|
||||||
let channel: GuildChannel | undefined;
|
let channel: GuildChannel | ThreadChannel | undefined;
|
||||||
|
|
||||||
if (args[1].includes('<#') && args[1].includes('>')) {
|
if (args[1].includes('<#') && args[1].includes('>')) {
|
||||||
channel = msg.guild!.channels.cache.find(c => c.id === args[1].slice(2, args[1].indexOf('>')));
|
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]);
|
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 Settings.update({channel: channel.id}, {where: {guildId: msg.guild!.id}});
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import {Message} from 'discord.js';
|
import {Message, Util} from 'discord.js';
|
||||||
import {injectable} from 'inversify';
|
import {injectable} from 'inversify';
|
||||||
import Command from '.';
|
import Command from '.';
|
||||||
import {TYPES} from '../types.js';
|
import {TYPES} from '../types.js';
|
||||||
|
@ -29,7 +29,7 @@ export default class implements Command {
|
||||||
|
|
||||||
const {prefix} = settings;
|
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) => {
|
const aliases = command.aliases.reduce((str, alias, i) => {
|
||||||
str += alias;
|
str += alias;
|
||||||
|
|
||||||
|
@ -53,9 +53,13 @@ export default class implements Command {
|
||||||
content += '\n';
|
content += '\n';
|
||||||
|
|
||||||
return content;
|
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('🇩');
|
||||||
await msg.react('🇲');
|
await msg.react('🇲');
|
||||||
}
|
}
|
||||||
|
|
|
@ -38,6 +38,7 @@ export default class implements Command {
|
||||||
this.getSongs = getSongs;
|
this.getSongs = getSongs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line complexity
|
||||||
public async execute(msg: Message, args: string[]): Promise<void> {
|
public async execute(msg: Message, args: string[]): Promise<void> {
|
||||||
const [targetVoiceChannel] = getMemberVoiceChannel(msg.member!) ?? getMostPopularVoiceChannel(msg.guild!);
|
const [targetVoiceChannel] = getMemberVoiceChannel(msg.member!) ?? getMostPopularVoiceChannel(msg.guild!);
|
||||||
const settings = await Settings.findByPk(msg.guild!.id);
|
const settings = await Settings.findByPk(msg.guild!.id);
|
||||||
|
@ -134,7 +135,6 @@ export default class implements Command {
|
||||||
if (song) {
|
if (song) {
|
||||||
newSongs.push(song);
|
newSongs.push(song);
|
||||||
} else {
|
} else {
|
||||||
console.log(_);
|
|
||||||
await res.stop(errorMsg('that doesn\'t exist'));
|
await res.stop(errorMsg('that doesn\'t exist'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
@ -74,7 +74,7 @@ export default class implements Command {
|
||||||
|
|
||||||
embed.addField('Page', `${queuePage} out of ${maxQueuePage}`, false);
|
embed.addField('Page', `${queuePage} out of ${maxQueuePage}`, false);
|
||||||
|
|
||||||
await msg.channel.send(embed);
|
await msg.channel.send({embeds: [embed]});
|
||||||
} else {
|
} else {
|
||||||
await msg.channel.send('queue empty');
|
await msg.channel.send('queue empty');
|
||||||
}
|
}
|
||||||
|
|
76
src/commands/remove.ts
Normal file
76
src/commands/remove.ts
Normal file
|
@ -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');
|
||||||
|
}
|
||||||
|
}
|
|
@ -55,7 +55,7 @@ export default class implements Command {
|
||||||
const newShortcut = {shortcut: shortcutName, command, guildId: msg.guild!.id, authorId: msg.author.id};
|
const newShortcut = {shortcut: shortcutName, command, guildId: msg.guild!.id, authorId: msg.author.id};
|
||||||
|
|
||||||
if (shortcut) {
|
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'));
|
await msg.channel.send(errorMsg('you do\'nt have permission to do that'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -80,7 +80,7 @@ export default class implements Command {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check permissions
|
// 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'));
|
await msg.channel.send(errorMsg('you don\'t have permission to do that'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,7 +9,7 @@ const DEFAULT_PREFIX = '!';
|
||||||
export default async (guild: Guild): Promise<void> => {
|
export default async (guild: Guild): Promise<void> => {
|
||||||
await Settings.upsert({guildId: guild.id, prefix: DEFAULT_PREFIX});
|
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';
|
let firstStep = '👋 Hi!\n';
|
||||||
firstStep += 'I just need to ask a few questions before you start listening to music.\n\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[] = [];
|
const emojiChannels: EmojiChannel[] = [];
|
||||||
|
|
||||||
for (const [channelId, channel] of guild.channels.cache) {
|
for (const [channelId, channel] of guild.channels.cache) {
|
||||||
if (channel.type === 'text') {
|
if (channel.type === 'GUILD_TEXT') {
|
||||||
emojiChannels.push({
|
emojiChannels.push({
|
||||||
name: channel.name,
|
name: channel.name,
|
||||||
id: channelId,
|
id: channelId,
|
||||||
|
@ -65,7 +65,7 @@ export default async (guild: Guild): Promise<void> => {
|
||||||
|
|
||||||
await owner.send(secondStep);
|
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;
|
const prefixCharacter = prefixResponses.first()!.content;
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import {VoiceState} from 'discord.js';
|
import {VoiceChannel, VoiceState} from 'discord.js';
|
||||||
import container from '../inversify.config.js';
|
import container from '../inversify.config.js';
|
||||||
import {TYPES} from '../types.js';
|
import {TYPES} from '../types.js';
|
||||||
import PlayerManager from '../managers/player.js';
|
import PlayerManager from '../managers/player.js';
|
||||||
|
@ -10,7 +10,8 @@ export default (oldState: VoiceState, _: VoiceState): void => {
|
||||||
const player = playerManager.get(oldState.guild.id);
|
const player = playerManager.get(oldState.guild.id);
|
||||||
|
|
||||||
if (player.voiceConnection) {
|
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();
|
player.disconnect();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,6 +5,7 @@ import {TYPES} from './types.js';
|
||||||
import Bot from './bot.js';
|
import Bot from './bot.js';
|
||||||
import {sequelize} from './utils/db.js';
|
import {sequelize} from './utils/db.js';
|
||||||
import Config from './services/config.js';
|
import Config from './services/config.js';
|
||||||
|
import FileCacheProvider from './services/file-cache.js';
|
||||||
|
|
||||||
const bot = container.get<Bot>(TYPES.Bot);
|
const bot = container.get<Bot>(TYPES.Bot);
|
||||||
|
|
||||||
|
@ -18,5 +19,7 @@ const bot = container.get<Bot>(TYPES.Bot);
|
||||||
|
|
||||||
await sequelize.sync({alter: true});
|
await sequelize.sync({alter: true});
|
||||||
|
|
||||||
|
await container.get<FileCacheProvider>(TYPES.FileCache).cleanup();
|
||||||
|
|
||||||
await bot.listen();
|
await bot.listen();
|
||||||
})();
|
})();
|
||||||
|
|
|
@ -2,7 +2,7 @@ import 'reflect-metadata';
|
||||||
import {Container} from 'inversify';
|
import {Container} from 'inversify';
|
||||||
import {TYPES} from './types.js';
|
import {TYPES} from './types.js';
|
||||||
import Bot from './bot.js';
|
import Bot from './bot.js';
|
||||||
import {Client} from 'discord.js';
|
import {Client, Intents} from 'discord.js';
|
||||||
import ConfigProvider from './services/config.js';
|
import ConfigProvider from './services/config.js';
|
||||||
|
|
||||||
// Managers
|
// Managers
|
||||||
|
@ -21,20 +21,30 @@ import ForwardSeek from './commands/fseek.js';
|
||||||
import Help from './commands/help.js';
|
import Help from './commands/help.js';
|
||||||
import Pause from './commands/pause.js';
|
import Pause from './commands/pause.js';
|
||||||
import Play from './commands/play.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 Seek from './commands/seek.js';
|
||||||
import Shortcuts from './commands/shortcuts.js';
|
import Shortcuts from './commands/shortcuts.js';
|
||||||
import Shuffle from './commands/shuffle.js';
|
import Shuffle from './commands/shuffle.js';
|
||||||
import Skip from './commands/skip.js';
|
import Skip from './commands/skip.js';
|
||||||
import Unskip from './commands/unskip.js';
|
import Unskip from './commands/unskip.js';
|
||||||
import ThirdParty from './services/third-party.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();
|
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
|
// Bot
|
||||||
container.bind<Bot>(TYPES.Bot).to(Bot).inSingletonScope();
|
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
|
// Managers
|
||||||
container.bind<PlayerManager>(TYPES.Managers.Player).to(PlayerManager).inSingletonScope();
|
container.bind<PlayerManager>(TYPES.Managers.Player).to(PlayerManager).inSingletonScope();
|
||||||
|
@ -52,7 +62,8 @@ container.bind<NaturalLanguage>(TYPES.Services.NaturalLanguage).to(NaturalLangua
|
||||||
Help,
|
Help,
|
||||||
Pause,
|
Pause,
|
||||||
Play,
|
Play,
|
||||||
QueueCommad,
|
QueueCommand,
|
||||||
|
Remove,
|
||||||
Seek,
|
Seek,
|
||||||
Shortcuts,
|
Shortcuts,
|
||||||
Shuffle,
|
Shuffle,
|
||||||
|
@ -68,6 +79,7 @@ container.bind(TYPES.Config).toConstantValue(new ConfigProvider());
|
||||||
// Static libraries
|
// Static libraries
|
||||||
container.bind(TYPES.ThirdParty).to(ThirdParty);
|
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;
|
export default container;
|
||||||
|
|
|
@ -2,25 +2,25 @@ import {inject, injectable} from 'inversify';
|
||||||
import {Client} from 'discord.js';
|
import {Client} from 'discord.js';
|
||||||
import {TYPES} from '../types.js';
|
import {TYPES} from '../types.js';
|
||||||
import Player from '../services/player.js';
|
import Player from '../services/player.js';
|
||||||
import Config from '../services/config.js';
|
import FileCacheProvider from '../services/file-cache.js';
|
||||||
|
|
||||||
@injectable()
|
@injectable()
|
||||||
export default class {
|
export default class {
|
||||||
private readonly guildPlayers: Map<string, Player>;
|
private readonly guildPlayers: Map<string, Player>;
|
||||||
private readonly cacheDir: string;
|
|
||||||
private readonly discordClient: Client;
|
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.guildPlayers = new Map();
|
||||||
this.cacheDir = config.CACHE_DIR;
|
|
||||||
this.discordClient = client;
|
this.discordClient = client;
|
||||||
|
this.fileCache = fileCache;
|
||||||
}
|
}
|
||||||
|
|
||||||
get(guildId: string): Player {
|
get(guildId: string): Player {
|
||||||
let player = this.guildPlayers.get(guildId);
|
let player = this.guildPlayers.get(guildId);
|
||||||
|
|
||||||
if (!player) {
|
if (!player) {
|
||||||
player = new Player(this.cacheDir, this.discordClient);
|
player = new Player(this.discordClient, this.fileCache);
|
||||||
|
|
||||||
this.guildPlayers.set(guildId, player);
|
this.guildPlayers.set(guildId, player);
|
||||||
}
|
}
|
||||||
|
|
14
src/models/file-cache.ts
Normal file
14
src/models/file-cache.ts
Normal file
|
@ -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;
|
||||||
|
}
|
|
@ -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 Settings from './settings.js';
|
||||||
import Shortcut from './shortcut.js';
|
import Shortcut from './shortcut.js';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Cache,
|
FileCache,
|
||||||
|
KeyValueCache,
|
||||||
Settings,
|
Settings,
|
||||||
Shortcut,
|
Shortcut,
|
||||||
};
|
};
|
||||||
|
|
|
@ -2,7 +2,7 @@ import {Table, Column, PrimaryKey, Model} from 'sequelize-typescript';
|
||||||
import sequelize from 'sequelize';
|
import sequelize from 'sequelize';
|
||||||
|
|
||||||
@Table
|
@Table
|
||||||
export default class Cache extends Model<Cache> {
|
export default class KeyValueCache extends Model {
|
||||||
@PrimaryKey
|
@PrimaryKey
|
||||||
@Column
|
@Column
|
||||||
key!: string;
|
key!: string;
|
|
@ -1,7 +1,7 @@
|
||||||
import {Table, Column, PrimaryKey, Model, Default} from 'sequelize-typescript';
|
import {Table, Column, PrimaryKey, Model, Default} from 'sequelize-typescript';
|
||||||
|
|
||||||
@Table
|
@Table
|
||||||
export default class Settings extends Model<Settings> {
|
export default class Settings extends Model {
|
||||||
@PrimaryKey
|
@PrimaryKey
|
||||||
@Column
|
@Column
|
||||||
guildId!: string;
|
guildId!: string;
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import {Table, Column, PrimaryKey, Model, AutoIncrement, Index} from 'sequelize-typescript';
|
import {Table, Column, PrimaryKey, Model, AutoIncrement, Index} from 'sequelize-typescript';
|
||||||
|
|
||||||
@Table
|
@Table
|
||||||
export default class Shortcut extends Model<Shortcut> {
|
export default class Shortcut extends Model {
|
||||||
@PrimaryKey
|
@PrimaryKey
|
||||||
@AutoIncrement
|
@AutoIncrement
|
||||||
@Column
|
@Column
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
import dotenv from 'dotenv';
|
import dotenv from 'dotenv';
|
||||||
import {injectable} from 'inversify';
|
import {injectable} from 'inversify';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import xbytes from 'xbytes';
|
||||||
|
import {ConditionalKeys} from 'type-fest';
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
export const DATA_DIR = path.resolve(process.env.DATA_DIR ? process.env.DATA_DIR : './data');
|
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,
|
SPOTIFY_CLIENT_SECRET: process.env.SPOTIFY_CLIENT_SECRET,
|
||||||
DATA_DIR,
|
DATA_DIR,
|
||||||
CACHE_DIR: path.join(DATA_DIR, 'cache'),
|
CACHE_DIR: path.join(DATA_DIR, 'cache'),
|
||||||
|
CACHE_LIMIT_IN_BYTES: xbytes.parseSize(process.env.CACHE_LIMIT ?? '2GB'),
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
@injectable()
|
@injectable()
|
||||||
|
@ -22,6 +25,7 @@ export default class Config {
|
||||||
readonly SPOTIFY_CLIENT_SECRET!: string;
|
readonly SPOTIFY_CLIENT_SECRET!: string;
|
||||||
readonly DATA_DIR!: string;
|
readonly DATA_DIR!: string;
|
||||||
readonly CACHE_DIR!: string;
|
readonly CACHE_DIR!: string;
|
||||||
|
readonly CACHE_LIMIT_IN_BYTES!: number;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
for (const [key, value] of Object.entries(CONFIG_MAP)) {
|
for (const [key, value] of Object.entries(CONFIG_MAP)) {
|
||||||
|
@ -30,7 +34,13 @@ export default class Config {
|
||||||
process.exit(1);
|
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}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
117
src/services/file-cache.ts
Normal file
117
src/services/file-cache.ts
Normal file
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -14,7 +14,7 @@ import {TYPES} from '../types.js';
|
||||||
import {cleanUrl} from '../utils/url.js';
|
import {cleanUrl} from '../utils/url.js';
|
||||||
import ThirdParty from './third-party.js';
|
import ThirdParty from './third-party.js';
|
||||||
import Config from './config.js';
|
import Config from './config.js';
|
||||||
import CacheProvider from './cache.js';
|
import KeyValueCacheProvider from './key-value-cache.js';
|
||||||
|
|
||||||
type QueuedSongWithoutChannel = Except<QueuedSong, 'addedInChannelId'>;
|
type QueuedSongWithoutChannel = Except<QueuedSong, 'addedInChannelId'>;
|
||||||
|
|
||||||
|
@ -26,14 +26,14 @@ export default class {
|
||||||
private readonly youtube: YouTube;
|
private readonly youtube: YouTube;
|
||||||
private readonly youtubeKey: string;
|
private readonly youtubeKey: string;
|
||||||
private readonly spotify: Spotify;
|
private readonly spotify: Spotify;
|
||||||
private readonly cache: CacheProvider;
|
private readonly cache: KeyValueCacheProvider;
|
||||||
|
|
||||||
private readonly ytsrQueue: PQueue;
|
private readonly ytsrQueue: PQueue;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@inject(TYPES.ThirdParty) thirdParty: ThirdParty,
|
@inject(TYPES.ThirdParty) thirdParty: ThirdParty,
|
||||||
@inject(TYPES.Config) config: Config,
|
@inject(TYPES.Config) config: Config,
|
||||||
@inject(TYPES.Cache) cache: CacheProvider) {
|
@inject(TYPES.KeyValueCache) cache: KeyValueCacheProvider) {
|
||||||
this.youtube = thirdParty.youtube;
|
this.youtube = thirdParty.youtube;
|
||||||
this.youtubeKey = config.YOUTUBE_API_KEY;
|
this.youtubeKey = config.YOUTUBE_API_KEY;
|
||||||
this.spotify = thirdParty.spotify;
|
this.spotify = thirdParty.spotify;
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import {injectable} from 'inversify';
|
import {injectable} from 'inversify';
|
||||||
import {Cache} from '../models/index.js';
|
import {KeyValueCache} from '../models/index.js';
|
||||||
import debug from '../utils/debug.js';
|
import debug from '../utils/debug.js';
|
||||||
|
|
||||||
type Seconds = number;
|
type Seconds = number;
|
||||||
|
@ -12,7 +12,7 @@ type Options = {
|
||||||
const futureTimeToDate = (time: Seconds) => new Date(new Date().getTime() + (time * 1000));
|
const futureTimeToDate = (time: Seconds) => new Date(new Date().getTime() + (time * 1000));
|
||||||
|
|
||||||
@injectable()
|
@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> {
|
async wrap<T extends [...any[], Options], F>(func: (...options: any) => Promise<F>, ...options: T): Promise<F> {
|
||||||
if (options.length === 0) {
|
if (options.length === 0) {
|
||||||
throw new Error('Missing cache options');
|
throw new Error('Missing cache options');
|
||||||
|
@ -29,7 +29,7 @@ export default class CacheProvider {
|
||||||
throw new Error(`Cache key ${key} is too short.`);
|
throw new Error(`Cache key ${key} is too short.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const cachedResult = await Cache.findByPk(key);
|
const cachedResult = await KeyValueCache.findByPk(key);
|
||||||
|
|
||||||
if (cachedResult) {
|
if (cachedResult) {
|
||||||
if (new Date() < cachedResult.expiresAt) {
|
if (new Date() < cachedResult.expiresAt) {
|
||||||
|
@ -45,7 +45,7 @@ export default class CacheProvider {
|
||||||
const result = await func(...options as any[]);
|
const result = await func(...options as any[]);
|
||||||
|
|
||||||
// Save result
|
// Save result
|
||||||
await Cache.upsert({
|
await KeyValueCache.upsert({
|
||||||
key,
|
key,
|
||||||
value: JSON.stringify(result),
|
value: JSON.stringify(result),
|
||||||
expiresAt: futureTimeToDate(expiresIn),
|
expiresAt: futureTimeToDate(expiresIn),
|
|
@ -1,13 +1,13 @@
|
||||||
import {VoiceConnection, VoiceChannel, StreamDispatcher, Snowflake, Client, TextChannel} from 'discord.js';
|
import {VoiceChannel, Snowflake, Client, TextChannel} from 'discord.js';
|
||||||
import {promises as fs, createWriteStream} from 'fs';
|
import {Readable} from 'stream';
|
||||||
import {Readable, PassThrough} from 'stream';
|
|
||||||
import path from 'path';
|
|
||||||
import hasha from 'hasha';
|
import hasha from 'hasha';
|
||||||
import ytdl from 'ytdl-core';
|
import ytdl from 'ytdl-core';
|
||||||
import {WriteStream} from 'fs-capacitor';
|
import {WriteStream} from 'fs-capacitor';
|
||||||
import ffmpeg from 'fluent-ffmpeg';
|
import ffmpeg from 'fluent-ffmpeg';
|
||||||
import shuffle from 'array-shuffle';
|
import shuffle from 'array-shuffle';
|
||||||
import errorMsg from '../utils/error-msg.js';
|
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 {
|
export interface QueuedPlaylist {
|
||||||
title: string;
|
title: string;
|
||||||
|
@ -34,8 +34,7 @@ export default class {
|
||||||
public voiceConnection: VoiceConnection | null = null;
|
public voiceConnection: VoiceConnection | null = null;
|
||||||
private queue: QueuedSong[] = [];
|
private queue: QueuedSong[] = [];
|
||||||
private queuePosition = 0;
|
private queuePosition = 0;
|
||||||
private readonly cacheDir: string;
|
private audioPlayer: AudioPlayer | null = null;
|
||||||
private dispatcher: StreamDispatcher | null = null;
|
|
||||||
private nowPlaying: QueuedSong | null = null;
|
private nowPlaying: QueuedSong | null = null;
|
||||||
private playPositionInterval: NodeJS.Timeout | undefined;
|
private playPositionInterval: NodeJS.Timeout | undefined;
|
||||||
private lastSongURL = '';
|
private lastSongURL = '';
|
||||||
|
@ -43,30 +42,34 @@ export default class {
|
||||||
private positionInSeconds = 0;
|
private positionInSeconds = 0;
|
||||||
|
|
||||||
private readonly discordClient: Client;
|
private readonly discordClient: Client;
|
||||||
|
private readonly fileCache: FileCacheProvider;
|
||||||
|
|
||||||
constructor(cacheDir: string, client: Client) {
|
constructor(client: Client, fileCache: FileCacheProvider) {
|
||||||
this.cacheDir = cacheDir;
|
|
||||||
this.discordClient = client;
|
this.discordClient = client;
|
||||||
|
this.fileCache = fileCache;
|
||||||
}
|
}
|
||||||
|
|
||||||
async connect(channel: VoiceChannel): Promise<void> {
|
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;
|
this.voiceConnection = conn;
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnect(breakConnection = true): void {
|
disconnect(): void {
|
||||||
if (this.voiceConnection) {
|
if (this.voiceConnection) {
|
||||||
if (this.status === STATUS.PLAYING) {
|
if (this.status === STATUS.PLAYING) {
|
||||||
this.pause();
|
this.pause();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (breakConnection) {
|
this.voiceConnection.destroy();
|
||||||
this.voiceConnection.disconnect();
|
this.audioPlayer?.stop();
|
||||||
}
|
|
||||||
|
|
||||||
this.voiceConnection = null;
|
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});
|
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.attachListeners();
|
||||||
this.startTrackingPosition(positionSeconds);
|
this.startTrackingPosition(positionSeconds);
|
||||||
|
|
||||||
|
@ -117,8 +123,8 @@ export default class {
|
||||||
|
|
||||||
// Resume from paused state
|
// Resume from paused state
|
||||||
if (this.status === STATUS.PAUSED && currentSong.url === this.nowPlaying?.url) {
|
if (this.status === STATUS.PAUSED && currentSong.url === this.nowPlaying?.url) {
|
||||||
if (this.dispatcher) {
|
if (this.audioPlayer) {
|
||||||
this.dispatcher.resume();
|
this.audioPlayer.unpause();
|
||||||
this.status = STATUS.PLAYING;
|
this.status = STATUS.PLAYING;
|
||||||
this.startTrackingPosition();
|
this.startTrackingPosition();
|
||||||
return;
|
return;
|
||||||
|
@ -132,7 +138,11 @@ export default class {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const stream = await this.getStream(currentSong.url);
|
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();
|
this.attachListeners();
|
||||||
|
|
||||||
|
@ -170,8 +180,8 @@ export default class {
|
||||||
|
|
||||||
this.status = STATUS.PAUSED;
|
this.status = STATUS.PAUSED;
|
||||||
|
|
||||||
if (this.dispatcher) {
|
if (this.audioPlayer) {
|
||||||
this.dispatcher.pause();
|
this.audioPlayer.pause();
|
||||||
}
|
}
|
||||||
|
|
||||||
this.stopTrackingPosition();
|
this.stopTrackingPosition();
|
||||||
|
@ -230,25 +240,12 @@ export default class {
|
||||||
}
|
}
|
||||||
|
|
||||||
add(song: QueuedSong, {immediate = false} = {}): void {
|
add(song: QueuedSong, {immediate = false} = {}): void {
|
||||||
if (song.playlist) {
|
if (song.playlist || !immediate) {
|
||||||
// Add to end of queue
|
// Add to end of queue
|
||||||
this.queue.push(song);
|
this.queue.push(song);
|
||||||
} else {
|
} else {
|
||||||
// Not from playlist, add immediately
|
// Add as the next song to be played
|
||||||
let insertAt = this.queuePosition + 1;
|
const insertAt = this.queuePosition + 1;
|
||||||
|
|
||||||
if (!immediate) {
|
|
||||||
// Loop until playlist song
|
|
||||||
this.queue.some(song => {
|
|
||||||
if (song.playlist) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
insertAt++;
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
this.queue = [...this.queue.slice(0, insertAt), song, ...this.queue.slice(insertAt)];
|
this.queue = [...this.queue.slice(0, insertAt), song, ...this.queue.slice(insertAt)];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -273,6 +270,10 @@ export default class {
|
||||||
this.queue = newQueue;
|
this.queue = newQueue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
removeFromQueue(index: number, amount = 1): void {
|
||||||
|
this.queue.splice(this.queuePosition + index, amount);
|
||||||
|
}
|
||||||
|
|
||||||
removeCurrent(): void {
|
removeCurrent(): void {
|
||||||
this.queue = [...this.queue.slice(0, this.queuePosition), ...this.queue.slice(this.queuePosition + 1)];
|
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;
|
return this.queueSize() === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getCachedPath(url: string): string {
|
private getHashForCache(url: string): string {
|
||||||
return path.join(this.cacheDir, hasha(url));
|
return 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 async getStream(url: string, options: {seek?: number} = {}): Promise<Readable> {
|
private async getStream(url: string, options: {seek?: number} = {}): Promise<Readable> {
|
||||||
const cachedPath = this.getCachedPath(url);
|
|
||||||
|
|
||||||
let ffmpegInput = '';
|
let ffmpegInput = '';
|
||||||
const ffmpegInputOptions: string[] = [];
|
const ffmpegInputOptions: string[] = [];
|
||||||
let shouldCacheVideo = false;
|
let shouldCacheVideo = false;
|
||||||
|
|
||||||
let format: ytdl.videoFormat | undefined;
|
let format: ytdl.videoFormat | undefined;
|
||||||
|
|
||||||
if (await this.isCached(url)) {
|
try {
|
||||||
ffmpegInput = cachedPath;
|
ffmpegInput = await this.fileCache.getPathFor(this.getHashForCache(url));
|
||||||
|
|
||||||
if (options.seek) {
|
if (options.seek) {
|
||||||
ffmpegInputOptions.push('-ss', options.seek.toString());
|
ffmpegInputOptions.push('-ss', options.seek.toString());
|
||||||
}
|
}
|
||||||
} else {
|
} catch {
|
||||||
// Not yet cached, must download
|
// Not yet cached, must download
|
||||||
const info = await ytdl.getInfo(url);
|
const info = await ytdl.getInfo(url);
|
||||||
|
|
||||||
|
@ -379,6 +364,17 @@ export default class {
|
||||||
|
|
||||||
// Create stream and pipe to capacitor
|
// Create stream and pipe to capacitor
|
||||||
return new Promise((resolve, reject) => {
|
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)
|
const youtubeStream = ffmpeg(ffmpegInput)
|
||||||
.inputOptions(ffmpegInputOptions)
|
.inputOptions(ffmpegInputOptions)
|
||||||
.noVideo()
|
.noVideo()
|
||||||
|
@ -387,29 +383,9 @@ export default class {
|
||||||
.on('error', error => {
|
.on('error', error => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
reject(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());
|
resolve(capacitor.createReadStream());
|
||||||
});
|
});
|
||||||
|
@ -440,22 +416,26 @@ export default class {
|
||||||
return;
|
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;
|
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 {
|
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
|
// 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);
|
await this.forward(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
export const TYPES = {
|
export const TYPES = {
|
||||||
Bot: Symbol('Bot'),
|
Bot: Symbol('Bot'),
|
||||||
Cache: Symbol('Cache'),
|
KeyValueCache: Symbol('KeyValueCache'),
|
||||||
|
FileCache: Symbol('FileCache'),
|
||||||
Client: Symbol('Client'),
|
Client: Symbol('Client'),
|
||||||
Config: Symbol('Config'),
|
Config: Symbol('Config'),
|
||||||
Command: Symbol('Command'),
|
Command: Symbol('Command'),
|
||||||
|
|
|
@ -3,8 +3,8 @@ import {Guild, VoiceChannel, User, GuildMember} from 'discord.js';
|
||||||
export const isUserInVoice = (guild: Guild, user: User): boolean => {
|
export const isUserInVoice = (guild: Guild, user: User): boolean => {
|
||||||
let inVoice = false;
|
let inVoice = false;
|
||||||
|
|
||||||
guild.channels.cache.filter(channel => channel.type === 'voice').forEach(channel => {
|
guild.channels.cache.filter(channel => channel.type === 'GUILD_VOICE').forEach(channel => {
|
||||||
if (channel.members.array().find(member => member.id === user.id)) {
|
if ((channel as VoiceChannel).members.find(member => member.id === user.id)) {
|
||||||
inVoice = true;
|
inVoice = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -12,7 +12,7 @@ export const isUserInVoice = (guild: Guild, user: User): boolean => {
|
||||||
return inVoice;
|
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) {
|
if (!member.user.bot) {
|
||||||
s++;
|
s++;
|
||||||
}
|
}
|
||||||
|
@ -22,7 +22,7 @@ export const getSizeWithoutBots = (channel: VoiceChannel): number => channel.mem
|
||||||
|
|
||||||
export const getMemberVoiceChannel = (member?: GuildMember): [VoiceChannel, number] | null => {
|
export const getMemberVoiceChannel = (member?: GuildMember): [VoiceChannel, number] | null => {
|
||||||
const channel = member?.voice?.channel;
|
const channel = member?.voice?.channel;
|
||||||
if (channel && channel.type === 'voice') {
|
if (channel && channel.type === 'GUILD_VOICE') {
|
||||||
return [
|
return [
|
||||||
channel,
|
channel,
|
||||||
getSizeWithoutBots(channel),
|
getSizeWithoutBots(channel),
|
||||||
|
@ -41,7 +41,7 @@ export const getMostPopularVoiceChannel = (guild: Guild): [VoiceChannel, number]
|
||||||
const voiceChannels: PopularResult[] = [];
|
const voiceChannels: PopularResult[] = [];
|
||||||
|
|
||||||
for (const [_, channel] of guild.channels.cache) {
|
for (const [_, channel] of guild.channels.cache) {
|
||||||
if (channel.type === 'voice') {
|
if (channel.type === 'GUILD_VOICE') {
|
||||||
const size = getSizeWithoutBots(channel as VoiceChannel);
|
const size = getSizeWithoutBots(channel as VoiceChannel);
|
||||||
|
|
||||||
voiceChannels.push({
|
voiceChannels.push({
|
||||||
|
|
|
@ -1,12 +1,12 @@
|
||||||
import {Sequelize} from 'sequelize-typescript';
|
import {Sequelize} from 'sequelize-typescript';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import {DATA_DIR} from '../services/config.js';
|
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({
|
export const sequelize = new Sequelize({
|
||||||
dialect: 'sqlite',
|
dialect: 'sqlite',
|
||||||
database: 'muse',
|
database: 'muse',
|
||||||
storage: path.join(DATA_DIR, 'db.sqlite'),
|
storage: path.join(DATA_DIR, 'db.sqlite'),
|
||||||
models: [Cache, Settings, Shortcut],
|
models: [FileCache, KeyValueCache, Settings, Shortcut],
|
||||||
logging: false,
|
logging: false,
|
||||||
});
|
});
|
||||||
|
|
Loading…
Reference in a new issue