Overhaul how statistics are counted and stored, also made a stats command

This commit is contained in:
Erik 2024-03-29 16:09:32 +02:00
parent eb08573bdd
commit f47250c834
8 changed files with 181 additions and 28 deletions

View File

@ -2,6 +2,7 @@ import { GatewayIntentBits } from 'discord.js';
import { MusicPlayerOptions } from './MusicPlayer.js'; import { MusicPlayerOptions } from './MusicPlayer.js';
import { If } from './Shared.js'; import { If } from './Shared.js';
import { CommandOption, CommandOptionDefinition } from '@navy.gif/commandparser'; import { CommandOption, CommandOptionDefinition } from '@navy.gif/commandparser';
import ExtendedCommandOption from '../src/client/extensions/ExtendedCommandOption.ts';
export type ClientOptions = { export type ClientOptions = {
rootDir: string, rootDir: string,
@ -27,9 +28,13 @@ export type ComponentOptions = {
disabled?: boolean disabled?: boolean
}; };
export type ExtendedCommandOptionDefinition = {
restricted?: boolean
} & CommandOptionDefinition
export type CommandDefinition = { export type CommandDefinition = {
aliases?: string[], aliases?: string[],
options?: (CommandOptionDefinition | CommandOption)[], options?: (ExtendedCommandOptionDefinition | ExtendedCommandOption)[],
restricted?: boolean, restricted?: boolean,
dmOnly?: boolean, dmOnly?: boolean,
guildOnly?: boolean, guildOnly?: boolean,

View File

@ -16,11 +16,14 @@ export type MusicIndexEntry = {
album?: string, album?: string,
year?: number, year?: number,
genre: string[] genre: string[]
file: string, file: string
stats: {
plays: number,
skips: number
} }
export type MusicStatsEntry = {
[key: string]: string | number,
name: string,
plays: number,
skips: number,
} }
export type MusicQuery = { export type MusicQuery = {

View File

@ -7,26 +7,34 @@ import Util from '../../utilities/Util.js';
import Initialisable from '../../interfaces/Initialisable.js'; import Initialisable from '../../interfaces/Initialisable.js';
import DiscordClient from '../DiscordClient.js'; import DiscordClient from '../DiscordClient.js';
import { Collection } from 'discord.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 similarity from 'similarity';
import MusicDownloader from './MusicDownloader.js'; import MusicDownloader from './MusicDownloader.js';
import MusicPlayerError from '../../errors/MusicPlayerError.js'; import MusicPlayerError from '../../errors/MusicPlayerError.js';
import { DownloaderResult } from '../../../@types/Downloader.js'; import { DownloaderResult } from '../../../@types/Downloader.js';
const linkReg = /(https?:\/\/(www\.)?)?(?<domain>([a-z0-9-]{1,63}\.)?([a-z0-9-]{1,63})(\.[a-z0-9-]{2,63})(\.[a-z0-9-]{2,63})?)(\/[^()\s]*)?/iu; const linkReg = /(https?:\/\/(www\.)?)?(?<domain>([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 class MusicLibrary implements Initialisable
{ {
#path: string; #path: string;
#ready: boolean; #ready: boolean;
#index: Collection<string, MusicIndexEntry>;
#logger: LoggerClient; #logger: LoggerClient;
#currentId: number; #currentId: number;
#downloader: MusicDownloader; #downloader: MusicDownloader;
#index: Collection<string, MusicIndexEntry>;
#stats: Collection<string, MusicStatsEntry>;
constructor (client: DiscordClient, libraryPath: string) constructor (client: DiscordClient, libraryPath: string)
{ {
this.#path = libraryPath; this.#path = libraryPath;
this.#ready = false; this.#ready = false;
this.#index = new Collection(); this.#index = new Collection();
this.#stats = new Collection();
this.#downloader = new MusicDownloader(client, path.join(libraryPath, '.downloads')); this.#downloader = new MusicDownloader(client, path.join(libraryPath, '.downloads'));
this.#logger = client.createLogger(this); this.#logger = client.createLogger(this);
this.#currentId = 0; this.#currentId = 0;
@ -42,12 +50,18 @@ class MusicLibrary implements Initialisable
return this.#index.size; return this.#index.size;
} }
get stats ()
{
return [ ...this.#stats.values() ];
}
async initialise (): Promise<void> async initialise (): Promise<void>
{ {
if (this.ready) if (this.ready)
return; return;
this.#logger.info('Initialising music library'); this.#logger.info('Initialising music library');
this.loadIndex(); this.#loadIndex();
this.#loadStats();
await this.#downloader.initialise(); await this.#downloader.initialise();
await this.scanLibrary(); await this.scanLibrary();
this.#ready = true; this.#ready = true;
@ -57,6 +71,7 @@ class MusicLibrary implements Initialisable
stop (): void | Promise<void> stop (): void | Promise<void>
{ {
this.#saveIndex(); this.#saveIndex();
this.#saveStats();
} }
async download (keyword: string) async download (keyword: string)
@ -127,7 +142,6 @@ class MusicLibrary implements Initialisable
return 1; return 1;
return -1; return -1;
} }
return 0; return 0;
}); });
} }
@ -142,20 +156,42 @@ class MusicLibrary implements Initialisable
const entries = [ ...this.#index.values() ]; const entries = [ ...this.#index.values() ];
return Util.shuffle(entries).sort((a, b) => 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.#countStat(name, 'plays');
this.#index.get(fp)!.stats.plays++;
} }
countSkip (fp: string) countSkip (name: string)
{ {
if (this.#index.has(fp)) this.#countStat(name, 'skip');
this.#index.get(fp)!.stats.skips++; }
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) async scanLibrary (full = false)
@ -163,9 +199,16 @@ class MusicLibrary implements Initialisable
this.#logger.info('Starting library scan'); this.#logger.info('Starting library scan');
const start = Date.now(); const start = Date.now();
const filePaths = Util.readdirRecursive(this.#path); const filePaths = Util.readdirRecursive(this.#path);
this.#logger.debug(`${filePaths.length} files to go through`);
if (!this.#index.size) 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'); 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; const initialSize = this.#index.size;
let idx = 0; let idx = 0;
for (const fp of filePaths) for (const fp of filePaths)
@ -190,12 +233,6 @@ class MusicLibrary implements Initialisable
if (this.#index.has(fp) && !force) if (this.#index.has(fp) && !force)
return; return;
if (force)
{
this.#currentId = 0;
this.#index.clear();
}
// Expensive call // Expensive call
let metadata = null; let metadata = null;
try try
@ -232,12 +269,38 @@ class MusicLibrary implements Initialisable
return entry; 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'); this.#logger.info('Loading index');
const indexPath = path.join(process.cwd(), 'cache', 'musicIndex.json'); const indexPath = path.join(process.cwd(), 'cache', 'musicIndex.json');
if (!fs.existsSync(indexPath)) if (!fs.existsSync(indexPath))
return this.#logger.info('No index file found'); return this.#logger.info('No index file found');
const raw = fs.readFileSync(indexPath, { encoding: 'utf-8' }); const raw = fs.readFileSync(indexPath, { encoding: 'utf-8' });
const parsed = JSON.parse(raw) as MusicIndexEntry[]; const parsed = JSON.parse(raw) as MusicIndexEntry[];
this.#index.clear(); this.#index.clear();

View File

@ -135,7 +135,7 @@ class MusicPlayer implements Initialisable
return; return;
if (this.#player.state.status !== AudioPlayerStatus.Idle && this.#currentSong) 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; let info: MusicIndexEntry | null = null;
if (this.#queue.length) if (this.#queue.length)
@ -154,7 +154,7 @@ class MusicPlayer implements Initialisable
this.#logger.info(`Now playing ${info.artist} - ${info.title}`); this.#logger.info(`Now playing ${info.artist} - ${info.title}`);
this.#player.play(this.#currentResource); this.#player.play(this.#currentResource);
this.#library.countPlay(info.file); this.#library.countPlay(info.title);
this.#shuffleIdx++; this.#shuffleIdx++;
if (this.#shuffleIdx === this.#shuffleList.length) if (this.#shuffleIdx === this.#shuffleList.length)

View File

@ -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;

View File

@ -10,6 +10,7 @@ import Command from '../../../interfaces/Command.js';
import CommandError from '../../../errors/CommandError.js'; import CommandError from '../../../errors/CommandError.js';
import Util from '../../../utilities/Util.js'; import Util from '../../../utilities/Util.js';
import { stripIndents } from 'common-tags'; import { stripIndents } from 'common-tags';
import ExtendedCommandOption from '../../extensions/ExtendedCommandOption.js';
const allowedMentions = { repliedUser: false }; 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) if (rest.globalFlags.help)
return this.#showUsage(message, command as Command, rest); return this.#showUsage(message, command as Command, rest);

View File

@ -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;

View File

@ -5,6 +5,7 @@ import { Snowflake, User } from 'discord.js';
import DiscordClient from '../client/DiscordClient.js'; import DiscordClient from '../client/DiscordClient.js';
import { CommandDefinition } from '../../@types/DiscordClient.js'; import { CommandDefinition } from '../../@types/DiscordClient.js';
import CommandError from '../errors/CommandError.js'; import CommandError from '../errors/CommandError.js';
import ExtendedCommandOption from '../client/extensions/ExtendedCommandOption.js';
abstract class Command extends Component implements ICommand abstract class Command extends Component implements ICommand
{ {
@ -46,10 +47,10 @@ abstract class Command extends Component implements ICommand
{ {
for (const opt of def.options) for (const opt of def.options)
{ {
if (opt instanceof CommandOption) if (opt instanceof ExtendedCommandOption)
this.#options.push(opt); this.#options.push(opt);
else else
this.#options.push(new CommandOption(opt)); this.#options.push(new ExtendedCommandOption(opt));
} }
} }