Merge branch 'master' into feature/slash-commands

This commit is contained in:
Max Isom 2021-12-21 21:14:59 -06:00
commit c20e3b1760
No known key found for this signature in database
GPG key ID: 25C9B1A7F6798880
11 changed files with 973 additions and 195 deletions

View file

@ -10,6 +10,7 @@ import PlayerManager from '../managers/player.js';
import {getMostPopularVoiceChannel, getMemberVoiceChannel} from '../utils/channels.js';
import errorMsg from '../utils/error-msg.js';
import GetSongs from '../services/get-songs.js';
import Settings from '../models/settings.js';
@injectable()
export default class implements Command {
@ -38,6 +39,9 @@ export default class implements Command {
public async executeFromInteraction(interaction: CommandInteraction): Promise<void> {
const [targetVoiceChannel] = getMemberVoiceChannel(interaction.member as GuildMember) ?? getMostPopularVoiceChannel(interaction.guild!);
const settings = await Settings.findByPk(interaction.guild!.id);
const {playlistLimit} = settings!;
const player = this.playerManager.get(interaction.guild!.id);
const wasPlayingSong = player.getCurrent() !== null;
@ -98,13 +102,13 @@ export default class implements Command {
}
}
} else if (url.protocol === 'spotify:' || url.host === 'open.spotify.com') {
const [convertedSongs, nSongsNotFound, totalSongs] = await this.getSongs.spotifySource(query);
const [convertedSongs, nSongsNotFound, totalSongs] = await this.getSongs.spotifySource(query, playlistLimit);
if (totalSongs > 50) {
extraMsg = 'a random sample of 50 songs was taken';
if (totalSongs > playlistLimit) {
extraMsg = `a random sample of ${playlistLimit} songs was taken`;
}
if (totalSongs > 50 && nSongsNotFound !== 0) {
if (totalSongs > playlistLimit && nSongsNotFound !== 0) {
extraMsg += ' and ';
}

View file

@ -15,4 +15,8 @@ export default class Settings extends Model {
@Default(false)
@Column
finishedSetup!: boolean;
@Default(50)
@Column
playlistLimit!: number;
}

View file

@ -5,9 +5,12 @@ import sequelize from 'sequelize';
import {FileCache} from '../models/index.js';
import {TYPES} from '../types.js';
import Config from './config.js';
import PQueue from 'p-queue';
import debug from '../utils/debug.js';
@injectable()
export default class FileCacheProvider {
private static readonly evictionQueue = new PQueue({concurrency: 1});
private readonly config: Config;
constructor(@inject(TYPES.Config) config: Config) {
@ -58,10 +61,14 @@ export default class FileCacheProvider {
const stats = await fs.stat(tmpPath);
if (stats.size !== 0) {
await fs.rename(tmpPath, finalPath);
}
try {
await fs.rename(tmpPath, finalPath);
await FileCache.create({hash, bytes: stats.size, accessedAt: new Date()});
await FileCache.create({hash, bytes: stats.size, accessedAt: new Date()});
} catch (error) {
debug('Errored when moving a finished cache file:', error);
}
}
await this.evictOldestIfNecessary();
});
@ -80,13 +87,19 @@ export default class FileCacheProvider {
}
private async evictOldestIfNecessary() {
const [{dataValues: {totalSizeBytes}}] = await FileCache.findAll({
attributes: [
[sequelize.fn('sum', sequelize.col('bytes')), 'totalSizeBytes'],
],
}) as unknown as [{dataValues: {totalSizeBytes: number}}];
void FileCacheProvider.evictionQueue.add(this.evictOldest.bind(this));
if (totalSizeBytes > this.config.CACHE_LIMIT_IN_BYTES) {
return FileCacheProvider.evictionQueue.onEmpty();
}
private async evictOldest() {
debug('Evicting oldest files...');
let totalSizeBytes = await this.getDiskUsageInBytes();
let numOfEvictedFiles = 0;
// Continue to evict until we're under the limit
/* eslint-disable no-await-in-loop */
while (totalSizeBytes > this.config.CACHE_LIMIT_IN_BYTES) {
const oldest = await FileCache.findOne({
order: [
['accessedAt', 'ASC'],
@ -96,22 +109,111 @@ export default class FileCacheProvider {
if (oldest) {
await oldest.destroy();
await fs.unlink(path.join(this.config.CACHE_DIR, oldest.hash));
debug(`${oldest.hash} has been evicted`);
numOfEvictedFiles++;
}
// Continue to evict until we're under the limit
await this.evictOldestIfNecessary();
totalSizeBytes = await this.getDiskUsageInBytes();
}
/* eslint-enable no-await-in-loop */
if (numOfEvictedFiles > 0) {
debug(`${numOfEvictedFiles} files have been evicted`);
} else {
debug(`No files needed to be evicted. Total size of the cache is currently ${totalSizeBytes} bytes, and the cache limit is ${this.config.CACHE_LIMIT_IN_BYTES} bytes.`);
}
}
private async removeOrphans() {
// Check filesystem direction (do files exist on the disk but not in the database?)
for await (const dirent of await fs.opendir(this.config.CACHE_DIR)) {
if (dirent.isFile()) {
const model = await FileCache.findByPk(dirent.name);
if (!model) {
debug(`${dirent.name} was present on disk but was not in the database. Removing from disk.`);
await fs.unlink(path.join(this.config.CACHE_DIR, dirent.name));
}
}
}
// Check database direction (do entries exist in the database but not on the disk?)
for await (const model of this.getFindAllIterable()) {
const filePath = path.join(this.config.CACHE_DIR, model.hash);
try {
await fs.access(filePath);
} catch {
debug(`${model.hash} was present in database but was not on disk. Removing from database.`);
await model.destroy();
}
}
}
/**
* Pulls from the database rather than the filesystem,
* so may be slightly inaccurate.
* @returns the total size of the cache in bytes
*/
private async getDiskUsageInBytes() {
const [{dataValues: {totalSizeBytes}}] = await FileCache.findAll({
attributes: [
[sequelize.fn('sum', sequelize.col('bytes')), 'totalSizeBytes'],
],
}) as unknown as [{dataValues: {totalSizeBytes: number}}];
return totalSizeBytes;
}
/**
* An efficient way to iterate over all rows.
* @returns an iterable for the result of FileCache.findAll()
*/
private getFindAllIterable() {
const limit = 50;
let previousCreatedAt: Date | null = null;
let models: FileCache[] = [];
const fetchNextBatch = async () => {
let where = {};
if (previousCreatedAt) {
where = {
createdAt: {
[sequelize.Op.gt]: previousCreatedAt,
},
};
}
models = await FileCache.findAll({
where,
limit,
order: ['createdAt'],
});
if (models.length > 0) {
previousCreatedAt = models[models.length - 1].createdAt as Date;
}
};
return {
[Symbol.asyncIterator]() {
return {
async next() {
if (models.length === 0) {
await fetchNextBatch();
}
if (models.length === 0) {
// Must return value here for types to be inferred correctly
return {done: true, value: null as unknown as FileCache};
}
return {value: models.shift()!, done: false};
},
};
},
};
}
}

View file

@ -188,7 +188,7 @@ export default class {
return songsToReturn;
}
async spotifySource(url: string): Promise<[QueuedSongWithoutChannel[], number, number]> {
async spotifySource(url: string, playlistLimit: number): Promise<[QueuedSongWithoutChannel[], number, number]> {
const parsed = spotifyURI.parse(url);
let tracks: SpotifyApi.TrackObjectSimplified[] = [];
@ -252,13 +252,13 @@ export default class {
}
}
// Get 50 random songs if many
// Get random songs if the playlist is larger than limit
const originalNSongs = tracks.length;
if (tracks.length > 50) {
if (tracks.length > playlistLimit) {
const shuffled = shuffle(tracks);
tracks = shuffled.slice(0, 50);
tracks = shuffled.slice(0, playlistLimit);
}
let songs = await Promise.all(tracks.map(async track => this.spotifyToYouTube(track, playlist)));