Compare commits

...

149 commits

Author SHA1 Message Date
3caa0f1dc5
Merge remote-tracking branch 'upstream/master'
All checks were successful
ci/woodpecker/push/docker Pipeline was successful
ci/woodpecker/cron/docker Pipeline was successful
2025-06-23 23:51:00 +02:00
Michael
15b7236798
Update ytdl-core (#1261)
* Update package.json > @distube/ytdl-core to ^4.16.10
2025-05-07 23:41:46 -07:00
Joe Howard
41c37e0998 Release 2.11.1 2025-04-07 19:07:25 -05:00
Joe Howard
4192a709fd
Merge pull request #1248 from SuperKali/patch-1
Bump: @distube/ytdl-core to v4.16.8
2025-04-07 19:03:43 -05:00
Joe Howard
e66556086e
Merge pull request #1251 from museofficial/revert-base-dockerfile-change
fix: revert Dockerfile to inherit dependencies properly, ref issue #1250
2025-04-07 19:01:04 -05:00
Joe Howard
4f5effbd3b fix: install Python so @discordjs/opus can build from source 2025-04-07 18:55:21 -05:00
Joe Howard
d786230242 fix: revert Dockerfile to inherit dependencies properly, ref issue #1250 2025-04-07 18:01:10 -05:00
SuperKali
1f8da0046a
Bump: @distube/ytdl-core to v4.16.8
In the latest version, the issue has been resolved where starting an instance of ffmpeg caused the server to return a 403 Forbidden error (access denied), this preventing the playback of any music tracks.
2025-04-05 18:28:16 +02:00
Joe Howard
99655220a7 Release 2.11.0 2025-03-31 20:46:40 -05:00
Joe Howard
27a5cf1804
Merge pull request #1243 from museofficial/update-changelog
chore: update changelog from last PR to allow build
2025-03-31 20:46:16 -05:00
Joe Howard
1028f3143a chore: update changelog from last PR to allow build 2025-03-31 20:45:29 -05:00
Joe Howard
effe482822
Merge pull request #1240 from Rob4001/master
Update ytdl-core, @discordjs/opus and @discordjs/voice. Update to Node 22
2025-03-31 20:42:11 -05:00
Robert Wilson
0afb9d2bc3 Update ytdl-core to 4.16.6 2025-03-31 23:10:56 +01:00
Robert Wilson
d0ab294907 Update ytdl-core, @discordjs/opus and @discordjs/voice. Update to Node 22 LTS 2025-03-27 23:23:45 +00:00
Joe Howard
d7f0a954e2 Release 2.10.1 2025-01-28 21:58:35 -06:00
Joe Howard
92c6de5adc
Merge pull request #1201 from museofficial/dependencies/ytdl-core/upgrade/4-15-9
Add updates to changelog to permit a new release
2025-01-28 21:57:33 -06:00
Michael
3e0d10af26
Merge branch 'master' into dependencies/ytdl-core/upgrade/4-15-9 2025-01-27 18:11:03 -08:00
Michael
5425b54e90 Revert "Update CHANGELOG.md with YT changes"
This reverts commit ebb5803653.
2025-01-27 17:30:19 -08:00
Michael
be53c46165
Revert "Improve YouTube API error handling (#1199)" (#1206)
This reverts commit 373e6a53ac so we can push a smaller release with just the dependency updates first.
2025-01-27 17:29:37 -08:00
Michael
0fcd6d0a40 Revert "Merge branch 'master' into dependencies/ytdl-core/upgrade/4-15-9"
This reverts commit b1a3ae3aaa, reversing
changes made to ebb5803653.
2025-01-27 17:25:28 -08:00
Michael
b1a3ae3aaa
Merge branch 'master' into dependencies/ytdl-core/upgrade/4-15-9 2025-01-27 17:16:47 -08:00
Michael
ebb5803653
Update CHANGELOG.md with YT changes 2025-01-27 17:13:20 -08:00
Michael
373e6a53ac
Improve YouTube API error handling (#1199)
Enhance YouTube API service with comprehensive error handling, retries, and
logging to improve reliability. Add typed YouTubeError interface, implement
exponential backoff with jitter, properly handle rate limits (429) and quota
exceeded (403), and improve cache key management.
2025-01-27 17:02:07 -08:00
Joe Howard
0bc39279a5 remove items destruturing 2025-01-26 20:26:10 -06:00
Joe Howard
8be58920ba try casting type directly 2025-01-26 20:11:40 -06:00
Joe Howard
b6f5b1f45a chore: add type declaration to force linter to recognize non-void return 2025-01-26 20:03:21 -06:00
Joe Howard
2aad2244d8 Add changes from #1197, #1192, #1139 to Changelog 2025-01-26 19:52:25 -06:00
dependabot[bot]
4c81b67869
Bump p-queue from 7.1.0 to 8.1.0
Bumps [p-queue](https://github.com/sindresorhus/p-queue) from 7.1.0 to 8.1.0.
- [Release notes](https://github.com/sindresorhus/p-queue/releases)
- [Commits](https://github.com/sindresorhus/p-queue/compare/v7.1.0...v8.1.0)

---
updated-dependencies:
- dependency-name: p-queue
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-01-26 00:01:52 +00:00
Michael
92f63e067d
Merge pull request #1197 from enigodupont/master
Update ytdl-core to 4.15.9 and use new playerClients default
2025-01-25 15:59:23 -08:00
Juan Carlos Ramirez
e729202d98 Updating fluent-ffmpeg to 2.1.3 to fix windows playback
amend: Fixing packages
2025-01-25 02:28:52 -06:00
Juan Carlos Ramirez
60c1c21d61 Update ytdl-core to 4.15.9 and use new playerClients default 2025-01-25 01:04:36 -06:00
Michael
6958af2b2e
Merge pull request #1189 from enigodupont/master
Reset playerclients for ytdl during getinfo to defaults
2025-01-21 22:43:35 -08:00
Michael
c2445a7c3c
Revert "Revert "Reset playerclients for ytdl during getinfo to defaults"" 2025-01-21 22:36:02 -08:00
Michael
591e9bab67
Merge pull request #1191 from museofficial/revert-1189-master
Revert "Reset playerclients for ytdl during getinfo to defaults"
2025-01-21 18:12:13 -08:00
Michael
045362c22c
Revert "Reset playerclients for ytdl during getinfo to defaults" 2025-01-21 17:44:37 -08:00
Michael
b10650bc0e
Merge pull request #1189 from enigodupont/master
Reset playerclients for ytdl during getinfo to defaults
2025-01-21 17:44:23 -08:00
Juan Carlos Ramirez
0db43cee32 Add logic to catch empty formats 2025-01-20 17:53:08 -06:00
Juan Carlos Ramirez
35855dd164 Update reflect-metadata package 2025-01-20 17:11:11 -06:00
Juan Carlos Ramirez
afbc570c81 Reset playerclients for ytdl during getinfo to defaults
Update ytdl-core and ytsr
2025-01-20 14:22:53 -06:00
Stefano
6ec6bb8aae
Merge pull request #1139 from sofushn/patch-1
remove requirement for Spotify in readme
2024-11-06 19:13:09 +01:00
Sofus Hilfling
32cd0f35ff
remove requirement for Spotify in readme 2024-11-05 17:09:48 +01:00
Stefano
716d6d9f4f
Release 2.10.0 2024-11-04 14:41:34 +00:00
Stefano
c0aadaf320
Lets see how this goes. Dependency downgrade due to broken update. 2024-11-04 14:41:11 +00:00
Stefano
63e55d9fd6
Update CHANGELOG.md 2024-11-04 14:52:23 +01:00
Stefano
250c7d316b
Merge pull request #1137 from museofficial/develop
Develop
2024-11-04 14:51:25 +01:00
Stefano
b2565a3a92
Merge pull request #1136 from museofficial/feature/lower-volume-when-talking
Feature/lower volume when talking
2024-11-04 13:51:29 +01:00
Stefano
d4e11241c5
Merge pull request #1096 from xytxxx/auto-turn-voice-down-when-people-speaks
feat: automatically turn volume down when people talks
2024-11-04 13:33:00 +01:00
Stefano
07bfd32cb3
Merge pull request #1092 from sofushn/master
feat: allow running without spotify
2024-11-04 13:31:01 +01:00
Stefano
4ec8a6d091
Merge branch 'master' into auto-turn-voice-down-when-people-speaks 2024-11-04 13:28:59 +01:00
Hazzajenko
8441935865 Add migration for prisma db changes 2024-11-01 17:47:22 +11:00
Hazzajenko
27d20a59d8 Added turnDownVolumeWhenPeopleSpeak and turnDownVolumeWhenPeopleSpeakTarget fields to the Setting model in Prisma 2024-11-01 17:45:55 +11:00
Hazzajenko
fe30673e23 Update README.md to the latest version of muse.
Changed the section to mention the discord commands rather than the env variables.
2024-11-01 17:45:01 +11:00
Hazzajenko
825c9a0c53 Refactor player service to use getGuildSettings instead of passing in the config. 2024-11-01 17:43:39 +11:00
Hazzajenko
ba3f1d60c3 Remove Config injection from PlayerManager as we are getting the values from prisma. 2024-11-01 17:42:09 +11:00
Hazzajenko
27b1a25e56 Remove obsolete TURN_DOWN_VOLUME_WHEN_PEOPLE_SPEAK from .env.example 2024-11-01 17:41:07 +11:00
Hazzajenko
a87bf60fe5 Updated Dockerfile to latest version of muse. 2024-11-01 17:39:42 +11:00
Hazzajenko
53af0074fc Update dotenv line to latest version of muse.
Removed obsolete TURN_DOWN_VOLUME_WHEN_PEOPLE_SPEAK and TURN_DOWN_VOLUME_WHEN_PEOPLE_SPEAK_TARGET environment variables and corresponding class properties as we will use the discord commands instead.
2024-11-01 17:38:23 +11:00
Hazzajenko
b42f27eba9 Added commands to set whether to reduce the volume when people speak and to set the target volume. 2024-11-01 17:36:01 +11:00
Hazzajenko
d6364ac2aa Update CHANGELOG for version 2.9.5 release.
Updated env variables with discord commands.
2024-11-01 17:31:06 +11:00
Stefano
418a7eccf3
Merge pull request #1119 from museofficial/dependabot/npm_and_yarn/p-limit-6.1.0
Bump p-limit from 4.0.0 to 6.1.0
2024-10-29 13:31:51 +01:00
Stefano
386903dfe0
Merge pull request #1120 from museofficial/dependabot/npm_and_yarn/types/ms-0.7.34
Bump @types/ms from 0.7.31 to 0.7.34
2024-10-29 13:31:22 +01:00
Stefano
ac6f4c68e1
Merge pull request #1121 from museofficial/dependabot/npm_and_yarn/iso8601-duration-2.1.2
Bump iso8601-duration from 1.3.0 to 2.1.2
2024-10-29 13:30:55 +01:00
dependabot[bot]
62aa39b674
Bump iso8601-duration from 1.3.0 to 2.1.2
Bumps [iso8601-duration](https://github.com/tolu/ISO8601-duration) from 1.3.0 to 2.1.2.
- [Release notes](https://github.com/tolu/ISO8601-duration/releases)
- [Commits](https://github.com/tolu/ISO8601-duration/compare/v1.3.0...v2.1.2)

---
updated-dependencies:
- dependency-name: iso8601-duration
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-10-29 10:32:25 +00:00
dependabot[bot]
6ff7d4b908
Bump @types/ms from 0.7.31 to 0.7.34
Bumps [@types/ms](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/ms) from 0.7.31 to 0.7.34.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/ms)

---
updated-dependencies:
- dependency-name: "@types/ms"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-10-29 10:32:14 +00:00
dependabot[bot]
6a81754a2e
Bump p-limit from 4.0.0 to 6.1.0
Bumps [p-limit](https://github.com/sindresorhus/p-limit) from 4.0.0 to 6.1.0.
- [Release notes](https://github.com/sindresorhus/p-limit/releases)
- [Commits](https://github.com/sindresorhus/p-limit/compare/v4.0.0...v6.1.0)

---
updated-dependencies:
- dependency-name: p-limit
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-10-29 10:31:47 +00:00
Stefano Haagmans
e82bc977ff
Release 2.9.5 2024-10-29 10:22:47 +01:00
Stefano Haagmans
c561eb70ba
Incorrect PR number 2024-10-29 10:21:01 +01:00
Stefano
bb9544b94d
Update CHANGELOG.md 2024-10-29 10:19:31 +01:00
Stefano
c0a879666f
Merge pull request #1118 from museofficial/develop
* Feat: allow users to change dotenv file with env var

This facilitates the usage of docker secrets for the keys/tokens.

* Feat: default ENV_FILE to /config in dockerfile

* Doc: document the existance and usage of ENV_FILe env var.

* Fix: legacy env key in dockerfile

* Dependency upgrade (for the past few missed dependabot updates) (#1117)

* Bump dotenv from 16.0.0 to 16.4.5

Bumps [dotenv](https://github.com/motdotla/dotenv) from 16.0.0 to 16.4.5.
- [Changelog](https://github.com/motdotla/dotenv/blob/master/CHANGELOG.md)
- [Commits](https://github.com/motdotla/dotenv/compare/v16.0.0...v16.4.5)

---
updated-dependencies:
- dependency-name: dotenv
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

* Bump p-retry from 4.6.2 to 6.2.0

Bumps [p-retry](https://github.com/sindresorhus/p-retry) from 4.6.2 to 6.2.0.
- [Release notes](https://github.com/sindresorhus/p-retry/releases)
- [Commits](https://github.com/sindresorhus/p-retry/compare/v4.6.2...v6.2.0)

---
updated-dependencies:
- dependency-name: p-retry
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

* Bump ora from 6.1.0 to 8.1.0

Bumps [ora](https://github.com/sindresorhus/ora) from 6.1.0 to 8.1.0.
- [Release notes](https://github.com/sindresorhus/ora/releases)
- [Commits](https://github.com/sindresorhus/ora/compare/v6.1.0...v8.1.0)

---
updated-dependencies:
- dependency-name: ora
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

* Bump @types/validator from 13.7.2 to 13.12.2

Bumps [@types/validator](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/validator) from 13.7.2 to 13.12.2.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/validator)

---
updated-dependencies:
- dependency-name: "@types/validator"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

* Bump prisma from 4.16.0 to 5.21.1

Bumps [prisma](https://github.com/prisma/prisma/tree/HEAD/packages/cli) from 4.16.0 to 5.21.1.
- [Release notes](https://github.com/prisma/prisma/releases)
- [Commits](https://github.com/prisma/prisma/commits/5.21.1/packages/cli)

---
updated-dependencies:
- dependency-name: prisma
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: JoaoCostaIFG <joaocosta.work@posteo.net>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-29 10:15:59 +01:00
Stefano
e23f89706e
Dependency upgrade (for the past few missed dependabot updates) (#1117)
* Bump dotenv from 16.0.0 to 16.4.5

Bumps [dotenv](https://github.com/motdotla/dotenv) from 16.0.0 to 16.4.5.
- [Changelog](https://github.com/motdotla/dotenv/blob/master/CHANGELOG.md)
- [Commits](https://github.com/motdotla/dotenv/compare/v16.0.0...v16.4.5)

---
updated-dependencies:
- dependency-name: dotenv
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

* Bump p-retry from 4.6.2 to 6.2.0

Bumps [p-retry](https://github.com/sindresorhus/p-retry) from 4.6.2 to 6.2.0.
- [Release notes](https://github.com/sindresorhus/p-retry/releases)
- [Commits](https://github.com/sindresorhus/p-retry/compare/v4.6.2...v6.2.0)

---
updated-dependencies:
- dependency-name: p-retry
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

* Bump ora from 6.1.0 to 8.1.0

Bumps [ora](https://github.com/sindresorhus/ora) from 6.1.0 to 8.1.0.
- [Release notes](https://github.com/sindresorhus/ora/releases)
- [Commits](https://github.com/sindresorhus/ora/compare/v6.1.0...v8.1.0)

---
updated-dependencies:
- dependency-name: ora
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

* Bump @types/validator from 13.7.2 to 13.12.2

Bumps [@types/validator](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/validator) from 13.7.2 to 13.12.2.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/validator)

---
updated-dependencies:
- dependency-name: "@types/validator"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

* Bump prisma from 4.16.0 to 5.21.1

Bumps [prisma](https://github.com/prisma/prisma/tree/HEAD/packages/cli) from 4.16.0 to 5.21.1.
- [Release notes](https://github.com/prisma/prisma/releases)
- [Commits](https://github.com/prisma/prisma/commits/5.21.1/packages/cli)

---
updated-dependencies:
- dependency-name: prisma
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-29 10:12:25 +01:00
Stefano
f1b4c74c09
Merge pull request #1040 from JoaoCostaIFG/feature/select-dotenv-path
Feat: allow users to change dotenv file with env var
2024-10-29 09:15:43 +01:00
João Costa
c954cccacb
Fix: legacy env key in dockerfile 2024-10-28 22:36:17 +00:00
João Costa
ce8edf4145
Merge branch 'master' into feature/select-dotenv-path 2024-10-28 16:16:27 +00:00
JoaoCostaIFG
ae40463712 Doc: document the existance and usage of ENV_FILe env var. 2024-10-28 16:15:29 +00:00
JoaoCostaIFG
18e5c5c80e Feat: default ENV_FILE to /config in dockerfile 2024-10-28 16:07:55 +00:00
sofushn
66e022489f fix: Spotify URL is part of query description even when disabled 2024-10-28 15:06:42 +01:00
Yitong Xiao
aae98255b1 feat: automatically turn down volume when people talks 2024-09-15 17:53:05 -04:00
sofushn
107464e222 chore: make linter parse 2024-09-07 21:51:35 +02:00
sofushn
af639159d1 feat: allow running without spotify 2024-09-07 13:09:45 +02:00
Harry Jenkins
534d8fafaa Release 2.9.4 2024-08-28 16:56:27 +10:00
Harry Jenkins
dede552ddd Merge branch 'TiagoGrosso-configurable-queue-page-size' 2024-08-28 16:44:18 +10:00
Harry Jenkins
a87078c2ad update page-size option description to reflect maximum value of 30 2024-08-28 16:41:12 +10:00
Tiago Grosso
8e7e12c8df fix: limit queue size to 30 2024-08-27 11:11:43 +01:00
Tiago Grosso
0912d95791 feat: add setting for default queue page size 2024-08-24 23:08:05 +01:00
Tiago Grosso
c46153f620 fix: fix page-size option name 2024-08-23 21:38:50 +01:00
Tiago Grosso
6e39c8d09e feat: add optional pageSize to /queue command 2024-08-23 21:22:34 +01:00
Harry Jenkins
1e17b94321 Release 2.9.3 2024-08-19 12:43:37 +10:00
Harry Jenkins
8ef68d2292 Merge branch 'shiftybitshiftr-ytdl-4.14.4' 2024-08-19 11:29:51 +10:00
Harry Jenkins
6c26138c23 Update changelog 2024-08-19 11:03:45 +10:00
Harry Jenkins
ee60a07d23 Merge branch 'ytdl-4.14.4' of https://github.com/shiftybitshiftr/muse into shiftybitshiftr-ytdl-4.14.4 2024-08-19 10:38:01 +10:00
Max Isom
e1dfd192e8
Add release docs 2024-08-18 16:27:39 -07:00
Max Isom
fb4d7885f7
Fix PAT for release comments 2024-08-18 16:27:20 -07:00
Max Isom
069774e709
Release 2.9.2 2024-08-18 16:18:39 -07:00
Max Isom
52df7d42f1
Update readme, changelog, release workflow 2024-08-18 16:14:46 -07:00
Max Isom
4c2f65ff2d
Fix PR publishing (#1068) 2024-08-18 16:12:08 -07:00
Max Isom
de4a9193b6
Update workflow 2024-08-18 16:01:21 -07:00
Max Isom
9751feb778
Update workflow 2024-08-18 15:55:06 -07:00
Max Isom
30c8b13102
Update workflow 2024-08-18 15:48:38 -07:00
Max Isom
0978d40e95
Update workflow 2024-08-18 15:43:55 -07:00
Max Isom
62b569d26b
Update workflow 2024-08-17 23:09:29 -07:00
Max Isom
7f274980b5
Update workflow 2024-08-17 22:51:33 -07:00
Max Isom
de56180036
Update workflow 2024-08-17 22:30:37 -07:00
Max Isom
d5f03c0df7
Update workflow 2024-08-17 22:20:54 -07:00
Max Isom
cb5c035bba
Update workflow 2024-08-17 22:09:55 -07:00
Max Isom
fdbd3cd527
Update workflow 2024-08-17 22:06:01 -07:00
Max Isom
c8fd5cf97e
Update workflow 2024-08-17 21:59:43 -07:00
Max Isom
fbe64036d8
Update workflow 2024-08-17 21:50:19 -07:00
Max Isom
72667bf00e
Update workflow 2024-08-17 21:35:25 -07:00
Max Isom
7e2d79f3fb
Update workflow 2024-08-17 21:22:51 -07:00
Max Isom
2d8f66f8a1
Update workflow 2024-08-17 21:16:35 -07:00
Max Isom
aa4612a45f
Update workflow 2024-08-17 21:08:03 -07:00
Max Isom
20e589f3bd
Update workflow 2024-08-17 21:05:17 -07:00
Max Isom
22be075c81
Update workflow 2024-08-17 20:59:09 -07:00
Max Isom
76236005c6
Update workflow 2024-08-17 20:54:02 -07:00
Max Isom
7f72a74ff8
Update workflow 2024-08-17 20:49:48 -07:00
Max Isom
6d6fc4c09d
Update PR release workflow 2024-08-17 20:42:08 -07:00
Max Isom
e494287d1d
Update Docker builds to publish to ghcr.io & update README (#1067) 2024-08-17 20:22:57 -07:00
Michael
5bf5547cd1 update discordjs/voice 2024-08-16 22:47:48 -07:00
Michael
ee6a2a0884 Update yarn.lock 2024-08-16 00:06:41 -07:00
Michael
5812a8bf0a Update yarn.lock 2024-08-16 00:04:10 -07:00
Michael
b5a8a4e5a2 Update package.json 2024-08-15 23:20:56 -07:00
Max Isom
62c954943d
Add looking for maintainers 2024-08-11 21:07:30 -07:00
Max Isom
938a2b19c1
Release 2.9.1 2024-08-04 11:58:13 -07:00
Max Isom
8ad0558da3
Update changelog 2024-08-04 11:57:59 -07:00
Bobby
0a82d6908b
Updated ytdl-core (#1053) 2024-08-04 11:57:05 -07:00
Max Isom
94c24263ba Release 2.9.0 2024-07-17 21:48:07 -07:00
MintyFreshers
d7261260a3
Added skip currently playing track option into the /play options. (#1046)
Co-authored-by: Max Isom <hi@maxisom.me>
2024-07-17 21:47:01 -07:00
Son Roy Almerol
62b1abcba0
Switch from node-ytsr to Distube's fork (#1024)
Co-authored-by: Max Isom <hi@maxisom.me>
2024-07-17 21:43:26 -07:00
MarcoCoreDuo
fcc8d8881a
Switch to Distube's ytdl-core fork (resolves playback issue) (#1042) 2024-07-17 21:32:16 -07:00
JoaoCostaIFG
3d7a85843f
Feat: allow users to change dotenv file with env var
This facilitates the usage of docker secrets for the keys/tokens.
2024-07-06 01:01:39 +01:00
MarcoCoreDuo
29b61cc0b6
Stop audioplayer properly (#1032) 2024-06-13 00:25:35 -05:00
Sheeley7
cc0cf8379c
fixed issue in subcommand name (#1025)
Co-authored-by: Max Isom <codetheweb@users.noreply.github.com>
2024-06-01 12:05:57 -07:00
Max Isom
cc1f18bc19 Release 2.8.1 2024-04-28 16:12:05 -07:00
Son
e0dfd8c9fd
Fix ERR_MODULE_NOT_FOUND for constants file (#1023)
Co-authored-by: Max Isom <hi@maxisom.me>
2024-04-28 18:11:38 -05:00
Max Isom
75d2714ff2 Release 2.8.0 2024-04-28 14:17:40 -07:00
Max Isom
7c1294828c
Update changelog 2024-04-28 14:17:16 -07:00
Charlignon
a27598c50a
Add SponsorBlock support (#1013) 2024-04-28 16:13:25 -05:00
Sheeley7
8e08919206
Added config to make add to queue responses for requester only (#1021)
Co-authored-by: Max Isom <codetheweb@users.noreply.github.com>
2024-04-28 16:11:35 -05:00
Max Isom
cf775c428c Release 2.7.1 2024-03-18 20:31:33 -07:00
Max Isom
1d5ca3d898
Update changelog 2024-03-18 20:31:08 -07:00
Matt Foxx
845899e96d
refactor: Use patch-package to patch ytdl-core (#1006) 2024-03-18 22:30:35 -05:00
Max Isom
bd446a3aeb Release 2.7.0 2024-03-12 19:27:51 -07:00
Matt Foxx
7de96515e9
refactor: Optimize apt commands and dependency versions (#1005) 2024-03-12 21:27:11 -05:00
Matt Foxx
6baaffb730
Implement volume control #830 (#994)
Co-authored-by: Max Isom <hi@maxisom.me>
2024-03-12 21:25:45 -05:00
Max Isom
786e6fd8f5 Release 2.6.0 2024-03-03 13:45:50 -08:00
Max Isom
2cb84128d0
Update changelog 2024-03-03 13:45:29 -08:00
Andrew Mike
ccd8793cc1
[RFC] Auto-announce when new song comes on (#914)
Co-authored-by: Andrew Mike <andrew@7thmagic.net>
Co-authored-by: Andrew Mike <logomancer@gmail.com>
2024-03-03 15:43:56 -06:00
Matt Foxx
1d5729fd6c
fix: Ensure guild settings exist in DB before updating (#999) 2024-02-16 21:13:45 -06:00
Matt Foxx
aba622cb8d
Bump major version for Prisma to support newer openssl versions (#993) 2024-02-05 23:45:55 -06:00
52 changed files with 4040 additions and 1937 deletions

View file

@ -10,6 +10,9 @@ SPOTIFY_CLIENT_SECRET=
# CACHE_LIMIT=2GB # CACHE_LIMIT=2GB
# ENABLE_SPONSORBLOCK=true
# SPONSORBLOCK_TIMEOUT=5 # Delay (in mn) before retrying when SponsorBlock server are unreachable.
# See the README for details on the below variables # See the README for details on the below variables
# BOT_STATUS= # BOT_STATUS=
# BOT_ACTIVITY_TYPE= # BOT_ACTIVITY_TYPE=

View file

@ -10,7 +10,7 @@ jobs:
- uses: actions/checkout@v1 - uses: actions/checkout@v1
- uses: actions/setup-node@v2 - uses: actions/setup-node@v2
with: with:
node-version: '16' node-version: '22'
cache: 'yarn' cache: 'yarn'
- name: Install dependencies - name: Install dependencies
run: yarn install run: yarn install

108
.github/workflows/pr-release.yml vendored Normal file
View file

@ -0,0 +1,108 @@
name: Release snapshot of PR
on:
workflow_run:
workflows: ["Build snapshot of PR"]
types:
- completed
env:
REGISTRY_IMAGE: ghcr.io/museofficial/muse
jobs:
release-and-comment:
name: Release snapshot and comment in PR
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
attestations: write
id-token: write
steps:
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Buildx
uses: docker/setup-buildx-action@v1
- name: Download images
uses: actions/download-artifact@v4
with:
path: /tmp/images
pattern: image-linux-*
merge-multiple: true
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.GH_PAT }}
- name: Load image
shell: bash
run: |
docker load -i /tmp/images/image-linux-amd64.tar
docker load -i /tmp/images/image-linux-arm64.tar
- name: Download SHA
uses: actions/download-artifact@v4
with:
path: /tmp/SHA
pattern: sha
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.GH_PAT }}
- name: Read SHA
shell: bash
run: |
echo "SHA=$(cat /tmp/SHA/sha/sha.txt | tr -d '\n')" >> $GITHUB_ENV
- name: Push images
run: |
docker push ${{ env.REGISTRY_IMAGE }}:${{ env.SHA }}-linux-amd64
docker push ${{ env.REGISTRY_IMAGE }}:${{ env.SHA }}-linux-arm64
- name: Download Docker metadata
uses: actions/download-artifact@v4
with:
path: /tmp/metadata
pattern: metadata
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.GH_PAT }}
- name: Read the metadata.json file
id: metadata_reader
uses: juliangruber/read-file-action@v1.0.0
with:
path: /tmp/metadata/metadata/metadata.json
- name: Download PR number
uses: actions/download-artifact@v4
with:
path: /tmp/pull_request_number
pattern: pull_request_number
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.GH_PAT }}
- name: Read PR number
shell: bash
run: |
echo "PR_NUMBER=$(cat /tmp/pull_request_number/pull_request_number/pull_request_number.txt | tr -d '\n')" >> $GITHUB_ENV
- name: Create manifest list and push
run: |
docker buildx imagetools create $(cat /tmp/metadata/metadata/metadata.json | jq -cr '.tags | map("-t " + .) | join(" ")') ${{ env.REGISTRY_IMAGE }}:${{ env.SHA }}-linux-amd64 ${{ env.REGISTRY_IMAGE }}:${{ env.SHA }}-linux-arm64
- name: Create comment
uses: marocchino/sticky-pull-request-comment@v2
with:
header: "pr-release"
number: ${{ env.PR_NUMBER }}
GITHUB_TOKEN: ${{ secrets.GH_PAT }}
message: |
#### :package: :robot: A new release has been made for this pull request.
To play around with this PR, pull `${{ env.REGISTRY_IMAGE }}:pr-${{ env.PR_NUMBER }}`.
Images are available for x86_64 and ARM64.
> Latest commit: ${{ env.SHA }}

101
.github/workflows/pr-snapshot.yml vendored Normal file
View file

@ -0,0 +1,101 @@
name: Build snapshot of PR
on: pull_request
env:
REGISTRY_IMAGE: ghcr.io/museofficial/muse
jobs:
build:
name: Build snapshot
strategy:
matrix:
runner-platform:
- ubuntu-latest
- namespace-profile-default-arm64
include:
- runner-platform: ubuntu-latest
build-arch: linux/amd64
- runner-platform: namespace-profile-default-arm64
build-arch: linux/arm64
runs-on: ${{ matrix.runner-platform }}
steps:
- name: Prepare
run: |
platform=${{ matrix.build-arch }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY_IMAGE }}
tags: type=ref,event=pr
- name: Set up Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Get current time
uses: josStorer/get-current-time@v2
id: current-time
- name: Build
id: build
uses: docker/build-push-action@v6
with:
outputs: type=docker,dest=/tmp/image-${{ env.PLATFORM_PAIR }}.tar
platforms: ${{ matrix.build-arch }}
tags: |
${{ env.REGISTRY_IMAGE }}:${{ github.sha }}-${{ env.PLATFORM_PAIR }}
build-args: |
COMMIT_HASH=${{ github.sha }}
BUILD_DATE=${{ steps.current-time.outputs.time }}
- name: Export Docker meta output
shell: bash
run: echo $DOCKER_METADATA_OUTPUT_JSON > /tmp/metadata.json
- name: Upload metadata
uses: actions/upload-artifact@v4
with:
name: metadata
path: /tmp/metadata.json
overwrite: true
- name: Export SHA
run: |
echo "${{ github.sha }}" > /tmp/sha.txt
- name: Upload SHA
uses: actions/upload-artifact@v4
with:
name: sha
path: /tmp/sha.txt
overwrite: true
- name: Upload image
uses: actions/upload-artifact@v4
with:
name: image-${{ env.PLATFORM_PAIR }}
path: /tmp/image-${{ env.PLATFORM_PAIR }}.tar
if-no-files-found: error
retention-days: 1
- name: Save PR number in artifact
shell: bash
env:
PR_NUMBER: ${{ github.event.number }}
run: echo $PR_NUMBER > /tmp/pull_request_number.txt
- name: Upload PR number
uses: actions/upload-artifact@v4
with:
name: pull_request_number
path: /tmp/pull_request_number.txt
overwrite: true

View file

@ -1,91 +0,0 @@
name: PR Workflow
on: pull_request_target
jobs:
release-snapshot:
name: Release snapshot
strategy:
matrix:
runner-platform:
- ubuntu-latest
- buildjet-4vcpu-ubuntu-2204-arm
include:
- runner-platform: ubuntu-latest
build-arch: linux/amd64
tagged-platform: amd64
- runner-platform: buildjet-4vcpu-ubuntu-2204-arm
build-arch: linux/arm64
tagged-platform: arm64
runs-on: ${{ matrix.runner-platform }}
steps:
- name: Set up Buildx
uses: docker/setup-buildx-action@v1
- name: Cache Docker layers
# AWS data transfer is pricy
if: ${{ matrix.runner-platform != 'buildjet-4vcpu-ubuntu-2204-arm' }}
uses: actions/cache@v2
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-prs-${{ matrix.build-arch }}-${{ github.event.pull_request.head.sha }}
restore-keys: |
${{ runner.os }}-buildx-prs-${{ matrix.build-arch }}
- name: Login to DockerHub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- uses: actions/checkout@v2
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Get current time
uses: josStorer/get-current-time@v2
id: current-time
- name: Build and push
id: docker_build
uses: docker/build-push-action@v2
with:
context: .
push: true
tags: codetheweb/muse:${{ github.event.pull_request.head.sha }}-${{ matrix.tagged-platform }}
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache,mode=min
platforms: ${{ matrix.build-arch }}
build-args: |
COMMIT_HASH=${{ github.sha }}
BUILD_DATE=${{ steps.current-time.outputs.time }}
combine-and-comment:
name: Combine platform tags and leave comment
runs-on: ubuntu-latest
needs: release-snapshot
steps:
- name: Set up Buildx
uses: docker/setup-buildx-action@v1
- name: Login to DockerHub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Combine tags
run: docker buildx imagetools create -t 'codetheweb/muse:pr-${{ github.event.number }}' -t 'codetheweb/muse:${{ github.event.pull_request.head.sha }}' 'codetheweb/muse:${{ github.event.pull_request.head.sha }}-arm64' 'codetheweb/muse:${{ github.event.pull_request.head.sha }}-amd64'
- name: Create comment
uses: marocchino/sticky-pull-request-comment@v2
with:
header: "pr-release"
message: |
#### :package: A new release has been made for this pull request.
To play around with this PR, pull `codetheweb/muse:pr-${{ github.event.number }}` or `codetheweb/muse:${{ github.event.pull_request.head.sha }}`.
Images are available for x86_64 and ARM64.
> Latest commit: ${{ github.event.pull_request.head.sha }}

View file

@ -5,34 +5,32 @@ on:
tags: tags:
- 'v*' - 'v*'
env:
REGISTRY_IMAGE: ghcr.io/museofficial/muse
jobs: jobs:
publish: publish:
strategy: strategy:
matrix: matrix:
runner-platform: runner-platform:
- ubuntu-latest - ubuntu-latest
- buildjet-4vcpu-ubuntu-2204-arm - namespace-profile-default-arm64
include: include:
- runner-platform: ubuntu-latest - runner-platform: ubuntu-latest
build-arch: linux/amd64 build-arch: linux/amd64
tagged-platform: amd64 tagged-platform: amd64
- runner-platform: buildjet-4vcpu-ubuntu-2204-arm - runner-platform: namespace-profile-default-arm64
build-arch: linux/arm64 build-arch: linux/arm64
tagged-platform: arm64 tagged-platform: arm64
runs-on: ${{ matrix.runner-platform }} runs-on: ${{ matrix.runner-platform }}
permissions:
contents: read
packages: write
attestations: write
id-token: write
steps: steps:
- name: Set up Buildx - name: Set up Buildx
uses: docker/setup-buildx-action@v1 uses: docker/setup-buildx-action@v3
- name: Cache Docker layers
# AWS data transfer is pricy
if: ${{ matrix.runner-platform != 'buildjet-4vcpu-ubuntu-2204-arm' }}
uses: actions/cache@v2
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-prs-${{ matrix.build-arch }}-${{ github.sha }}
restore-keys: |
${{ runner.os }}-buildx-prs-${{ matrix.build-arch }}
- name: Login to DockerHub - name: Login to DockerHub
uses: docker/login-action@v1 uses: docker/login-action@v1
@ -40,19 +38,26 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Get current time - name: Get current time
uses: josStorer/get-current-time@v2 uses: josStorer/get-current-time@v2
id: current-time id: current-time
- name: Build and push - name: Build and push
id: docker_build id: docker_build
uses: docker/build-push-action@v2 uses: docker/build-push-action@v6
with: with:
push: true push: true
tags: codetheweb/muse:${{ github.sha }}-${{ matrix.tagged-platform }} tags: |
codetheweb/muse:${{ github.sha }}-${{ matrix.tagged-platform }}
${{ env.REGISTRY_IMAGE }}:${{ github.sha }}-${{ matrix.tagged-platform }}
platforms: ${{ matrix.build-arch }} platforms: ${{ matrix.build-arch }}
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache,mode=min
build-args: | build-args: |
COMMIT_HASH=${{ github.sha }} COMMIT_HASH=${{ github.sha }}
BUILD_DATE=${{ steps.current-time.outputs.time }} BUILD_DATE=${{ steps.current-time.outputs.time }}
@ -61,6 +66,11 @@ jobs:
name: Combine platform tags name: Combine platform tags
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: publish needs: publish
permissions:
contents: read
packages: write
attestations: write
id-token: write
steps: steps:
- uses: actions/checkout@v1 - uses: actions/checkout@v1
@ -73,21 +83,37 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Get tags - name: Login to GitHub Container Registry
id: get-tags uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Get tags (Docker Hub)
id: get-tags-dockerhub
uses: Surgo/docker-smart-tag-action@v1 uses: Surgo/docker-smart-tag-action@v1
with: with:
docker_image: codetheweb/muse docker_image: codetheweb/muse
- name: Combine tags - name: Get tags (ghcr.io)
run: docker buildx imagetools create $(echo '${{ steps.get-tags.outputs.tag }}' | tr "," "\0" | xargs -0 printf -- '-t %s ') 'codetheweb/muse:${{ github.sha }}-arm64' 'codetheweb/muse:${{ github.sha }}-amd64' id: get-tags-ghcr
uses: Surgo/docker-smart-tag-action@v1
with:
docker_image: ${{ env.REGISTRY_IMAGE }}
- name: Combine tags (Docker Hub)
run: docker buildx imagetools create $(echo '${{ steps.get-tags-dockerhub.outputs.tag }}' | tr "," "\0" | xargs -0 printf -- '-t %s ') 'codetheweb/muse:${{ github.sha }}-arm64' 'codetheweb/muse:${{ github.sha }}-amd64'
- name: Combine tags (GitHub Container Registry)
run: docker buildx imagetools create $(echo '${{ steps.get-tags-ghcr.outputs.tag }}' | tr "," "\0" | xargs -0 printf -- '-t %s ') '${{ env.REGISTRY_IMAGE }}:${{ github.sha }}-arm64' '${{ env.REGISTRY_IMAGE }}:${{ github.sha }}-amd64'
- name: Update Docker Hub description - name: Update Docker Hub description
uses: peter-evans/dockerhub-description@v2.4.3 uses: peter-evans/dockerhub-description@v2.4.3
env: with:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKERHUB_PASSWORD: ${{ secrets.DOCKERHUB_PASSWORD }} password: ${{ secrets.DOCKERHUB_PASSWORD }}
DOCKERHUB_REPOSITORY: codetheweb/muse repository: codetheweb/muse
release: release:
name: Create GitHub release name: Create GitHub release

View file

@ -8,6 +8,6 @@ jobs:
steps: steps:
- uses: apexskier/github-release-commenter@v1 - uses: apexskier/github-release-commenter@v1
with: with:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GH_PAT }}
comment-template: | comment-template: |
🚀 Released in {release_link}. 🚀 Released in {release_link}.

View file

@ -10,7 +10,7 @@ jobs:
- uses: actions/checkout@v1 - uses: actions/checkout@v1
- uses: actions/setup-node@v2 - uses: actions/setup-node@v2
with: with:
node-version: '16' node-version: '22'
cache: 'yarn' cache: 'yarn'
- name: Install dependencies - name: Install dependencies
run: yarn install run: yarn install

View file

@ -6,6 +6,93 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [2.11.1] - 2025-04-07
- Revert Dockerfile to inherit dependencies image from base image
## [2.11.0] - 2025-03-31
- Updated ytdl-core to 4.16.5 distubejs/ytdl-core@4.15.9...4.16.6 which includes distubejs/ytdl-core@1f57d78 fixing the sig parsing
- ytdl-core dropped node 18 support distubejs/ytdl-core@60f0ab1 so updated to latest Node LTS 22
- Updated to @discordjs/opus v0.10.0 for Node 22 support
- Updated to @discordjs/voice v0.18.0 to remove support for depricated encryption https://github.com/discordjs/discord.js/releases/tag/%40discordjs%2Fvoice%400.18.0
## [2.10.1] - 2025-01-28
- Remove Spotify requirement
- Dependency update
## [2.10.0] - 2024-11-04
- New `/config set-reduce-vol-when-voice` command to automatically turn down the volume when people are speaking in the channel
- New `/config set-reduce-vol-when-voice-target` command to set the target volume percentage (0-100) when people are speaking in the channel
- Support for using only YouTube, spotify credentials are now optional.
- Dependency update (Additional downgrade for p-queue)
## [2.9.5] - 2024-10-29
- Dependency update
- Pull request #1040 merged (Used incorrect PR number, apoligies)
## [2.9.4] - 2024-08-28
### Added
- An optional `page-size` to `/queue` command
- Add `default-queue-page-size` setting
## [2.9.3] - 2024-08-19
### Fixed
- bumped @discordjs/voice
- bumped @distube/ytdl-core
## [2.9.2] - 2024-08-18
### Changed
- Muse has new maintainers! I ([@codetheweb](https://github.com/codetheweb)) am stepping aside as I haven't used Muse myself for a few years and haven't been able to spend as much time on Muse as I'd like. See [this issue](https://github.com/museofficial/muse/issues/1063) for details. Welcome @museofficial/maintainers!
- This repository has been moved to museofficial/muse.
- Docker images are now published to `ghcr.io/museofficial/muse`. **Please update your image source if you use Docker**.
## [2.9.1] - 2024-08-04
### Fixed
- bumped ytdl-core
## [2.9.0] - 2024-07-17
### Added
- A `skip` option to the `/play` command
### Fixed
- Fixed playback issue
- Audioplayer not stopping properly
## [2.8.1] - 2024-04-28
### Fixed
- Fixed import issue that broke Muse inside of Docker. Thanks @sonroyaalmerol!
## [2.8.0] - 2024-04-28
### Added
- SponsorBlock is now supported as an opt-in feature and will skip non-music segments of videos when possible. Check the readme for config details. Thanks @Charlignon!
- There's a new config setting to make Muse responses when adding items to the queue visible only to the requester. Thanks @Sheeley7!
## [2.7.1] - 2024-03-18
### Changed
- Reduced Docker image size
## [2.7.0] - 2024-03-12
### Added 🔊
- A `/volume` command is now available.
- Set the default volume with `/config set-default-volume`
## [2.6.0] - 2024-03-03
### Added
- Muse can now auto-announce new tracks in your voice channel on the transition of a new track. Use `/config set-auto-announce-next-song True` to enable.
## [2.5.0] - 2024-01-16 ## [2.5.0] - 2024-01-16
### Added ### Added
@ -280,7 +367,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Initial release - Initial release
[unreleased]: https://github.com/codetheweb/muse/compare/v2.5.0...HEAD [unreleased]: https://github.com/museofficial/muse/compare/v2.11.1...HEAD
[2.11.1]: https://github.com/museofficial/muse/compare/v2.11.0...v2.11.1
[2.11.0]: https://github.com/museofficial/muse/compare/v2.10.1...v2.11.0
[2.10.1]: https://github.com/museofficial/muse/compare/v2.10.0...v2.10.1
[2.10.0]: https://github.com/museofficial/muse/compare/v2.9.5...v2.10.0
[2.9.5]: https://github.com/museofficial/muse/compare/v2.9.4...v2.9.5
[2.9.4]: https://github.com/codetheweb/muse/compare/v2.9.3...v2.9.4
[2.9.3]: https://github.com/codetheweb/muse/compare/v2.9.2...v2.9.3
[2.9.2]: https://github.com/codetheweb/muse/compare/v2.9.1...v2.9.2
[2.9.1]: https://github.com/codetheweb/muse/compare/v2.9.0...v2.9.1
[2.9.0]: https://github.com/codetheweb/muse/compare/v2.8.1...v2.9.0
[2.8.1]: https://github.com/codetheweb/muse/compare/v2.8.0...v2.8.1
[2.8.0]: https://github.com/codetheweb/muse/compare/v2.7.1...v2.8.0
[2.7.1]: https://github.com/codetheweb/muse/compare/v2.7.0...v2.7.1
[2.7.0]: https://github.com/codetheweb/muse/compare/v2.6.0...v2.7.0
[2.6.0]: https://github.com/codetheweb/muse/compare/v2.5.0...v2.6.0
[2.5.0]: https://github.com/codetheweb/muse/compare/v2.4.4...v2.5.0 [2.5.0]: https://github.com/codetheweb/muse/compare/v2.4.4...v2.5.0
[2.4.4]: https://github.com/codetheweb/muse/compare/v2.4.3...v2.4.4 [2.4.4]: https://github.com/codetheweb/muse/compare/v2.4.3...v2.4.4
[2.4.3]: https://github.com/codetheweb/muse/compare/v2.4.2...v2.4.3 [2.4.3]: https://github.com/codetheweb/muse/compare/v2.4.2...v2.4.3

View file

@ -1,15 +1,33 @@
FROM node:18.7.0-slim AS base FROM node:22-bookworm-slim AS base
# openssl will be a required package if base is updated to 18.16+ due to node:*-slim base distro change
# https://github.com/prisma/prisma/issues/19729#issuecomment-1591270599
# Install ffmpeg # Install ffmpeg
RUN apt-get update && \ RUN apt-get update \
apt-get install -y ffmpeg tini libssl-dev ca-certificates git && \ && apt-get install --no-install-recommends -y \
rm -rf /var/lib/apt/lists/* ffmpeg \
tini \
openssl \
ca-certificates \
&& apt-get autoclean \
&& apt-get autoremove \
&& rm -rf /var/lib/apt/lists/*
# Install dependencies # Install dependencies
FROM base AS dependencies FROM base AS dependencies
WORKDIR /usr/app WORKDIR /usr/app
# Add Python and build tools to compile native modules
RUN apt-get update \
&& apt-get install --no-install-recommends -y \
python3 \
python-is-python3 \
build-essential \
&& apt-get autoclean \
&& apt-get autoremove \
&& rm -rf /var/lib/apt/lists/*
COPY package.json . COPY package.json .
COPY yarn.lock . COPY yarn.lock .
@ -40,9 +58,10 @@ COPY . .
ARG COMMIT_HASH=unknown ARG COMMIT_HASH=unknown
ARG BUILD_DATE=unknown ARG BUILD_DATE=unknown
ENV DATA_DIR /data ENV DATA_DIR=/data
ENV NODE_ENV production ENV NODE_ENV=production
ENV COMMIT_HASH $COMMIT_HASH ENV COMMIT_HASH=$COMMIT_HASH
ENV BUILD_DATE $BUILD_DATE ENV BUILD_DATE=$BUILD_DATE
ENV ENV_FILE=/config
CMD ["tini", "--", "node", "--enable-source-maps", "dist/scripts/migrate-and-start.js"] CMD ["tini", "--", "node", "--enable-source-maps", "dist/scripts/migrate-and-start.js"]

View file

@ -1,8 +1,10 @@
<p align="center"> <p align="center">
<img width="250" height="250" src="https://raw.githubusercontent.com/codetheweb/muse/master/.github/logo.png"> <img width="250" height="250" src="https://raw.githubusercontent.com/museofficial/muse/master/.github/logo.png">
</p> </p>
🚨: v1.0.0 was a breaking change. Please take a look at the [release notes](https://github.com/codetheweb/muse/releases/tag/v1.0.0) for upgrade instructions > [!WARNING]
> I ([@codetheweb](https://github.com/codetheweb)) am no longer the primary maintainer of Muse. **If you use the Docker image, update your image source to `ghcr.io/museofficial/muse`.** We are currently publishing new releases to both `ghcr.io/museofficial/muse` and `codetheweb/muse`, but this may change in the future.
> Thank you to all the people who stepped up to help maintain Muse!
------ ------
@ -28,12 +30,9 @@ Muse is a **highly-opinionated midwestern self-hosted** Discord music bot **that
Muse is written in TypeScript. You can either run Muse with Docker (recommended) or directly with Node.js. Both methods require API keys passed in as environment variables: Muse is written in TypeScript. You can either run Muse with Docker (recommended) or directly with Node.js. Both methods require API keys passed in as environment variables:
- `DISCORD_TOKEN` can be acquired [here](https://discordapp.com/developers/applications) by creating a 'New Application', then going to 'Bot'. - `DISCORD_TOKEN` can be acquired [here](https://discordapp.com/developers/applications) by creating a 'New Application', then going to 'Bot'.
- `SPOTIFY_CLIENT_ID` and `SPOTIFY_CLIENT_SECRET` can be acquired [here](https://developer.spotify.com/dashboard/applications) with 'Create a Client ID'. - `SPOTIFY_CLIENT_ID` and `SPOTIFY_CLIENT_SECRET` can be acquired [here](https://developer.spotify.com/dashboard/applications) with 'Create a Client ID' (Optional).
- `YOUTUBE_API_KEY` can be acquired by [creating a new project](https://console.developers.google.com) in Google's Developer Console, enabling the YouTube API, and creating an API key under credentials. - `YOUTUBE_API_KEY` can be acquired by [creating a new project](https://console.developers.google.com) in Google's Developer Console, enabling the YouTube API, and creating an API key under credentials.
> [!WARNING]
> Even if you don't plan on using Spotify, you must still provide the client ID and secret; otherwise Muse will not function.
Muse will log a URL when run. Open this URL in a browser to invite Muse to your server. Muse will DM the server owner after it's added with setup instructions. Muse will log a URL when run. Open this URL in a browser to invite Muse to your server. Muse will DM the server owner after it's added with setup instructions.
A 64-bit OS is required to run Muse. A 64-bit OS is required to run Muse.
@ -42,7 +41,7 @@ A 64-bit OS is required to run Muse.
The `master` branch acts as the developing / bleeding edge branch and is not guaranteed to be stable. The `master` branch acts as the developing / bleeding edge branch and is not guaranteed to be stable.
When running a production instance, I recommend that you use the [latest release](https://github.com/codetheweb/muse/releases/). When running a production instance, I recommend that you use the [latest release](https://github.com/museofficial/muse/releases/).
### 🐳 Docker ### 🐳 Docker
@ -56,19 +55,19 @@ There are a variety of image tags available:
(Replace empty config strings with correct values.) (Replace empty config strings with correct values.)
```bash ```bash
docker run -it -v "$(pwd)/data":/data -e DISCORD_TOKEN='' -e SPOTIFY_CLIENT_ID='' -e SPOTIFY_CLIENT_SECRET='' -e YOUTUBE_API_KEY='' codetheweb/muse:latest docker run -it -v "$(pwd)/data":/data -e DISCORD_TOKEN='' -e SPOTIFY_CLIENT_ID='' -e SPOTIFY_CLIENT_SECRET='' -e YOUTUBE_API_KEY='' ghcr.io/museofficial/muse:latest
``` ```
This starts Muse and creates a data directory in your current directory. This starts Muse and creates a data directory in your current directory.
You can also store your tokens in an environment file and make it available to your container. By default, the container will look for a `/config` environment file. You can customize this path with the `ENV_FILE` environment variable to use with, for example, [docker secrets](https://docs.docker.com/engine/swarm/secrets/).
**Docker Compose**: **Docker Compose**:
```yaml ```yaml
version: '3.4'
services: services:
muse: muse:
image: codetheweb/muse:latest image: ghcr.io/museofficial/muse:latest
restart: always restart: always
volumes: volumes:
- ./muse:/data - ./muse:/data
@ -82,16 +81,16 @@ services:
### Node.js ### Node.js
**Prerequisites**: **Prerequisites**:
* Node.js (16.x is recommended because it's the current LTS version) * Node.js (18.17.0 or latest 18.xx.xx is required and latest 18.x.x LTS is recommended) (Version 18 due to opus dependency)
* ffmpeg (4.1 or later) * ffmpeg (4.1 or later)
1. `git clone https://github.com/codetheweb/muse.git && cd muse` 1. `git clone https://github.com/museofficial/muse.git && cd muse`
2. Copy `.env.example` to `.env` and populate with values 2. Copy `.env.example` to `.env` and populate with values
3. I recommend checking out a tagged release with `git checkout v[latest release]` 3. I recommend checking out a tagged release with `git checkout v[latest release]`
4. `yarn install` (or `npm i`) 4. `yarn install` (or `npm i`)
5. `yarn start` (or `npm run start`) 5. `yarn start` (or `npm run start`)
**Note**: if you're on Windows, you may need to manually set the ffmpeg path. See [#345](https://github.com/codetheweb/muse/issues/345) for details. **Note**: if you're on Windows, you may need to manually set the ffmpeg path. See [#345](https://github.com/museofficial/muse/issues/345) for details.
## ⚙️ Additional configuration (advanced) ## ⚙️ Additional configuration (advanced)
@ -99,6 +98,11 @@ services:
By default, Muse limits the total cache size to around 2 GB. If you want to change this, set the environment variable `CACHE_LIMIT`. For example, `CACHE_LIMIT=512MB` or `CACHE_LIMIT=10GB`. By default, Muse limits the total cache size to around 2 GB. If you want to change this, set the environment variable `CACHE_LIMIT`. For example, `CACHE_LIMIT=512MB` or `CACHE_LIMIT=10GB`.
### SponsorBlock
Muse can skip non-music segments at the beginning or end of a Youtube music video (Using [SponsorBlock](https://sponsor.ajay.app/)). It is disabled by default. If you want to enable it, set the environment variable `ENABLE_SPONSORBLOCK=true` or uncomment it in your .env.
Being a community project, the server may be down or overloaded. When it happen, Muse will skip requests to SponsorBlock for a few minutes. You can change the skip duration by setting the value of `SPONSORBLOCK_TIMEOUT`.
### Custom Bot Status ### Custom Bot Status
In the default state, Muse has the status "Online" and the text "Listening to Music". You can change the status through environment variables: In the default state, Muse has the status "Online" and the text "Listening to Music". You can change the status through environment variables:
@ -134,3 +138,12 @@ In the default state, Muse has the status "Online" and the text "Listening to Mu
### Bot-wide commands ### Bot-wide commands
If you have Muse running in a lot of guilds (10+) you may want to switch to registering commands bot-wide rather than for each guild. (The downside to this is that command updates can take up to an hour to propagate.) To do this, set the environment variable `REGISTER_COMMANDS_ON_BOT` to `true`. If you have Muse running in a lot of guilds (10+) you may want to switch to registering commands bot-wide rather than for each guild. (The downside to this is that command updates can take up to an hour to propagate.) To do this, set the environment variable `REGISTER_COMMANDS_ON_BOT` to `true`.
### Automatically turn down volume when people speak
You can configure the bot to automatically turn down the volume when people are speaking in the channel using the following commands:
- `/config set-reduce-vol-when-voice true` - Enable automatic volume reduction
- `/config set-reduce-vol-when-voice false` - Disable automatic volume reduction
- `/config set-reduce-vol-when-voice-target <volume>` - Set the target volume percentage when people speak (0-100, default is 70)

5
RELEASING.md Normal file
View file

@ -0,0 +1,5 @@
# Releasing
1. Confirm that CHANGELOG.md is updated (any new changes should go under "Unreleased" at the top).
2. On the master branch, run `yarn release` and follow the prompts.
3. After a new tag is pushed from the above step, the [publish workflow](./.github/workflows/publish.yml) will automatically build & push Docker images and create a GitHub release for the tag.

View file

@ -0,0 +1,17 @@
-- RedefineTables
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Setting" (
"guildId" TEXT NOT NULL PRIMARY KEY,
"playlistLimit" INTEGER NOT NULL DEFAULT 50,
"secondsToWaitAfterQueueEmpties" INTEGER NOT NULL DEFAULT 30,
"leaveIfNoListeners" BOOLEAN NOT NULL DEFAULT true,
"autoAnnounceNextSong" BOOLEAN NOT NULL DEFAULT false,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
INSERT INTO "new_Setting" ("createdAt", "guildId", "leaveIfNoListeners", "playlistLimit", "secondsToWaitAfterQueueEmpties", "updatedAt") SELECT "createdAt", "guildId", "leaveIfNoListeners", "playlistLimit", "secondsToWaitAfterQueueEmpties", "updatedAt" FROM "Setting";
DROP TABLE "Setting";
ALTER TABLE "new_Setting" RENAME TO "Setting";
PRAGMA foreign_key_check;
PRAGMA foreign_keys=ON;

View file

@ -0,0 +1,17 @@
-- RedefineTables
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Setting" (
"guildId" TEXT NOT NULL PRIMARY KEY,
"playlistLimit" INTEGER NOT NULL DEFAULT 50,
"secondsToWaitAfterQueueEmpties" INTEGER NOT NULL DEFAULT 30,
"leaveIfNoListeners" BOOLEAN NOT NULL DEFAULT true,
"autoAnnounceNextSong" BOOLEAN NOT NULL DEFAULT false,
"defaultVolume" INTEGER NOT NULL DEFAULT 100,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
INSERT INTO "new_Setting" ("autoAnnounceNextSong", "createdAt", "guildId", "leaveIfNoListeners", "playlistLimit", "secondsToWaitAfterQueueEmpties", "updatedAt") SELECT "autoAnnounceNextSong", "createdAt", "guildId", "leaveIfNoListeners", "playlistLimit", "secondsToWaitAfterQueueEmpties", "updatedAt" FROM "Setting";
DROP TABLE "Setting";
ALTER TABLE "new_Setting" RENAME TO "Setting";
PRAGMA foreign_key_check;
PRAGMA foreign_keys=ON;

View file

@ -0,0 +1,18 @@
-- RedefineTables
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Setting" (
"guildId" TEXT NOT NULL PRIMARY KEY,
"playlistLimit" INTEGER NOT NULL DEFAULT 50,
"secondsToWaitAfterQueueEmpties" INTEGER NOT NULL DEFAULT 30,
"leaveIfNoListeners" BOOLEAN NOT NULL DEFAULT true,
"queueAddResponseEphemeral" BOOLEAN NOT NULL DEFAULT false,
"autoAnnounceNextSong" BOOLEAN NOT NULL DEFAULT false,
"defaultVolume" INTEGER NOT NULL DEFAULT 100,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
INSERT INTO "new_Setting" ("autoAnnounceNextSong", "createdAt", "defaultVolume", "guildId", "leaveIfNoListeners", "playlistLimit", "secondsToWaitAfterQueueEmpties", "updatedAt") SELECT "autoAnnounceNextSong", "createdAt", "defaultVolume", "guildId", "leaveIfNoListeners", "playlistLimit", "secondsToWaitAfterQueueEmpties", "updatedAt" FROM "Setting";
DROP TABLE "Setting";
ALTER TABLE "new_Setting" RENAME TO "Setting";
PRAGMA foreign_key_check;
PRAGMA foreign_keys=ON;

View file

@ -0,0 +1,19 @@
-- RedefineTables
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Setting" (
"guildId" TEXT NOT NULL PRIMARY KEY,
"playlistLimit" INTEGER NOT NULL DEFAULT 50,
"secondsToWaitAfterQueueEmpties" INTEGER NOT NULL DEFAULT 30,
"leaveIfNoListeners" BOOLEAN NOT NULL DEFAULT true,
"queueAddResponseEphemeral" BOOLEAN NOT NULL DEFAULT false,
"autoAnnounceNextSong" BOOLEAN NOT NULL DEFAULT false,
"defaultVolume" INTEGER NOT NULL DEFAULT 100,
"defaultQueuePageSize" INTEGER NOT NULL DEFAULT 10,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
INSERT INTO "new_Setting" ("autoAnnounceNextSong", "createdAt", "defaultVolume", "guildId", "leaveIfNoListeners", "playlistLimit", "queueAddResponseEphemeral", "secondsToWaitAfterQueueEmpties", "updatedAt") SELECT "autoAnnounceNextSong", "createdAt", "defaultVolume", "guildId", "leaveIfNoListeners", "playlistLimit", "queueAddResponseEphemeral", "secondsToWaitAfterQueueEmpties", "updatedAt" FROM "Setting";
DROP TABLE "Setting";
ALTER TABLE "new_Setting" RENAME TO "Setting";
PRAGMA foreign_key_check;
PRAGMA foreign_keys=ON;

View file

@ -0,0 +1,21 @@
-- RedefineTables
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Setting" (
"guildId" TEXT NOT NULL PRIMARY KEY,
"playlistLimit" INTEGER NOT NULL DEFAULT 50,
"secondsToWaitAfterQueueEmpties" INTEGER NOT NULL DEFAULT 30,
"leaveIfNoListeners" BOOLEAN NOT NULL DEFAULT true,
"queueAddResponseEphemeral" BOOLEAN NOT NULL DEFAULT false,
"autoAnnounceNextSong" BOOLEAN NOT NULL DEFAULT false,
"defaultVolume" INTEGER NOT NULL DEFAULT 100,
"defaultQueuePageSize" INTEGER NOT NULL DEFAULT 10,
"turnDownVolumeWhenPeopleSpeak" BOOLEAN NOT NULL DEFAULT false,
"turnDownVolumeWhenPeopleSpeakTarget" INTEGER NOT NULL DEFAULT 20,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
INSERT INTO "new_Setting" ("autoAnnounceNextSong", "createdAt", "defaultQueuePageSize", "defaultVolume", "guildId", "leaveIfNoListeners", "playlistLimit", "queueAddResponseEphemeral", "secondsToWaitAfterQueueEmpties", "updatedAt") SELECT "autoAnnounceNextSong", "createdAt", "defaultQueuePageSize", "defaultVolume", "guildId", "leaveIfNoListeners", "playlistLimit", "queueAddResponseEphemeral", "secondsToWaitAfterQueueEmpties", "updatedAt" FROM "Setting";
DROP TABLE "Setting";
ALTER TABLE "new_Setting" RENAME TO "Setting";
PRAGMA foreign_key_check;
PRAGMA foreign_keys=ON;

View file

@ -1,15 +1,15 @@
{ {
"name": "muse", "name": "muse",
"version": "2.5.0", "version": "2.11.1",
"description": "🎧 a self-hosted Discord music bot that doesn't suck ", "description": "🎧 a self-hosted Discord music bot that doesn't suck ",
"repository": "git@github.com:codetheweb/muse.git", "repository": "git@github.com:museofficial/muse.git",
"author": "Max Isom <hi@maxisom.me>", "author": "Max Isom <hi@maxisom.me>",
"license": "MIT", "license": "MIT",
"private": true, "private": true,
"types": "dts/types", "types": "dts/types",
"type": "module", "type": "module",
"engines": { "engines": {
"node": ">=16.0.0" "node": ">=18.17.0"
}, },
"files": [ "files": [
"src" "src"
@ -25,9 +25,11 @@
"migrations:generate": "npm run prisma:with-env migrate dev", "migrations:generate": "npm run prisma:with-env migrate dev",
"migrations:run": "npm run prisma:with-env migrate deploy", "migrations:run": "npm run prisma:with-env migrate deploy",
"prisma:with-env": "npm run env:set-database-url prisma", "prisma:with-env": "npm run env:set-database-url prisma",
"prisma:generate": "prisma generate",
"env:set-database-url": "tsx src/scripts/run-with-database-url.ts", "env:set-database-url": "tsx src/scripts/run-with-database-url.ts",
"release": "release-it", "release": "release-it",
"build": "tsc" "build": "tsc",
"postinstall": "patch-package"
}, },
"devDependencies": { "devDependencies": {
"@release-it/keep-a-changelog": "^2.3.0", "@release-it/keep-a-changelog": "^2.3.0",
@ -35,19 +37,20 @@
"@types/debug": "^4.1.5", "@types/debug": "^4.1.5",
"@types/fluent-ffmpeg": "^2.1.17", "@types/fluent-ffmpeg": "^2.1.17",
"@types/fs-capacitor": "^2.0.0", "@types/fs-capacitor": "^2.0.0",
"@types/ms": "0.7.31", "@types/ms": "0.7.34",
"@types/node": "^17.0.0", "@types/node": "^17.0.0",
"@types/node-emoji": "^1.8.1", "@types/node-emoji": "^1.8.1",
"@types/spotify-web-api-node": "^5.0.2", "@types/spotify-web-api-node": "^5.0.2",
"@types/validator": "^13.1.4", "@types/validator": "^13.12.2",
"@types/ws": "8.5.4", "@types/ws": "8.5.4",
"@typescript-eslint/eslint-plugin": "^4.31.1", "@typescript-eslint/eslint-plugin": "^4.31.1",
"@typescript-eslint/parser": "^4.31.1", "@typescript-eslint/parser": "^4.31.1",
"eslint": "^7.32.0", "eslint": "^7.32.0",
"eslint-config-xo": "^0.39.0", "eslint-config-xo": "^0.39.0",
"eslint-config-xo-typescript": "^0.44.0", "eslint-config-xo-typescript": "^0.44.0",
"eslint-plugin-import": "2.29.1",
"husky": "^4.3.8", "husky": "^4.3.8",
"prisma": "^3.14.0", "prisma": "5.21.1",
"release-it": "^14.11.8", "release-it": "^14.11.8",
"type-fest": "^2.12.0", "type-fest": "^2.12.0",
"typescript": "^4.6.4" "typescript": "^4.6.4"
@ -61,12 +64,19 @@
"parserOptions": { "parserOptions": {
"project": "./tsconfig.json" "project": "./tsconfig.json"
}, },
"plugins": [
"import"
],
"rules": { "rules": {
"new-cap": "off", "new-cap": "off",
"@typescript-eslint/no-unused-vars": "off", "@typescript-eslint/no-unused-vars": "off",
"@typescript-eslint/no-unused-vars-experimental": "error", "@typescript-eslint/no-unused-vars-experimental": "error",
"@typescript-eslint/prefer-readonly-parameter-types": "off", "@typescript-eslint/prefer-readonly-parameter-types": "off",
"@typescript-eslint/no-implicit-any-catch": "off" "@typescript-eslint/no-implicit-any-catch": "off",
"import/extensions": [
"error",
"ignorePackages"
]
} }
}, },
"husky": { "husky": {
@ -76,47 +86,52 @@
}, },
"dependencies": { "dependencies": {
"@discordjs/builders": "1.1.0", "@discordjs/builders": "1.1.0",
"@discordjs/opus": "^0.8.0", "@discordjs/opus": "^0.10.0",
"@discordjs/rest": "1.0.1", "@discordjs/rest": "1.0.1",
"@discordjs/voice": "0.11.0", "@discordjs/voice": "0.18.0",
"@prisma/client": "^4.1.1", "@distube/ytdl-core": "^4.16.10",
"@distube/ytsr": "^2.0.4",
"@prisma/client": "4.16.0",
"@types/libsodium-wrappers": "^0.7.9", "@types/libsodium-wrappers": "^0.7.9",
"array-shuffle": "^3.0.0", "array-shuffle": "^3.0.0",
"debug": "^4.3.3", "debug": "^4.3.3",
"delay": "^5.0.0", "delay": "^5.0.0",
"discord-api-types": "0.32.1", "discord-api-types": "0.32.1",
"discord.js": "14.11.0", "discord.js": "14.11.0",
"dotenv": "^16.0.0", "dotenv": "^16.4.5",
"execa": "^6.1.0", "execa": "^6.1.0",
"fluent-ffmpeg": "^2.1.2", "fluent-ffmpeg": "^2.1.3",
"fs-capacitor": "^7.0.1", "fs-capacitor": "^7.0.1",
"get-youtube-id": "^1.0.1", "get-youtube-id": "^1.0.1",
"got": "^12.0.2", "got": "^12.0.2",
"hasha": "^5.2.2", "hasha": "^5.2.2",
"inversify": "^6.0.1", "inversify": "^6.0.1",
"iso8601-duration": "^1.3.0", "iso8601-duration": "^2.1.2",
"libsodium-wrappers": "^0.7.9", "libsodium-wrappers": "^0.7.9",
"make-dir": "^3.1.0", "make-dir": "^3.1.0",
"node-emoji": "^1.10.0", "node-emoji": "^1.10.0",
"nodesplash": "^0.1.1", "nodesplash": "^0.1.1",
"ora": "^6.1.0", "ora": "^8.1.0",
"p-event": "^5.0.1", "p-event": "^5.0.1",
"p-limit": "^4.0.0", "p-limit": "^6.1.0",
"p-queue": "^7.2.0", "p-queue": "8.1.0",
"p-retry": "4.6.2", "p-retry": "6.2.0",
"pagination.djs": "^4.0.10", "pagination.djs": "^4.0.10",
"parse-duration": "1.0.2", "parse-duration": "1.0.2",
"patch-package": "^8.0.0",
"postinstall-postinstall": "^2.1.0",
"read-pkg": "7.1.0", "read-pkg": "7.1.0",
"reflect-metadata": "^0.1.13", "reflect-metadata": "^0.2.2",
"sponsorblock-api": "^0.2.4",
"spotify-uri": "^3.0.2", "spotify-uri": "^3.0.2",
"spotify-web-api-node": "^5.0.2", "spotify-web-api-node": "^5.0.2",
"sync-fetch": "^0.3.1", "sync-fetch": "^0.3.1",
"tsx": "3.8.2", "tsx": "3.8.2",
"xbytes": "^1.7.0", "xbytes": "^1.7.0",
"ytdl-core": "git+https://github.com/khlevon/node-ytdl-core.git#v4.11.4-patch.2",
"ytsr": "^3.8.4" "ytsr": "^3.8.4"
}, },
"resolutions": { "resolutions": {
"@types/ws": "8.5.4" "@types/ws": "8.5.4"
} },
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
} }

View file

@ -28,6 +28,12 @@ model Setting {
playlistLimit Int @default(50) playlistLimit Int @default(50)
secondsToWaitAfterQueueEmpties Int @default(30) secondsToWaitAfterQueueEmpties Int @default(30)
leaveIfNoListeners Boolean @default(true) leaveIfNoListeners Boolean @default(true)
queueAddResponseEphemeral Boolean @default(false)
autoAnnounceNextSong Boolean @default(false)
defaultVolume Int @default(100)
defaultQueuePageSize Int @default(10)
turnDownVolumeWhenPeopleSpeak Boolean @default(false)
turnDownVolumeWhenPeopleSpeakTarget Int @default(20)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }

View file

@ -3,7 +3,7 @@ import {ChatInputCommandInteraction} from 'discord.js';
import {SlashCommandBuilder} from '@discordjs/builders'; import {SlashCommandBuilder} from '@discordjs/builders';
import {TYPES} from '../types.js'; import {TYPES} from '../types.js';
import PlayerManager from '../managers/player.js'; import PlayerManager from '../managers/player.js';
import Command from '.'; import Command from './index.js';
@injectable() @injectable()
export default class implements Command { export default class implements Command {

View file

@ -33,11 +33,62 @@ export default class implements Command {
.setName('value') .setName('value')
.setDescription('whether to leave when everyone else leaves') .setDescription('whether to leave when everyone else leaves')
.setRequired(true))) .setRequired(true)))
.addSubcommand(subcommand => subcommand
.setName('set-queue-add-response-hidden')
.setDescription('set whether bot responses to queue additions are only displayed to the requester')
.addBooleanOption(option => option
.setName('value')
.setDescription('whether bot responses to queue additions are only displayed to the requester')
.setRequired(true)))
.addSubcommand(subcommand => subcommand
.setName('set-reduce-vol-when-voice')
.setDescription('set whether to turn down the volume when people speak')
.addBooleanOption(option => option
.setName('value')
.setDescription('whether to turn down the volume when people speak')
.setRequired(true)))
.addSubcommand(subcommand => subcommand
.setName('set-reduce-vol-when-voice-target')
.setDescription('set the target volume when people speak')
.addIntegerOption(option => option
.setName('volume')
.setDescription('volume percentage (0 is muted, 100 is max & default)')
.setMinValue(0)
.setMaxValue(100)
.setRequired(true)))
.addSubcommand(subcommand => subcommand
.setName('set-auto-announce-next-song')
.setDescription('set whether to announce the next song in the queue automatically')
.addBooleanOption(option => option
.setName('value')
.setDescription('whether to announce the next song in the queue automatically')
.setRequired(true)))
.addSubcommand(subcommand => subcommand
.setName('set-default-volume')
.setDescription('set default volume used when entering the voice channel')
.addIntegerOption(option => option
.setName('level')
.setDescription('volume percentage (0 is muted, 100 is max & default)')
.setMinValue(0)
.setMaxValue(100)
.setRequired(true)))
.addSubcommand(subcommand => subcommand
.setName('set-default-queue-page-size')
.setDescription('set the default page size of the /queue command')
.addIntegerOption(option => option
.setName('page-size')
.setDescription('page size of the /queue command')
.setMinValue(1)
.setMaxValue(30)
.setRequired(true)))
.addSubcommand(subcommand => subcommand .addSubcommand(subcommand => subcommand
.setName('get') .setName('get')
.setDescription('show all settings')); .setDescription('show all settings'));
async execute(interaction: ChatInputCommandInteraction) { async execute(interaction: ChatInputCommandInteraction) {
// Ensure guild settings exist before trying to update
await getGuildSettings(interaction.guild!.id);
switch (interaction.options.getSubcommand()) { switch (interaction.options.getSubcommand()) {
case 'set-playlist-limit': { case 'set-playlist-limit': {
const limit: number = interaction.options.getInteger('limit')!; const limit: number = interaction.options.getInteger('limit')!;
@ -94,6 +145,108 @@ export default class implements Command {
break; break;
} }
case 'set-queue-add-response-hidden': {
const value = interaction.options.getBoolean('value')!;
await prisma.setting.update({
where: {
guildId: interaction.guild!.id,
},
data: {
queueAddResponseEphemeral: value,
},
});
await interaction.reply('👍 queue add notification setting updated');
break;
}
case 'set-auto-announce-next-song': {
const value = interaction.options.getBoolean('value')!;
await prisma.setting.update({
where: {
guildId: interaction.guild!.id,
},
data: {
autoAnnounceNextSong: value,
},
});
await interaction.reply('👍 auto announce setting updated');
break;
}
case 'set-default-volume': {
const value = interaction.options.getInteger('level')!;
await prisma.setting.update({
where: {
guildId: interaction.guild!.id,
},
data: {
defaultVolume: value,
},
});
await interaction.reply('👍 volume setting updated');
break;
}
case 'set-default-queue-page-size': {
const value = interaction.options.getInteger('page-size')!;
await prisma.setting.update({
where: {
guildId: interaction.guild!.id,
},
data: {
defaultQueuePageSize: value,
},
});
await interaction.reply('👍 default queue page size updated');
break;
}
case 'set-reduce-vol-when-voice': {
const value = interaction.options.getBoolean('value')!;
await prisma.setting.update({
where: {
guildId: interaction.guild!.id,
},
data: {
turnDownVolumeWhenPeopleSpeak: value,
},
});
await interaction.reply('👍 turn down volume setting updated');
break;
}
case 'set-reduce-vol-when-voice-target': {
const value = interaction.options.getInteger('volume')!;
await prisma.setting.update({
where: {
guildId: interaction.guild!.id,
},
data: {
turnDownVolumeWhenPeopleSpeakTarget: value,
},
});
await interaction.reply('👍 turn down volume target setting updated');
break;
}
case 'get': { case 'get': {
const embed = new EmbedBuilder().setTitle('Config'); const embed = new EmbedBuilder().setTitle('Config');
@ -105,6 +258,11 @@ export default class implements Command {
? 'never leave' ? 'never leave'
: `${config.secondsToWaitAfterQueueEmpties}s`, : `${config.secondsToWaitAfterQueueEmpties}s`,
'Leave if there are no listeners': config.leaveIfNoListeners ? 'yes' : 'no', 'Leave if there are no listeners': config.leaveIfNoListeners ? 'yes' : 'no',
'Auto announce next song in queue': config.autoAnnounceNextSong ? 'yes' : 'no',
'Add to queue reponses show for requester only': config.autoAnnounceNextSong ? 'yes' : 'no',
'Default Volume': config.defaultVolume,
'Default queue page size': config.defaultQueuePageSize,
'Reduce volume when people speak': config.turnDownVolumeWhenPeopleSpeak ? 'yes' : 'no',
}; };
let description = ''; let description = '';

View file

@ -3,7 +3,7 @@ import {SlashCommandBuilder} from '@discordjs/builders';
import {TYPES} from '../types.js'; import {TYPES} from '../types.js';
import {inject, injectable} from 'inversify'; import {inject, injectable} from 'inversify';
import PlayerManager from '../managers/player.js'; import PlayerManager from '../managers/player.js';
import Command from '.'; import Command from './index.js';
@injectable() @injectable()
export default class implements Command { export default class implements Command {

View file

@ -1,7 +1,7 @@
import {SlashCommandBuilder} from '@discordjs/builders'; import {SlashCommandBuilder} from '@discordjs/builders';
import {APIEmbedField, AutocompleteInteraction, ChatInputCommandInteraction} from 'discord.js'; import {APIEmbedField, AutocompleteInteraction, ChatInputCommandInteraction} from 'discord.js';
import {inject, injectable} from 'inversify'; import {inject, injectable} from 'inversify';
import Command from '.'; import Command from './index.js';
import AddQueryToQueue from '../services/add-query-to-queue.js'; import AddQueryToQueue from '../services/add-query-to-queue.js';
import {TYPES} from '../types.js'; import {TYPES} from '../types.js';
import {prisma} from '../utils/db.js'; import {prisma} from '../utils/db.js';
@ -28,7 +28,10 @@ export default class implements Command {
.setDescription('shuffle the input if you\'re adding multiple tracks')) .setDescription('shuffle the input if you\'re adding multiple tracks'))
.addBooleanOption(option => option .addBooleanOption(option => option
.setName('split') .setName('split')
.setDescription('if a track has chapters, split it'))) .setDescription('if a track has chapters, split it'))
.addBooleanOption(option => option
.setName('skip')
.setDescription('skip the currently playing track')))
.addSubcommand(subcommand => subcommand .addSubcommand(subcommand => subcommand
.setName('list') .setName('list')
.setDescription('list all favorites')) .setDescription('list all favorites'))
@ -124,6 +127,7 @@ export default class implements Command {
shuffleAdditions: interaction.options.getBoolean('shuffle') ?? false, shuffleAdditions: interaction.options.getBoolean('shuffle') ?? false,
addToFrontOfQueue: interaction.options.getBoolean('immediate') ?? false, addToFrontOfQueue: interaction.options.getBoolean('immediate') ?? false,
shouldSplitChapters: interaction.options.getBoolean('split') ?? false, shouldSplitChapters: interaction.options.getBoolean('split') ?? false,
skipCurrentTrack: interaction.options.getBoolean('skip') ?? false,
}); });
} }

View file

@ -3,7 +3,7 @@ import {SlashCommandBuilder} from '@discordjs/builders';
import {TYPES} from '../types.js'; import {TYPES} from '../types.js';
import {inject, injectable} from 'inversify'; import {inject, injectable} from 'inversify';
import PlayerManager from '../managers/player.js'; import PlayerManager from '../managers/player.js';
import Command from '.'; import Command from './index.js';
import {prettyTime} from '../utils/time.js'; import {prettyTime} from '../utils/time.js';
import durationStringToSeconds from '../utils/duration-string-to-seconds.js'; import durationStringToSeconds from '../utils/duration-string-to-seconds.js';

View file

@ -2,7 +2,7 @@ import {ChatInputCommandInteraction} from 'discord.js';
import {TYPES} from '../types.js'; import {TYPES} from '../types.js';
import {inject, injectable} from 'inversify'; import {inject, injectable} from 'inversify';
import PlayerManager from '../managers/player.js'; import PlayerManager from '../managers/player.js';
import Command from '.'; import Command from './index.js';
import {SlashCommandBuilder} from '@discordjs/builders'; import {SlashCommandBuilder} from '@discordjs/builders';
import {STATUS} from '../services/player.js'; import {STATUS} from '../services/player.js';

View file

@ -2,7 +2,7 @@ import {ChatInputCommandInteraction} from 'discord.js';
import {TYPES} from '../types.js'; import {TYPES} from '../types.js';
import {inject, injectable} from 'inversify'; import {inject, injectable} from 'inversify';
import PlayerManager from '../managers/player.js'; import PlayerManager from '../managers/player.js';
import Command from '.'; import Command from './index.js';
import {SlashCommandBuilder} from '@discordjs/builders'; import {SlashCommandBuilder} from '@discordjs/builders';
import {STATUS} from '../services/player.js'; import {STATUS} from '../services/player.js';

View file

@ -2,7 +2,7 @@ import {ChatInputCommandInteraction} from 'discord.js';
import {inject, injectable} from 'inversify'; import {inject, injectable} from 'inversify';
import {TYPES} from '../types.js'; import {TYPES} from '../types.js';
import PlayerManager from '../managers/player.js'; import PlayerManager from '../managers/player.js';
import Command from '.'; import Command from './index.js';
import {SlashCommandBuilder} from '@discordjs/builders'; import {SlashCommandBuilder} from '@discordjs/builders';
@injectable() @injectable()

View file

@ -2,7 +2,7 @@ import {ChatInputCommandInteraction} from 'discord.js';
import {TYPES} from '../types.js'; import {TYPES} from '../types.js';
import {inject, injectable} from 'inversify'; import {inject, injectable} from 'inversify';
import PlayerManager from '../managers/player.js'; import PlayerManager from '../managers/player.js';
import Command from '.'; import Command from './index.js';
import {SlashCommandBuilder} from '@discordjs/builders'; import {SlashCommandBuilder} from '@discordjs/builders';
import {buildPlayingMessageEmbed} from '../utils/build-embed.js'; import {buildPlayingMessageEmbed} from '../utils/build-embed.js';

View file

@ -4,7 +4,7 @@ import {TYPES} from '../types.js';
import {inject, injectable} from 'inversify'; import {inject, injectable} from 'inversify';
import PlayerManager from '../managers/player.js'; import PlayerManager from '../managers/player.js';
import {STATUS} from '../services/player.js'; import {STATUS} from '../services/player.js';
import Command from '.'; import Command from './index.js';
@injectable() @injectable()
export default class implements Command { export default class implements Command {

View file

@ -1,9 +1,9 @@
import {AutocompleteInteraction, ChatInputCommandInteraction} from 'discord.js'; import {AutocompleteInteraction, ChatInputCommandInteraction} from 'discord.js';
import {URL} from 'url'; import {URL} from 'url';
import {SlashCommandBuilder} from '@discordjs/builders'; import {SlashCommandBuilder, SlashCommandSubcommandsOnlyBuilder} from '@discordjs/builders';
import {inject, injectable} from 'inversify'; import {inject, injectable, optional} from 'inversify';
import Spotify from 'spotify-web-api-node'; import Spotify from 'spotify-web-api-node';
import Command from '.'; import Command from './index.js';
import {TYPES} from '../types.js'; import {TYPES} from '../types.js';
import ThirdParty from '../services/third-party.js'; import ThirdParty from '../services/third-party.js';
import getYouTubeAndSpotifySuggestionsFor from '../utils/get-youtube-and-spotify-suggestions-for.js'; import getYouTubeAndSpotifySuggestionsFor from '../utils/get-youtube-and-spotify-suggestions-for.js';
@ -13,12 +13,29 @@ import AddQueryToQueue from '../services/add-query-to-queue.js';
@injectable() @injectable()
export default class implements Command { export default class implements Command {
public readonly slashCommand = new SlashCommandBuilder() public readonly slashCommand: Partial<SlashCommandBuilder | SlashCommandSubcommandsOnlyBuilder> & Pick<SlashCommandBuilder, 'toJSON'>;
public requiresVC = true;
private readonly spotify?: Spotify;
private readonly cache: KeyValueCacheProvider;
private readonly addQueryToQueue: AddQueryToQueue;
constructor(@inject(TYPES.ThirdParty) @optional() thirdParty: ThirdParty, @inject(TYPES.KeyValueCache) cache: KeyValueCacheProvider, @inject(TYPES.Services.AddQueryToQueue) addQueryToQueue: AddQueryToQueue) {
this.spotify = thirdParty?.spotify;
this.cache = cache;
this.addQueryToQueue = addQueryToQueue;
const queryDescription = thirdParty === undefined
? 'YouTube URL or search query'
: 'YouTube URL, Spotify URL, or search query';
this.slashCommand = new SlashCommandBuilder()
.setName('play') .setName('play')
.setDescription('play a song') .setDescription('play a song')
.addStringOption(option => option .addStringOption(option => option
.setName('query') .setName('query')
.setDescription('YouTube URL, Spotify URL, or search query') .setDescription(queryDescription)
.setAutocomplete(true) .setAutocomplete(true)
.setRequired(true)) .setRequired(true))
.addBooleanOption(option => option .addBooleanOption(option => option
@ -29,18 +46,10 @@ export default class implements Command {
.setDescription('shuffle the input if you\'re adding multiple tracks')) .setDescription('shuffle the input if you\'re adding multiple tracks'))
.addBooleanOption(option => option .addBooleanOption(option => option
.setName('split') .setName('split')
.setDescription('if a track has chapters, split it')); .setDescription('if a track has chapters, split it'))
.addBooleanOption(option => option
public requiresVC = true; .setName('skip')
.setDescription('skip the currently playing track'));
private readonly spotify: Spotify;
private readonly cache: KeyValueCacheProvider;
private readonly addQueryToQueue: AddQueryToQueue;
constructor(@inject(TYPES.ThirdParty) thirdParty: ThirdParty, @inject(TYPES.KeyValueCache) cache: KeyValueCacheProvider, @inject(TYPES.Services.AddQueryToQueue) addQueryToQueue: AddQueryToQueue) {
this.spotify = thirdParty.spotify;
this.cache = cache;
this.addQueryToQueue = addQueryToQueue;
} }
public async execute(interaction: ChatInputCommandInteraction): Promise<void> { public async execute(interaction: ChatInputCommandInteraction): Promise<void> {
@ -52,6 +61,7 @@ export default class implements Command {
addToFrontOfQueue: interaction.options.getBoolean('immediate') ?? false, addToFrontOfQueue: interaction.options.getBoolean('immediate') ?? false,
shuffleAdditions: interaction.options.getBoolean('shuffle') ?? false, shuffleAdditions: interaction.options.getBoolean('shuffle') ?? false,
shouldSplitChapters: interaction.options.getBoolean('split') ?? false, shouldSplitChapters: interaction.options.getBoolean('split') ?? false,
skipCurrentTrack: interaction.options.getBoolean('skip') ?? false,
}); });
} }

View file

@ -3,8 +3,9 @@ import {SlashCommandBuilder} from '@discordjs/builders';
import {inject, injectable} from 'inversify'; import {inject, injectable} from 'inversify';
import {TYPES} from '../types.js'; import {TYPES} from '../types.js';
import PlayerManager from '../managers/player.js'; import PlayerManager from '../managers/player.js';
import Command from '.'; import Command from './index.js';
import {buildQueueEmbed} from '../utils/build-embed.js'; import {buildQueueEmbed} from '../utils/build-embed.js';
import {getGuildSettings} from '../utils/get-guild-settings.js';
@injectable() @injectable()
export default class implements Command { export default class implements Command {
@ -14,6 +15,12 @@ export default class implements Command {
.addIntegerOption(option => option .addIntegerOption(option => option
.setName('page') .setName('page')
.setDescription('page of queue to show [default: 1]') .setDescription('page of queue to show [default: 1]')
.setRequired(false))
.addIntegerOption(option => option
.setName('page-size')
.setDescription('how many items to display per page [default: 10, max: 30]')
.setMinValue(1)
.setMaxValue(30)
.setRequired(false)); .setRequired(false));
private readonly playerManager: PlayerManager; private readonly playerManager: PlayerManager;
@ -23,9 +30,17 @@ export default class implements Command {
} }
public async execute(interaction: ChatInputCommandInteraction) { public async execute(interaction: ChatInputCommandInteraction) {
const player = this.playerManager.get(interaction.guild!.id); const guildId = interaction.guild!.id;
const player = this.playerManager.get(guildId);
const embed = buildQueueEmbed(player, interaction.options.getInteger('page') ?? 1); const pageSizeFromOptions = interaction.options.getInteger('page-size');
const pageSize = pageSizeFromOptions ?? (await getGuildSettings(guildId)).defaultQueuePageSize;
const embed = buildQueueEmbed(
player,
interaction.options.getInteger('page') ?? 1,
pageSize,
);
await interaction.reply({embeds: [embed]}); await interaction.reply({embeds: [embed]});
} }

View file

@ -2,7 +2,7 @@ import {ChatInputCommandInteraction} from 'discord.js';
import {inject, injectable} from 'inversify'; import {inject, injectable} from 'inversify';
import {TYPES} from '../types.js'; import {TYPES} from '../types.js';
import PlayerManager from '../managers/player.js'; import PlayerManager from '../managers/player.js';
import Command from '.'; import Command from './index.js';
import {SlashCommandBuilder} from '@discordjs/builders'; import {SlashCommandBuilder} from '@discordjs/builders';
@injectable() @injectable()

View file

@ -2,7 +2,7 @@ import {ChatInputCommandInteraction} from 'discord.js';
import {TYPES} from '../types.js'; import {TYPES} from '../types.js';
import {inject, injectable} from 'inversify'; import {inject, injectable} from 'inversify';
import PlayerManager from '../managers/player.js'; import PlayerManager from '../managers/player.js';
import Command from '.'; import Command from './index.js';
import {SlashCommandBuilder} from '@discordjs/builders'; import {SlashCommandBuilder} from '@discordjs/builders';
@injectable() @injectable()

View file

@ -1,6 +1,6 @@
import {SlashCommandBuilder} from '@discordjs/builders'; import {SlashCommandBuilder} from '@discordjs/builders';
import {inject, injectable} from 'inversify'; import {inject, injectable} from 'inversify';
import Command from '.'; import Command from './index.js';
import {TYPES} from '../types.js'; import {TYPES} from '../types.js';
import PlayerManager from '../managers/player.js'; import PlayerManager from '../managers/player.js';
import {STATUS} from '../services/player.js'; import {STATUS} from '../services/player.js';

View file

@ -2,7 +2,7 @@ import {ChatInputCommandInteraction} from 'discord.js';
import {TYPES} from '../types.js'; import {TYPES} from '../types.js';
import {inject, injectable} from 'inversify'; import {inject, injectable} from 'inversify';
import PlayerManager from '../managers/player.js'; import PlayerManager from '../managers/player.js';
import Command from '.'; import Command from './index.js';
import {parseTime, prettyTime} from '../utils/time.js'; import {parseTime, prettyTime} from '../utils/time.js';
import {SlashCommandBuilder} from '@discordjs/builders'; import {SlashCommandBuilder} from '@discordjs/builders';
import durationStringToSeconds from '../utils/duration-string-to-seconds.js'; import durationStringToSeconds from '../utils/duration-string-to-seconds.js';

View file

@ -2,7 +2,7 @@ import {ChatInputCommandInteraction} from 'discord.js';
import {TYPES} from '../types.js'; import {TYPES} from '../types.js';
import {inject, injectable} from 'inversify'; import {inject, injectable} from 'inversify';
import PlayerManager from '../managers/player.js'; import PlayerManager from '../managers/player.js';
import Command from '.'; import Command from './index.js';
import {SlashCommandBuilder} from '@discordjs/builders'; import {SlashCommandBuilder} from '@discordjs/builders';
@injectable() @injectable()

View file

@ -2,7 +2,7 @@ import {ChatInputCommandInteraction} from 'discord.js';
import {TYPES} from '../types.js'; import {TYPES} from '../types.js';
import {inject, injectable} from 'inversify'; import {inject, injectable} from 'inversify';
import PlayerManager from '../managers/player.js'; import PlayerManager from '../managers/player.js';
import Command from '.'; import Command from './index.js';
import {SlashCommandBuilder} from '@discordjs/builders'; import {SlashCommandBuilder} from '@discordjs/builders';
import {buildPlayingMessageEmbed} from '../utils/build-embed.js'; import {buildPlayingMessageEmbed} from '../utils/build-embed.js';

View file

@ -4,7 +4,7 @@ import {TYPES} from '../types.js';
import {inject, injectable} from 'inversify'; import {inject, injectable} from 'inversify';
import PlayerManager from '../managers/player.js'; import PlayerManager from '../managers/player.js';
import {STATUS} from '../services/player.js'; import {STATUS} from '../services/player.js';
import Command from '.'; import Command from './index.js';
@injectable() @injectable()
export default class implements Command { export default class implements Command {

View file

@ -2,7 +2,7 @@ import {ChatInputCommandInteraction} from 'discord.js';
import {TYPES} from '../types.js'; import {TYPES} from '../types.js';
import {inject, injectable} from 'inversify'; import {inject, injectable} from 'inversify';
import PlayerManager from '../managers/player.js'; import PlayerManager from '../managers/player.js';
import Command from '.'; import Command from './index.js';
import {SlashCommandBuilder} from '@discordjs/builders'; import {SlashCommandBuilder} from '@discordjs/builders';
import {buildPlayingMessageEmbed} from '../utils/build-embed.js'; import {buildPlayingMessageEmbed} from '../utils/build-embed.js';

42
src/commands/volume.ts Normal file
View file

@ -0,0 +1,42 @@
import {ChatInputCommandInteraction} from 'discord.js';
import {TYPES} from '../types.js';
import {inject, injectable} from 'inversify';
import PlayerManager from '../managers/player.js';
import Command from './index.js';
import {SlashCommandBuilder} from '@discordjs/builders';
@injectable()
export default class implements Command {
public readonly slashCommand = new SlashCommandBuilder()
.setName('volume')
.setDescription('set current player volume level')
.addIntegerOption(option =>
option.setName('level')
.setDescription('volume percentage (0 is muted, 100 is max & default)')
.setMinValue(0)
.setMaxValue(100)
.setRequired(true),
);
public requiresVC = true;
private readonly playerManager: PlayerManager;
constructor(@inject(TYPES.Managers.Player) playerManager: PlayerManager) {
this.playerManager = playerManager;
}
public async execute(interaction: ChatInputCommandInteraction): Promise<void> {
const player = this.playerManager.get(interaction.guild!.id);
const currentSong = player.getCurrent();
if (!currentSong) {
throw new Error('nothing is playing');
}
const level = interaction.options.getInteger('level') ?? 100;
player.setVolume(level);
await interaction.reply(`Set volume to ${level}%`);
}
}

View file

@ -1,6 +1,6 @@
import {Client, Guild} from 'discord.js'; import {Client, Guild} from 'discord.js';
import container from '../inversify.config.js'; import container from '../inversify.config.js';
import Command from '../commands'; import Command from '../commands/index.js';
import {TYPES} from '../types.js'; import {TYPES} from '../types.js';
import Config from '../services/config.js'; import Config from '../services/config.js';
import {prisma} from '../utils/db.js'; import {prisma} from '../utils/db.js';
@ -40,5 +40,5 @@ export default async (guild: Guild): Promise<void> => {
} }
const owner = await guild.fetchOwner(); const owner = await guild.fetchOwner();
await owner.send('👋 Hi! Someone (probably you) just invited me to a server you own. By default, I\'m usable by all guild member in all guild channels. To change this, check out the wiki page on permissions: https://github.com/codetheweb/muse/wiki/Configuring-Bot-Permissions.'); await owner.send('👋 Hi! Someone (probably you) just invited me to a server you own. By default, I\'m usable by all guild member in all guild channels. To change this, check out the wiki page on permissions: https://github.com/museofficial/muse/wiki/Configuring-Bot-Permissions.');
}; };

View file

@ -15,7 +15,7 @@ import YoutubeAPI from './services/youtube-api.js';
import SpotifyAPI from './services/spotify-api.js'; import SpotifyAPI from './services/spotify-api.js';
// Commands // Commands
import Command from './commands'; import Command from './commands/index.js';
import Clear from './commands/clear.js'; import Clear from './commands/clear.js';
import Config from './commands/config.js'; import Config from './commands/config.js';
import Disconnect from './commands/disconnect.js'; import Disconnect from './commands/disconnect.js';
@ -37,6 +37,7 @@ import Shuffle from './commands/shuffle.js';
import Skip from './commands/skip.js'; import Skip from './commands/skip.js';
import Stop from './commands/stop.js'; import Stop from './commands/stop.js';
import Unskip from './commands/unskip.js'; import Unskip from './commands/unskip.js';
import Volume from './commands/volume.js';
import ThirdParty from './services/third-party.js'; import ThirdParty from './services/third-party.js';
import FileCacheProvider from './services/file-cache.js'; import FileCacheProvider from './services/file-cache.js';
import KeyValueCacheProvider from './services/key-value-cache.js'; import KeyValueCacheProvider from './services/key-value-cache.js';
@ -56,11 +57,20 @@ container.bind<Client>(TYPES.Client).toConstantValue(new Client({intents}));
// Managers // Managers
container.bind<PlayerManager>(TYPES.Managers.Player).to(PlayerManager).inSingletonScope(); container.bind<PlayerManager>(TYPES.Managers.Player).to(PlayerManager).inSingletonScope();
// Config values
container.bind(TYPES.Config).toConstantValue(new ConfigProvider());
// Services // Services
container.bind<GetSongs>(TYPES.Services.GetSongs).to(GetSongs).inSingletonScope(); container.bind<GetSongs>(TYPES.Services.GetSongs).to(GetSongs).inSingletonScope();
container.bind<AddQueryToQueue>(TYPES.Services.AddQueryToQueue).to(AddQueryToQueue).inSingletonScope(); container.bind<AddQueryToQueue>(TYPES.Services.AddQueryToQueue).to(AddQueryToQueue).inSingletonScope();
container.bind<YoutubeAPI>(TYPES.Services.YoutubeAPI).to(YoutubeAPI).inSingletonScope(); container.bind<YoutubeAPI>(TYPES.Services.YoutubeAPI).to(YoutubeAPI).inSingletonScope();
// Only instanciate spotify dependencies if the Spotify client ID and secret are set
const config = container.get<ConfigProvider>(TYPES.Config);
if (config.SPOTIFY_CLIENT_ID !== '' && config.SPOTIFY_CLIENT_SECRET !== '') {
container.bind<SpotifyAPI>(TYPES.Services.SpotifyAPI).to(SpotifyAPI).inSingletonScope(); container.bind<SpotifyAPI>(TYPES.Services.SpotifyAPI).to(SpotifyAPI).inSingletonScope();
container.bind(TYPES.ThirdParty).to(ThirdParty);
}
// Commands // Commands
[ [
@ -85,16 +95,12 @@ container.bind<SpotifyAPI>(TYPES.Services.SpotifyAPI).to(SpotifyAPI).inSingleton
Skip, Skip,
Stop, Stop,
Unskip, Unskip,
Volume,
].forEach(command => { ].forEach(command => {
container.bind<Command>(TYPES.Command).to(command).inSingletonScope(); container.bind<Command>(TYPES.Command).to(command).inSingletonScope();
}); });
// Config values
container.bind(TYPES.Config).toConstantValue(new ConfigProvider());
// Static libraries // Static libraries
container.bind(TYPES.ThirdParty).to(ThirdParty);
container.bind(TYPES.FileCache).to(FileCacheProvider); container.bind(TYPES.FileCache).to(FileCacheProvider);
container.bind(TYPES.KeyValueCache).to(KeyValueCacheProvider); container.bind(TYPES.KeyValueCache).to(KeyValueCacheProvider);

View file

@ -1,19 +1,35 @@
/* eslint-disable complexity */ /* eslint-disable complexity */
import {ChatInputCommandInteraction, GuildMember} from 'discord.js'; import {ChatInputCommandInteraction, GuildMember} from 'discord.js';
import {URL} from 'node:url';
import {inject, injectable} from 'inversify'; import {inject, injectable} from 'inversify';
import shuffle from 'array-shuffle'; import shuffle from 'array-shuffle';
import {TYPES} from '../types.js'; import {TYPES} from '../types.js';
import GetSongs from '../services/get-songs.js'; import GetSongs from '../services/get-songs.js';
import {SongMetadata, STATUS} from './player.js'; import {MediaSource, SongMetadata, STATUS} from './player.js';
import PlayerManager from '../managers/player.js'; import PlayerManager from '../managers/player.js';
import {buildPlayingMessageEmbed} from '../utils/build-embed.js'; import {buildPlayingMessageEmbed} from '../utils/build-embed.js';
import {getMemberVoiceChannel, getMostPopularVoiceChannel} from '../utils/channels.js'; import {getMemberVoiceChannel, getMostPopularVoiceChannel} from '../utils/channels.js';
import {getGuildSettings} from '../utils/get-guild-settings.js'; import {getGuildSettings} from '../utils/get-guild-settings.js';
import {SponsorBlock} from 'sponsorblock-api';
import Config from './config.js';
import KeyValueCacheProvider from './key-value-cache.js';
import {ONE_HOUR_IN_SECONDS} from '../utils/constants.js';
@injectable() @injectable()
export default class AddQueryToQueue { export default class AddQueryToQueue {
constructor(@inject(TYPES.Services.GetSongs) private readonly getSongs: GetSongs, @inject(TYPES.Managers.Player) private readonly playerManager: PlayerManager) { private readonly sponsorBlock?: SponsorBlock;
private sponsorBlockDisabledUntil?: Date;
private readonly sponsorBlockTimeoutDelay;
private readonly cache: KeyValueCacheProvider;
constructor(@inject(TYPES.Services.GetSongs) private readonly getSongs: GetSongs,
@inject(TYPES.Managers.Player) private readonly playerManager: PlayerManager,
@inject(TYPES.Config) private readonly config: Config,
@inject(TYPES.KeyValueCache) cache: KeyValueCacheProvider) {
this.sponsorBlockTimeoutDelay = config.SPONSORBLOCK_TIMEOUT;
this.sponsorBlock = config.ENABLE_SPONSORBLOCK
? new SponsorBlock('muse-sb-integration') // UserID matters only for submissions
: undefined;
this.cache = cache;
} }
public async addToQueue({ public async addToQueue({
@ -21,12 +37,14 @@ export default class AddQueryToQueue {
addToFrontOfQueue, addToFrontOfQueue,
shuffleAdditions, shuffleAdditions,
shouldSplitChapters, shouldSplitChapters,
skipCurrentTrack,
interaction, interaction,
}: { }: {
query: string; query: string;
addToFrontOfQueue: boolean; addToFrontOfQueue: boolean;
shuffleAdditions: boolean; shuffleAdditions: boolean;
shouldSplitChapters: boolean; shouldSplitChapters: boolean;
skipCurrentTrack: boolean;
interaction: ChatInputCommandInteraction; interaction: ChatInputCommandInteraction;
}): Promise<void> { }): Promise<void> {
const guildId = interaction.guild!.id; const guildId = interaction.guild!.id;
@ -37,78 +55,11 @@ export default class AddQueryToQueue {
const settings = await getGuildSettings(guildId); const settings = await getGuildSettings(guildId);
const {playlistLimit} = settings; const {playlistLimit, queueAddResponseEphemeral} = settings;
await interaction.deferReply(); await interaction.deferReply({ephemeral: queueAddResponseEphemeral});
let newSongs: SongMetadata[] = []; let [newSongs, extraMsg] = await this.getSongs.getSongs(query, playlistLimit, shouldSplitChapters);
let extraMsg = '';
// Test if it's a complete URL
try {
const url = new URL(query);
const YOUTUBE_HOSTS = [
'www.youtube.com',
'youtu.be',
'youtube.com',
'music.youtube.com',
'www.music.youtube.com',
];
if (YOUTUBE_HOSTS.includes(url.host)) {
// YouTube source
if (url.searchParams.get('list')) {
// YouTube playlist
newSongs.push(...await this.getSongs.youtubePlaylist(url.searchParams.get('list')!, shouldSplitChapters));
} else {
const songs = await this.getSongs.youtubeVideo(url.href, shouldSplitChapters);
if (songs) {
newSongs.push(...songs);
} else {
throw new Error('that doesn\'t exist');
}
}
} else if (url.protocol === 'spotify:' || url.host === 'open.spotify.com') {
const [convertedSongs, nSongsNotFound, totalSongs] = await this.getSongs.spotifySource(query, playlistLimit, shouldSplitChapters);
if (totalSongs > playlistLimit) {
extraMsg = `a random sample of ${playlistLimit} songs was taken`;
}
if (totalSongs > playlistLimit && nSongsNotFound !== 0) {
extraMsg += ' and ';
}
if (nSongsNotFound !== 0) {
if (nSongsNotFound === 1) {
extraMsg += '1 song was not found';
} else {
extraMsg += `${nSongsNotFound.toString()} songs were not found`;
}
}
newSongs.push(...convertedSongs);
} else {
const song = await this.getSongs.httpLiveStream(query);
if (song) {
newSongs.push(song);
} else {
throw new Error('that doesn\'t exist');
}
}
} catch (_: unknown) {
// Not a URL, must search YouTube
const songs = await this.getSongs.youtubeVideoSearch(query, shouldSplitChapters);
if (songs) {
newSongs.push(...songs);
} else {
throw new Error('that doesn\'t exist');
}
}
if (newSongs.length === 0) { if (newSongs.length === 0) {
throw new Error('no songs found'); throw new Error('no songs found');
@ -118,6 +69,10 @@ export default class AddQueryToQueue {
newSongs = shuffle(newSongs); newSongs = shuffle(newSongs);
} }
if (this.config.ENABLE_SPONSORBLOCK) {
newSongs = await Promise.all(newSongs.map(this.skipNonMusicSegments.bind(this)));
}
newSongs.forEach(song => { newSongs.forEach(song => {
player.add({ player.add({
...song, ...song,
@ -148,6 +103,14 @@ export default class AddQueryToQueue {
await player.play(); await player.play();
} }
if (skipCurrentTrack) {
try {
await player.forward(1);
} catch (_: unknown) {
throw new Error('no song to skip to');
}
}
// Build response message // Build response message
if (statusMsg !== '') { if (statusMsg !== '') {
if (extraMsg === '') { if (extraMsg === '') {
@ -164,7 +127,69 @@ export default class AddQueryToQueue {
if (newSongs.length === 1) { if (newSongs.length === 1) {
await interaction.editReply(`**${firstSong.title}** added to the${addToFrontOfQueue ? ' front of the' : ''} queue${extraMsg}`); await interaction.editReply(`**${firstSong.title}** added to the${addToFrontOfQueue ? ' front of the' : ''} queue${extraMsg}`);
} else { } else {
await interaction.editReply(`**${firstSong.title}** and ${newSongs.length - 1} other songs were added to the queue${extraMsg}`); await interaction.editReply(`**${firstSong.title}** and ${newSongs.length - 1} other songs were added to the queue${skipCurrentTrack ? 'and current track skipped' : ''}${extraMsg}`);
}
}
private async skipNonMusicSegments(song: SongMetadata) {
if (!this.sponsorBlock
|| (this.sponsorBlockDisabledUntil && new Date() < this.sponsorBlockDisabledUntil)
|| song.source !== MediaSource.Youtube
|| !song.url) {
return song;
}
try {
const segments = await this.cache.wrap(
async () => this.sponsorBlock?.getSegments(song.url, ['music_offtopic']),
{
key: song.url, // Value is too short for hashing
expiresIn: ONE_HOUR_IN_SECONDS,
},
) ?? [];
const skipSegments = segments
.sort((a, b) => a.startTime - b.startTime)
.reduce((acc: Array<{startTime: number; endTime: number}>, {startTime, endTime}) => {
const previousSegment = acc[acc.length - 1];
// If segments overlap merge
if (previousSegment && previousSegment.endTime > startTime) {
acc[acc.length - 1].endTime = endTime;
} else {
acc.push({startTime, endTime});
}
return acc;
}, []);
const intro = skipSegments[0];
const outro = skipSegments.at(-1);
if (outro && outro?.endTime >= song.length - 2) {
song.length -= outro.endTime - outro.startTime;
}
if (intro?.startTime <= 2) {
song.offset = Math.floor(intro.endTime);
song.length -= song.offset;
}
return song;
} catch (e) {
if (!(e instanceof Error)) {
console.error('Unexpected event occurred while fetching skip segments : ', e);
return song;
}
if (!e.message.includes('404')) {
// Don't log 404 response, it just means that there are no segments for given video
console.warn(`Could not fetch skip segments for "${song.url}" :`, e);
}
if (e.message.includes('504')) {
// Stop fetching SponsorBlock data when servers are down
this.sponsorBlockDisabledUntil = new Date(new Date().getTime() + (this.sponsorBlockTimeoutDelay * 60_000));
}
return song;
} }
} }
} }

View file

@ -5,15 +5,15 @@ import path from 'path';
import xbytes from 'xbytes'; import xbytes from 'xbytes';
import {ConditionalKeys} from 'type-fest'; import {ConditionalKeys} from 'type-fest';
import {ActivityType, PresenceStatusData} from 'discord.js'; import {ActivityType, PresenceStatusData} from 'discord.js';
dotenv.config(); dotenv.config({path: process.env.ENV_FILE ?? path.resolve(process.cwd(), '.env')});
export const DATA_DIR = path.resolve(process.env.DATA_DIR ? process.env.DATA_DIR : './data'); export const DATA_DIR = path.resolve(process.env.DATA_DIR ? process.env.DATA_DIR : './data');
const CONFIG_MAP = { const CONFIG_MAP = {
DISCORD_TOKEN: process.env.DISCORD_TOKEN, DISCORD_TOKEN: process.env.DISCORD_TOKEN,
YOUTUBE_API_KEY: process.env.YOUTUBE_API_KEY, YOUTUBE_API_KEY: process.env.YOUTUBE_API_KEY,
SPOTIFY_CLIENT_ID: process.env.SPOTIFY_CLIENT_ID, SPOTIFY_CLIENT_ID: process.env.SPOTIFY_CLIENT_ID ?? '',
SPOTIFY_CLIENT_SECRET: process.env.SPOTIFY_CLIENT_SECRET, SPOTIFY_CLIENT_SECRET: process.env.SPOTIFY_CLIENT_SECRET ?? '',
REGISTER_COMMANDS_ON_BOT: process.env.REGISTER_COMMANDS_ON_BOT === 'true', REGISTER_COMMANDS_ON_BOT: process.env.REGISTER_COMMANDS_ON_BOT === 'true',
DATA_DIR, DATA_DIR,
CACHE_DIR: path.join(DATA_DIR, 'cache'), CACHE_DIR: path.join(DATA_DIR, 'cache'),
@ -22,6 +22,8 @@ const CONFIG_MAP = {
BOT_ACTIVITY_TYPE: process.env.BOT_ACTIVITY_TYPE ?? 'LISTENING', BOT_ACTIVITY_TYPE: process.env.BOT_ACTIVITY_TYPE ?? 'LISTENING',
BOT_ACTIVITY_URL: process.env.BOT_ACTIVITY_URL ?? '', BOT_ACTIVITY_URL: process.env.BOT_ACTIVITY_URL ?? '',
BOT_ACTIVITY: process.env.BOT_ACTIVITY ?? 'music', BOT_ACTIVITY: process.env.BOT_ACTIVITY ?? 'music',
ENABLE_SPONSORBLOCK: process.env.ENABLE_SPONSORBLOCK === 'true',
SPONSORBLOCK_TIMEOUT: process.env.ENABLE_SPONSORBLOCK ?? 5,
} as const; } as const;
const BOT_ACTIVITY_TYPE_MAP = { const BOT_ACTIVITY_TYPE_MAP = {
@ -45,6 +47,8 @@ export default class Config {
readonly BOT_ACTIVITY_TYPE!: Exclude<ActivityType, ActivityType.Custom>; readonly BOT_ACTIVITY_TYPE!: Exclude<ActivityType, ActivityType.Custom>;
readonly BOT_ACTIVITY_URL!: string; readonly BOT_ACTIVITY_URL!: string;
readonly BOT_ACTIVITY!: string; readonly BOT_ACTIVITY!: string;
readonly ENABLE_SPONSORBLOCK!: boolean;
readonly SPONSORBLOCK_TIMEOUT!: number;
constructor() { constructor() {
for (const [key, value] of Object.entries(CONFIG_MAP)) { for (const [key, value] of Object.entries(CONFIG_MAP)) {

View file

@ -1,34 +1,120 @@
import {inject, injectable} from 'inversify'; import {inject, injectable, optional} from 'inversify';
import * as spotifyURI from 'spotify-uri'; import * as spotifyURI from 'spotify-uri';
import {SongMetadata, QueuedPlaylist, MediaSource} from './player.js'; import {SongMetadata, QueuedPlaylist, MediaSource} from './player.js';
import {TYPES} from '../types.js'; import {TYPES} from '../types.js';
import ffmpeg from 'fluent-ffmpeg'; import ffmpeg from 'fluent-ffmpeg';
import YoutubeAPI from './youtube-api.js'; import YoutubeAPI from './youtube-api.js';
import SpotifyAPI, {SpotifyTrack} from './spotify-api.js'; import SpotifyAPI, {SpotifyTrack} from './spotify-api.js';
import {URL} from 'node:url';
@injectable() @injectable()
export default class { export default class {
private readonly youtubeAPI: YoutubeAPI; private readonly youtubeAPI: YoutubeAPI;
private readonly spotifyAPI: SpotifyAPI; private readonly spotifyAPI?: SpotifyAPI;
constructor(@inject(TYPES.Services.YoutubeAPI) youtubeAPI: YoutubeAPI, @inject(TYPES.Services.SpotifyAPI) spotifyAPI: SpotifyAPI) { constructor(@inject(TYPES.Services.YoutubeAPI) youtubeAPI: YoutubeAPI, @inject(TYPES.Services.SpotifyAPI) @optional() spotifyAPI?: SpotifyAPI) {
this.youtubeAPI = youtubeAPI; this.youtubeAPI = youtubeAPI;
this.spotifyAPI = spotifyAPI; this.spotifyAPI = spotifyAPI;
} }
async youtubeVideoSearch(query: string, shouldSplitChapters: boolean): Promise<SongMetadata[]> { async getSongs(query: string, playlistLimit: number, shouldSplitChapters: boolean): Promise<[SongMetadata[], string]> {
const newSongs: SongMetadata[] = [];
let extraMsg = '';
// Test if it's a complete URL
try {
const url = new URL(query);
const YOUTUBE_HOSTS = [
'www.youtube.com',
'youtu.be',
'youtube.com',
'music.youtube.com',
'www.music.youtube.com',
];
if (YOUTUBE_HOSTS.includes(url.host)) {
// YouTube source
if (url.searchParams.get('list')) {
// YouTube playlist
newSongs.push(...await this.youtubePlaylist(url.searchParams.get('list')!, shouldSplitChapters));
} else {
const songs = await this.youtubeVideo(url.href, shouldSplitChapters);
if (songs) {
newSongs.push(...songs);
} else {
throw new Error('that doesn\'t exist');
}
}
} else if (url.protocol === 'spotify:' || url.host === 'open.spotify.com') {
if (this.spotifyAPI === undefined) {
throw new Error('Spotify is not enabled!');
}
const [convertedSongs, nSongsNotFound, totalSongs] = await this.spotifySource(query, playlistLimit, shouldSplitChapters);
if (totalSongs > playlistLimit) {
extraMsg = `a random sample of ${playlistLimit} songs was taken`;
}
if (totalSongs > playlistLimit && nSongsNotFound !== 0) {
extraMsg += ' and ';
}
if (nSongsNotFound !== 0) {
if (nSongsNotFound === 1) {
extraMsg += '1 song was not found';
} else {
extraMsg += `${nSongsNotFound.toString()} songs were not found`;
}
}
newSongs.push(...convertedSongs);
} else {
const song = await this.httpLiveStream(query);
if (song) {
newSongs.push(song);
} else {
throw new Error('that doesn\'t exist');
}
}
} catch (err: any) {
if (err instanceof Error && err.message === 'Spotify is not enabled!') {
throw err;
}
// Not a URL, must search YouTube
const songs = await this.youtubeVideoSearch(query, shouldSplitChapters);
if (songs) {
newSongs.push(...songs);
} else {
throw new Error('that doesn\'t exist');
}
}
return [newSongs, extraMsg];
}
private async youtubeVideoSearch(query: string, shouldSplitChapters: boolean): Promise<SongMetadata[]> {
return this.youtubeAPI.search(query, shouldSplitChapters); return this.youtubeAPI.search(query, shouldSplitChapters);
} }
async youtubeVideo(url: string, shouldSplitChapters: boolean): Promise<SongMetadata[]> { private async youtubeVideo(url: string, shouldSplitChapters: boolean): Promise<SongMetadata[]> {
return this.youtubeAPI.getVideo(url, shouldSplitChapters); return this.youtubeAPI.getVideo(url, shouldSplitChapters);
} }
async youtubePlaylist(listId: string, shouldSplitChapters: boolean): Promise<SongMetadata[]> { private async youtubePlaylist(listId: string, shouldSplitChapters: boolean): Promise<SongMetadata[]> {
return this.youtubeAPI.getPlaylist(listId, shouldSplitChapters); return this.youtubeAPI.getPlaylist(listId, shouldSplitChapters);
} }
async spotifySource(url: string, playlistLimit: number, shouldSplitChapters: boolean): Promise<[SongMetadata[], number, number]> { private async spotifySource(url: string, playlistLimit: number, shouldSplitChapters: boolean): Promise<[SongMetadata[], number, number]> {
if (this.spotifyAPI === undefined) {
return [[], 0, 0];
}
const parsed = spotifyURI.parse(url); const parsed = spotifyURI.parse(url);
switch (parsed.type) { switch (parsed.type) {
@ -58,7 +144,7 @@ export default class {
} }
} }
async httpLiveStream(url: string): Promise<SongMetadata> { private async httpLiveStream(url: string): Promise<SongMetadata> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
ffmpeg(url).ffprobe((err, _) => { ffmpeg(url).ffprobe((err, _) => {
if (err) { if (err) {

View file

@ -1,14 +1,14 @@
import {VoiceChannel, Snowflake} from 'discord.js'; import {VoiceChannel, Snowflake} from 'discord.js';
import {Readable} from 'stream'; import {Readable} from 'stream';
import hasha from 'hasha'; import hasha from 'hasha';
import ytdl, {videoFormat} from 'ytdl-core'; import ytdl, {videoFormat} from '@distube/ytdl-core';
import {WriteStream} from 'fs-capacitor'; import {WriteStream} from 'fs-capacitor';
import ffmpeg from 'fluent-ffmpeg'; import ffmpeg from 'fluent-ffmpeg';
import shuffle from 'array-shuffle'; import shuffle from 'array-shuffle';
import { import {
AudioPlayer, AudioPlayer,
AudioPlayerState, AudioPlayerState,
AudioPlayerStatus, AudioPlayerStatus, AudioResource,
createAudioPlayer, createAudioPlayer,
createAudioResource, DiscordGatewayAdapterCreator, createAudioResource, DiscordGatewayAdapterCreator,
joinVoiceChannel, joinVoiceChannel,
@ -19,6 +19,8 @@ import {
import FileCacheProvider from './file-cache.js'; import FileCacheProvider from './file-cache.js';
import debug from '../utils/debug.js'; import debug from '../utils/debug.js';
import {getGuildSettings} from '../utils/get-guild-settings.js'; import {getGuildSettings} from '../utils/get-guild-settings.js';
import {buildPlayingMessageEmbed} from '../utils/build-embed.js';
import {Setting} from '@prisma/client';
export enum MediaSource { export enum MediaSource {
Youtube, Youtube,
@ -33,7 +35,7 @@ export interface QueuedPlaylist {
export interface SongMetadata { export interface SongMetadata {
title: string; title: string;
artist: string; artist: string;
url: string; url: string; // For YT, it's the video ID (not the full URI)
length: number; length: number;
offset: number; offset: number;
playlist: QueuedPlaylist | null; playlist: QueuedPlaylist | null;
@ -58,16 +60,21 @@ export interface PlayerEvents {
type YTDLVideoFormat = videoFormat & {loudnessDb?: number}; type YTDLVideoFormat = videoFormat & {loudnessDb?: number};
export const DEFAULT_VOLUME = 100;
export default class { export default class {
public voiceConnection: VoiceConnection | null = null; public voiceConnection: VoiceConnection | null = null;
public status = STATUS.PAUSED; public status = STATUS.PAUSED;
public guildId: string; public guildId: string;
public loopCurrentSong = false; public loopCurrentSong = false;
public loopCurrentQueue = false; public loopCurrentQueue = false;
private currentChannel: VoiceChannel | undefined;
private queue: QueuedSong[] = []; private queue: QueuedSong[] = [];
private queuePosition = 0; private queuePosition = 0;
private audioPlayer: AudioPlayer | null = null; private audioPlayer: AudioPlayer | null = null;
private audioResource: AudioResource | null = null;
private volume?: number;
private defaultVolume: number = DEFAULT_VOLUME;
private nowPlaying: QueuedSong | null = null; private nowPlaying: QueuedSong | null = null;
private playPositionInterval: NodeJS.Timeout | undefined; private playPositionInterval: NodeJS.Timeout | undefined;
private lastSongURL = ''; private lastSongURL = '';
@ -76,18 +83,28 @@ export default class {
private readonly fileCache: FileCacheProvider; private readonly fileCache: FileCacheProvider;
private disconnectTimer: NodeJS.Timeout | null = null; private disconnectTimer: NodeJS.Timeout | null = null;
private readonly channelToSpeakingUsers: Map<string, Set<string>> = new Map();
constructor(fileCache: FileCacheProvider, guildId: string) { constructor(fileCache: FileCacheProvider, guildId: string) {
this.fileCache = fileCache; this.fileCache = fileCache;
this.guildId = guildId; this.guildId = guildId;
} }
async connect(channel: VoiceChannel): Promise<void> { async connect(channel: VoiceChannel): Promise<void> {
// Always get freshest default volume setting value
const settings = await getGuildSettings(this.guildId);
const {defaultVolume = DEFAULT_VOLUME} = settings;
this.defaultVolume = defaultVolume;
this.voiceConnection = joinVoiceChannel({ this.voiceConnection = joinVoiceChannel({
channelId: channel.id, channelId: channel.id,
guildId: channel.guild.id, guildId: channel.guild.id,
selfDeaf: false,
adapterCreator: channel.guild.voiceAdapterCreator as DiscordGatewayAdapterCreator, adapterCreator: channel.guild.voiceAdapterCreator as DiscordGatewayAdapterCreator,
}); });
const guildSettings = await getGuildSettings(this.guildId);
// Workaround to disable keepAlive // Workaround to disable keepAlive
this.voiceConnection.on('stateChange', (oldState, newState) => { this.voiceConnection.on('stateChange', (oldState, newState) => {
/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call */ /* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call */
@ -102,6 +119,11 @@ export default class {
oldNetworking?.off('stateChange', networkStateChangeHandler); oldNetworking?.off('stateChange', networkStateChangeHandler);
newNetworking?.on('stateChange', networkStateChangeHandler); newNetworking?.on('stateChange', networkStateChangeHandler);
/* eslint-enable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call */ /* eslint-enable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call */
this.currentChannel = channel;
if (newState.status === VoiceConnectionStatus.Ready) {
this.registerVoiceActivityListener(guildSettings);
}
}); });
} }
@ -113,10 +135,11 @@ export default class {
this.loopCurrentSong = false; this.loopCurrentSong = false;
this.voiceConnection.destroy(); this.voiceConnection.destroy();
this.audioPlayer?.stop(); this.audioPlayer?.stop(true);
this.voiceConnection = null; this.voiceConnection = null;
this.audioPlayer = null; this.audioPlayer = null;
this.audioResource = null;
} }
} }
@ -152,9 +175,7 @@ export default class {
}, },
}); });
this.voiceConnection.subscribe(this.audioPlayer); this.voiceConnection.subscribe(this.audioPlayer);
this.audioPlayer.play(createAudioResource(stream, { this.playAudioPlayerResource(this.createAudioStream(stream));
inputType: StreamType.WebmOpus,
}));
this.attachListeners(); this.attachListeners();
this.startTrackingPosition(positionSeconds); this.startTrackingPosition(positionSeconds);
@ -217,11 +238,7 @@ export default class {
}, },
}); });
this.voiceConnection.subscribe(this.audioPlayer); this.voiceConnection.subscribe(this.audioPlayer);
const resource = createAudioResource(stream, { this.playAudioPlayerResource(this.createAudioStream(stream));
inputType: StreamType.WebmOpus,
});
this.audioPlayer.play(resource);
this.attachListeners(); this.attachListeners();
@ -272,8 +289,8 @@ export default class {
if (this.getCurrent() && this.status !== STATUS.PAUSED) { if (this.getCurrent() && this.status !== STATUS.PAUSED) {
await this.play(); await this.play();
} else { } else {
this.audioPlayer?.stop();
this.status = STATUS.IDLE; this.status = STATUS.IDLE;
this.audioPlayer?.stop(true);
const settings = await getGuildSettings(this.guildId); const settings = await getGuildSettings(this.guildId);
@ -294,6 +311,63 @@ export default class {
} }
} }
registerVoiceActivityListener(guildSettings: Setting) {
const {turnDownVolumeWhenPeopleSpeak, turnDownVolumeWhenPeopleSpeakTarget} = guildSettings;
if (!turnDownVolumeWhenPeopleSpeak || !this.voiceConnection) {
return;
}
this.voiceConnection.receiver.speaking.on('start', (userId: string) => {
if (!this.currentChannel) {
return;
}
const member = this.currentChannel.members.get(userId);
const channelId = this.currentChannel?.id;
if (member) {
if (!this.channelToSpeakingUsers.has(channelId)) {
this.channelToSpeakingUsers.set(channelId, new Set());
}
this.channelToSpeakingUsers.get(channelId)?.add(member.id);
}
this.suppressVoiceWhenPeopleAreSpeaking(turnDownVolumeWhenPeopleSpeakTarget);
});
this.voiceConnection.receiver.speaking.on('end', (userId: string) => {
if (!this.currentChannel) {
return;
}
const member = this.currentChannel.members.get(userId);
const channelId = this.currentChannel.id;
if (member) {
if (!this.channelToSpeakingUsers.has(channelId)) {
this.channelToSpeakingUsers.set(channelId, new Set());
}
this.channelToSpeakingUsers.get(channelId)?.delete(member.id);
}
this.suppressVoiceWhenPeopleAreSpeaking(turnDownVolumeWhenPeopleSpeakTarget);
});
}
suppressVoiceWhenPeopleAreSpeaking(turnDownVolumeWhenPeopleSpeakTarget: number): void {
if (!this.currentChannel) {
return;
}
const speakingUsers = this.channelToSpeakingUsers.get(this.currentChannel.id);
if (speakingUsers && speakingUsers.size > 0) {
this.setVolume(turnDownVolumeWhenPeopleSpeakTarget);
} else {
this.setVolume(this.defaultVolume);
}
}
canGoForward(skip: number) { canGoForward(skip: number) {
return (this.queuePosition + skip - 1) < this.queue.length; return (this.queuePosition + skip - 1) < this.queue.length;
} }
@ -405,11 +479,28 @@ export default class {
return this.queue[this.queuePosition + to]; return this.queue[this.queuePosition + to];
} }
setVolume(level: number): void {
// Level should be a number between 0 and 100 = 0% => 100%
this.volume = level;
this.setAudioPlayerVolume(level);
}
getVolume(): number {
// Only use default volume if player volume is not already set (in the event of a reconnect we shouldn't reset)
return this.volume ?? this.defaultVolume;
}
private getHashForCache(url: string): string { private getHashForCache(url: string): string {
return hasha(url); return hasha(url);
} }
private async getStream(song: QueuedSong, options: {seek?: number; to?: number} = {}): Promise<Readable> { private async getStream(song: QueuedSong, options: {seek?: number; to?: number} = {}): Promise<Readable> {
if (this.status === STATUS.PLAYING) {
this.audioPlayer?.stop();
} else if (this.status === STATUS.PAUSED) {
this.audioPlayer?.stop(true);
}
if (song.source === MediaSource.HLS) { if (song.source === MediaSource.HLS) {
return this.createReadStream({url: song.url, cacheKey: song.url}); return this.createReadStream({url: song.url, cacheKey: song.url});
} }
@ -433,6 +524,10 @@ export default class {
format = formats.find(filter); format = formats.find(filter);
const nextBestFormat = (formats: ytdl.videoFormat[]): ytdl.videoFormat | undefined => { const nextBestFormat = (formats: ytdl.videoFormat[]): ytdl.videoFormat | undefined => {
if (formats.length < 1) {
return undefined;
}
if (formats[0].isLive) { if (formats[0].isLive) {
formats = formats.sort((a, b) => (b as unknown as {audioBitrate: number}).audioBitrate - (a as unknown as {audioBitrate: number}).audioBitrate); // Bad typings formats = formats.sort((a, b) => (b as unknown as {audioBitrate: number}).audioBitrate - (a as unknown as {audioBitrate: number}).audioBitrate); // Bad typings
@ -559,6 +654,14 @@ export default class {
if (newState.status === AudioPlayerStatus.Idle && this.status === STATUS.PLAYING) { if (newState.status === AudioPlayerStatus.Idle && this.status === STATUS.PLAYING) {
await this.forward(1); await this.forward(1);
// Auto announce the next song if configured to
const settings = await getGuildSettings(this.guildId);
const {autoAnnounceNextSong} = settings;
if (autoAnnounceNextSong && this.currentChannel) {
await this.currentChannel.send({
embeds: this.getCurrent() ? [buildPlayingMessageEmbed(this)] : [],
});
}
} }
} }
@ -586,17 +689,40 @@ export default class {
} }
}) })
.on('start', command => { .on('start', command => {
debug(`Spawned ffmpeg with ${command as string}`); debug(`Spawned ffmpeg with ${command}`);
}); });
stream.pipe(capacitor); stream.pipe(capacitor);
returnedStream.on('close', () => { returnedStream.on('close', () => {
if (!options.cache) {
stream.kill('SIGKILL'); stream.kill('SIGKILL');
}
hasReturnedStreamClosed = true; hasReturnedStreamClosed = true;
}); });
resolve(returnedStream); resolve(returnedStream);
}); });
} }
private createAudioStream(stream: Readable) {
return createAudioResource(stream, {
inputType: StreamType.WebmOpus,
inlineVolume: true,
});
}
private playAudioPlayerResource(resource: AudioResource) {
if (this.audioPlayer !== null) {
this.audioResource = resource;
this.setAudioPlayerVolume();
this.audioPlayer.play(this.audioResource);
}
}
private setAudioPlayerVolume(level?: number) {
// Audio resource expects a float between 0 and 1 to represent level percentage
this.audioResource?.volume?.setVolume((level ?? this.getVolume()) / 100);
}
} }

View file

@ -1,7 +1,7 @@
import {inject, injectable} from 'inversify'; import {inject, injectable} from 'inversify';
import {toSeconds, parse} from 'iso8601-duration'; import {toSeconds, parse} from 'iso8601-duration';
import got, {Got} from 'got'; import got, {Got} from 'got';
import ytsr, {Video} from 'ytsr'; import ytsr, {Video} from '@distube/ytsr';
import PQueue from 'p-queue'; import PQueue from 'p-queue';
import {SongMetadata, QueuedPlaylist, MediaSource} from './player.js'; import {SongMetadata, QueuedPlaylist, MediaSource} from './player.js';
import {TYPES} from '../types.js'; import {TYPES} from '../types.js';
@ -74,7 +74,7 @@ export default class {
} }
async search(query: string, shouldSplitChapters: boolean): Promise<SongMetadata[]> { async search(query: string, shouldSplitChapters: boolean): Promise<SongMetadata[]> {
const {items} = await this.ytsrQueue.add(async () => this.cache.wrap( const result = await this.ytsrQueue.add<ytsr.VideoResult>(async () => this.cache.wrap(
ytsr, ytsr,
query, query,
{ {
@ -85,9 +85,13 @@ export default class {
}, },
)); ));
if (result === undefined) {
return [];
}
let firstVideo: Video | undefined; let firstVideo: Video | undefined;
for (const item of items) { for (const item of result.items) {
if (item.type === 'video') { if (item.type === 'video') {
firstVideo = item; firstVideo = item;
break; break;
@ -95,7 +99,7 @@ export default class {
} }
if (!firstVideo) { if (!firstVideo) {
throw new Error('No video found.'); return [];
} }
return this.getVideo(firstVideo.url, shouldSplitChapters); return this.getVideo(firstVideo.url, shouldSplitChapters);

View file

@ -5,8 +5,6 @@ import getProgressBar from './get-progress-bar.js';
import {prettyTime} from './time.js'; import {prettyTime} from './time.js';
import {truncate} from './string.js'; import {truncate} from './string.js';
const PAGE_SIZE = 10;
const getMaxSongTitleLength = (title: string) => { const getMaxSongTitleLength = (title: string) => {
// eslint-disable-next-line no-control-regex // eslint-disable-next-line no-control-regex
const nonASCII = /[^\x00-\x7F]+/; const nonASCII = /[^\x00-\x7F]+/;
@ -44,10 +42,11 @@ const getPlayerUI = (player: Player) => {
const position = player.getPosition(); const position = player.getPosition();
const button = player.status === STATUS.PLAYING ? '⏹️' : '▶️'; const button = player.status === STATUS.PLAYING ? '⏹️' : '▶️';
const progressBar = getProgressBar(15, position / song.length); const progressBar = getProgressBar(10, position / song.length);
const elapsedTime = song.isLive ? 'live' : `${prettyTime(position)}/${prettyTime(song.length)}`; const elapsedTime = song.isLive ? 'live' : `${prettyTime(position)}/${prettyTime(song.length)}`;
const loop = player.loopCurrentSong ? '🔂' : player.loopCurrentQueue ? '🔁' : ''; const loop = player.loopCurrentSong ? '🔂' : player.loopCurrentQueue ? '🔁' : '';
return `${button} ${progressBar} \`[${elapsedTime}]\` 🔉 ${loop}`; const vol: string = typeof player.getVolume() === 'number' ? `${player.getVolume()!}%` : '';
return `${button} ${progressBar} \`[${elapsedTime}]\`🔉 ${vol} ${loop}`;
}; };
export const buildPlayingMessageEmbed = (player: Player): EmbedBuilder => { export const buildPlayingMessageEmbed = (player: Player): EmbedBuilder => {
@ -76,7 +75,7 @@ export const buildPlayingMessageEmbed = (player: Player): EmbedBuilder => {
return message; return message;
}; };
export const buildQueueEmbed = (player: Player, page: number): EmbedBuilder => { export const buildQueueEmbed = (player: Player, page: number, pageSize: number): EmbedBuilder => {
const currentlyPlaying = player.getCurrent(); const currentlyPlaying = player.getCurrent();
if (!currentlyPlaying) { if (!currentlyPlaying) {
@ -84,14 +83,14 @@ export const buildQueueEmbed = (player: Player, page: number): EmbedBuilder => {
} }
const queueSize = player.queueSize(); const queueSize = player.queueSize();
const maxQueuePage = Math.ceil((queueSize + 1) / PAGE_SIZE); const maxQueuePage = Math.ceil((queueSize + 1) / pageSize);
if (page > maxQueuePage) { if (page > maxQueuePage) {
throw new Error('the queue isn\'t that big'); throw new Error('the queue isn\'t that big');
} }
const queuePageBegin = (page - 1) * PAGE_SIZE; const queuePageBegin = (page - 1) * pageSize;
const queuePageEnd = queuePageBegin + PAGE_SIZE; const queuePageEnd = queuePageBegin + pageSize;
const queuedSongs = player const queuedSongs = player
.getQueue() .getQueue()
.slice(queuePageBegin, queuePageEnd) .slice(queuePageBegin, queuePageEnd)

View file

@ -14,28 +14,18 @@ const filterDuplicates = <T extends {name: string}>(items: T[]) => {
return results; return results;
}; };
const getYouTubeAndSpotifySuggestionsFor = async (query: string, spotify: SpotifyWebApi, limit = 10): Promise<APIApplicationCommandOptionChoice[]> => { const getYouTubeAndSpotifySuggestionsFor = async (query: string, spotify?: SpotifyWebApi, limit = 10): Promise<APIApplicationCommandOptionChoice[]> => {
const [youtubeSuggestions, spotifyResults] = await Promise.all([ // Only search Spotify if enabled
getYouTubeSuggestionsFor(query), const spotifySuggestionPromise = spotify === undefined
spotify.search(query, ['track', 'album'], {limit: 5}), ? undefined
]); : spotify.search(query, ['album', 'track'], {limit});
const youtubeSuggestions = await getYouTubeSuggestionsFor(query);
const totalYouTubeResults = youtubeSuggestions.length; const totalYouTubeResults = youtubeSuggestions.length;
const numOfYouTubeSuggestions = Math.min(limit, totalYouTubeResults);
const spotifyAlbums = filterDuplicates(spotifyResults.body.albums?.items ?? []); let suggestions: APIApplicationCommandOptionChoice[] = [];
const spotifyTracks = filterDuplicates(spotifyResults.body.tracks?.items ?? []);
const totalSpotifyResults = spotifyAlbums.length + spotifyTracks.length;
// Number of results for each source should be roughly the same.
// If we don't have enough Spotify suggestions, prioritize YouTube results.
const maxSpotifySuggestions = Math.floor(limit / 2);
const numOfSpotifySuggestions = Math.min(maxSpotifySuggestions, totalSpotifyResults);
const maxYouTubeSuggestions = limit - numOfSpotifySuggestions;
const numOfYouTubeSuggestions = Math.min(maxYouTubeSuggestions, totalYouTubeResults);
const suggestions: APIApplicationCommandOptionChoice[] = [];
suggestions.push( suggestions.push(
...youtubeSuggestions ...youtubeSuggestions
@ -46,10 +36,26 @@ const getYouTubeAndSpotifySuggestionsFor = async (query: string, spotify: Spotif
}), }),
)); ));
if (spotify !== undefined && spotifySuggestionPromise !== undefined) {
const spotifyResponse = (await spotifySuggestionPromise).body;
const spotifyAlbums = filterDuplicates(spotifyResponse.albums?.items ?? []);
const spotifyTracks = filterDuplicates(spotifyResponse.tracks?.items ?? []);
const totalSpotifyResults = spotifyAlbums.length + spotifyTracks.length;
// Number of results for each source should be roughly the same.
// If we don't have enough Spotify suggestions, prioritize YouTube results.
const maxSpotifySuggestions = Math.floor(limit / 2);
const numOfSpotifySuggestions = Math.min(maxSpotifySuggestions, totalSpotifyResults);
const maxSpotifyAlbums = Math.floor(numOfSpotifySuggestions / 2); const maxSpotifyAlbums = Math.floor(numOfSpotifySuggestions / 2);
const numOfSpotifyAlbums = Math.min(maxSpotifyAlbums, spotifyResults.body.albums?.items.length ?? 0); const numOfSpotifyAlbums = Math.min(maxSpotifyAlbums, spotifyResponse.albums?.items.length ?? 0);
const maxSpotifyTracks = numOfSpotifySuggestions - numOfSpotifyAlbums; const maxSpotifyTracks = numOfSpotifySuggestions - numOfSpotifyAlbums;
// Make room for spotify results
const maxYouTubeSuggestions = limit - numOfSpotifySuggestions;
suggestions = suggestions.slice(0, maxYouTubeSuggestions);
suggestions.push( suggestions.push(
...spotifyAlbums.slice(0, maxSpotifyAlbums).map(album => ({ ...spotifyAlbums.slice(0, maxSpotifyAlbums).map(album => ({
name: `Spotify: 💿 ${album.name}${album.artists.length > 0 ? ` - ${album.artists[0].name}` : ''}`, name: `Spotify: 💿 ${album.name}${album.artists.length > 0 ? ` - ${album.artists[0].name}` : ''}`,
@ -63,6 +69,7 @@ const getYouTubeAndSpotifySuggestionsFor = async (query: string, spotify: Spotif
value: `spotify:track:${track.id}`, value: `spotify:track:${track.id}`,
})), })),
); );
}
return suggestions; return suggestions;
}; };

View file

@ -1,6 +1,6 @@
import {REST} from '@discordjs/rest'; import {REST} from '@discordjs/rest';
import {Routes} from 'discord-api-types/v10'; import {Routes} from 'discord-api-types/v10';
import Command from '../commands'; import Command from '../commands/index.js';
interface RegisterCommandsOnGuildOptions { interface RegisterCommandsOnGuildOptions {
rest: REST; rest: REST;

View file

@ -9,7 +9,8 @@
"esModuleInterop": true, "esModuleInterop": true,
"sourceMap": true, "sourceMap": true,
"resolveJsonModule": true, "resolveJsonModule": true,
"outDir": "dist" "outDir": "dist",
"skipLibCheck": true
}, },
"include": ["src"], "include": ["src"],
"exclude": ["node_modules"] "exclude": ["node_modules"]

4345
yarn.lock

File diff suppressed because it is too large Load diff