Overhaul how statistics are counted and stored, also made a stats command
This commit is contained in:
parent
eb08573bdd
commit
f47250c834
7
@types/DiscordClient.d.ts
vendored
7
@types/DiscordClient.d.ts
vendored
@ -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,
|
||||
|
11
@types/MusicPlayer.d.ts
vendored
11
@types/MusicPlayer.d.ts
vendored
@ -16,11 +16,14 @@ export type MusicIndexEntry = {
|
||||
album?: string,
|
||||
year?: number,
|
||||
genre: string[]
|
||||
file: string,
|
||||
stats: {
|
||||
file: string
|
||||
}
|
||||
|
||||
export type MusicStatsEntry = {
|
||||
[key: string]: string | number,
|
||||
name: string,
|
||||
plays: number,
|
||||
skips: number
|
||||
}
|
||||
skips: number,
|
||||
}
|
||||
|
||||
export type MusicQuery = {
|
||||
|
@ -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\.)?)?(?<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
|
||||
{
|
||||
#path: string;
|
||||
#ready: boolean;
|
||||
#index: Collection<string, MusicIndexEntry>;
|
||||
#logger: LoggerClient;
|
||||
#currentId: number;
|
||||
#downloader: MusicDownloader;
|
||||
#index: Collection<string, MusicIndexEntry>;
|
||||
#stats: Collection<string, MusicStatsEntry>;
|
||||
|
||||
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<void>
|
||||
{
|
||||
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<void>
|
||||
{
|
||||
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();
|
||||
|
@ -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)
|
||||
|
46
src/client/components/commands/Stats.ts
Normal file
46
src/client/components/commands/Stats.ts
Normal 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;
|
@ -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);
|
||||
|
||||
|
19
src/client/extensions/ExtendedCommandOption.ts
Normal file
19
src/client/extensions/ExtendedCommandOption.ts
Normal 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;
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user