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 { 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,
|
||||||
|
13
@types/MusicPlayer.d.ts
vendored
13
@types/MusicPlayer.d.ts
vendored
@ -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 = {
|
||||||
|
@ -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();
|
||||||
|
@ -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)
|
||||||
|
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 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);
|
||||||
|
|
||||||
|
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 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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user