diff --git a/.gitignore b/.gitignore index be1e39e..afebcbd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules +cache build logs diff --git a/@types/MusicPlayer.d.ts b/@types/MusicPlayer.d.ts index 1dda903..7cbce91 100644 --- a/@types/MusicPlayer.d.ts +++ b/@types/MusicPlayer.d.ts @@ -7,4 +7,12 @@ type GuildConfig = { export type MusicPlayerOptions = { library: string, guilds: GuildConfig[] +} + +export type MusicIndexEntry = { + arist: string, + title: string, + album?: string, + year?: number, + file: string, } \ No newline at end of file diff --git a/package.json b/package.json index db9eb50..50ead3b 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "dotenv": "^16.4.5", "ffmpeg": "^0.0.4", "humanize-duration": "^3.31.0", + "music-metadata": "^7.14.0", "sodium-native": "^4.1.1", "typescript": "^5.4.3", "utf-8-validate": "^6.0.3", diff --git a/src/client/DiscordClient.ts b/src/client/DiscordClient.ts index 48f077e..8cf3567 100644 --- a/src/client/DiscordClient.ts +++ b/src/client/DiscordClient.ts @@ -95,7 +95,7 @@ class DiscordClient extends Client this.#built = true; this.emit('built'); - await this.#musicPlayer.initialise(); + this.#musicPlayer.initialise(); return this; } diff --git a/src/client/components/MusicLibrary.ts b/src/client/components/MusicLibrary.ts new file mode 100644 index 0000000..6b89c68 --- /dev/null +++ b/src/client/components/MusicLibrary.ts @@ -0,0 +1,133 @@ + +import path from 'node:path'; +import fs from 'node:fs'; +import { LoggerClient } from '@navy.gif/logger'; +import { parseFile } from 'music-metadata'; +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'; + + +class MusicLibrary implements Initialisable +{ + #path: string; + #ready: boolean; + #index: Collection; + #logger: LoggerClient; + constructor (client: DiscordClient, libraryPath: string) + { + this.#path = libraryPath; + this.#ready = false; + this.#index = new Collection(); + this.#logger = client.createLogger(this); + } + + get ready () + { + return this.#ready; + } + + get size () + { + return this.#index.size; + } + + async initialise (): Promise + { + if (this.ready) + return; + this.#logger.info('Initialising music library'); + this.loadIndex(); + await this.scanLibrary(); + this.#ready = true; + this.#logger.info(`Music library initialised with ${this.#index.size} entries`); + } + + stop (): void | Promise + { + throw new Error('Method not implemented.'); + } + + getRandom () + { + return this.#index.random(); + } + + getShufflePlaylist () + { + const entries = [ ...this.#index.values() ]; + return Util.shuffle(entries); + } + + async scanLibrary (full = false) + { + this.#logger.info('Starting library scan'); + const start = Date.now(); + const filePaths = Util.readdirRecursive(this.#path); + if (!this.#index.size) + this.#logger.info('No index built, performing first time scan. This may take some time depending on the size of your music library'); + + const initialSize = this.#index.size; + let idx = 0; + for (const fp of filePaths) + { + // Progress reporting + idx++; + if (idx % 50 === 0 || idx === filePaths.length) + this.#logger.info(`Scan progress: ${(idx / filePaths.length * 100).toFixed(2)}%`); + + // Skip already scanned files + if (this.#index.has(fp) && !full) + continue; + + // Expensive call + const metadata = await parseFile(fp, { skipCovers: true }); + const { common } = metadata; + // Fall back to file and folder name if artist or title not available + const segmets = fp.replace(this.#path, '').split(path.sep); + const artist = segmets[segmets.length - 2]; + const title = segmets[segmets.length - 1].replace((/\..+$/u), ''); + + const entry = { + arist: common.artist ?? artist, + title: common.title ?? title, + album: common.album, + year: common.year, + file: fp + }; + 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.saveIndex(); + return newFiles; + } + + loadIndex () + { + this.#logger.info('Loading index'); + const indexPath = path.join(process.cwd(), 'cache', 'musicIndex.json'); + if (!fs.existsSync(indexPath)) + return this.#logger.info('No index file found'); + const raw = fs.readFileSync(indexPath, { encoding: 'utf-8' }); + const parsed = JSON.parse(raw) as MusicIndexEntry[]; + for (const entry of parsed) + this.#index.set(entry.file, entry); + } + + saveIndex () + { + this.#logger.info('Saving index to file'); + const cachePath = path.join(process.cwd(), 'cache'); + if (!fs.existsSync(cachePath)) + fs.mkdirSync(cachePath); + const indexPath = path.join(cachePath, 'musicIndex.json'); + fs.writeFileSync(indexPath, JSON.stringify([ ...this.#index.values() ])); + } + +} + +export default MusicLibrary; \ No newline at end of file diff --git a/src/client/components/MusicPlayer.ts b/src/client/components/MusicPlayer.ts index 6313ca5..a299cbf 100644 --- a/src/client/components/MusicPlayer.ts +++ b/src/client/components/MusicPlayer.ts @@ -1,13 +1,11 @@ -import path from 'node:path'; - -import { ActivityType, ChannelType, Collection, GuildTextBasedChannel } from 'discord.js'; +import { ActivityType, ChannelType, Collection, Guild, GuildTextBasedChannel, VoiceChannel } from 'discord.js'; import { AudioPlayer, AudioPlayerStatus, AudioResource, PlayerSubscription, VoiceConnection, createAudioPlayer, createAudioResource, joinVoiceChannel } from '@discordjs/voice'; import { LoggerClient } from '@navy.gif/logger'; import Initialisable from '../../interfaces/Initialisable.js'; import DiscordClient from '../DiscordClient.js'; -import Util from '../../utilities/Util.js'; -import { MusicPlayerOptions } from '../../../@types/MusicPlayer.js'; +import { MusicIndexEntry, MusicPlayerOptions } from '../../../@types/MusicPlayer.js'; +import MusicLibrary from './MusicLibrary.js'; type ConnectionDetails = { subscription: PlayerSubscription, @@ -24,13 +22,13 @@ class MusicPlayer implements Initialisable #connections: Collection; #player: AudioPlayer; - - #library: string[]; - #currentIdx: number; #currentResource!: AudioResource; #options: MusicPlayerOptions; #volume: number; + #library: MusicLibrary; + #shuffleIdx: number; + #shuffleList: MusicIndexEntry[]; constructor (client: DiscordClient, options: MusicPlayerOptions) { @@ -42,10 +40,12 @@ class MusicPlayer implements Initialisable this.#player = createAudioPlayer(); this.#options = options; - this.#library = []; - this.#currentIdx = 0; this.#volume = 0.3; + this.#library = new MusicLibrary(client, this.#options.library); + this.#shuffleList = []; + this.#shuffleIdx = 0; + this.#player.on(AudioPlayerStatus.Idle, this.playNext.bind(this)); } @@ -54,28 +54,33 @@ class MusicPlayer implements Initialisable return this.#ready; } + get library () + { + return this.#library; + } + playNext () { - const uri = this.#library[this.#currentIdx]; - const segmets = uri.replace(this.#options.library, '').split(path.sep); - const artist = segmets[segmets.length - 2]; - const title = segmets[segmets.length - 1].replace((/\..+$/u), ''); - const display = `[${artist ?? 'Unknown'}] ${title}`; - this.#currentResource = createAudioResource(uri, { + const info = this.#shuffleList[this.#shuffleIdx]; + + this.#currentResource = createAudioResource(info.file, { inlineVolume: true, metadata: { - display - } }); + title: info.title + } + }); this.#currentResource.volume?.setVolume(this.#volume); - this.#logger.info(`Now playing ${display}`); + + this.#logger.info(`Now playing ${info.arist} - ${info.title}`); this.#player.play(this.#currentResource); - this.#currentIdx++; + this.#shuffleIdx++; this.#client.user?.setPresence({ activities: [{ - name: display, + name: `${info.arist} - ${info.title}`, type: ActivityType.Playing }] }); + const outputChannels = this.#connections.map(obj => obj.textOutput); outputChannels.forEach(async channel => { @@ -84,11 +89,17 @@ class MusicPlayer implements Initialisable const messages = await channel.messages.fetch({ limit: 100 }); const filtered = messages.filter(msg => msg.author.id === this.#client.user?.id); - await channel.send(`Now playing **${display}**`); - // await channel.bulkDelete(filtered); + await channel.send({ + embeds: [{ + title: 'Now playing :notes:', + description: `**${info.title}** by ${info.arist}`, + color: 0xffafff + }] + }); + for (const [ , msg ] of filtered) { - if (msg.deletable && msg.content.startsWith('Now playing')) + if (msg.deletable && (msg.embeds.length || msg.content.startsWith('Now playing'))) await msg.delete(); } }); @@ -117,10 +128,23 @@ class MusicPlayer implements Initialisable } } - initialise () + async initialise () { if (this.ready) return; + this.#logger.info('Initialising music player'); + + await this.#library.initialise(); + + this.#shuffleList = this.#library.getShufflePlaylist(); + + this.initialiseVoiceChannels(); + + this.playNext(); + } + + initialiseVoiceChannels () + { for (const config of this.#options.guilds) { const { id, voiceChannel, textOutput } = config; @@ -137,44 +161,38 @@ class MusicPlayer implements Initialisable continue; } - const connection = joinVoiceChannel({ - channelId: channel.id, - guildId: guild.id, - adapterCreator: guild.voiceAdapterCreator - }); - const subscription = connection.subscribe(this.#player); - if (!subscription) - { - connection.disconnect(); + if (!this.joinVoiceChannel(guild, channel, textOutput)) continue; - } - - let textChannel = null; - if (textOutput) - { - textChannel = guild.channels.resolve(textOutput); - if (!textChannel?.isTextBased()) - { - textChannel = null; - this.#logger.warn(`Invalid output channel given for guild ${guild.name}`); - } - } - this.#connections.set(guild.id, { connection, subscription, textOutput: textChannel }); - this.#logger.info(`Connected to voice in ${guild.name}`); } - - this.scanLibrary(); - this.playNext(); } - scanLibrary () + joinVoiceChannel (guild: Guild, channel: VoiceChannel, output?: string) { - const current = this.#library.length; - const library = Util.readdirRecursive(this.#options.library); - this.#logger.info(`Scanned music library, found ${library.length} tracks`); - this.#library = Util.shuffle(library); - const after = this.#library.length; - return [ after, after - current ]; + const connection = joinVoiceChannel({ + channelId: channel.id, + guildId: guild.id, + adapterCreator: guild.voiceAdapterCreator + }); + const subscription = connection.subscribe(this.#player); + if (!subscription) + { + connection.disconnect(); + return false; + } + + let textChannel = null; + if (output) + { + textChannel = guild.channels.resolve(output); + if (!textChannel?.isTextBased()) + { + textChannel = null; + this.#logger.warn(`Invalid output channel given for guild ${guild.name}`); + } + } + this.#connections.set(guild.id, { connection, subscription, textOutput: textChannel }); + this.#logger.info(`Connected to voice in ${guild.name}`); + return true; } } diff --git a/src/client/components/commands/Rescan.ts b/src/client/components/commands/Rescan.ts index e3d77de..f89ba4c 100644 --- a/src/client/components/commands/Rescan.ts +++ b/src/client/components/commands/Rescan.ts @@ -13,7 +13,8 @@ class PingCommand extends Command async execute () { - const [ songs, diff ] = this.client.musicPlayer.scanLibrary(); + const diff = 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/observers/CommandHandler.ts b/src/client/components/observers/CommandHandler.ts index 5e2adcf..565f0f4 100644 --- a/src/client/components/observers/CommandHandler.ts +++ b/src/client/components/observers/CommandHandler.ts @@ -29,7 +29,7 @@ class CommandHandler extends Observer commands: this.client.commands.values(), prefix: client.prefix, resolver: client.resolver, - debug: true + debug: false }); this.#parser.on('debug', (str: string) => this.logger.debug(str)); diff --git a/src/utilities/Util.ts b/src/utilities/Util.ts index 1420606..693f96c 100644 --- a/src/utilities/Util.ts +++ b/src/utilities/Util.ts @@ -559,6 +559,16 @@ class Util return null; } + + /** + * Shuffles array in place + * @date 3/25/2024 - 11:25:09 AM + * + * @static + * @template T + * @param {T[]} array + * @returns {T[]} + */ static shuffle (array: T[]): T[] { let current = array.length; diff --git a/yarn.lock b/yarn.lock index 692b70b..77d74ed 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1749,6 +1749,13 @@ __metadata: languageName: node linkType: hard +"@tokenizer/token@npm:^0.3.0": + version: 0.3.0 + resolution: "@tokenizer/token@npm:0.3.0" + checksum: 10/889c1f1e63ac7c92c0ea22d4a2861142f1b43c3d92eb70ec42aa9e9851fab2e9952211d50f541b287781280df2f979bf5600a9c1f91fbc61b7fcf9994e9376a5 + languageName: node + linkType: hard + "@types/babel__core@npm:^7": version: 7.20.5 resolution: "@types/babel__core@npm:7.20.5" @@ -2360,6 +2367,13 @@ __metadata: languageName: node linkType: hard +"content-type@npm:^1.0.5": + version: 1.0.5 + resolution: "content-type@npm:1.0.5" + checksum: 10/585847d98dc7fb8035c02ae2cb76c7a9bd7b25f84c447e5ed55c45c2175e83617c8813871b4ee22f368126af6b2b167df655829007b21aa10302873ea9c62662 + languageName: node + linkType: hard + "convert-source-map@npm:^2.0.0": version: 2.0.0 resolution: "convert-source-map@npm:2.0.0" @@ -2722,6 +2736,17 @@ __metadata: languageName: node linkType: hard +"file-type@npm:^16.5.4": + version: 16.5.4 + resolution: "file-type@npm:16.5.4" + dependencies: + readable-web-to-node-stream: "npm:^3.0.0" + strtok3: "npm:^6.2.4" + token-types: "npm:^4.1.1" + checksum: 10/46ced46bb925ab547e0a6d43108a26d043619d234cb0588d7abce7b578dafac142bcfd2e23a6adb0a4faa4b951bd1b14b355134a193362e07cd352f9bf0dc349 + languageName: node + linkType: hard + "fill-range@npm:^7.0.1": version: 7.0.1 resolution: "fill-range@npm:7.0.1" @@ -2999,6 +3024,13 @@ __metadata: languageName: node linkType: hard +"ieee754@npm:^1.2.1": + version: 1.2.1 + resolution: "ieee754@npm:1.2.1" + checksum: 10/d9f2557a59036f16c282aaeb107832dc957a93d73397d89bbad4eb1130560560eb695060145e8e6b3b498b15ab95510226649a0b8f52ae06583575419fe10fc4 + languageName: node + linkType: hard + "ignore@npm:^5.2.0, ignore@npm:^5.2.4": version: 5.3.1 resolution: "ignore@npm:5.3.1" @@ -3326,6 +3358,13 @@ __metadata: languageName: node linkType: hard +"media-typer@npm:^1.1.0": + version: 1.1.0 + resolution: "media-typer@npm:1.1.0" + checksum: 10/a58dd60804df73c672942a7253ccc06815612326dc1c0827984b1a21704466d7cde351394f47649e56cf7415e6ee2e26e000e81b51b3eebb5a93540e8bf93cbd + languageName: node + linkType: hard + "merge2@npm:^1.3.0, merge2@npm:^1.4.1": version: 1.4.1 resolution: "merge2@npm:1.4.1" @@ -3468,6 +3507,21 @@ __metadata: languageName: node linkType: hard +"music-metadata@npm:^7.14.0": + version: 7.14.0 + resolution: "music-metadata@npm:7.14.0" + dependencies: + "@tokenizer/token": "npm:^0.3.0" + content-type: "npm:^1.0.5" + debug: "npm:^4.3.4" + file-type: "npm:^16.5.4" + media-typer: "npm:^1.1.0" + strtok3: "npm:^6.3.0" + token-types: "npm:^4.2.1" + checksum: 10/b6fbfb874e06a540439a8ed67e69c0a73866085e5a1304d5f3808de5b3f912132add13007fc2892114da6a7f72300a54821cc8fc5ae0383910b2a0d2ced61074 + languageName: node + linkType: hard + "nan@npm:^2.18.0": version: 2.19.0 resolution: "nan@npm:2.19.0" @@ -3508,6 +3562,7 @@ __metadata: eslint: "npm:^8.57.0" ffmpeg: "npm:^0.0.4" humanize-duration: "npm:^3.31.0" + music-metadata: "npm:^7.14.0" sodium-native: "npm:^4.1.1" typescript: "npm:^5.4.3" utf-8-validate: "npm:^6.0.3" @@ -3728,6 +3783,13 @@ __metadata: languageName: node linkType: hard +"peek-readable@npm:^4.1.0": + version: 4.1.0 + resolution: "peek-readable@npm:4.1.0" + checksum: 10/97373215dcf382748645c3d22ac5e8dbd31759f7bd0c539d9fdbaaa7d22021838be3e55110ad0ed8f241c489342304b14a50dfee7ef3bcee2987d003b24ecc41 + languageName: node + linkType: hard + "picocolors@npm:^1.0.0": version: 1.0.0 resolution: "picocolors@npm:1.0.0" @@ -3812,6 +3874,15 @@ __metadata: languageName: node linkType: hard +"readable-web-to-node-stream@npm:^3.0.0": + version: 3.0.2 + resolution: "readable-web-to-node-stream@npm:3.0.2" + dependencies: + readable-stream: "npm:^3.6.0" + checksum: 10/d3a5bf9d707c01183d546a64864aa63df4d9cb835dfd2bf89ac8305e17389feef2170c4c14415a10d38f9b9bfddf829a57aaef7c53c8b40f11d499844bf8f1a4 + languageName: node + linkType: hard + "regenerate-unicode-properties@npm:^10.1.0": version: 10.1.1 resolution: "regenerate-unicode-properties@npm:10.1.1" @@ -4124,6 +4195,16 @@ __metadata: languageName: node linkType: hard +"strtok3@npm:^6.2.4, strtok3@npm:^6.3.0": + version: 6.3.0 + resolution: "strtok3@npm:6.3.0" + dependencies: + "@tokenizer/token": "npm:^0.3.0" + peek-readable: "npm:^4.1.0" + checksum: 10/98fba564d3830202aa3a6bcd5ccaf2cbd849bd87ae79ece91d337e1913916705a8e633c9577138d030a984f8ec987dea51807e01252f995cf5e183fdea35eb2b + languageName: node + linkType: hard + "supports-color@npm:^5.3.0": version: 5.5.0 resolution: "supports-color@npm:5.5.0" @@ -4186,6 +4267,16 @@ __metadata: languageName: node linkType: hard +"token-types@npm:^4.1.1, token-types@npm:^4.2.1": + version: 4.2.1 + resolution: "token-types@npm:4.2.1" + dependencies: + "@tokenizer/token": "npm:^0.3.0" + ieee754: "npm:^1.2.1" + checksum: 10/2995257d246387e773758c3c92a3cc99d0c0bf13cbafe0de5d712e4c35ed298da6704e21545cb123fa1f1b42ad62936c35bbd0611018b735e78c30b8b22b42d9 + languageName: node + linkType: hard + "tr46@npm:~0.0.3": version: 0.0.3 resolution: "tr46@npm:0.0.3"