diff --git a/@types/DiscordClient.d.ts b/@types/DiscordClient.d.ts index 3105857..013f56d 100644 --- a/@types/DiscordClient.d.ts +++ b/@types/DiscordClient.d.ts @@ -35,6 +35,7 @@ export type CommandDefinition = { guildOnly?: boolean, help?: string, limited?: Snowflake[], + showUsage?: boolean } & Omit export type ObserverOptions = { diff --git a/@types/MusicPlayer.d.ts b/@types/MusicPlayer.d.ts index 7cbce91..e458160 100644 --- a/@types/MusicPlayer.d.ts +++ b/@types/MusicPlayer.d.ts @@ -15,4 +15,19 @@ export type MusicIndexEntry = { album?: string, year?: number, file: string, + stats: { + plays: number, + skips: number + } +} + +export type MusicQuery = { + title?: string + artist?: string, + keyword?: string +} + +export type QueueOrder = { + artist?: string, + title: string } \ No newline at end of file diff --git a/package.json b/package.json index 50ead3b..4498af3 100644 --- a/package.json +++ b/package.json @@ -22,11 +22,13 @@ "@navy.gif/logger": "^2.5.4", "@navy.gif/timestring": "^6.0.6", "bufferutil": "^4.0.8", + "common-tags": "^1.8.2", "discord.js": "^14.14.1", "dotenv": "^16.4.5", "ffmpeg": "^0.0.4", "humanize-duration": "^3.31.0", "music-metadata": "^7.14.0", + "similarity": "^1.2.1", "sodium-native": "^4.1.1", "typescript": "^5.4.3", "utf-8-validate": "^6.0.3", @@ -38,8 +40,10 @@ "@babel/preset-typescript": "^7.24.1", "@types/babel__core": "^7", "@types/babel__preset-env": "^7", + "@types/common-tags": "^1", "@types/eslint": "^8", "@types/humanize-duration": "^3", + "@types/similarity": "^1", "@typescript-eslint/eslint-plugin": "^7.3.1", "@typescript-eslint/parser": "^7.3.1", "eslint": "^8.57.0" diff --git a/src/client/components/MusicLibrary.ts b/src/client/components/MusicLibrary.ts index 6b89c68..49a4c49 100644 --- a/src/client/components/MusicLibrary.ts +++ b/src/client/components/MusicLibrary.ts @@ -7,8 +7,8 @@ import Util from '../../utilities/Util.js'; import Initialisable from '../../interfaces/Initialisable.js'; import DiscordClient from '../DiscordClient.js'; import { Collection } from 'discord.js'; -import { MusicIndexEntry } from '../../../@types/MusicPlayer.js'; - +import { MusicIndexEntry, MusicQuery } from '../../../@types/MusicPlayer.js'; +import similarity from 'similarity'; class MusicLibrary implements Initialisable { @@ -47,7 +47,43 @@ class MusicLibrary implements Initialisable stop (): void | Promise { - throw new Error('Method not implemented.'); + this.saveIndex(); + } + + search (query: MusicQuery) + { + if (!Object.keys(query).length) + throw new Error('Invalid query'); + const results: MusicIndexEntry[] = []; + for (const entry of this.#index.values()) + { + if (query.artist && !entry.arist.toLowerCase().includes(query.artist.toLowerCase())) + continue; + if (query.title && !entry.title.toLowerCase().includes(query.title.toLowerCase())) + continue; + if (query.keyword + && !entry.title.toLowerCase().includes(query.keyword?.toLowerCase()) + && !entry.arist.toLowerCase().includes(query.keyword.toLowerCase())) + continue; + results.push(entry); + } + return results.sort((a, b) => + { + if (query.artist) + { + if (similarity(a.arist, query.artist) > similarity(b.arist, query.artist)) + return 1; + return -1; + } + if (query.title) + { + if (similarity(a.title, query.title) > similarity(b.title, query.title)) + return 1; + return -1; + } + + return 0; + }); } getRandom () @@ -61,6 +97,18 @@ class MusicLibrary implements Initialisable return Util.shuffle(entries); } + countPlay (fp: string) + { + if (this.#index.has(fp)) + this.#index.get(fp)!.stats.plays++; + } + + countSkip (fp: string) + { + if (this.#index.has(fp)) + this.#index.get(fp)!.stats.skips++; + } + async scanLibrary (full = false) { this.#logger.info('Starting library scan'); @@ -95,13 +143,17 @@ class MusicLibrary implements Initialisable title: common.title ?? title, album: common.album, year: common.year, - file: fp + file: fp, + stats: { + plays: 0, + skips: 0 + } }; this.#index.set(fp, entry); } const end = Date.now(); const newFiles = this.#index.size - initialSize; - this.#logger.info(`Library scan took ${end - start} ms, ${this.#index.size} (new ${newFiles}) files indexed`); + this.#logger.info(`Library scan took ${end - start} ms, ${this.#index.size} (${newFiles} new) files indexed`); this.saveIndex(); return newFiles; } @@ -115,7 +167,12 @@ class MusicLibrary implements Initialisable const raw = fs.readFileSync(indexPath, { encoding: 'utf-8' }); const parsed = JSON.parse(raw) as MusicIndexEntry[]; for (const entry of parsed) + { + if (typeof entry.stats === 'undefined') + entry.stats = { plays: 0, skips: 0 }; this.#index.set(entry.file, entry); + } + this.#logger.info(`Index loaded with ${this.#index.size} entries`); } saveIndex () diff --git a/src/client/components/MusicPlayer.ts b/src/client/components/MusicPlayer.ts index a299cbf..dad0303 100644 --- a/src/client/components/MusicPlayer.ts +++ b/src/client/components/MusicPlayer.ts @@ -4,7 +4,7 @@ import { LoggerClient } from '@navy.gif/logger'; import Initialisable from '../../interfaces/Initialisable.js'; import DiscordClient from '../DiscordClient.js'; -import { MusicIndexEntry, MusicPlayerOptions } from '../../../@types/MusicPlayer.js'; +import { MusicIndexEntry, MusicPlayerOptions, QueueOrder } from '../../../@types/MusicPlayer.js'; import MusicLibrary from './MusicLibrary.js'; type ConnectionDetails = { @@ -27,9 +27,13 @@ class MusicPlayer implements Initialisable #options: MusicPlayerOptions; #volume: number; #library: MusicLibrary; + #shuffleIdx: number; #shuffleList: MusicIndexEntry[]; + #queue: MusicIndexEntry[]; + #currentSong: MusicIndexEntry | null; + constructor (client: DiscordClient, options: MusicPlayerOptions) { this.#client = client; @@ -45,6 +49,8 @@ class MusicPlayer implements Initialisable this.#library = new MusicLibrary(client, this.#options.library); this.#shuffleList = []; this.#shuffleIdx = 0; + this.#queue = []; + this.#currentSong = null; this.#player.on(AudioPlayerStatus.Idle, this.playNext.bind(this)); } @@ -59,10 +65,30 @@ class MusicPlayer implements Initialisable return this.#library; } + queue (order: QueueOrder) + { + const [ result ] = this.library.search(order); + if (!result) + return null; + this.#queue.push(result); + return result; + } + playNext () { - const info = this.#shuffleList[this.#shuffleIdx]; + if (!this.#ready) + return; + if (this.#player.state.status !== AudioPlayerStatus.Idle && this.#currentSong) + this.library.countSkip(this.#currentSong.file); + + let info: MusicIndexEntry | null = null; + if (this.#queue.length) + info = this.#queue.shift()!; + else + info = this.#shuffleList[this.#shuffleIdx]; + + this.#currentSong = info; this.#currentResource = createAudioResource(info.file, { inlineVolume: true, metadata: { @@ -73,11 +99,16 @@ class MusicPlayer implements Initialisable this.#logger.info(`Now playing ${info.arist} - ${info.title}`); this.#player.play(this.#currentResource); + this.#library.countPlay(info.file); + this.#shuffleIdx++; + if (this.#shuffleIdx === this.#shuffleList.length) + this.#shuffleIdx = 0; + this.#client.user?.setPresence({ activities: [{ name: `${info.arist} - ${info.title}`, - type: ActivityType.Playing + type: ActivityType.Listening }] }); @@ -92,7 +123,7 @@ class MusicPlayer implements Initialisable await channel.send({ embeds: [{ title: 'Now playing :notes:', - description: `**${info.title}** by ${info.arist}`, + description: `**${info!.title}** by ${info!.arist}`, color: 0xffafff }] }); @@ -119,6 +150,8 @@ class MusicPlayer implements Initialisable stop (): void | Promise { + this.#ready = false; + this.#logger.info('Stopping music player'); this.#logger.info('Disconnecting all guilds'); for (const [ guildId, { connection, subscription }] of this.#connections) { @@ -126,6 +159,7 @@ class MusicPlayer implements Initialisable subscription.unsubscribe(); connection.disconnect(); } + this.#library.stop(); } async initialise () @@ -140,6 +174,7 @@ class MusicPlayer implements Initialisable this.initialiseVoiceChannels(); + this.#ready = true; this.playNext(); } diff --git a/src/client/components/commands/Queue.ts b/src/client/components/commands/Queue.ts new file mode 100644 index 0000000..c9c053b --- /dev/null +++ b/src/client/components/commands/Queue.ts @@ -0,0 +1,40 @@ +import { Message } from 'discord.js'; +import Command from '../../../interfaces/Command.js'; +import DiscordClient from '../../DiscordClient.js'; +import { CommandOpts } from '@navy.gif/commandparser'; + +class QueueCommand extends Command +{ + constructor (client: DiscordClient) + { + super(client, { + name: 'queue', + showUsage: true, + options: [{ + name: 'artist', + flag: true + }, { + name: 'song', + required: true + }] + }); + } + + async execute (message: Message, { args }: CommandOpts) + { + const { member, guild } = message; + const { me } = guild.members; + if (!member?.voice || member.voice.channelId !== me?.voice.channelId) + return 'Only vc participants can queue songs'; + const query = { + title: args.song!.value as string, + artist: args.artist?.value as string | undefined, + }; + const result = this.client.musicPlayer.queue(query); + if (!result) + return 'Query yielded no results'; + return `Song **${result.title}** by ${result.arist} queued`; + } +} + +export default QueueCommand; \ No newline at end of file diff --git a/src/client/components/commands/Rescan.ts b/src/client/components/commands/Rescan.ts index f89ba4c..c327d63 100644 --- a/src/client/components/commands/Rescan.ts +++ b/src/client/components/commands/Rescan.ts @@ -13,7 +13,7 @@ class PingCommand extends Command async execute () { - const diff = this.client.musicPlayer.library.scanLibrary(); + const diff = await this.client.musicPlayer.library.scanLibrary(); const songs = this.client.musicPlayer.library.size; return `Found ${songs} tracks with ${diff} new ones`; } diff --git a/src/client/components/commands/Search.ts b/src/client/components/commands/Search.ts new file mode 100644 index 0000000..91c1f0f --- /dev/null +++ b/src/client/components/commands/Search.ts @@ -0,0 +1,42 @@ +import { Message } from 'discord.js'; +import Command from '../../../interfaces/Command.js'; +import DiscordClient from '../../DiscordClient.js'; +import { CommandOpts } from '@navy.gif/commandparser'; + +class SearchCommand extends Command +{ + constructor (client: DiscordClient) + { + super(client, { + name: 'search', + showUsage: true, + options: [{ + name: 'keyword', + }, { + name: 'artist', + flag: true + }, { + name: 'song', + flag: true + }] + }); + } + + async execute (_message: Message, { args }: CommandOpts) + { + const query = { + title: args.song?.value as string | undefined, + artist: args.artist?.value as string | undefined, + keyword: args.keyword?.value as string | undefined + }; + const results = this.client.musicPlayer.library.search(query); + + if (!results.length) + return 'No results found'; + return ` + **Search results:**\n${results.map(result => `\t\\- **${result.arist}** - ${result.title} (${result.album ?? 'Unknown album'}) [${result.year ?? 'Unknown year'}]`).join('\n')} + `; + } +} + +export default SearchCommand; \ No newline at end of file diff --git a/src/client/components/commands/SetAvatar.ts b/src/client/components/commands/SetAvatar.ts new file mode 100644 index 0000000..47db357 --- /dev/null +++ b/src/client/components/commands/SetAvatar.ts @@ -0,0 +1,41 @@ +import { ArgsResult, CommandOpts, OptionType } from '@navy.gif/commandparser'; +import Command from '../../../interfaces/Command.js'; +import DiscordClient from '../../DiscordClient.js'; +import { Message } from 'discord.js'; + +class SetAvatarCommand extends Command +{ + constructor (client: DiscordClient) + { + super(client, { + name: 'set', + options: [{ + name: 'avatar', + type: OptionType.SUB_COMMAND, + options: [{ + name: 'asset', + required: true + }] + }], + restricted: true + }); + } + + async execute (message: Message, { subcommand, args }: CommandOpts) + { + if (subcommand === 'avatar') + return this.#setAvatar(message, args); + return 'Unknown subcommand'; + } + + async #setAvatar (_message: Message, args : ArgsResult) + { + const { asset } = args; + if (!asset?.value) + return 'Missing value'; + await this.client.user?.setAvatar(asset.value as string); + return 'Avatar successfully set'; + } +} + +export default SetAvatarCommand; \ No newline at end of file diff --git a/src/client/components/commands/Skip.ts b/src/client/components/commands/Skip.ts index 08bc6b0..11c16b2 100644 --- a/src/client/components/commands/Skip.ts +++ b/src/client/components/commands/Skip.ts @@ -1,3 +1,4 @@ +import { Message } from 'discord.js'; import Command from '../../../interfaces/Command.js'; import DiscordClient from '../../DiscordClient.js'; @@ -7,12 +8,19 @@ class SkipCommand extends Command { super(client, { name: 'skip', - guildOnly: true + guildOnly: true, + restricted: true }); } - async execute () + async execute (message: Message) { + const { member, author, guild } = message; + const { me } = guild.members; + if (!member?.voice || member.voice.channelId !== me?.voice.channelId) + return 'Only vc participants can adjust volume'; + + this.logger.info(`${author.username} (${author.id}) skipped a song`); this.client.musicPlayer.playNext(); return 'Song skipped'; } diff --git a/src/client/components/commands/Volume.ts b/src/client/components/commands/Volume.ts index 7fc610f..c79d389 100644 --- a/src/client/components/commands/Volume.ts +++ b/src/client/components/commands/Volume.ts @@ -9,7 +9,6 @@ class VolumeCommand extends Command { super(client, { name: 'volume', - aliases: [ 'v' ], options: [ { name: 'volume', @@ -19,7 +18,8 @@ class VolumeCommand extends Command maximum: 100 } ], - guildOnly: true + guildOnly: true, + restricted: true }); } diff --git a/src/client/components/observers/CommandHandler.ts b/src/client/components/observers/CommandHandler.ts index 565f0f4..dd4befb 100644 --- a/src/client/components/observers/CommandHandler.ts +++ b/src/client/components/observers/CommandHandler.ts @@ -1,7 +1,7 @@ import { inspect } from 'node:util'; import { APIEmbed, ChannelType, DiscordAPIError, EmbedBuilder, Events, Message, MessagePayload } from 'discord.js'; -import { CommandOpts, ICommand, Parser, ParserError } from '@navy.gif/commandparser'; +import { CommandOpts, ICommand, OptionType, Parser, ParserError } from '@navy.gif/commandparser'; import Observer from '../../../interfaces/Observer.js'; import DiscordClient from '../../DiscordClient.js'; @@ -9,6 +9,7 @@ import { InhibitorResponse } from '../../../../@types/DiscordClient.js'; import Command from '../../../interfaces/Command.js'; import CommandError from '../../../errors/CommandError.js'; import Util from '../../../utilities/Util.js'; +import { stripIndents } from 'common-tags'; class CommandHandler extends Observer @@ -119,6 +120,9 @@ class CommandHandler extends Observer }); } + if ((command as Command).showUsage && !Object.keys(rest.args).length && !rest.subcommand && !rest.subcommandGroup) + return this.#showUsage(message, command as Command); + this.#executeCommand(message, command, rest); } @@ -220,6 +224,34 @@ class CommandHandler extends Observer } } + #showUsage (message: Message, command: Command): void | PromiseLike + { + const { options } = command; + const flags = options.filter(option => option.flag); + const nonFlags = options.filter(option => !option.flag); + let output = stripIndents` + USAGE: \`${this.client.prefix}${command.name} [OPTIONS] [FLAGS]\` + `; + + if (nonFlags.length) + { + output += '\n\n' + stripIndents` + OPTIONS: + ${nonFlags.map(opt => `\t \\- ${opt.name} (${OptionType[opt.type]})`).join('\n')} + `; + } + + if (flags.length) + { + output += '\n\n' + stripIndents` + FLAGS: + ${flags.map(flag => `\t \\- ${flag.name} (${OptionType[flag.type]})`).join('\n')} + `; + } + + message.reply(output); + } + } export default CommandHandler; \ No newline at end of file diff --git a/src/interfaces/Command.ts b/src/interfaces/Command.ts index 8832c08..4a7ba4b 100644 --- a/src/interfaces/Command.ts +++ b/src/interfaces/Command.ts @@ -18,6 +18,7 @@ abstract class Command extends Component implements ICommand #guildOnly: boolean; #dmOnly: boolean; #limited: Snowflake[] | null; // Limited to specific roles + #showUsage: boolean; constructor (client: DiscordClient, def: CommandDefinition) { @@ -33,6 +34,7 @@ abstract class Command extends Component implements ICommand this.#guildOnly = def.guildOnly ?? false; this.#dmOnly = def.dmOnly ?? false; this.#limited = def.limited ?? null; + this.#showUsage = def.showUsage ?? false; this.#options = []; @@ -67,6 +69,11 @@ abstract class Command extends Component implements ICommand throw new CommandError(this, { reason: 'Command timed out', user }); } + get showUsage () + { + return this.#showUsage; + } + get restricted () { return this.#restricted; diff --git a/yarn.lock b/yarn.lock index 77d74ed..ab95239 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1804,6 +1804,13 @@ __metadata: languageName: node linkType: hard +"@types/common-tags@npm:^1": + version: 1.8.4 + resolution: "@types/common-tags@npm:1.8.4" + checksum: 10/40c95a2f6388beb1cdeed3c9986ac0d6a3a551fce706e3e364a00ded48ab624b06b1ac8b94679bb2da9653e5eb3e450bad26873f5189993a5d8e8bdace74cbb2 + languageName: node + linkType: hard + "@types/eslint@npm:^8": version: 8.56.6 resolution: "@types/eslint@npm:8.56.6" @@ -1851,6 +1858,13 @@ __metadata: languageName: node linkType: hard +"@types/similarity@npm:^1": + version: 1.2.3 + resolution: "@types/similarity@npm:1.2.3" + checksum: 10/1f3c9ad6e803e3c1d161c6701da09686a86a8772703765b259d3614ab1b0a84294b0b9f9044d596f7310cf583cc8614e1b1699f3e22175ba4af01fd3b295804e + languageName: node + linkType: hard + "@types/ws@npm:8.5.9": version: 8.5.9 resolution: "@types/ws@npm:8.5.9" @@ -2353,6 +2367,13 @@ __metadata: languageName: node linkType: hard +"common-tags@npm:^1.8.2": + version: 1.8.2 + resolution: "common-tags@npm:1.8.2" + checksum: 10/c665d0f463ee79dda801471ad8da6cb33ff7332ba45609916a508ad3d77ba07ca9deeb452e83f81f24c2b081e2c1315347f23d239210e63d1c5e1a0c7c019fe2 + languageName: node + linkType: hard + "concat-map@npm:0.0.1": version: 0.0.1 resolution: "concat-map@npm:0.0.1" @@ -3251,6 +3272,15 @@ __metadata: languageName: node linkType: hard +"levenshtein-edit-distance@npm:^2.0.0": + version: 2.0.5 + resolution: "levenshtein-edit-distance@npm:2.0.5" + bin: + levenshtein-edit-distance: cli.js + checksum: 10/50618c01cd0c9bae6d4371d75af62c17c25a8f91bfd8d06400315b8b15976900cff951b48e102e074e9c5c6758260fff1675cfad186732afe124a5708e1032fd + languageName: node + linkType: hard + "levn@npm:^0.4.1": version: 0.4.1 resolution: "levn@npm:0.4.1" @@ -3552,17 +3582,21 @@ __metadata: "@navy.gif/timestring": "npm:^6.0.6" "@types/babel__core": "npm:^7" "@types/babel__preset-env": "npm:^7" + "@types/common-tags": "npm:^1" "@types/eslint": "npm:^8" "@types/humanize-duration": "npm:^3" + "@types/similarity": "npm:^1" "@typescript-eslint/eslint-plugin": "npm:^7.3.1" "@typescript-eslint/parser": "npm:^7.3.1" bufferutil: "npm:^4.0.8" + common-tags: "npm:^1.8.2" discord.js: "npm:^14.14.1" dotenv: "npm:^16.4.5" eslint: "npm:^8.57.0" ffmpeg: "npm:^0.0.4" humanize-duration: "npm:^3.31.0" music-metadata: "npm:^7.14.0" + similarity: "npm:^1.2.1" sodium-native: "npm:^4.1.1" typescript: "npm:^5.4.3" utf-8-validate: "npm:^6.0.3" @@ -4078,6 +4112,17 @@ __metadata: languageName: node linkType: hard +"similarity@npm:^1.2.1": + version: 1.2.1 + resolution: "similarity@npm:1.2.1" + dependencies: + levenshtein-edit-distance: "npm:^2.0.0" + bin: + similarity: cli.js + checksum: 10/7010abfb53ea72fcecb3b9f59e0753f589256b8a134886a5282cd1ee21226fdc7f11bfe45f673631928d8ae88283f23e6b6f5c4e84ab3f15d175b2d520e1bffe + languageName: node + linkType: hard + "slash@npm:^3.0.0": version: 3.0.0 resolution: "slash@npm:3.0.0"