From f47250c834e5da6f2cad6bb64057ab3a91c0520e Mon Sep 17 00:00:00 2001 From: "Navy.gif" Date: Fri, 29 Mar 2024 16:09:32 +0200 Subject: [PATCH] Overhaul how statistics are counted and stored, also made a stats command --- @types/DiscordClient.d.ts | 7 +- @types/MusicPlayer.d.ts | 13 ++- src/client/components/MusicLibrary.ts | 99 +++++++++++++++---- src/client/components/MusicPlayer.ts | 4 +- src/client/components/commands/Stats.ts | 46 +++++++++ .../components/observers/CommandHandler.ts | 16 +++ .../extensions/ExtendedCommandOption.ts | 19 ++++ src/interfaces/Command.ts | 5 +- 8 files changed, 181 insertions(+), 28 deletions(-) create mode 100644 src/client/components/commands/Stats.ts create mode 100644 src/client/extensions/ExtendedCommandOption.ts diff --git a/@types/DiscordClient.d.ts b/@types/DiscordClient.d.ts index db6e30f..5883e14 100644 --- a/@types/DiscordClient.d.ts +++ b/@types/DiscordClient.d.ts @@ -2,6 +2,7 @@ import { GatewayIntentBits } from 'discord.js'; import { MusicPlayerOptions } from './MusicPlayer.js'; import { If } from './Shared.js'; import { CommandOption, CommandOptionDefinition } from '@navy.gif/commandparser'; +import ExtendedCommandOption from '../src/client/extensions/ExtendedCommandOption.ts'; export type ClientOptions = { rootDir: string, @@ -27,9 +28,13 @@ export type ComponentOptions = { disabled?: boolean }; +export type ExtendedCommandOptionDefinition = { + restricted?: boolean +} & CommandOptionDefinition + export type CommandDefinition = { aliases?: string[], - options?: (CommandOptionDefinition | CommandOption)[], + options?: (ExtendedCommandOptionDefinition | ExtendedCommandOption)[], restricted?: boolean, dmOnly?: boolean, guildOnly?: boolean, diff --git a/@types/MusicPlayer.d.ts b/@types/MusicPlayer.d.ts index d308c45..e33d3fa 100644 --- a/@types/MusicPlayer.d.ts +++ b/@types/MusicPlayer.d.ts @@ -16,11 +16,14 @@ export type MusicIndexEntry = { album?: string, year?: number, genre: string[] - file: string, - stats: { - plays: number, - skips: number - } + file: string +} + +export type MusicStatsEntry = { + [key: string]: string | number, + name: string, + plays: number, + skips: number, } export type MusicQuery = { diff --git a/src/client/components/MusicLibrary.ts b/src/client/components/MusicLibrary.ts index b0268e4..6f7d554 100644 --- a/src/client/components/MusicLibrary.ts +++ b/src/client/components/MusicLibrary.ts @@ -7,26 +7,34 @@ import Util from '../../utilities/Util.js'; import Initialisable from '../../interfaces/Initialisable.js'; import DiscordClient from '../DiscordClient.js'; import { Collection } from 'discord.js'; -import { MusicIndexEntry, MusicQuery } from '../../../@types/MusicPlayer.js'; +import { MusicIndexEntry, MusicQuery, MusicStatsEntry } from '../../../@types/MusicPlayer.js'; import similarity from 'similarity'; import MusicDownloader from './MusicDownloader.js'; import MusicPlayerError from '../../errors/MusicPlayerError.js'; import { DownloaderResult } from '../../../@types/Downloader.js'; const linkReg = /(https?:\/\/(www\.)?)?(?([a-z0-9-]{1,63}\.)?([a-z0-9-]{1,63})(\.[a-z0-9-]{2,63})(\.[a-z0-9-]{2,63})?)(\/[^()\s]*)?/iu; +const defaultStats: MusicStatsEntry = { + name: '', + plays: 0, + skips: 0 +}; class MusicLibrary implements Initialisable { #path: string; #ready: boolean; - #index: Collection; #logger: LoggerClient; #currentId: number; #downloader: MusicDownloader; + #index: Collection; + #stats: Collection; + constructor (client: DiscordClient, libraryPath: string) { this.#path = libraryPath; this.#ready = false; this.#index = new Collection(); + this.#stats = new Collection(); this.#downloader = new MusicDownloader(client, path.join(libraryPath, '.downloads')); this.#logger = client.createLogger(this); this.#currentId = 0; @@ -42,12 +50,18 @@ class MusicLibrary implements Initialisable return this.#index.size; } + get stats () + { + return [ ...this.#stats.values() ]; + } + async initialise (): Promise { if (this.ready) return; this.#logger.info('Initialising music library'); - this.loadIndex(); + this.#loadIndex(); + this.#loadStats(); await this.#downloader.initialise(); await this.scanLibrary(); this.#ready = true; @@ -57,6 +71,7 @@ class MusicLibrary implements Initialisable stop (): void | Promise { this.#saveIndex(); + this.#saveStats(); } async download (keyword: string) @@ -127,7 +142,6 @@ class MusicLibrary implements Initialisable return 1; return -1; } - return 0; }); } @@ -142,20 +156,42 @@ class MusicLibrary implements Initialisable const entries = [ ...this.#index.values() ]; return Util.shuffle(entries).sort((a, b) => { - return a.stats.plays - b.stats.plays; + return (this.#stats.get(a.title)?.plays ?? 0) - (this.#stats.get(b.title)?.plays ?? 0); }); } - countPlay (fp: string) + countPlay (name: string) { - if (this.#index.has(fp)) - this.#index.get(fp)!.stats.plays++; + this.#countStat(name, 'plays'); } - countSkip (fp: string) + countSkip (name: string) { - if (this.#index.has(fp)) - this.#index.get(fp)!.stats.skips++; + this.#countStat(name, 'skip'); + } + + resetStats () + { + this.#logger.info('Resetting statistics'); + this.#stats.clear(); + this.#saveStats(); + } + + #countStat (name: string, stat: keyof MusicStatsEntry) + { + if (stat === 'name') + throw new Error('Invalid stat name'); + if (this.#stats.has(name)) + { + const entry = this.#stats.get(name)!; + (entry[stat] as number)++; + } + else + { + const entry = { ...defaultStats }; + (entry[stat] as number)++; + this.#stats.set(name, entry); + } } async scanLibrary (full = false) @@ -163,9 +199,16 @@ class MusicLibrary implements Initialisable this.#logger.info('Starting library scan'); const start = Date.now(); const filePaths = Util.readdirRecursive(this.#path); + this.#logger.debug(`${filePaths.length} files to go through`); 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'); + if (full) + { + this.#currentId = 0; + this.#index.clear(); + } + const initialSize = this.#index.size; let idx = 0; for (const fp of filePaths) @@ -190,12 +233,6 @@ class MusicLibrary implements Initialisable if (this.#index.has(fp) && !force) return; - if (force) - { - this.#currentId = 0; - this.#index.clear(); - } - // Expensive call let metadata = null; try @@ -232,12 +269,38 @@ class MusicLibrary implements Initialisable return entry; } - loadIndex () + #loadStats () + { + this.#logger.info('Loading stats cache'); + const statsPath = path.join(process.cwd(), 'cache', 'stats.json'); + if (!fs.existsSync(statsPath)) + return this.#logger.info('No stats file found'); + + const raw = fs.readFileSync(statsPath, { encoding: 'utf-8' }); + const parsed = JSON.parse(raw) as MusicStatsEntry[]; + this.#stats.clear(); + for (const entry of parsed) + this.#stats.set(entry.name, { ...defaultStats, ...entry }); + this.#logger.info(`Stats loaded with ${this.#stats.size} entries`); + } + + #saveStats () + { + this.#logger.info('Saving stats to file'); + const cachePath = path.join(process.cwd(), 'cache'); + if (!fs.existsSync(cachePath)) + fs.mkdirSync(cachePath); + const statsPath = path.join(cachePath, 'stats.json'); + fs.writeFileSync(statsPath, JSON.stringify([ ...this.#stats.values() ])); + } + + #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[]; this.#index.clear(); diff --git a/src/client/components/MusicPlayer.ts b/src/client/components/MusicPlayer.ts index 0b68b7d..e7031dc 100644 --- a/src/client/components/MusicPlayer.ts +++ b/src/client/components/MusicPlayer.ts @@ -135,7 +135,7 @@ class MusicPlayer implements Initialisable return; if (this.#player.state.status !== AudioPlayerStatus.Idle && this.#currentSong) - this.library.countSkip(this.#currentSong.file); + this.library.countSkip(this.#currentSong.title); let info: MusicIndexEntry | null = null; if (this.#queue.length) @@ -154,7 +154,7 @@ class MusicPlayer implements Initialisable this.#logger.info(`Now playing ${info.artist} - ${info.title}`); this.#player.play(this.#currentResource); - this.#library.countPlay(info.file); + this.#library.countPlay(info.title); this.#shuffleIdx++; if (this.#shuffleIdx === this.#shuffleList.length) diff --git a/src/client/components/commands/Stats.ts b/src/client/components/commands/Stats.ts new file mode 100644 index 0000000..deb9b53 --- /dev/null +++ b/src/client/components/commands/Stats.ts @@ -0,0 +1,46 @@ +import { CommandOpts, OptionType } from '@navy.gif/commandparser'; +import Command from '../../../interfaces/Command.js'; +import DiscordClient from '../../DiscordClient.js'; +import { Message } from 'discord.js'; +import { MusicStatsEntry } from '../../../../@types/MusicPlayer.js'; + +class StatsCommand extends Command +{ + constructor (client: DiscordClient) + { + super(client, { + name: 'stats', + description: 'Display statistics about the songs played', + options: [{ + name: 'reset', + type: OptionType.BOOLEAN, + defaultValue: true, + valueOptional: true, + flag: true, + restricted: true + }], + guildOnly: true + }); + } + + async execute (_message: Message, { args }: CommandOpts) + { + if (args.reset?.value) + this.client.musicPlayer.library.resetStats(); + const { stats } = this.client.musicPlayer.library; + const mostPlayed = stats.sort((a, b) => b.plays - a.plays).slice(0, 10); + const mostSkipped = stats.sort((a, b) => b.skips - a.skips).slice(0, 10); + + const mapper = (entry: MusicStatsEntry, pos: number) => + { + pos++; + const [ idx ] = this.client.musicPlayer.library.search({ title: entry.name }); + return `\t[${('00' + pos).slice(-2)}] **${idx.title}** by ${idx.artist}`; + }; + const top10Plays = mostPlayed.map(mapper).join('\n'); + const top10Skips = mostSkipped.map(mapper).join('\n'); + return `**Music statistics**\n\nMost played:\n${top10Plays}\n\nMost skipped:\n${top10Skips}`; + } +} + +export default StatsCommand; \ No newline at end of file diff --git a/src/client/components/observers/CommandHandler.ts b/src/client/components/observers/CommandHandler.ts index 43f8383..7b2efc3 100644 --- a/src/client/components/observers/CommandHandler.ts +++ b/src/client/components/observers/CommandHandler.ts @@ -10,6 +10,7 @@ import Command from '../../../interfaces/Command.js'; import CommandError from '../../../errors/CommandError.js'; import Util from '../../../utilities/Util.js'; import { stripIndents } from 'common-tags'; +import ExtendedCommandOption from '../../extensions/ExtendedCommandOption.js'; const allowedMentions = { repliedUser: false }; @@ -130,6 +131,21 @@ class CommandHandler extends Observer }); } + const restrictedArgs = Object.values(rest.args).filter((arg) => (arg as ExtendedCommandOption).restricted); + const insufficientPerms = (restrictedArgs.length && !this.client.isDeveloper(author)); + if (insufficientPerms) + { + this.logger.info(`${author.username} (${author.id}) ran into ${restrictedArgs.length} restricted arguments`); + return void message.reply({ + embeds: [{ + title: 'Restricted arguments', + description: `${Util.plural(restrictedArgs.length, 'Argument')} ${restrictedArgs.map(flag => flag?.name).join(', ')} are restricted`, + color: 0xd88c49 + }], + allowedMentions + }); + } + if (rest.globalFlags.help) return this.#showUsage(message, command as Command, rest); diff --git a/src/client/extensions/ExtendedCommandOption.ts b/src/client/extensions/ExtendedCommandOption.ts new file mode 100644 index 0000000..a8c9329 --- /dev/null +++ b/src/client/extensions/ExtendedCommandOption.ts @@ -0,0 +1,19 @@ +import { CommandOption } from '@navy.gif/commandparser'; +import { ExtendedCommandOptionDefinition } from '../../../@types/DiscordClient.js'; + +class ExtendedCommandOption extends CommandOption +{ + #restricted: boolean; + constructor (def: ExtendedCommandOptionDefinition | ExtendedCommandOption) + { + super(def); + this.#restricted = def.restricted ?? false; + } + + get restricted () + { + return this.#restricted; + } +} + +export default ExtendedCommandOption; \ No newline at end of file diff --git a/src/interfaces/Command.ts b/src/interfaces/Command.ts index 326be08..19110e8 100644 --- a/src/interfaces/Command.ts +++ b/src/interfaces/Command.ts @@ -5,6 +5,7 @@ import { Snowflake, User } from 'discord.js'; import DiscordClient from '../client/DiscordClient.js'; import { CommandDefinition } from '../../@types/DiscordClient.js'; import CommandError from '../errors/CommandError.js'; +import ExtendedCommandOption from '../client/extensions/ExtendedCommandOption.js'; abstract class Command extends Component implements ICommand { @@ -46,10 +47,10 @@ abstract class Command extends Component implements ICommand { for (const opt of def.options) { - if (opt instanceof CommandOption) + if (opt instanceof ExtendedCommandOption) this.#options.push(opt); else - this.#options.push(new CommandOption(opt)); + this.#options.push(new ExtendedCommandOption(opt)); } }