diff --git a/CHANGELOG.md b/CHANGELOG.md index 52a21ec..56e7687 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [2.3.0] - 2023-05-13 +### Added +- Muse now normalizes playback volume across tracks. Thanks to @UniversalSuperBox for sponsoring this feature! + +### Fixed +- Fixed a bug where tracks wouldn't be cached + +## [2.2.4] - 2023-04-17 +### Fixed +- Bumped ytdl-core + ## [2.2.3] - 2023-04-04 - Updated ytsr dependency to fix (reading 'reelPlayerHeaderRenderer') error ## [2.2.2] - 2023-03-18 @@ -231,7 +242,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Initial release -[unreleased]: https://github.com/codetheweb/muse/compare/v2.2.3...HEAD +[unreleased]: https://github.com/codetheweb/muse/compare/v2.3.0...HEAD +[2.3.0]: https://github.com/codetheweb/muse/compare/v2.2.4...v2.3.0 +[2.2.4]: https://github.com/codetheweb/muse/compare/v2.2.3...v2.2.4 [2.2.3]: https://github.com/codetheweb/muse/compare/v2.2.2...v2.2.3 [2.2.2]: https://github.com/codetheweb/muse/compare/v2.2.1...v2.2.2 [2.2.1]: https://github.com/codetheweb/muse/compare/v2.2.0...v2.2.1 diff --git a/README.md b/README.md index 1300a07..d474a53 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ Muse is a **highly-opinionated midwestern self-hosted** Discord music bot **that - ↔️ Autoconverts playlists / artists / albums / songs from Spotify - ↗️ Users can add custom shortcuts (aliases) - 1️⃣ Muse instance supports multiple guilds +- 🔊 Normalizes volume across tracks - ✍️ Written in TypeScript, easily extendable - ❤️ Loyal Packers fan diff --git a/package.json b/package.json index 8dbba98..acbe61c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "muse", - "version": "2.2.3", + "version": "2.3.0", "description": "🎧 a self-hosted Discord music bot that doesn't suck ", "repository": "git@github.com:codetheweb/muse.git", "author": "Max Isom ", @@ -111,7 +111,7 @@ "sync-fetch": "^0.3.1", "tsx": "3.8.2", "xbytes": "^1.7.0", - "ytdl-core": "^4.11.2", - "ytsr": "^3.8.1" + "ytdl-core": "^4.11.4", + "ytsr": "^3.8.2" } } diff --git a/src/services/file-cache.ts b/src/services/file-cache.ts index 98a079c..3906772 100644 --- a/src/services/file-cache.ts +++ b/src/services/file-cache.ts @@ -18,11 +18,11 @@ export default class FileCacheProvider { } /** - * Returns path to cached file if it exists, otherwise throws an error. + * Returns path to cached file if it exists, otherwise returns null. * Updates the `accessedAt` property of the cached file. * @param hash lookup key */ - async getPathFor(hash: string): Promise { + async getPathFor(hash: string): Promise { const model = await prisma.fileCache.findUnique({ where: { hash, @@ -30,7 +30,7 @@ export default class FileCacheProvider { }); if (!model) { - throw new Error('File is not cached'); + return null; } const resolvedPath = path.join(this.config.CACHE_DIR, hash); @@ -44,7 +44,7 @@ export default class FileCacheProvider { }, }); - throw new Error('File is not cached'); + return null; } await prisma.fileCache.update({ @@ -76,19 +76,15 @@ export default class FileCacheProvider { const stats = await fs.stat(tmpPath); if (stats.size !== 0) { - try { - await fs.rename(tmpPath, finalPath); + await fs.rename(tmpPath, finalPath); - await prisma.fileCache.create({ - data: { - hash, - accessedAt: new Date(), - bytes: stats.size, - }, - }); - } catch (error) { - debug('Errored when moving a finished cache file:', error); - } + await prisma.fileCache.create({ + data: { + hash, + accessedAt: new Date(), + bytes: stats.size, + }, + }); } await this.evictOldestIfNecessary(); diff --git a/src/services/player.ts b/src/services/player.ts index 9c1a122..c888896 100644 --- a/src/services/player.ts +++ b/src/services/player.ts @@ -1,7 +1,7 @@ import {VoiceChannel, Snowflake} from 'discord.js'; import {Readable} from 'stream'; import hasha from 'hasha'; -import ytdl from 'ytdl-core'; +import ytdl, {videoFormat} from 'ytdl-core'; import {WriteStream} from 'fs-capacitor'; import ffmpeg from 'fluent-ffmpeg'; import shuffle from 'array-shuffle'; @@ -56,6 +56,8 @@ export interface PlayerEvents { statusChange: (oldStatus: STATUS, newStatus: STATUS) => void; } +type YTDLVideoFormat = videoFormat & {loudnessDb?: number}; + export default class { public voiceConnection: VoiceConnection | null = null; public status = STATUS.PAUSED; @@ -408,30 +410,22 @@ export default class { private async getStream(song: QueuedSong, options: {seek?: number; to?: number} = {}): Promise { if (song.source === MediaSource.HLS) { - return this.createReadStream(song.url); + return this.createReadStream({url: song.url, cacheKey: song.url}); } - let ffmpegInput = ''; + let ffmpegInput: string | null; const ffmpegInputOptions: string[] = []; let shouldCacheVideo = false; - let format: ytdl.videoFormat | undefined; + let format: YTDLVideoFormat | undefined; - try { - ffmpegInput = await this.fileCache.getPathFor(this.getHashForCache(song.url)); + ffmpegInput = await this.fileCache.getPathFor(this.getHashForCache(song.url)); - if (options.seek) { - ffmpegInputOptions.push('-ss', options.seek.toString()); - } - - if (options.to) { - ffmpegInputOptions.push('-to', options.to.toString()); - } - } catch { + if (!ffmpegInput) { // Not yet cached, must download const info = await ytdl.getInfo(song.url); - const {formats} = info; + const formats = info.formats as YTDLVideoFormat[]; const filter = (format: ytdl.videoFormat): boolean => format.codecs === 'opus' && format.container === 'webm' && format.audioSampleRate !== undefined && parseInt(format.audioSampleRate, 10) === 48000; @@ -465,12 +459,16 @@ export default class { } } + debug('Using format', format); + ffmpegInput = format.url; // Don't cache livestreams or long videos const MAX_CACHE_LENGTH_SECONDS = 30 * 60; // 30 minutes shouldCacheVideo = !info.player_response.videoDetails.isLiveContent && parseInt(info.videoDetails.lengthSeconds, 10) < MAX_CACHE_LENGTH_SECONDS && !options.seek; + debug(shouldCacheVideo ? 'Caching video' : 'Not caching video'); + ffmpegInputOptions.push(...[ '-reconnect', '1', @@ -479,17 +477,23 @@ export default class { '-reconnect_delay_max', '5', ]); - - if (options.seek) { - ffmpegInputOptions.push('-ss', options.seek.toString()); - } - - if (options.to) { - ffmpegInputOptions.push('-to', options.to.toString()); - } } - return this.createReadStream(ffmpegInput, {ffmpegInputOptions, cache: shouldCacheVideo}); + if (options.seek) { + ffmpegInputOptions.push('-ss', options.seek.toString()); + } + + if (options.to) { + ffmpegInputOptions.push('-to', options.to.toString()); + } + + return this.createReadStream({ + url: ffmpegInput, + cacheKey: song.url, + ffmpegInputOptions, + cache: shouldCacheVideo, + volumeAdjustment: format?.loudnessDb ? `${-format.loudnessDb}dB` : undefined, + }); } private startTrackingPosition(initalPosition?: number): void { @@ -546,23 +550,24 @@ export default class { } } - private async createReadStream(url: string, options: {ffmpegInputOptions?: string[]; cache?: boolean} = {}): Promise { + private async createReadStream(options: {url: string; cacheKey: string; ffmpegInputOptions?: string[]; cache?: boolean; volumeAdjustment?: string}): Promise { return new Promise((resolve, reject) => { const capacitor = new WriteStream(); if (options?.cache) { - const cacheStream = this.fileCache.createWriteStream(this.getHashForCache(url)); + const cacheStream = this.fileCache.createWriteStream(this.getHashForCache(options.cacheKey)); capacitor.createReadStream().pipe(cacheStream); } const returnedStream = capacitor.createReadStream(); let hasReturnedStreamClosed = false; - const stream = ffmpeg(url) + const stream = ffmpeg(options.url) .inputOptions(options?.ffmpegInputOptions ?? ['-re']) .noVideo() .audioCodec('libopus') .outputFormat('webm') + .addOutputOption(['-filter:a', `volume=${options?.volumeAdjustment ?? '1'}`]) .on('error', error => { if (!hasReturnedStreamClosed) { reject(error); diff --git a/yarn.lock b/yarn.lock index 5470597..7af8579 100644 --- a/yarn.lock +++ b/yarn.lock @@ -714,13 +714,6 @@ asynckit@^0.4.0: resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" integrity sha1-x57Zf380y48robyXkLzDZkdLS3k= -axios@^0.19.0: - version "0.19.2" - resolved "https://registry.yarnpkg.com/axios/-/axios-0.19.2.tgz#3ea36c5d8818d0d5f8a8a97a6d36b86cdc00cb27" - integrity sha512-fjgm5MvRHLhx+osE2xoekY70AhARk3a6hkN+3Io1jc00jtquGvxYlKlsFUhmUET0V5te6CcZI7lcv2Ym61mjHA== - dependencies: - follow-redirects "1.5.10" - balanced-match@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" @@ -1069,13 +1062,6 @@ debug@4, debug@4.3.4, debug@^4.0.1, debug@^4.1.1, debug@^4.3.1, debug@^4.3.3: dependencies: ms "2.1.2" -debug@=3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261" - integrity sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g== - dependencies: - ms "2.0.0" - decode-uri-component@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.0.tgz#eb3913333458775cb84cd1a1fae062106bb87545" @@ -1775,13 +1761,6 @@ fluent-ffmpeg@^2.1.2: async ">=0.2.9" which "^1.1.1" -follow-redirects@1.5.10: - version "1.5.10" - resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.5.10.tgz#7b7a9f9aea2fdff36786a94ff643ed07f4ff5e2a" - integrity sha512-0V5l4Cizzvqt5D44aTXbFZz+FtyXV1vrDN6qrelxtfYQKW0KO0W2T/hkE8xvGa/540LkZlkaUjO4ailYTFtHVQ== - dependencies: - debug "=3.1.0" - form-data-encoder@1.7.1: version "1.7.1" resolved "https://registry.yarnpkg.com/form-data-encoder/-/form-data-encoder-1.7.1.tgz#ac80660e4f87ee0d3d3c3638b7da8278ddb8ec96" @@ -2869,11 +2848,6 @@ mkdirp@^1.0.3: resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== -ms@2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" - integrity sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g= - ms@2.1.2: version "2.1.2" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" @@ -4374,35 +4348,18 @@ yocto-queue@^1.0.0: resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-1.0.0.tgz#7f816433fb2cbc511ec8bf7d263c3b58a1a3c251" integrity sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g== -youtube.ts@^0.2.9: - version "0.2.9" - resolved "https://registry.yarnpkg.com/youtube.ts/-/youtube.ts-0.2.9.tgz#f66f210ede81ae8116dc614788d90f6016e269c2" - integrity sha512-s7udygKcvNpjGM4AieWQ84+H0McJIKCWCTBjxKOJsBm5jtk8D6eRtwHxbAmDgOQ9X+mykMEopDK1jdfI8VpExg== - dependencies: - axios "^0.19.0" - ytdl-core "^4.11.0" - -ytdl-core@^4.11.0: - version "4.11.0" - resolved "https://registry.yarnpkg.com/ytdl-core/-/ytdl-core-4.11.0.tgz#79a3ea94d9d662b4b3acecdb1372ed3f1a9ea9db" - integrity sha512-Q3hCLiUA9AOGQXzPvno14GN+HgF9wsO1ZBHlj0COTcyxjIyFpWvMfii0UC4/cAbVaIjEdbWB71GdcGuc4J1Lmw== +ytdl-core@^4.11.4: + version "4.11.4" + resolved "https://registry.yarnpkg.com/ytdl-core/-/ytdl-core-4.11.4.tgz#0ee2bd04d8effa7b8762a3ba0e3d038e37dc10f2" + integrity sha512-tsVvqt++B5LSTMnCKQb4H/PFBewKj7gGPJ6KIM5gOFGMKNZj4qglGAl4QGFG8cNPP6wY54P80FDID5eN2di0GQ== dependencies: m3u8stream "^0.8.6" miniget "^4.2.2" sax "^1.1.3" -ytdl-core@^4.11.2: - version "4.11.2" - resolved "https://registry.yarnpkg.com/ytdl-core/-/ytdl-core-4.11.2.tgz#c2341916b9757171741a2fa118b6ffbb4ffd92f7" - integrity sha512-D939t9b4ZzP3v0zDvehR2q+KgG97UTgrTKju3pOPGQcXtl4W6W5z0EpznzcJFu+OOpl7S7Ge8hv8zU65QnxYug== - dependencies: - m3u8stream "^0.8.6" - miniget "^4.2.2" - sax "^1.1.3" - -ytsr@^3.8.0: - version "3.8.0" - resolved "https://registry.yarnpkg.com/ytsr/-/ytsr-3.8.0.tgz#49a8e5dc413f41515fc3d79d93ee3e073d10e772" - integrity sha512-R+RfYXvBBMAr2e4OxrQ5SBv5x/Mdhmcj1Q8TH0f2HK5d2jbhHOtK4BdzPvLriA6MDoMwqqX04GD8Rpf9UNtSTg== +ytsr@^3.8.2: + version "3.8.2" + resolved "https://registry.yarnpkg.com/ytsr/-/ytsr-3.8.2.tgz#10a60d0c1adcc3522b0810368c18dff49e875ba7" + integrity sha512-J+t+a1Ic6jL0Hd0zGX8eFn3uEKtXTf6naa96KO0q7H00GKBfCG8aXW55NAMnaBeUi9Hni6u1xKnf8xZF2F0E/A== dependencies: miniget "^4.2.2"