Compare commits

...

302 commits

Author SHA1 Message Date
Simon Bruder 327235c451
be less silly next time (hard coded version number where it is dynamic)
All checks were successful
continuous-integration/drone/push Build is passing
2019-06-23 00:15:18 +00:00
Simon Bruder d63397b1ed
make docker image much smaller
Some checks reported errors
continuous-integration/drone/push Build was killed
2019-06-02 09:05:11 +00:00
Simon Bruder 1da5439358 add drone config
All checks were successful
continuous-integration/drone/push Build is passing
2019-03-25 16:25:41 +01:00
Simon Bruder 58c9465add always use latest alpine version 2019-03-25 16:24:07 +01:00
Matthieu Grieger dff929ddc9 Update README.md 2017-05-20 18:04:02 -07:00
Matthieu Grieger 138c1008eb Fixed YouTube playback offsets 2016-11-05 21:30:42 -07:00
Matthieu Grieger 2f6bda5018 Update version number and changelog 2016-11-05 19:41:58 -07:00
Matthieu Grieger a1c5399223 https://github.com/matthieugrieger/mumbledj/issues/182: Added back track/playlist submitter immediate skipping 2016-11-05 19:39:31 -07:00
Matthieu Grieger e1e3a334cd Fix https://github.com/matthieugrieger/mumbledj/issues/180: Panic on playlist with private video 2016-11-05 19:22:58 -07:00
Matthieu Grieger 51db9c3061 Fix https://github.com/matthieugrieger/mumbledj/issues/176: Empty IDs for SoundCloud tracks 2016-08-22 20:20:41 -07:00
Matthieu Grieger 9222608962 Fixed https://github.com/matthieugrieger/mumbledj/issues/174: Fixed deadlock during track skip/finish 2016-08-21 17:58:11 -07:00
Matthieu Grieger 466e9189c6 Fix https://github.com/matthieugrieger/mumbledj/issues/172: Index out of range error during skip 2016-08-14 10:00:38 -07:00
Matthieu Grieger 786ab8c3d6 Small stylistic and spelling changes 2016-07-11 16:08:16 -07:00
Matthieu Grieger f918d2397d Updated vendored dependencies 2016-07-11 16:03:02 -07:00
Gabriel Plassard 66be67719a Docker (#170)
* added dockerfile

* cleanup dockerfile

* update Dockerfile and create raspberry dockerfile

* doc & aria2
2016-07-11 15:57:47 -07:00
Matthieu Grieger 0a4d0aead1 Implemented register command 2016-07-10 21:09:54 -07:00
Matthieu Grieger 32167c1294 p12 files can now be provided to the bot to authenticate as a registered user 2016-07-10 20:41:51 -07:00
Matthieu Grieger 5434077d73 Remove crypto/pkcs12 2016-07-10 20:15:15 -07:00
Matthieu Grieger 1e174fde46 Update dependencies, add crypto/pkcs12 2016-07-04 10:06:28 -07:00
Matthieu Grieger 3de4917972 Potential fix for PEM IP SANs issue 2016-07-01 18:34:43 -07:00
Matthieu Grieger d0becb9c10 Fix https://github.com/matthieugrieger/mumbledj/issues/162: Key is no longer mistakenly overwritten by cert 2016-06-29 19:08:01 -07:00
Matthieu Grieger cb1bc84323 Fix https://github.com/matthieugrieger/mumbledj/issues/161: Queue is now reset after disconnect 2016-06-28 21:31:19 -07:00
Matthieu Grieger 89def4c197 Update CHANGELOG and version number 2016-06-26 22:05:39 -07:00
Matt Kemp c85fddcb4f Allow playlists larger than 50 items (#159)
This commit ensures that playlists longer than 50 items return
successfully when `max_tracks_per_playlist` is set higher than 50.

Previously if this value was raised higher than 50 the addition of a
playlist with more than 50 items would hang indefinitely.
2016-06-26 21:57:15 -07:00
Daniel Marquard 57eaf7c3db Make the config's full directory path (#160) 2016-06-26 21:55:26 -07:00
Matthieu Grieger 8c61ca2d6f Fix typo in CHANGELOG 2016-06-25 23:30:58 -07:00
Matthieu Grieger a44bac5302 Resolve https://github.com/matthieugrieger/mumbledj/issues/158: Config values can now be overriden directly via commandline arguments 2016-06-25 23:27:12 -07:00
Matthieu Grieger 918c59317a Removed extra period on error messages 2016-06-25 23:23:33 -07:00
Matthieu Grieger 26098d7a88 Made volume range check inclusive 2016-06-25 12:50:08 -07:00
Matthieu Grieger 95dbd75e19 Added gitter badge 2016-06-25 12:43:02 -07:00
Matthieu Grieger 48ce224596 Fix https://github.com/matthieugrieger/mumbledj/issues/156: Audio not stopping after forceskip 2016-06-25 12:27:51 -07:00
Matthieu Grieger b24417deda Fix https://github.com/matthieugrieger/mumbledj/issues/155: Admin settings not being respected 2016-06-25 10:43:33 -07:00
Matthieu Grieger 1731047317 Fix https://github.com/matthieugrieger/mumbledj/issues/154: Crash on !forceskip 2016-06-25 09:03:13 -07:00
Matthieu Grieger 0320ca75fa Bump version number 2016-06-23 09:17:48 -07:00
Matthieu Grieger 7ed70c8410 Update CHANGELOG.md 2016-06-23 09:12:44 -07:00
Matthieu Grieger 3ed06c57e6 Merge pull request #153 from alucardRD/master
Fixed initial Soundcloud API test
2016-06-23 08:07:05 -07:00
alucardRD 3e867ff54a Fixed initial Soundcloud API test 2016-06-23 04:10:13 -05:00
Matthieu Grieger 834f96ccbe Fixed typo on table HTML tag 2016-06-22 10:18:25 -07:00
Matthieu Grieger d5a37fd84a Merge branch 'master' of github.com:matthieugrieger/mumbledj 2016-06-21 23:28:43 -07:00
Matthieu Grieger 8da1edf2b6 Added some tests and fixed some others 2016-06-21 23:27:41 -07:00
Matthieu Grieger 9160c02bee Implemented rest of track tests 2016-06-21 23:01:54 -07:00
Matthieu Grieger ae4d863dd2 Renamed test target to coverage, added test target for local testing 2016-06-21 23:01:45 -07:00
Matthieu Grieger 23b05b714b Added codecov coverage badge to README 2016-06-21 19:37:27 -07:00
Matthieu Grieger e63d5ebfa0 Fixed codecov integration 2016-06-21 19:31:16 -07:00
Matthieu Grieger f193a3ae03 Switch from coveralls to codecov 2016-06-21 19:11:46 -07:00
Matthieu Grieger c9f8ff74ca Removed glide install from .travis.yml 2016-06-21 19:04:05 -07:00
Matthieu Grieger 5f8977944a Added coveralls back in 2016-06-21 19:01:21 -07:00
Matthieu Grieger 74676119cd Update installation instructions 2016-06-21 18:13:48 -07:00
Matthieu Grieger c0e5793f02 Added Go Report Card badge, changed styling of other badges 2016-06-21 16:21:32 -07:00
Matthieu Grieger 377110892a Fixed typo on admin command message selector 2016-06-21 16:15:28 -07:00
Matthieu Grieger 7f1b9595c1 Resolve https://github.com/matthieugrieger/mumbledj/issues/152: Command messages are now set and configured in config.yaml 2016-06-21 16:00:13 -07:00
Matthieu Grieger cbc850da95 Removed go-i18n 2016-06-21 09:23:11 -07:00
Matthieu Grieger 50c5e6cb61 Update vendored dependencies, added goi18n 2016-06-21 09:05:20 -07:00
Matthieu Grieger 9e2679bfaa Removed coveralls stuff for now 2016-06-20 17:56:01 -07:00
Matthieu Grieger a9495a5856 Checked in vendor/ with stripped vcs data 2016-06-20 17:50:40 -07:00
Matthieu Grieger 8bd8f447d6 Removed vendored dependencies 2016-06-20 17:50:10 -07:00
Matthieu Grieger 83a34de1c5 Update travis.yml to build master 2016-06-20 17:37:58 -07:00
Matthieu Grieger b5c9a74257 Update README.md 2016-06-20 17:32:49 -07:00
Matthieu Grieger 9ea5482949 Updated vendored dependencies 2016-06-20 17:30:35 -07:00
Matthieu Grieger 4f252381d1 Removed Goopfile 2016-06-20 17:17:01 -07:00
Matthieu Grieger b32d064480 Bump to version 3.0.0 2016-06-20 17:16:05 -07:00
Matthieu Grieger dcd2e1315f Update version number 2016-06-17 10:44:58 -07:00
Matthieu Grieger a6aed046c6 Update CHANGELOG.md 2016-06-17 10:44:38 -07:00
azlux 99c19cf8a8 !joinme feacture (#148)
!joinme feature implementation
2016-06-17 10:43:11 -07:00
Matthieu Grieger e133c0efcb Fixed player command setting not being applied to youtube-dl calls 2016-05-24 11:34:17 -07:00
Matthieu Grieger 7e9b6fb10e Update README.md 2016-04-10 12:41:15 -07:00
Matthieu Grieger 54353316e4 Update README.md 2016-04-09 16:28:08 -07:00
Matthieu Grieger 41301d1a48 Update version number 2016-04-09 16:27:28 -07:00
Matthieu Grieger 7a14fed3dc Update CHANGELOG.md 2016-04-09 16:26:58 -07:00
Matthieu Grieger ae894f4008 Merge pull request #133 from benklett/mixcloud
Add support for mixcloud
2016-04-09 16:25:55 -07:00
benklett be5fad2fb2 Add support for mixcloud 2016-04-09 16:32:05 +02:00
Matthieu Grieger 6bea261d68 Update version number 2016-02-14 19:57:56 -08:00
Matthieu Grieger 8a25577bdc Update CHANGELOG.md 2016-02-14 19:57:30 -08:00
Matthieu Grieger f2cb0e7d5a Merge pull request #123 from GabrielPlassard/master
Display the songtitle when the audio download fails #120
2016-02-14 19:55:58 -08:00
Gabriel Plassard 27c0fc332f Display the songtitle when the audio download fails #120 2016-02-14 16:01:48 +01:00
Matthieu Grieger e14dc879a1 Update CHANGELOG.md 2016-02-12 12:27:18 -08:00
Matthieu Grieger 31720b1e8e Merge pull request #121 from mpacella88/master
Download "bestaudio" format instead of m4a with youtube-dl
2016-02-12 12:25:48 -08:00
Michael Pacella d84260b030 realized that having bestaudio as a file extension is perfectly valid in this case so there is no need to tack m4a on arbitrarily 2016-02-11 01:13:22 -05:00
Michael Pacella a81f934047 hack workaround to get certain youtube videos which do not provide m4a format audio to work. --format bestaudio can be used with youtube-dl instead of specifying the m4a format. 2016-02-11 01:05:52 -05:00
Matthieu Grieger aa285bf817 Small version string change 2016-02-06 02:45:52 -08:00
Matthieu Grieger 619c39ac74 Update version number, changed version message slightly 2016-02-06 02:45:16 -08:00
Matthieu Grieger c944c67d46 Update README.md 2016-02-06 02:43:08 -08:00
Matthieu Grieger 99e067077e Update CHANGELOG.md 2016-02-06 02:41:44 -08:00
Matthieu Grieger 0595e75350 Merge pull request #116 from zeblau/master
Added version command both commandline and in chat
2016-02-06 02:39:39 -08:00
root e888271bc5 Added version command both commandline and in chat
Hi! I've added a command line version argument, as well as a version command that the bot will respond to in chat.

The current version is listed in the strings.go file.
2016-02-02 13:45:32 +01:00
Matthieu Grieger e9093c1307 Fix incorrect import in parseconfig 2016-01-26 20:52:00 -08:00
Matthieu Grieger 53afd78e47 Fix another broken code.google.com import 2016-01-26 20:44:30 -08:00
Matthieu Grieger 9361783f4f Update CHANGELOG.md 2016-01-26 18:25:28 -08:00
Matthieu Grieger 7451811c22 Fix https://github.com/matthieugrieger/mumbledj/issues/115: Temporary fix for discontinued code.google.com imports 2016-01-26 18:24:08 -08:00
Matthieu Grieger f4a7c7bfab Update CHANGELOG.md 2016-01-14 12:34:01 -08:00
Matthieu Grieger 408d7e4835 Merge pull request #112 from fiveofeight/upstream
Fix https://github.com/matthieugrieger/mumbledj/issues/111: Fixed youtube offsets not working when the url used &t and #t vs ?t.
2016-01-14 12:32:10 -08:00
fiveofeight e88ae814d6 Fixed youtube offsets not working when the url used &t and #t vs ?t. 2016-01-14 14:40:56 -05:00
Matthieu Grieger 2d6fd3ea10 Fix https://github.com/matthieugrieger/mumbledj/issues/110: Allow use of avconv instead of ffmpeg 2016-01-11 19:27:29 -08:00
Matthieu Grieger 80b5b44b23 Fix https://github.com/matthieugrieger/mumbledj/issues/108: Incorrect currentsong message for song within playlist 2015-12-26 18:09:56 -08:00
Matthieu Grieger 4ba4a0812b Update CHANGELOG.md 2015-12-21 10:01:26 -08:00
Matthieu Grieger 2e53e8e5ea Merge pull request #107 from mkody/mkody-patch-1
Typo with the `</b>` tag on SONG_LIST_HTML
2015-12-21 10:00:17 -08:00
Kody 57417179bb Typo with the </b> tag on SONG_LIST_HTML 2015-12-21 00:45:27 +01:00
Matthieu Grieger ceccdc2f76 Update CHANGELOG.md 2015-12-19 21:43:35 -08:00
Matthieu Grieger 32beab0856 Merge pull request #106 from HowIChrgeLazer/master
Added AnnounceNewTrack config bool
2015-12-19 21:42:18 -08:00
HowIChrgeLazer d4ea58e6e9 Small line fix as requested 2015-12-19 21:36:47 -08:00
HowIChrgeLazer 3ebb51fbcd Added AnnounceNewTrack config bool
Added a new boolean named AnnounceNewTrack that has the ability to
disable the song information upon playing the track.
2015-12-19 21:21:11 -08:00
Matthieu Grieger cd24a79e1f Update CHANGELOG.md 2015-12-16 19:10:32 -08:00
Matthieu Grieger da096944f2 Merge pull request #105 from nkhoit/master
Added addnext command and an argument for listsongs command (fixes https://github.com/matthieugrieger/mumbledj/issues/62)
2015-12-16 19:08:44 -08:00
Khoi Tran 26e271ee59 Fixed typo in README 2015-12-16 06:51:54 -05:00
Khoi Tran 6210f620bb Added an argument for listsongs command that limits the number of songs in the list 2015-12-15 20:32:20 -05:00
Khoi Tran f36c18bee7 Changed comment about playlist size in add 2015-12-14 20:16:21 -05:00
Khoi Tran 924ececba4 Added addnext to README 2015-12-14 20:11:45 -05:00
Khoi Tran 4e9b30c513 Added addnext command 2015-12-14 20:07:12 -05:00
Matthieu Grieger ebfd810099 Added !listsongs to README 2015-12-14 13:03:38 -08:00
Matthieu Grieger 1cc2a39fe0 Update CHANGELOG.md 2015-12-14 13:02:27 -08:00
Matthieu Grieger 7b9086c0f7 Merge pull request #104 from nkhoit/master
Added listsongs command
2015-12-14 13:01:06 -08:00
Khoi Tran 6e0b95b9b2 Added listsongs command 2015-12-13 22:34:33 -05:00
Matthieu Grieger c5d6b68d5e Update CHANGELOG.md 2015-12-07 22:39:24 -08:00
Matthieu Grieger efe5fb5bb9 Merge pull request #100 from Gamah/master
Use config vars instead of env vars
2015-12-07 22:37:20 -08:00
gamah 7d27f9087b Modify make install to use existing env vars if possible. 2015-11-19 14:08:34 -06:00
gamah 912e657c8b Use config vars instead of env vars 2015-11-19 02:05:06 -06:00
Matthieu Grieger ca08f5710f Update CHANGELOG.md 2015-10-16 14:54:44 -07:00
Matthieu Grieger caac88d8b2 Merge pull request #91 from GabrielPlassard/support_playlists_over_50_songs
Supports adding youtube playlist with more than 50 items (Closes https://github.com/matthieugrieger/mumbledj/issues/71)
2015-10-16 14:52:45 -07:00
Gabriel Plassard c1f89fbaaf Support MaxSongPerPlaylist configuration for soundcloud playlists 2015-10-16 23:15:33 +02:00
Gabriel Plassard 6776d6869f Refactor max playlist size 2015-10-15 23:26:30 +02:00
Matthieu Grieger 995e85b0c8 Update CHANGELOG.md 2015-10-14 18:27:49 -07:00
Matthieu Grieger 5fcf31f094 Merge pull request #92 from GabrielPlassard/fixIndexOutOfRange
Fix possible index out of range when auto shuffle is on
2015-10-14 18:26:05 -07:00
Gabriel Plassard 3ac7077263 Fix possible index out of range when auto shuffle is on 2015-10-15 00:07:50 +02:00
Gabriel Plassard 6ef13568fa support maximum songs per playlist config 2015-10-14 20:49:18 +02:00
Gabriel Plassard 411cbadb59 Supports adding youtube playlist with more than 50 items 2015-10-13 00:08:36 +02:00
Matthieu Grieger 5d56a368f8 Update CHANGELOG.md 2015-10-12 14:06:09 -07:00
Matthieu Grieger 392607709a Update README with shuffle commands 2015-10-12 14:04:35 -07:00
Matthieu Grieger ae61d8cdf2 Merge pull request #90 from GabrielPlassard/master
Add support for !shuffle, !shuffleon and !shuffleoff commands #39
2015-10-12 14:01:06 -07:00
Gabriel Plassard 5e6b72c03e add missing config entry 2015-10-12 18:05:14 +02:00
Gabriel Plassard 5deb85d8fb cosmetic 2015-10-10 11:31:39 +02:00
Gabriel Plassard c5409a8d8b Fix first song not being shuffled 2015-10-10 00:03:39 +02:00
Gabriel Plassard 7dbbd77c4e Fixup 2015-10-08 00:24:59 +02:00
Gabriel Plassard 2246695220 [WIP] Added support for !shuffleon and !shuffleoff commands #39 2015-10-08 00:02:34 +02:00
Gabriel Plassard 03a51ed3b6 added support for !shuffle command 2015-10-06 22:33:37 +02:00
Matthieu Grieger a1b957596c Capitalization fix 2015-10-02 23:14:20 -07:00
Matthieu Grieger fb7d6dea98 Update README with note about SoundCloud, other small changes 2015-10-02 23:10:38 -07:00
Matthieu Grieger d45d576c72 Fixed version number 2015-10-01 13:00:14 -07:00
Matthieu Grieger 68709a40b3 Update CHANGELOG.md 2015-10-01 12:59:03 -07:00
Matthieu Grieger 3b0b77858b Merged PR #87. Added Soundcloud support. Fixes #87. 2015-10-01 12:56:05 -07:00
Matthieu Grieger 9928986bfe Update CHANGELOG.md 2015-08-12 21:24:41 -07:00
Matthieu Grieger ccf4f6c747 Merge pull request #80 from CMahaff/master
Fixed cache clearing earlier than expected
2015-08-12 21:21:42 -07:00
Connor Mahaffey 2df3613636 Fixed cache clearing earlier than expected
By default, the date on a file downloaded by youtube-dl is the date the video was originally uploaded to youtube. As a result, items will always be cleared from the cache because they appear to be years old - even though they were just downloaded. Adding the directive "--no-mtime" will make the date on files the date the file was downloaded.

See this issue on youtube-dl: https://github.com/rg3/youtube-dl/issues/4667
2015-08-12 11:46:09 -04:00
Matthieu Grieger 6415a597da Remove debug print statement 2015-05-19 01:22:59 -07:00
Matthieu Grieger 0af6a94dbc Update CHANGELOG 2015-05-19 01:22:21 -07:00
Matthieu Grieger 2173edd1c6 Fix https://github.com/matthieugrieger/mumbledj/issues/70: panic on playlist add 2015-05-19 01:21:33 -07:00
Matthieu Grieger 1615ed38e9 Fixed README formatting 2015-05-18 18:33:46 -07:00
Matthieu Grieger 0697bcd8a2 Add new troubleshooting solution 2015-05-18 18:33:11 -07:00
Matthieu Grieger 9e16b73b53 Updated README with more detailed descriptions of commandline options 2015-05-14 10:54:14 -07:00
Matthieu Grieger 189153f888 Update CHANGELOG 2015-05-14 10:47:43 -07:00
Matthieu Grieger 12b7cb3a77 Fixed error message for videos exceeding time limit not showing 2015-05-14 10:44:28 -07:00
Matthieu Grieger fce0f0e378 Merge pull request #68 from fiveofeight/upstream
Now gives an error message if you have an invalid API key, instead of…
2015-05-14 10:41:08 -07:00
Matthieu Grieger 6398ef43c8 Merge pull request #67 from mkbwong/master
Fix for issue #34: !move does not work with sub channels
2015-05-14 10:39:50 -07:00
fiveofeight 522bfa465b Now gives an error message if you have an invalid API key, instead of simply saying "The Youtube URL you supplied did not contain a valid YouTube ID." 2015-05-14 11:01:51 -04:00
Miguel Wong b15cd9e835 Fix for issue #34: !move does not work with sub channels
Added -accesstokens command line argument
2015-05-14 02:15:18 -07:00
Matthieu Grieger 5f1d84169b Update CHANGELOG 2015-05-12 22:55:59 -07:00
Matthieu Grieger b39eb72cb9 Cleaned up timestamp display 2015-05-12 22:55:06 -07:00
Matthieu Grieger 2843d94179 Timestamps and offsets correctly calculated and displayed for days and hours 2015-05-12 22:47:14 -07:00
Matthieu Grieger e947524524 Update CHANGELOG 2015-05-09 22:07:40 -07:00
Matthieu Grieger 36ff581886 Fixed duration showing 0:00 when song is less than a minute long 2015-05-09 22:05:05 -07:00
Matthieu Grieger 20c2d82ff8 Comment cleanups 2015-05-09 22:00:24 -07:00
Matthieu Grieger 34431c9fa5 https://github.com/matthieugrieger/mumbledj/issues/65: Add support for YouTube offsets 2015-05-09 21:45:00 -07:00
Matthieu Grieger 35f673447f Updated dependencies 2015-05-09 21:44:36 -07:00
Matthieu Grieger 2b43cb979a Removed extra stuff from README 2015-05-02 00:52:23 -07:00
Matthieu Grieger b138c68662 Added Troubleshooting section to README 2015-05-02 00:49:41 -07:00
Matthieu Grieger 23be5da322 Make it more clear which IP address to use in API 2015-04-21 12:46:38 -07:00
Matthieu Grieger 4e389fc189 Update Golang version requirement 2015-04-17 21:35:21 -07:00
Matthieu Grieger 8751c7e723 Updated CHANGELOG 2015-04-17 16:57:56 -07:00
Matthieu Grieger bf5ec20416 Add PerformStartupChecks() to check for YOUTUBE_API_KEY and other stuff in the future 2015-04-17 16:54:30 -07:00
Matthieu Grieger f7e8cf1e40 More formatting fixes 2015-04-17 16:45:48 -07:00
Matthieu Grieger 07d24d0bb2 Fix README formatting 2015-04-17 16:45:07 -07:00
Matthieu Grieger 4f0bb2f14d Add YouTube API key guide to README 2015-04-17 16:44:09 -07:00
Matthieu Grieger 6477c17116 Fully functional songs and playlists 2015-04-17 16:30:13 -07:00
Matthieu Grieger 1df88a41de Fixed more go build errors 2015-04-15 23:44:35 -07:00
Matthieu Grieger f6b301edd6 More changes and additions to Song and Playlist types 2015-04-15 23:32:32 -07:00
Matthieu Grieger e0a936ab63 Restructured project to use interfaces instead of packages 2015-04-13 23:40:39 -07:00
Matthieu Grieger b37937769b Small tweaks 2015-04-09 13:52:32 -07:00
Matthieu Grieger 26a9d78fac go fmt 2015-04-09 13:23:21 -07:00
Matthieu Grieger 73fb4bd82b Removed playlist.go and song.go 2015-04-09 13:21:28 -07:00
Matthieu Grieger 985f48572f Finished YouTube API 2015-04-09 13:20:40 -07:00
Matthieu Grieger 14bb9df146 Added PerformGetRequest 2015-04-08 23:44:48 -07:00
Matthieu Grieger 6a836f13b7 gofmt 2015-04-08 18:47:39 -07:00
Matthieu Grieger ccbfe12340 Added services and youtube packages 2015-04-08 18:44:22 -07:00
Matthieu Grieger ccb0b4a3de Fix https://github.com/matthieugrieger/mumbledj/issues/55: Crash after skipping last song of playlist 2015-03-28 16:12:14 -07:00
Matthieu Grieger b6c5310240 Update CHANGELOG.md 2015-03-27 20:46:09 -07:00
Matthieu Grieger 3af978fb38 Merge pull request #54 from dylanetaft/master
Fix https://github.com/matthieugrieger/mumbledj/issues/50: Load config before connecting to mumble server
2015-03-27 20:44:11 -07:00
Dylan E Taft fbd355afaa Load config before connecting to mumble server 2015-03-27 20:56:47 -04:00
Matthieu Grieger c05967047c Tweaked Makefile to handle version numbers in executable name 2015-03-26 10:33:52 -07:00
Matthieu Grieger c73c3794e1 Renamed mumbledj.gcfg to config.gcfg 2015-03-26 10:27:44 -07:00
Matthieu Grieger 7662c6466d Update CHANGELOG 2015-03-20 21:37:37 -07:00
Matthieu Grieger 2393727137 Song/playlist is now skipped when submitter skips 2015-03-20 21:35:19 -07:00
Matthieu Grieger 3ecee614ca Fixed typo in mumbledj.gcfg 2015-03-20 21:21:00 -07:00
Matthieu Grieger 579245900b Update CHANGELOG.md 2015-03-07 01:32:46 -08:00
Matthieu Grieger e6265da26f Added missing AdminSkipPlaylistAlias option 2015-03-07 01:32:10 -08:00
Matthieu Grieger 7d254f68a3 Update CHANGELOG.md 2015-02-25 22:05:01 -08:00
Matthieu Grieger d43dde1943 https://github.com/matthieugrieger/mumbledj/issues/40: Add auto-reconnect on disconnect 2015-02-25 20:47:53 -08:00
Matthieu Grieger f80ffbc745 Update gumble and gumble_ffmpeg dependencies 2015-02-25 20:33:05 -08:00
Matthieu Grieger 727c6be8d0 Fix README formatting 2015-02-20 11:06:11 -08:00
Matthieu Grieger 6202b58bca Add -insecure flag to README example 2015-02-20 11:04:01 -08:00
Matthieu Grieger 85a57feb14 Fix https://github.com/matthieugrieger/mumbledj/issues/46: Download fail on songs with "-" at beginning of ID 2015-02-20 11:02:23 -08:00
Matthieu Grieger ca558ffdd6 Add CertificateLockFile() for more secure connections 2015-02-19 16:48:40 -08:00
Matthieu Grieger 5c3644797a Update gumbleutil dependency 2015-02-19 16:06:03 -08:00
Matthieu Grieger 542a631913 Updated gumble dependency 2015-02-19 15:58:31 -08:00
Matthieu Grieger 180761117b Fix https://github.com/matthieugrieger/mumbledj/issues/43: Queue freezing up on download fail 2015-02-18 15:11:13 -08:00
Matthieu Grieger 873748bef3 Fix https://github.com/matthieugrieger/mumbledj/issues/45: Bot crashing after 5 minutes with no songs in queue 2015-02-18 15:09:06 -08:00
Matthieu Grieger 879af4798f Add features section to README 2015-02-17 21:06:17 -08:00
Matthieu Grieger 72983e8e6f Add navigation to README 2015-02-17 21:02:33 -08:00
Matthieu Grieger 6b1853ed5f Update CHANGELOG, small typo fix 2015-02-17 20:56:45 -08:00
Matthieu Grieger 3815634971 Merge pull request #42 from jakexks/song-length-limit
Added a restriction on maximum video length
2015-02-17 20:49:33 -08:00
Jake 2b9394a371 Fixed panic when attempting to play empty queue 2015-02-18 00:11:02 +00:00
Jake 38be691913 Fixed panic when a song exceeded allowed duration 2015-02-18 00:11:02 +00:00
Jake 541f371239 Tidied playlist song duration check 2015-02-18 00:11:02 +00:00
Jake 6a954e04b1 Check whether song duration is restricted first 2015-02-18 00:11:01 +00:00
Jake eec8d9c3d2 Fixed playlists ignoring the max song length check 2015-02-18 00:11:01 +00:00
Jake d37995b136 Made the maximum song duration optional 2015-02-18 00:11:01 +00:00
Jake d11b6326cc Added a restriction on maximum video length 2015-02-18 00:11:01 +00:00
Matthieu Grieger 4957133c14 https://github.com/matthieugrieger/mumbledj/issues/27: Added song caching 2015-02-17 15:43:20 -08:00
Matthieu Grieger d2056d57c0 Fix changelog date 2015-02-12 13:29:12 -08:00
Matthieu Grieger b6a6da718d Greatly simplified song queue data structure 2015-02-12 13:27:04 -08:00
Matthieu Grieger 24fb620e1f Updated dependencies, updated code to reflect gumble API changes 2015-02-11 15:25:47 -08:00
Matthieu Grieger 16572caad9 Fix https://github.com/matthieugrieger/mumbledj/issues/37: Images in text messages crashing bot 2015-02-09 15:12:04 -08:00
Matthieu Grieger e251b0ccda Add DefaultComment configuration option 2015-02-09 15:08:09 -08:00
Matthieu Grieger 44e0d55d9d Added easier to read failed connection message 2015-02-07 18:25:59 -08:00
Matthieu Grieger c8c59c2970 https://github.com/matthieugrieger/mumbledj/issues/36: Add PEM cert support 2015-02-07 18:20:00 -08:00
Matthieu Grieger d8b31e60c2 https://github.com/matthieugrieger/mumbledj/issues/33: Playlist titles in notifications 2015-02-07 17:54:24 -08:00
Matthieu Grieger 0bb039b52f https://github.com/matthieugrieger/mumbledj/issues/33: Add !setcomment 2015-02-07 14:23:47 -08:00
Matthieu Grieger a28450e5ca Replace sanitize with gumbleutil.PlainText 2015-02-07 14:10:45 -08:00
Matthieu Grieger 9930f4c9ea Removed sanitize 2015-02-07 14:09:59 -08:00
Matthieu Grieger 4c19cfd383 Reworked Makefile slightly 2015-02-07 14:09:05 -08:00
Matthieu Grieger 2eaa90e4d6 Updated dependencies 2015-02-07 14:08:33 -08:00
Matthieu Grieger 72549575f3 Fix numbering 2015-02-03 10:58:38 -08:00
Matthieu Grieger 8559a929e6 Made it possible to place binary in ~/bin 2015-02-03 10:56:07 -08:00
Matthieu Grieger dc8b66c5b9 Update version number 2015-02-02 19:47:06 -08:00
Matthieu Grieger 2219b6b914 Update CHANGELOG 2015-02-02 19:46:33 -08:00
Matthieu Grieger 7c57cbc3d9 Fix crash on invalid playlist URL 2015-02-02 19:45:17 -08:00
Matthieu Grieger 00fb7ff05a Add goop dependency management 2015-02-02 18:16:16 -08:00
Matthieu Grieger a8a6129c9f Update CHANGELOG 2015-02-02 17:46:57 -08:00
Matthieu Grieger 42d9147b41 Fix go build issues 2015-02-02 17:45:55 -08:00
Matthieu Grieger d3ed404885 Update CHANGELOG version number 2015-02-02 12:06:06 -08:00
Matthieu Grieger fc9f3c8f23 Fixed empty song/playlist entry being added upon !add with invalid YouTube ID. 2015-02-02 12:05:00 -08:00
Matthieu Grieger 6ba9f94e71 Fix https://github.com/matthieugrieger/mumbledj/issues/32: Newlines messing up \!add commands 2015-02-02 11:50:31 -08:00
Matthieu Grieger 0275ab2fb3 Fix https://github.com/matthieugrieger/mumbledj/issues/32: \!reset crash when no audio is playing 2015-02-02 11:41:11 -08:00
Matthieu Grieger 23ef674313 Fix https://github.com/matthieugrieger/mumbledj/issues/32: '\!' being recognized as '\!skipplaylist' 2015-02-02 11:34:56 -08:00
Matthieu Grieger 5c6a87bdc0 Added panic on audio play fail 2015-02-02 11:19:24 -08:00
Matthieu Grieger b0a2704e26 Fix https://github.com/matthieugrieger/mumbledj/issues/31: Crash on private message 2015-01-30 18:26:51 -08:00
Matthieu Grieger 097b504947 Fix https://github.com/matthieugrieger/mumbledj/issues/26: nextsong showing incorrect info 2015-01-26 22:12:09 -08:00
Matthieu Grieger 8beb1322ba Update CHANGELOG.md 2015-01-25 14:06:09 -08:00
Matthieu Grieger 32d6d63f9f Fix crash on user disconnect when no song is playing 2015-01-25 14:04:47 -08:00
Matthieu Grieger 7ea9a89fa5 Skips now removed when user disconnects 2015-01-25 11:45:50 -08:00
Matthieu Grieger 9940939542 Add currentsong command 2015-01-25 11:27:28 -08:00
Matthieu Grieger 5cd7f7c6c2 Address https://github.com/matthieugrieger/mumbledj/issues/24: Add warning about youtube-dl installation 2015-01-23 10:50:13 -08:00
Matthieu Grieger d39c8bbfdb Remove note about old MumbleDJ 2015-01-21 23:41:30 -08:00
Matthieu Grieger a258480532 Make mumbledj.gcfg location more specific in README 2015-01-21 23:40:33 -08:00
Matthieu Grieger 6e4abd723d Update README.md 2015-01-21 23:38:47 -08:00
Matthieu Grieger b73cffb08e Update README with update instructions and opusthreshold note 2015-01-21 23:36:08 -08:00
Matthieu Grieger 7465922c43 Fix https://github.com/matthieugrieger/mumbledj/issues/23: Move to channel with spaces in name 2015-01-19 13:28:25 -08:00
Matthieu Grieger 4d08287530 More copyright year updates 2015-01-18 14:44:40 -08:00
Matthieu Grieger c6c4e9ce10 Fix typo in version number 2015-01-14 17:52:58 -08:00
Matthieu Grieger 5af55643c7 Update CHANGELOG.md 2015-01-14 17:51:58 -08:00
Matthieu Grieger 87ed05052d Update HELP_HTML string with missing commands 2015-01-14 17:51:10 -08:00
Matthieu Grieger f93e9c49c4 Fix typo in README 2015-01-14 12:06:49 -08:00
Matthieu Grieger c8782447c1 Changed AudioEncoder Application to gopus.Audio 2015-01-14 11:56:46 -08:00
Matthieu Grieger 4376fe7394 Added nextsong command 2015-01-12 16:21:53 -08:00
Matthieu Grieger 98d2ce08ab Add SongQueue.PeekNext() 2015-01-12 16:02:20 -08:00
Matthieu Grieger 8cb8a30467 Command listing formatting 2015-01-11 18:50:58 -08:00
Matthieu Grieger d9abcca0c9 Update LICENSE year 2015-01-11 18:28:38 -08:00
Matthieu Grieger fbd225b98c Update README license year 2015-01-11 18:28:18 -08:00
Matthieu Grieger 269f1cdd7e Add missing version number to CHANGELOG 2015-01-10 16:19:46 -08:00
Matthieu Grieger 6a172fc81b Slight tweaks and fixes to help command 2015-01-10 16:16:05 -08:00
Matthieu Grieger ca71c9cfea Merge pull request #18 from MrKrucible/master
Add !help command
2015-01-10 16:09:14 -08:00
MrKrucible ed51d58f0f Update CHANGELOG 2015-01-10 15:25:07 -08:00
MrKrucible 55752a1a8b Update README 2015-01-10 15:23:24 -08:00
MrKrucible 888215a828 Add numsongs to HELP_MSG string. 2015-01-10 15:21:27 -08:00
MrKrucible 7d29ff727e Add help command. 2015-01-10 15:15:12 -08:00
Matthieu Grieger 25a5cb327b Update CHANGELOG 2015-01-10 13:36:46 -08:00
Matthieu Grieger 3b5a015593 Update README 2015-01-10 13:06:13 -08:00
Matthieu Grieger a31da73f39 Add request https://github.com/matthieugrieger/mumbledj/issues/15: numsongs command 2015-01-10 13:03:52 -08:00
Matthieu Grieger aa60f2bad7 Fix https://github.com/matthieugrieger/mumbledj/issues/16: Type mismatch on make 2015-01-10 12:32:27 -08:00
Matthieu Grieger 7fe12c4e43 Fix https://github.com/matthieugrieger/mumbledj/issues/14: Bot crash on YouTube playlist URLs with '-' character 2015-01-09 16:45:17 -08:00
Matthieu Grieger f8c4078438 Update CHANGELOG.md 2015-01-08 19:34:42 -08:00
Matthieu Grieger 5d91ca95f4 Possible fix for crash after skipping more than once 2015-01-08 18:17:36 -08:00
Matthieu Grieger 07787f9186 Update README with Opus dev header reminder 2015-01-07 11:17:35 -08:00
Matthieu Grieger 9667157d63 Fix https://github.com/matthieugrieger/mumbledj/issues/11: Crash on skip when no song is playing 2015-01-07 11:06:58 -08:00
Matthieu Grieger 73d5050f9d Merge branch 'master' of https://github.com/matthieugrieger/mumbledj 2015-01-05 19:19:00 -08:00
Matthieu Grieger fb5b893286 Update CHANGELOG 2015-01-05 19:18:18 -08:00
Matthieu Grieger 1586180246 Added reset command 2015-01-05 19:16:59 -08:00
Matthieu Grieger 803d977e27 Hopefully fixed queue not working after awhile 2015-01-05 19:00:05 -08:00
Matthieu Grieger 2ccc87135a Update README with new Makefile instructions 2015-01-05 18:18:22 -08:00
Matthieu Grieger de90223661 Update CHANGELOG 2015-01-05 12:05:46 -08:00
Matthieu Grieger 3d6427b14a Added gumbleutil.AutoBitrate EventListener 2015-01-05 12:05:38 -08:00
Matthieu Grieger 698da090b9 Made dependency updates required 2015-01-05 12:05:20 -08:00
Matthieu Grieger 2480241107 Update README with new commands 2015-01-03 13:16:22 -08:00
Matthieu Grieger 972f3a933b Formatting fix 2015-01-03 11:50:02 -08:00
Matthieu Grieger d9b138af7a Update README 2015-01-03 11:49:06 -08:00
Matthieu Grieger 10e0d17401 Update CHANGELOG 2015-01-03 11:48:57 -08:00
Matthieu Grieger aa86b570a0 Added ability to add YouTube playlists to queue 2015-01-03 11:31:29 -08:00
Matthieu Grieger 39f7b8dcab Add note about CELT 2015-01-01 07:56:15 -08:00
Matthieu Grieger bc43b183de Cleaned and relocated some code 2015-01-01 07:54:11 -08:00
Matthieu Grieger 3f874d6322 Should now skip to next song in queue if download fails 2015-01-01 07:20:11 -08:00
Matthieu Grieger 5f39ce7b5b Fixed user.Send() in main.go 2014-12-31 15:15:29 -08:00
Matthieu Grieger ae5bd564d0 Update Makefile 2014-12-31 15:09:26 -08:00
Matthieu Grieger 83f35f2484 Fixed panic on song download fail 2014-12-31 14:51:36 -08:00
Matthieu Grieger f6c86f8566 Switched from queue to Golang slice for SongQueue 2014-12-31 14:36:53 -08:00
1362 changed files with 368623 additions and 955 deletions

1
.dockerignore Normal file
View file

@ -0,0 +1 @@
Dockerfile

13
.drone.yml Normal file
View file

@ -0,0 +1,13 @@
kind: pipeline
name: default
steps:
- name: docker
image: plugins/docker
settings:
registry: r.sbruder.de
username:
from_secret: docker_username
password:
from_secret: docker_password
repo: r.sbruder.de/mumbledj

20
.github/ISSUE_TEMPLATE.md vendored Normal file
View file

@ -0,0 +1,20 @@
#### Before submitting this issue, please acknowledge that you have done the following:
- [ ] I've at least skimmed through the [README](https://github.com/matthieugrieger/mumbledj/blob/master/README.md)
- [ ] I have checked that I am running the latest version of MumbleDJ (use `mumbledj --version` when starting the bot or use the MumbleDJ version command in Mumble)
- [ ] I have [searched through the existing issues](https://github.com/matthieugrieger/mumbledj/issues) to see if my issue has been answered already
---
#### What type of issue is this?
- [ ] Bug report (encountered problems with MumbleDJ)
- [ ] Feature request (request for a new functionality)
- [ ] Question
- [ ] Other:
---
#### Log output of bot with `--debug` flag (likely only for bug reports):
---
#### Description of your issue:

18
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View file

@ -0,0 +1,18 @@
#### Before submitting this pull request, please acknowledge that you have done the following:
- [ ] I have tested my changes to make sure that they work
- [ ] I have at least attempted to write relevant unit tests to verify that my changes work
- [ ] I have read through the [contribution guidelines](https://github.com/matthieugrieger/mumbledj/blob/master/.github/CONTRIBUTING.md)
- [ ] I have written any necessary documentation (for example, adding information about a feature to the README)
---
#### What type of pull request is this?
- [ ] Bug fix
- [ ] Typo fix
- [ ] New feature implementation
- [ ] New service implementation
- [ ] Other
---
#### Description of your pull request:

4
.gitignore vendored
View file

@ -1 +1,3 @@
mumbledj
mumbledj*
coverage.txt
*.coverprofile

22
.travis.yml Normal file
View file

@ -0,0 +1,22 @@
language: go
sudo: false
go:
- 1.5
- 1.6
- tip
before_install:
- go get github.com/Masterminds/glide
- go get github.com/go-playground/overalls
script:
- make coverage
after_success:
- bash <(curl -s https://codecov.io/bash)
env:
global:
- GO15VENDOREXPERIMENT="1"

View file

@ -1,6 +1,299 @@
MumbleDJ Changelog
==================
### November 5, 2016 -- `v3.2.1`
* Fixed YouTube video offsets. Now YouTube URLs with `?t=<timestamp>` at the end will start the audio playback at the appropriate position.
### November 5, 2016 -- `v3.2.0`
* Fixed a Go panic that would occur when a YouTube playlist contained a private video.
* Added back immediate skipping for tracks/playlists that are skipped by the submitter. This was a feature that was present in the last major version of MumbleDJ but was forgotten when rewriting the bot (sorry!).
### August 22, 2016 -- `v3.1.4`
* Fixed a SoundCloud API response parsing issue that would result in empty IDs for tracks.
* Fixed the startup check for SoundCloud API.
### August 21, 2016 -- `v3.1.3`
* Fixed a deadlock that would occur during the transition from the first to second track in a queue.
### August 14, 2016 -- `v3.1.2`
* Fixed an index out of range crash in the queue skipping function.
### July 11, 2016 -- `v3.1.1`
* Updated vendored dependencies to hopefully address the following issue: https://github.com/matthieugrieger/mumbledj/issues/169.
### July 10, 2016 -- `v3.1.0`
* File path for user `p12` certificate can now be provided for authenticating as a registered user via the `--p12` commandline flag or the `connection.user_p12` configuration value.
* Added `!register` command for registering the bot on the server.
### July 1, 2016 -- `v3.0.11`
* Potential fix for an issue with IP SANs on PEM certs.
### June 29, 2016 -- `v3.0.10`
* Fixed issue related to PEM keys being overwritten by PEM certs.
### June 28, 2016 -- `v3.0.9`
* Queue is now reset after disconnecting from the server to avoid unpredictable behavior.
### June 26, 2016 -- `v3.0.8`
* Fixed hang on setting `max_tracks_per_playlist` to a value larger than 50 (thanks [@mattikus](https://github.com/mattikus)).
* Fixed full directory path not being created properly for config file (thanks [@DanielMarquard](https://github.com/DanielMarquard)).
### June 25, 2016 -- `v3.0.7`
* Volume can now be set to `volume.lowest` and `volume.highest` (in other words, the range is inclusive now instead of exclusive).
* All configuration values can now be overridden via commandline arguments. For example: `mumbledj --admins.names="SuperUser,Matt" --volume.default="0.5" --commands.add.is_admin="false"`
* __NOTE__: Configuration settings that contain commas (",") are interpreted as string slices (or arrays if you aren't familiar with Go).
* Removed an extra period that was sometimes output in error messages.
### June 25, 2016 -- `v3.0.6`
* Fixed an issue with `!forceskip` not stopping audio playback.
### June 25, 2016 -- `v3.0.5`
* Fixed admin settings not being respected.
### June 25, 2016 -- `v3.0.4`
* Fixed a crash on `!forceskip`.
### June 22, 2016 -- `v3.0.3`
* Fixed SoundCloud API startup check (thanks [@alucardRD](https://github.com/alucardRD)).
### June 21, 2016 -- `v3.0.2`
* Fixed typo on admin command header message selector.
### June 21, 2016 -- `v3.0.1`
* Added all strings that are output by commands to `config.yaml` for easier translation and tweaking.
### June 20, 2016 -- `v3.0.0`
* Significantly simplified installation process, now installable via `go install`.
* Commands may now have multiple aliases, configurable via config file.
* Command aliases are checked for duplicates and closes the bot if duplicates are found.
* Added new commands: `!resume`, `!pause`. These commands allow you to resume and pause audio streams respectively.
* `!add` and `!addnext` can now take any number of space-separated URLs as arguments.
* Commands are now executed asynchronously for better performance and responsiveness.
* Restructured project into subpackages: `bot`, `commands`, `interfaces`, `services`.
* Config file is now in `.yaml` format and is written to `$HOME/.config/mumbledj/config.yaml`.
* Altered config file layout to make it easier to read.
* When an updated config file is available it is written to `$HOME/.config/mumbledj/config.yaml.new`.
* Alternate config file locations can be supplied via `--config` commandline flag.
* Added logging for easier monitoring and issue debugging.
* Added `--debug` flag for more verbose logging when debugging an issue.
* Access tokens in config file and `--accesstokens` commandline flag are now comma-separated instead of space-separated.
* Mixcloud now requires the installation of `aria2` to work properly due to download throttling.
* Startup checks are performed before the bot connects to the server to determine if any required software is missing or misconfigured.
* API startup checks are performed before the bot connects to the server to determine if any services have missing/invalid API keys.
* Dependencies are now vendored via `/vendor` folder for more reproducible builds.
* `glide` has replaced `goop` as the dependency management tool.
* Added `CONTRIBUTING.md` and templates for GitHub Issues and Pull Requests.
* Revamped `Makefile` and made it less complicated.
* Implemented continuous integration support with [Travis CI](https://travis-ci.org).
* Builds for `linux/arm64` and `linux/386` are now provided as downloads for each release.
* Implemented many unit tests to test functionality of bot subsystems.
* Much more not listed here!
I hope you guys enjoy this update, it has been in the works for a few months. :)
### June 17, 2016 -- `v2.10.0`
* Added `!joinme` command (thanks [@azlux](https://github.com/azlux)).
### May 24, 2016 -- `v2.9.1`
* Fixed player command configuration setting not being applied to youtube-dl calls.
### April 9, 2016 -- `v2.9.0`
* Added support for Mixcloud (thanks [@benklett](https://github.com/benklett)).
### February 14, 2016 -- `v2.8.15`
* Fixed an incorrectly formatted error message (thanks [@GabrielPlassard](https://github.com/GabrielPlassard)).
### February 12, 2016 -- `v2.8.14`
* Audio is now downloaded using the `bestaudio` format. This prevents situations in which some audio would not play because an `.m4a` file was not available (thanks [@mpacella88](https://github.com/mpacella88)).
### February 6, 2016 -- `v2.8.13`
* Added `!version` command to display the version of MumbleDJ that is running (thanks [@zeblau](https://github.com/zeblau)).
* Added `version` commandline argument to display the version of MumbleDJ that is running (thanks [@zeblau](https://github.com/zeblau)).
### January 26, 2016 -- `v2.8.12`
* Temporarily fixed discontinued code.google.com imports.
### January 14, 2016 -- `v2.8.11`
* Fixed: Unable to use offsets if it's formatted as &t vs ?t in the URL (thanks [@fiveofeight](https://github.com/fiveofeight)).
### January 11, 2016 -- `v2.8.10`
* Created a new configuration value in the General section called PlayerCommand. This allows the user to change between "ffmpeg" and "avconv" for playing audio files.
* Added check for valid PlayerCommand value. If the value is invalid the bot will default to `ffmpeg`.
### December 26, 2015 -- `v2.8.9`
* Fixed an incorrect `!currentsong` message for songs within playlists.
### December 21, 2015 -- `v2.8.8`
* Fixed a typo in song list HTML (thanks [@mkody](https://github.com/mkody)).
### December 19, 2015 -- `v2.8.7`
* Added AnnounceNewTracks config option (thanks [@HowIChrgeLazer](https://github.com/HowIChrgeLazer)).
### December 16, 2015 -- `v2.8.6`
* Added !addnext command (thanks [@nkhoit](https://github.com/nkhoit)).
* Added argument to !listsongs command to specify how many songs to list (thanks [@nkhoit](https://github.com/nkhoit)).
### December 14, 2015 -- `v2.8.5`
* Added !listsongs command (thanks [@nkhoit](https://github.com/nkhoit)).
### December 7, 2015 -- `v2.8.4`
* YouTube and SoundCloud API keys are now stored in the configuration file instead of environment variables. Existing installations with API keys in environment variables will automatically be migrated to the configuration file (thanks [@Gamah](https://github.com/Gamah)).
### October 16, 2015 -- `v2.8.3`
* Playlists can now be over 50 songs in length (thanks [@GabrielPlassard](https://github.com/GabrielPlassard)).
* Added MaxSongPerPlaylist configuration option.
### October 14, 2015 -- `v2.8.2`
* Fixed possible index out of range panic when auto shuffle is on (thanks [@GabrielPlassard](https://github.com/GabrielPlassard)).
### October 12, 2015 -- `v2.8.1`
* Added !shuffle, !shuffleon, and !shuffleoff commands (thanks [@GabrielPlassard](https://github.com/GabrielPlassard)).
### October 1, 2015 -- `v2.8.0`
* Added Soundcloud support (thanks [@MichaelOultram](https://github.com/MichaelOultram)).
### August 12, 2015 -- `v2.7.5`
* Fixed cache clearing earlier than expected (thanks [@CMahaff](https://github.com/CMahaff)).
### May 19, 2015 -- `v2.7.4`
* Fixed a panic that occurred when certain YouTube playlists were added to the queue.
### May 14, 2015 -- `v2.7.3`
* Fixed `!move` not working for subchannels (thanks [@mkbwong](https://github.com/mkbwong)).
* Fixed MumbleDJ showing invalid YouTube ID error message in chat when an invalid YouTube API key is supplied (thanks [@fiveofeight](https://github.com/fiveofeight)).
* Fixed MumbleDJ showing invalid YouTube ID error message in chat when a song exceeds the allowed time duration.
### May 12, 2015 -- `v2.7.2`
* Fixed incorrect values shown in timestamp for videos over an hour long.
* Reworked timestamp parsing.
### May 9, 2015 -- `v2.7.1`
* Added support for YouTube offsets. This means that YouTube URLs with the `t` parameter will start at the time specified in the URL instead of the beginning.
* Cleaned up comments in some files and removed some unnecessary code.
* Fixed a bug in which a duration of 0:00 was shown for songs that were less than a minute long.
### April 17, 2015 -- `v2.7.0`
* Migrated all YouTube API calls to YouTube Data API v3. This means that you **MUST** follow the instructions in the following link if you were using a previous version of MumbleDJ: https://github.com/matthieugrieger/mumbledj#youtube-api-keys.
* Made the SongQueue much more flexible. These changes will allow easy addition of support for other music services.
### March 28, 2015 -- `v2.6.10`
* Fixed a crash that would occur when the last song of a playlist was skipped.
### March 27, 2015 -- `v2.6.9`
* Fixed a race condition that would sometimes cause the bot to crash (thanks [dylanetaft](https://github.com/dylanetaft)!).
### March 26, 2015 -- `v2.6.8`
* Renamed `mumbledj.gcfg` to `config.gcfg`. However, please note that it will still be called `mumbledj.gcfg` in your `~/.mumbledj` directory. Hopefully this will avoid any ambiguity when referring to the
config files.
* Tweaked the `Makefile` to handle situations where `go build` creates an executable with an appended version number.
### March 20, 2015 -- `v2.6.7`
* Fixed a typo in `mumbledj.gcfg`.
* Songs and playlists are now skipped immediately if the submitter submits a skip command.
* `SONG_SKIPPED_HTML` and `PLAYLIST_SKIPPED_HTML` are no longer shown if the submitter or admin skips a song/playlist.
### March 7, 2015 -- `v2.6.6`
* Added missing AdminSkipPlaylistAlias option to `mumbledj.gcfg`.
### February 25, 2015 -- `v2.6.5`
* Added automatic connection retries if the bot loses connection to the server. The bot will attempt to reconnect to the server every 30 seconds for a period of 15 minutes, then exit if a connection cannot be made.
### February 20, 2015 -- `v2.6.4`
* Fixed failed audio downloads for YouTube videos with IDs beginning with "-".
### February 19, 2015 -- `v2.6.3`
* Added `gumbleutil.CertificateLockFile()` for more secure connections.
* Added `-insecure` boolean commandline flag to allow MumbleDJ to connect to a server without overwriting `~/.mumbledj/cert.lock`.
### February 18, 2015 -- `v2.6.2`
* Fixed bot crashing after 5 minutes if there is nothing in the song queue.
* Fixed queue freezing up if the download of the first song in queue fails.
### February 17, 2015 -- `v2.6.0, v2.6.1`
* Added caching system to MumbleDJ.
* Added configuration variables in `mumbledj.gcfg` for caching related settings (please note that caching is off by default).
* Added `!numcached` and `!cachesize` commands for admins.
* Added optional song length limit (thanks [jakexks](https://github.com/jakexks)!)
### February 12, 2015 -- `v2.5.0`
* Updated dependencies and fixed code to match `gumble` API changes.
* Greatly simplified the song queue data structure. Some new bugs could potentially have arisen. Let me know if you find any!
### February 9, 2015 -- `v2.4.3`
* Added configuration option in `mumbledj.gcfg` for default bot comment.
* Fixed text messages only containing images crashing the bot.
### February 7, 2015 -- `v2.4.2`
* Updated `gumble` and `gumbleutil` dependencies.
* Removed `sanitize` dependency.
* Reworked `Makefile` slightly.
* Now uses `gumbleutil.PlainText` for removing HTML tags instead of `sanitize`.
* Added `!setcomment` which allows admin users to set the comment for the bot.
* Made "Now Playing" notification and `!currentsong` show the playlist title of the song if it is included in a playlist.
* Added ability to connect to Mumble server using a PEM cert/key pair. Use the commandline flags `cert` and `key` to make use of this.
* Added an easier to read error message upon unsuccessful connection to server.
### February 3, 2015 -- `v2.4.1`
* Made it possible to place MumbleDJ binary in `~/bin` instead of `/usr/local/bin` if the folder exists.
### February 2, 2015 -- `v2.3.4, v2.3.5, v2.3.6, v2.3.7, v2.4.0`
* Added panic on audio play fail for debugging purposes.
* Fixed '!' being recognized as '!skipplaylist'.
* Fixed !reset crash when there is no audio playing.
* Fixed newlines after YouTube URL messing up !add commands.
* Fixed empty song/playlist entry being added upon !add with invalid YouTube ID.
* Fixed go build issues.
* Added `goop` dependency management. Make sure you have `openal` installed, or it won't work right!
* Fixed crash on invalid playlist URL.
### January 30, 2015 -- `v2.3.3`
* Fixed private messages crashing the bot when the target user switches channels or disconnects.
### January 26, 2015 -- `v2.3.2`
* Fixed !nextsong showing incorrect information about the next song in the queue.
### January 25, 2015 -- `v2.3.0, v2.3.1`
* Added !currentsong command, which displays information about the song currently playing.
* MumbleDJ now removes disconnected users from skiplists for playlists and songs within the SongQueue.
* Fixed crash when a user disconnects when no song is playing.
### January 19, 2015 -- `v2.2.11`
* Fixed not being able to use the move command with channels with spaces in their name.
### January 14, 2015 -- `v2.2.9, v2.2.10`
* Set AudioEncoder Application to `gopus.Audio` instead of `gopus.Voice` for hopefully better sound quality.
* Added some commands to the !help string that were missing.
### January 12, 2015 -- `v2.2.8`
* Added !nextsong command, which outputs some information about the next song in the queue if it exists.
### January 10, 2015 -- `v2.2.6, v2.2.7`
* Fixed type mismatch error when building MumbleDJ.
* Added traversal function to SongQueue.
* Added !numsongs command, which outputs how many songs are currently in the SongQueue.
* Added !help command, which displays a list of valid commands in Mumble chat.
### January 9, 2015 -- `v2.2.5`
* Fixed some YouTube playlist URLs crashing the bot and not retrieving metadata correctly.
### January 8, 2015 -- `v2.2.4`
* Fixed a crash caused by a user trying to skip the same song more than once.
### January 7, 2015 -- `v2.2.3`
* Fixed a crash caused by entering a skip request when no song is currently playing.
### January 5, 2015 -- `v2.2.1, v2.2.2`
* Attached `gumbleutil.AutoBitrate` EventListener to client. This should hopefully fix the issues with audio cutting in and out.
* Moved dependency installation to default `make` command to better enforce new updates.
* Added `make build` to `Makefile` to allow previous functionality of the default `make` command.
* Hopefully fixed a situation that would cause the song queue to stop working.
* Added `!reset` command. Use this to reset the song queue.
### January 3, 2015 -- `v2.2.0`
* Added ability to add YouTube playlists to the queue. Note that the max size of a playlist is 25 songs, anything larger will only use the first 25 songs in the playlist.
* Fixed a crash while attempting to add URLs to the queue.
* Re-made the song queue using my own "queue-like" structure using slices.
### December 30, 2014 -- `v2.1.3`
* Fixed YouTube URL parsing not working for some forms of YouTube URLs.
* Now recovers more gracefully if an audio download fails. Instead of panicking, the bot will send a message to the user who added the URL, telling them the audio download failed.
@ -43,7 +336,7 @@ MumbleDJ Changelog
### October 18, 2014
* Fixed a crash when an error occurs during the audio downloading & encoding process.
* Fixed a crash that occurs when the bot tries to join a default channel that does not exist. If the default channel does not exist, the bot will just move itself
* Fixed a crash that occurs when the bot tries to join a default channel that does not exist. If the default channel does not exist, the bot will just move itself
to the root of the server instead.
### October 13, 2014

260
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,260 @@
Contributing to MumbleDJ
========================
Contributions are always welcome to MumbleDJ. This document will give you some tips and guidelines to follow while implementing your contribution.
## Table of Contents
* [Implementing a new command](#implementing-a-new-command)
* [Create files for your new command](#create-files-for-your-new-command)
* [Copy templates into your new files](#copy-templates-into-your-new-files)
* [Command implementation template (`command.go`)](#command-implementation-template-commandgo)
* [Command test suite template (`command_test.go`)](#command-test-suite-template-command_testgo)
* [Implement your new command](#implement-your-new-command)
* [Add command to `commands/pkg_init.go`](#add-command-to-commandspkg_initgo)
* [Add necessary configuration values to `config.yaml` and `config.go`](#add-necessary-configuration-values-to-configyaml-and-configgo)
* [Regenerate `bindata.go`](#regenerate-bindatago)
* [Document your new command](#document-your-new-command)
* [Implementing support for a new service](#implementing-support-for-a-new-service)
* [Create file for your new service](#create-file-for-your-new-service)
* [Copy template into your new file](#copy-template-into-your-new-file)
* [Implement your new service](#implement-your-new-service)
* [Add service to `services/pkg_init.go`](#add-command-to-servicespkg_initgo)
* [Add API key configuration value to `config.yaml` and `config.go` if necessary](#add-api-key-configuration-value-to-configyaml-and-configgo-if-necessary)
* [Document your new service](#document-your-new-service)
## Implementing a new command
Commands are the portion of MumbleDJ that allows users to interact with the bot. Here is a step-by-step guide on how to implement a new command:
### Create files for your new command
All commands possess their own `command.go` file and `command_test.go` file. These files must reside in the `commands` directory.
### Copy templates into your new files
Templates for both command implementations and command tests have been created to ensure consistency across the codebase. Please use these templates, they will make your implementation easier and will make the codebase much cleaner.
#### Command implementation template (`command.go`)
```go
/*
* MumbleDJ
* By Matthieu Grieger
* commands/yournewcommand.go
* Copyright (c) 2016 Matthieu Grieger (MIT License)
*/
package commands
import (
"errors"
"fmt"
"github.com/layeh/gumble/gumble"
"github.com/matthieugrieger/mumbledj/interfaces"
"github.com/spf13/viper"
)
// YourNewCommand is a command... (put a short description of the command here)
type YourNewCommand struct{}
// Aliases returns the current aliases for the command.
func (c *YourNewCommand) Aliases() []string {
return viper.GetStringSlice("commands.yournewcommand.aliases")
}
// Description returns the description for the command.
func (c *YourNewCommand) Description() string {
return viper.GetString("commands.yournewcommand.description")
}
// IsAdminCommand returns true if the command is only for admin use, and
// returns false otherwise.
func (c *YourNewCommand) IsAdminCommand() bool {
return viper.GetBool("commands.yournewcommand.is_admin")
}
// Execute executes the command with the given user and arguments.
// Return value descriptions:
// string: A message to be returned to the user upon successful execution.
// bool: Whether the message should be private or not. true = private,
// false = public (sent to whole channel).
// error: An error message to be returned upon unsuccessful execution.
// If no error has occurred, pass nil instead.
// Example return statement:
// return "This is a private message!", true, nil
func (c *YourNewCommand) Execute(user *gumble.User, args ...string) (string, bool, error) {
}
```
#### Command test suite template (`command_test.go`)
```go
/*
* MumbleDJ
* By Matthieu Grieger
* commands/yournewcommand_test.go
* Copyright (c) 2016 Matthieu Grieger (MIT License)
*/
package commands
import (
"testing"
"github.com/layeh/gumble/gumbleffmpeg"
"github.com/matthieugrieger/mumbledj/bot"
"github.com/spf13/viper"
"github.com/stretchr/testify/suite"
)
type YourNewCommandTestSuite struct {
Command YourNewCommand
suite.Suite
}
func (suite *YourNewCommandTestSuite) SetupSuite() {
DJ = bot.NewMumbleDJ()
bot.DJ = DJ
viper.Set("commands.yournewcommand.aliases", []string{"yournewcommand", "c"})
viper.Set("commands.yournewcommand.description", "yournewcommand")
viper.Set("commands.yournewcommand.is_admin", false)
}
func (suite *YourNewCommandTestSuite) SetupTest() {
DJ.Queue = bot.NewQueue()
}
func (suite *YourNewCommandTestSuite) TestAliases() {
suite.Equal([]string{"yournewcommand", "c"}, suite.Command.Aliases())
}
func (suite *YourNewCommandTestSuite) TestDescription() {
suite.Equal("yournewcommand", suite.Command.Description())
}
func (suite *YourNewCommandTestSuite) TestIsAdminCommand() {
suite.False(suite.Command.IsAdminCommand())
}
// Implement more tests here as necessary! It may be helpful to take a look
// at the stretchr/testify documentation:
// https://github.com/stretchr/testify
// Remove this comment before sending a pull request.
func TestYourNewCommandTestSuite(t *testing.T) {
suite.Run(t, new(YourNewCommandTestSuite))
}
```
### Implement your new command
Now the fun starts! Write the implementation for your command in the `Execute()` method. Then, write tests for your new command, making sure to test each possible execution flow of your command.
For writing the implementation and unit tests for your new command, it may be helpful to [look at previously created commands](https://github.com/matthieugrieger/mumbledj/blob/master/commands).
**Make sure to rename the example names to represent your new command!**
### Add command to `commands/pkg_init.go`
`commands/pkg_init.go` contains a slice of enabled commands. If you do not put your command in this slice, your command will not be enabled.
**Please keep the commands in alphabetical order!**
### Add necessary configuration values to `config.yaml` and `config.go`
Go to `config.yaml` and `bot/config.go` and add the necessary configuration values for your new command.
**Please keep the commands in alphabetical order!**
### Regenerate `bindata.go`
This step is very easy, but is very important. This allows the bot to store a copy of the new `config.yaml` internally and use it to write to disk.
Simply execute `make bindata` and this step will be taken care of!
### Document your new command
Make sure to put information in `README.md` about your new command. It would be a shame for your new command to go unnoticed!
**Please keep the commands in alphabetical order!**
## Implementing support for a new service
Services are the portion of MumbleDJ that allows the bot to interact with various media services. Here is a step-by-step guide on how to implement support for a new service:
### Create file for your new service
All services possess their own `service.go` file. This file must reside in the `services` directory.
### Copy template into your new file
A template for service implementations has been created to ensure consistency across the codebase. Please use this template, it will make your implementation easier and will make the codebase much cleaner.
```go
/*
* MumbleDJ
* By Matthieu Grieger
* services/yournewservice.go
* Copyright (c) 2016 Matthieu Grieger (MIT License)
*/
package services
import (
"fmt"
"net/http"
"regexp"
"strings"
"time"
"github.com/antonholmquist/jason"
"github.com/layeh/gumble/gumble"
"github.com/matthieugrieger/mumbledj/bot"
"github.com/matthieugrieger/mumbledj/interfaces"
)
// YourNewService is a... (description here)
type YourNewService struct {
*GenericService
}
// NewYourNewServiceService returns an initialized YourNewService service object.
func NewYourNewServiceService() *YourNewService {
return &YourNewService{
&GenericService{
ReadableName: "Your new service",
Format: "bestaudio",
TrackRegex: []*regexp.Regexp{
regexp.MustCompile(`regex for track URLs in your new service`),
},
PlaylistRegex: []*regexp.Regexp{
regexp.MustCompile(`regex for playlist URLs in your new service`),
},
},
}
}
// CheckAPIKey performs a test API call with the API key
// provided in the configuration file to determine if the
// service should be enabled.
func (yn *YourNewService) CheckAPIKey() error {
}
// GetTracks uses the passed URL to find and return
// tracks associated with the URL. An error is returned
// if any error occurs during the API call.
func (yn *YourNewService) GetTracks(url string, submitter *gumble.User) ([]interfaces.Track, error) {
}
```
### Implement your new service
Now the fun starts! Implement `CheckAPIKey()` and `GetTracks()`.
For writing the implementation for your new service, it may be helpful to [look at previously created service wrappers](https://github.com/matthieugrieger/mumbledj/blob/master/services).
**Make sure to rename the example names to represent your new service!**
### Add service to `services/pkg_init.go`
`services/pkg_init.go` contains a slice of enabled services. If you do not put your service in this slice, your service will not be enabled.
**Please keep the services in alphabetical order!**
### Add API key configuration value to `config.yaml` and `config.go` if necessary
Some services will require an API key for users to interact with their service. If an API key is required for your service, add it to the configuration file and `config.go` and run `make bindata` to regenerate the `bindata.go` file.
### Document your new service
In sections of `README.md` that describe which services are supported, add your new service to the list. It would be a shame for your new service to go unnoticed!
Also, if your service requires an API key, make sure to document the steps to retrieve an API key in the "Requirements" section of the `README`.

39
Dockerfile Normal file
View file

@ -0,0 +1,39 @@
FROM golang:alpine as builder
RUN apk add --no-cache \
build-base \
opus-dev
COPY . /go/src/github.com/matthieugrieger/mumbledj
WORKDIR /go/src/github.com/matthieugrieger/mumbledj
RUN go get -v \
&& go build -v -ldflags="-s -w"
FROM alpine
RUN adduser -D mumbledj
RUN apk add --no-cache \
aria2 \
libressl \
python2
RUN wget -O /usr/bin/youtube-dl https://yt-dl.org/downloads/latest/youtube-dl \
&& chmod +x /usr/bin/youtube-dl
RUN wget https://johnvansickle.com/ffmpeg/builds/ffmpeg-git-amd64-static.tar.xz \
&& tar xvf ffmpeg-git-amd64-static.tar.xz '*/ffmpeg' || true \
&& mv ffmpeg-git-*-amd64-static/ffmpeg /usr/bin/ffmpeg \
&& rm -rf ffmpeg-git-* \
&& apk add --no-cache upx \
&& upx /usr/bin/ffmpeg \
&& apk del upx
COPY --from=builder /go/src/github.com/matthieugrieger/mumbledj/mumbledj /usr/bin/mumbledj
COPY config.yaml /home/mumbledj/.config/mumbledj/config.yaml
USER mumbledj
ENTRYPOINT ["/usr/bin/mumbledj"]

View file

@ -1,6 +1,6 @@
The MIT License (MIT)
Copyright (c) 2014 Matthieu Grieger
Copyright (c) 2016 Matthieu Grieger
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
THE SOFTWARE.

View file

@ -1,22 +1,37 @@
dirs = ./interfaces/... ./commands/... ./services/... ./bot/... .
all: mumbledj
mumbledj: main.go commands.go parseconfig.go strings.go queue.go song.go songqueue.go
go build .
clean:
rm -f mumbledj
install:
mkdir -p ~/.mumbledj/config
mkdir -p ~/.mumbledj/songs
if [ -a ~/.mumbledj/config/mumbledj.gcfg ]; then mv ~/.mumbledj/config/mumbledj.gcfg ~/.mumbledj/config/mumbledj_backup.gcfg; fi;
cp -u mumbledj.gcfg ~/.mumbledj/config/mumbledj.gcfg
sudo cp -f mumbledj /usr/local/bin/mumbledj
install_deps:
go get -u github.com/layeh/gumble/gumble
go get -u github.com/layeh/gumble/gumbleutil
go get -u github.com/layeh/gumble/gumble_ffmpeg
go get -u code.google.com/p/gcfg
go get -u github.com/kennygrant/sanitize
go get -u github.com/jmoiron/jsonq
mumbledj: ## Default action. Builds MumbleDJ.
@env GO15VENDOREXPERIMENT="1" go build .
.PHONY: test
test: ## Runs unit tests for MumbleDJ.
@env GO15VENDOREXPERIMENT="1" go test $(dirs)
.PHONY: coverage
coverage: ## Runs coverage tests for MumbleDJ.
@env GO15VENDOREXPERIMENT="1" overalls -project=github.com/matthieugrieger/mumbledj -covermode=atomic
@mv overalls.coverprofile coverage.txt
.PHONY: clean
clean: ## Removes compiled MumbleDJ binaries.
@rm -f mumbledj*
.PHONY: install
install: ## Copies MumbleDJ binary to /usr/local/bin for easy execution.
@cp -f mumbledj* /usr/local/bin/mumbledj
.PHONY: dist
dist: ## Performs cross-platform builds via gox for multiple Linux platforms.
@go get -u github.com/mitchellh/gox
@gox -cgo -osarch="linux/amd64 linux/386"
.PHONY: bindata
bindata: ## Regenerates bindata.go with an updated configuration file.
@go get -u github.com/jteeuwen/go-bindata/...
@go-bindata config.yaml
.PHONY: help
help: ## Shows this helptext.
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'

409
README.md
View file

@ -1,70 +1,378 @@
MumbleDJ
========
A Mumble bot that plays music fetched from YouTube videos.
**IMPORTANT NOTE:** If you were using the Lua version of MumbleDJ previously, you will need to follow the installation guide once more to install new dependencies.
<h1 align="center">MumbleDJ</h1>
<p align="center"><b>A Mumble bot that plays audio fetched from various media websites.</b></p>
<p align="center"><a href="https://travis-ci.org/matthieugrieger/mumbledj"><img src="https://travis-ci.org/matthieugrieger/mumbledj.svg?branch=master"/></a> <a href="https://raw.githubusercontent.com/matthieugrieger/mumbledj/master/LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg"/></a> <a href="https://github.com/matthieugrieger/mumbledj/releases"><img src="https://img.shields.io/github/release/matthieugrieger/mumbledj.svg"/></a> <a href="https://goreportcard.com/report/github.com/matthieugrieger/mumbledj"><img src="https://goreportcard.com/badge/github.com/matthieugrieger/mumbledj"/></a> <a href="https://codecov.io/gh/matthieugrieger/mumbledj"><img src="https://img.shields.io/codecov/c/github/matthieugrieger/mumbledj.svg"/></a> <a href="https://gitter.im/matthieugrieger/mumbledj"><img src="https://img.shields.io/gitter/room/matthieugrieger/mumbledj.svg" /></a></p>
## USAGE
`$ mumbledj -server=localhost -port=64738 -username=MumbleDJ -password="" -channel=root`
All parameters are optional, the example above shows the default values for each field.
<p align="center"><b>Unfortunately, this project is no longer maintained. Don't expect any responses on bug reports, feature requests, etc. Forks are welcome!</b></p>
## COMMANDS
These are all of the chat commands currently supported by MumbleDJ. All command names and command prefixes may be changed in `mumbledj.gcfg`. All fields surrounded by `<>` indicate fields that *must* be supplied to the bot for the command to execute. All fields surrounded by `<>?` are optional fields.
## Table of Contents
####`!add <youtube_url>`
Adds a YouTube video's audio to the song queue. If no songs are currently in the queue, the audio will begin playing immediately.
* [Features](#features)
* [Installation](#installation)
* [Requirements](#requirements)
* [YouTube API Key](#youtube-api-key)
* [SoundCloud API Key](#soundcloud-api-key)
* [Via `go get`](#via-go-get-recommended)
* [Pre-compiled Binaries](#pre-compiled-binaries-easiest)
* [From Source](#from-source)
* [Docker](#docker)
* [Usage](#usage)
* [Commands](#commands)
* [Contributing](#contributing)
* [Author](#author)
* [License](#license)
* [Thanks](#thanks)
####`!skip`
Submits a vote to skip the current song. Once the skip ratio target (specified in `mumbledj.gcfg`) is met, the song will be skipped and the next will start playing. Each user may only submit one skip per song.
## Features
* Plays audio from many media websites, including YouTube, SoundCloud, and Mixcloud.
* Supports playlists and individual videos/tracks.
* Displays metadata in the text chat whenever a new track starts playing.
* Incredibly customizable. Nearly everything is able to be tweaked via configuration files (by default located at `$HOME/.config/mumbledj/config.yaml`).
* A large array of [commands](#commands) that perform a wide variety of functions.
* Built-in vote-skipping.
* Built-in caching system (disabled by default).
* Built-in play/pause/volume control.
####`!forceskip`
An admin command that forces a song skip.
## Installation
**IMPORTANT NOTE:** MumbleDJ is only tested and developed for Linux systems. Support will not be given for non-Linux systems if problems are encountered.
####`!volume <desired_volume>?`
Either outputs the current volume or changes the current volume. If `desired_volume` is not provided, the current volume will be displayed in chat. Otherwise, the volume for the bot will be changed to `desired_volume` if it is within the allowed volume range.
### Requirements
**All MumbleDJ installations must also have the following installed:**
* [`youtube-dl`](https://rg3.github.io/youtube-dl/download.html)
* [`ffmpeg`](https://ffmpeg.org) OR [`avconv`](https://libav.org)
* [`aria2`](https://aria2.github.io/) if you plan on using services that throttle download speeds (like Mixcloud)
####`!move <channel>`
Moves MumbleDJ into `channel` if it exists.
**If installing via `go install` or from source, the following must be installed:**
* [Go 1.5+](https://golang.org)
* __NOTE__: Extra installation steps are required for a working Go installation. Once Go is installed, type `go help gopath` for more information.
* If the repositories for your distro contain a version of Go older than 1.5, try using [`gvm`](https://github.com/moovweb/gvm) to install Go 1.5 or newer.
####`!reload`
Reloads `mumbledj.gcfg` to retrieve updated configuration settings.
#### YouTube API Key
A YouTube API key must be present in your configuration file in order to use the YouTube service within the bot. Below is a guide for retrieving an API key:
####`!kill`
Safely cleans the bot environment and disconnects from the server. Please use this command to stop the bot instead of force closing, as the kill command deletes any remaining songs in the `~/.mumbledj/songs` directory.
**1)** Navigate to the [Google Developers Console](https://console.developers.google.com) and sign in with your Google account, or create one if you haven't already.
## INSTALLATION
Installation for v2 of MumbleDJ is much easier than it was before, due to the reduced dependency list and a `Makefile` which automates some of the process.
**2)** Click the "Create Project" button and give your project a name. It doesn't matter what you set your project name to. Once you have a name click the "Create" button. You should be redirected to your new project once it's ready.
**NOTE:** This bot was designed for use on Linux machines. If you wish to run the bot on another OS, it will require tweaking and is not something I will be able to help with.
**3)** Click on "APIs & auth" on the sidebar, and then click APIs. Under the "YouTube APIs" header, click "YouTube Data API". Click on the "Enable API" button.
**SETUP GUIDE**
**1)** Install and correctly configure [`Go`](https://golang.org/) (1.3 or higher). Specifically, make sure to follow [this guide](https://golang.org/doc/code.html) and set the `GOPATH` environment variable properly.
**4)** Click on the "Credentials" option underneath "APIs & auth" on the sidebar. Underneath "Public API access" click on "Create New Key". Choose the "Server key" option.
**2)** Install [`ffmpeg`](https://www.ffmpeg.org/) and [`mercurial`](http://mercurial.selenic.com/) if they are not already installed on your system.
**5)** Add the IP address of the machine MumbleDJ will run on in the box that appears (this is optional, but improves security). Click "Create".
**3)** Install [`youtube-dl`](https://github.com/rg3/youtube-dl#installation).
**6)** You should now see that an API key has been generated. Copy/paste this API key into the configuration file located at `$HOME/.config/mumbledj/mumbledj.yaml`.
**4)** Clone the `MumbleDJ` repository or [download the latest release](https://github.com/matthieugrieger/mumbledj/releases).
#### SoundCloud API Key
A SoundCloud client ID must be present in your configuration file in order to use the SoundCloud service within the bot. Below is a guide for retrieving a client ID:
**5)** `cd` into the `MumbleDJ` repository directory and execute the following commands:
**1)** Login/sign up for a SoundCloud account on https://soundcloud.com.
**2)** Create a new app: https://soundcloud.com/you/apps/new.
**3)** You should now see that a client ID has been generated. Copy/paste this ID (NOT the client secret) into the configuration file located at `$HOME/.config/mumbledj/mumbledj.yaml`.
### Via `go get` (recommended)
After verifying that the [requirements](#requirements) are installed, simply issue the following command:
```
$ make install_deps
$ make
$ make install
go get -u github.com/matthieugrieger/mumbledj
```
**5)** Edit `~/.mumbledj/config/mumbledj.gcfg` to your liking. This file will be overwritten if the config file structure is changed in a commit, but a backup is always stored at `~/.mumbledj/config/mumbledj_backup.gcfg`.
This should place a binary in `$GOPATH/bin` that can be used to start the bot.
**6)** Execute the command shown at the top of this `README` document with your credentials, and the bot should be up and running!
**NOTE:** If using Go 1.5, you MUST execute the following for `go get` to work:
```
export GO15VENDOREXPERIMENT=1
```
## AUTHOR
[Matthieu Grieger](http://matthieugrieger.com)
### Pre-compiled Binaries (easiest)
Pre-compiled binaries are provided for convenience. Overall, I do not recommend using these unless you cannot get `go install` to work properly. Binaries compiled on your own machine are likely more efficient as these binaries are cross-compiled from a 64-bit Linux system.
## LICENSE
After verifying that the [requirements](#requirements) are installed, simply visit the [releases page](https://github.com/matthieugrieger/mumbledj/releases) and download the appropriate binary for your platform.
### From Source
First, clone the MumbleDJ repository to your machine:
```
git clone https://github.com/matthieugrieger/mumbledj.git
```
Install the required software as described in the [requirements section](#requirements), and execute the following:
```
make
```
This will place a compiled `mumbledj` binary in the cloned directory if successful. If you would like to make the binary more accessible by adding it to `/usr/local/bin`, simply execute the following:
```
sudo make install
```
### Docker
You can also use [Docker](https://www.docker.com) to run MumbleDJ.
First you need to clone the MumbleDJ repository to your machine:
```
git clone https://github.com/matthieugrieger/mumbledj.git
```
Assuming you have [Docker installed](https://www.docker.com/products/docker), you will have to build the image:
```
docker build -t mumbledj .
```
And then you can run it, passing the configuration through the command line:
```
docker run --rm --name=mumbledj mumbledj --server=SERVER --api_keys.youtube=YOUR_YOUTUBE_API_KEY --api_keys.soundcloud=YOUR_SOUNDCLOUD_API_KEY
```
In order to run the process as a daemon and restart it automatically on reboot you can use:
```
docker run -d --restart=unless-stopped --name=mumbledj mumbledj --server=SERVER --api_keys.youtube=YOUR_YOUTUBE_API_KEY --api_keys.soundcloud=YOUR_SOUNDCLOUD_API_KEY
```
You can also install Docker on a [Raspberry Pi](https://www.raspberrypi.org/) for instance with [hypriot](http://blog.hypriot.com/getting-started-with-docker-on-your-arm-device/) or with [archlinux](https://archlinuxarm.org/packages/arm/docker). You just need to build the ARM image:
```
docker build -f raspberry.Dockerfile -t mumbledj .
```
## Usage
MumbleDJ is a compiled program that is executed via a terminal.
Here is an example helptext that gives you a feel for the various commandline arguments you can give MumbleDJ:
```
NAME:
MumbleDJ - A Mumble bot that plays audio from various media sites.
USAGE:
mumbledj [global options] command [command options] [arguments...]
VERSION:
v3.1.0
COMMANDS:
GLOBAL OPTIONS:
--config value, -c value location of MumbleDJ configuration file (default: "/home/matthieu/.config/mumbledj/config.yaml")
--server value, -s value address of Mumble server to connect to (default: "127.0.0.1")
--port value, -o value port of Mumble server to connect to (default: "64738")
--username value, -u value username for the bot (default: "MumbleDJ")
--password value, -p value password for the Mumble server
--channel value, -n value channel the bot enters after connecting to the Mumble server
--p12 value path to user p12 file for authenticating as a registered user
--cert value, -e value path to PEM certificate
--key value, -k value path to PEM key
--accesstokens value, -a value list of access tokens separated by spaces
--insecure, -i if present, the bot will not check Mumble certs for consistency
--debug, -d if present, all debug messages will be shown
--help, -h show help
--version, -v print the version
```
__NOTE__: You can also override all settings found within `config.yaml` directly from the commandline. Here's an example:
```
mumbledj --admins.names="SuperUser,Matt" --volume.default="0.5" --volume.lowest="0.2" --queue.automatic_shuffle_on="true"
```
Keep in mind that values that contain commas (such as `"SuperUser,Matt"`) will be interpreted as string slices, or arrays if you are not familiar with Go. If you want your value to be interpreted as a normal string, it is best to avoid commas for now.
## Commands
### add
* __Description__: Adds a track or playlist from a media site to the queue.
* __Default Aliases__: add, a
* __Arguments__: (Required) URL(s) to a track or playlist from a supported media site.
* __Admin-only by default__: No
* __Example__: `!add https://www.youtube.com/watch?v=KQY9zrjPBjo`
### addnext
* __Description__: Adds a track or playlist from a media site as the next item in the queue.
* __Default Aliases__: addnext, an
* __Arguments__: (Required) URL(s) to a track or playlist from a supported media site.
* __Admin-only by default__: Yes
* __Example__: `!addnext https://www.youtube.com/watch?v=KQY9zrjPBjo`
### cachesize
* __Description__: Outputs the file size of the cache in MiB if caching is enabled.
* __Default Aliases__: cachesize, cs
* __Arguments__: None
* __Admin-only by default__: Yes
* __Example__: `!cachesize`
### currenttrack
* __Description__: Outputs information about the current track in the queue if one exists.
* __Default Aliases__: currenttrack, currentsong, current
* __Arguments__: None
* __Admin-only by default__: No
* __Example__: `!currenttrack`
### forceskip
* __Description__: Immediately skips the current track.
* __Default Aliases__: forceskip, fs
* __Arguments__: None
* __Admin-only by default__: Yes
* __Example__: `!forceskip`
### forceskipplaylist
* __Description__: Immediately skips the current playlist.
* __Default Aliases__: forceskipplaylist, fsp
* __Arguments__: None
* __Admin-only by default__: Yes
* __Example__: `!forceskipplaylist`
### help
* __Description__: Outputs a list of available commands and their descriptions.
* __Default Aliases__: help, h
* __Arguments__: None
* __Admin-only by default__: No
* __Example__: `!help`
### joinme
* __Description__: Moves MumbleDJ into your current channel if not playing audio to someone else.
* __Default Aliases__: joinme, join
* __Arguments__: None
* __Admin-only by default__: Yes
* __Example__: `!joinme`
### kill
* __Description__: Stops the bot and cleans its cache directory.
* __Default Aliases__: kill, k
* __Arguments__: None
* __Admin-only by default__: Yes
* __Example__: `!kill`
### listtracks
* __Description__: Outputs a list of the tracks currently in the queue.
* __Default Aliases__: listtracks, listsongs, list, l
* __Arguments__: (Optional) Number of tracks to list
* __Admin-only by default__: No
* __Example__: `!listtracks 10`
### move
* __Description__: Moves the bot into the Mumble channel provided via argument.
* __Default Aliases__: move, m
* __Arguments__: (Required) Mumble channel to move the bot into
* __Admin-only by default__: Yes
* __Example__: `!move Music`
### nexttrack
* __Description__: Outputs information about the next track in the queue if one exists.
* __Default Aliases__: nexttrack, nextsong, next
* __Arguments__: None
* __Admin-only by default__: No
* __Example__: `!nexttrack`
### numcached
* __Description__: Outputs the number of tracks cached on disk if caching is enabled.
* __Default Aliases__: numcached, nc
* __Arguments__: None
* __Admin-only by default__: Yes
* __Example__: `!numcached`
### numtracks
* __Description__: Outputs the number of tracks currently in the queue.
* __Default Aliases__: numtracks, numsongs, nt
* __Arguments__: None
* __Admin-only by default__: No
* __Example__: `!numtracks`
### pause
* __Description__: Pauses audio playback.
* __Default Aliases__: pause
* __Arguments__: None
* __Admin-only by default__: No
* __Example__: `!pause`
### register
* __Description__: Registers the bot on the server.
* __Default Aliases__: register, reg
* __Arguments__: None
* __Admin-only by default__: Yes
* __Example__: `!register`
### reload
* __Description__: Reloads the configuration file.
* __Default Aliases__: reload, r
* __Arguments__: None
* __Admin-only by default__: Yes
* __Example__: `!reload`
### reset
* __Description__: Resets the queue by removing all queue items.
* __Default Aliases__: reset, re
* __Arguments__: None
* __Admin-only by default__: Yes
* __Example__: `!reset`
### resume
* __Description__: Resumes audio playback.
* __Default Aliases__: resume
* __Arguments__: None
* __Admin-only by default__: No
* __Example__: `!pause`
### setcomment
* __Description__: Sets the comment displayed next to MumbleDJ's username in Mumble. If the argument is left empty, the current comment is removed.
* __Default Aliases__: setcomment, comment, sc
* __Arguments__: (Optional) New comment
* __Admin-only by default__: Yes
* __Example__: `!setcomment Hello! I'm a bot. Beep boop.`
### shuffle
* __Description__: Randomizes the tracks currently in the queue.
* __Default Aliases__: shuffle, shuf, sh
* __Arguments__: None
* __Admin-only by default__: Yes
* __Example__: `!shuffle`
### skip
* __Description__: Places a vote to skip the current track.
* __Default Aliases__: skip, s
* __Arguments__: None
* __Admin-only by default__: No
* __Example__: `!skip`
### skipplaylist
* __Description__: Places a vote to skip the current playlist.
* __Default Aliases__: skipplaylist, sp
* __Arguments__: None
* __Admin-only by default__: No
* __Example__: `!skipplaylist`
### toggleshuffle
* __Description__: Toggles permanent track shuffling on/off.
* __Default Aliases__: toggleshuffle, toggleshuf, togshuf, tsh
* __Arguments__: None
* __Admin-only by default__: Yes
* __Example__: `!toggleshuffle`
### version
* __Description__: Outputs the current version of MumbleDJ.
* __Default Aliases__: version, v
* __Arguments__: None
* __Admin-only by default__: No
* __Example__: `!version`
### volume
* __Description__: Changes the volume if an argument is provided, outputs the current volume otherwise.
* __Default Aliases__: volume, vol
* __Arguments__: (Optional) New volume
* __Admin-only by default__: No
* __Example__: `!volume 0.5`
## Contributing
Contributions to MumbleDJ are always welcome! Please see the [contribution guidelines](https://github.com/matthieugrieger/mumbledj/blob/master/CONTRIBUTING.md) for instructions and suggestions!
## Author
[Matthieu Grieger](https://github.com/matthieugrieger)
## License
```
The MIT License (MIT)
Copyright (c) 2014 Matthieu Grieger
Copyright (c) 2016 Matthieu Grieger
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
@ -85,11 +393,14 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
```
## THANKS
* All those who contribute to [Mumble](https://github.com/mumble-voip/mumble).
* [Tim Cooper](https://github.com/bontibon) for [gumble](https://github.com/layeh/gumble).
* [Ricardo Garcia](https://github.com/rg3) for [youtube-dl](https://github.com/rg3/youtube-dl).
* [ScalingData](https://github.com/scalingdata) for [gcfg](https://github.com/scalingdata/gcfg).
* [Hicham Bouabdallah](https://github.com/hishboy) for [Golang queue implementation](https://github.com/hishboy/gocommons/blob/master/lang/queue.go).
* [kennygrant](https://github.com/kennygrant) for [sanitize](https://github.com/kennygrant/sanitize).
* [Jason Moiron](https://github.com/jmoiron) for [jsonq](https://github.com/jmoiron/jsonq).
## Thanks
* [All those who contribute to Mumble](https://github.com/mumble-voip/mumble/graphs/contributors)
* [Tim Cooper](https://github.com/bontibon) for [gumble, gumbleffmpeg, and gumbleutil](https://github.com/layeh/gumble)
* [Jeremy Saenz](https://github.com/codegangsta) for [cli](https://github.com/urfave/cli)
* [Anton Holmquist](https://github.com/antonholmquist) for [jason](https://github.com/antonholmquist/jason)
* [Stretchr, Inc.](https://github.com/stretchr) for [testify](https://github.com/stretchr/testify)
* [ChannelMeter](https://github.com/ChannelMeter) for [iso8601duration](https://github.com/ChannelMeter/iso8601duration)
* [Steve Francia](https://github.com/spf13) for [viper](https://github.com/spf13/viper)
* [Simon Eskildsen](https://github.com/Sirupsen) for [logrus](https://github.com/Sirupsen/logrus)
* [Mitchell Hashimoto](https://github.com/mitchellh) for [gox](https://github.com/mitchellh/gox)
* [Jim Teeuwen](https://github.com/jteeuwen) for [go-bindata](https://github.com/jteeuwen/go-bindata)

235
bindata.go Normal file

File diff suppressed because one or more lines are too long

144
bot/cache.go Normal file
View file

@ -0,0 +1,144 @@
/*
* MumbleDJ
* By Matthieu Grieger
* bot/cache.go
* Copyright (c) 2016 Matthieu Grieger (MIT License)
*/
package bot
import (
"errors"
"fmt"
"io/ioutil"
"os"
"path/filepath"
"sort"
"time"
"github.com/Sirupsen/logrus"
"github.com/spf13/viper"
)
// SortFilesByAge is a type that holds file information for cached items for
// sorting.
type SortFilesByAge []os.FileInfo
// Len returns the length of the file slice.
func (a SortFilesByAge) Len() int {
return len(a)
}
// Swap swaps two elements in the file slice.
func (a SortFilesByAge) Swap(i, j int) {
a[i], a[j] = a[j], a[i]
}
// Less compares two file modification times to determine if one is less than
// the other. Returns true if the item in index i is older than the item in
// index j, false otherwise.
func (a SortFilesByAge) Less(i, j int) bool {
return time.Since(a[i].ModTime()) < time.Since(a[j].ModTime())
}
// Cache keeps track of the filesize of the audio cache and
// provides methods for pruning the cache.
type Cache struct {
NumAudioFiles int
TotalFileSize int64
}
// NewCache creates an empty Cache and returns it.
func NewCache() *Cache {
return &Cache{
NumAudioFiles: 0,
TotalFileSize: 0,
}
}
// CheckDirectorySize checks the cache directory to determine if the filesize
// of the files within exceed the user-specified size limit. If so, the oldest
// files are cleared until it is no longer exceeding the limit.
func (c *Cache) CheckDirectorySize() {
const bytesInMiB int = 1048576
c.UpdateStatistics()
for c.TotalFileSize > int64(viper.GetInt("cache.maximum_size")*bytesInMiB) {
if err := c.DeleteOldest(); err != nil {
break
}
}
}
// UpdateStatistics updates the statistics relevant to the cache (number of
// audio files cached, total current size of the cache).
func (c *Cache) UpdateStatistics() {
c.NumAudioFiles, c.TotalFileSize = c.getCurrentStatistics()
logrus.WithFields(logrus.Fields{
"num_audio_files": c.NumAudioFiles,
"total_file_size": c.TotalFileSize,
}).Infoln("Updated cache statistics.")
}
// CleanPeriodically loops forever, deleting expired cached audio files as necessary.
func (c *Cache) CleanPeriodically() {
for range time.Tick(time.Duration(viper.GetInt("cache.check_interval")) * time.Minute) {
logrus.Infoln("Checking cache for expired files...")
files, _ := ioutil.ReadDir(os.ExpandEnv(viper.GetString("cache.directory")))
for _, file := range files {
// It is safe to check the modification time because when audio files are
// played their modification time is updated. This ensures that audio
// files will not get deleted while they are playing, assuming a reasonable
// expiry time is set in the configuration.
hours := time.Since(file.ModTime()).Hours()
if hours >= viper.GetFloat64("cache.expire_time") {
logrus.WithFields(logrus.Fields{
"expired_file": file.Name(),
}).Infoln("Removing expired cache entry.")
os.Remove(fmt.Sprintf("%s/%s", os.ExpandEnv(viper.GetString("cache.directory")), file.Name()))
}
}
}
}
// DeleteOldest deletes the oldest file in the cache.
func (c *Cache) DeleteOldest() error {
files, _ := ioutil.ReadDir(os.ExpandEnv(viper.GetString("cache.directory")))
if len(files) > 0 {
sort.Sort(SortFilesByAge(files))
os.Remove(fmt.Sprintf("%s/%s", os.ExpandEnv(viper.GetString("cache.directory")), files[0].Name()))
return nil
}
return errors.New("There are no files currently cached")
}
// DeleteAll deletes all cached audio files.
func (c *Cache) DeleteAll() error {
dir, err := os.Open(os.ExpandEnv(viper.GetString("cache.directory")))
if err != nil {
return err
}
defer dir.Close()
names, err := dir.Readdirnames(-1)
if err != nil {
return err
}
logrus.Infoln("Deleting all cached audio files...")
for _, name := range names {
err = os.RemoveAll(filepath.Join(os.ExpandEnv(viper.GetString("cache.directory")), name))
if err != nil {
return err
}
}
return nil
}
func (c *Cache) getCurrentStatistics() (int, int64) {
var totalSize int64
files, _ := ioutil.ReadDir(os.ExpandEnv(viper.GetString("cache.directory")))
for _, file := range files {
totalSize += file.Size()
}
return len(files), totalSize
}

259
bot/config.go Normal file
View file

@ -0,0 +1,259 @@
/*
* MumbleDJ
* By Matthieu Grieger
* bot/config.go
* Copyright (c) 2016 Matthieu Grieger (MIT License)
*/
package bot
import (
"fmt"
"sort"
"strings"
"github.com/Sirupsen/logrus"
"github.com/spf13/viper"
)
// SetDefaultConfig sets default values for all configuration options.
func SetDefaultConfig() {
// API key defaults.
viper.SetDefault("api_keys.youtube", "")
viper.SetDefault("api_keys.soundcloud", "")
// General defaults.
viper.SetDefault("defaults.comment", "Hello! I am a bot. Type !help for a list of commands.")
viper.SetDefault("defaults.channel", "")
viper.SetDefault("defaults.player_command", "ffmpeg")
// Queue defaults.
viper.SetDefault("queue.track_skip_ratio", 0.5)
viper.SetDefault("queue.playlist_skip_ratio", 0.5)
viper.SetDefault("queue.max_track_duration", 0)
viper.SetDefault("queue.max_tracks_per_playlist", 50)
viper.SetDefault("queue.automatic_shuffle_on", false)
viper.SetDefault("queue.announce_new_tracks", true)
// Connection defaults.
viper.SetDefault("connection.address", "127.0.0.1")
viper.SetDefault("connection.port", 64738)
viper.SetDefault("connection.password", "")
viper.SetDefault("connection.username", "MumbleDJ")
viper.SetDefault("connection.user_p12", "")
viper.SetDefault("connection.insecure", false)
viper.SetDefault("connection.cert", "")
viper.SetDefault("connection.key", "")
viper.SetDefault("connection.access_tokens", "")
viper.SetDefault("connection.retry_enabled", true)
viper.SetDefault("connection.retry_attempts", 10)
viper.SetDefault("connection.retry_interval", 5)
// Cache defaults.
viper.SetDefault("cache.enabled", false)
viper.SetDefault("cache.maximum_size", "512MiB")
viper.SetDefault("cache.expire_time", 24)
viper.SetDefault("cache.check_interval", 5)
viper.SetDefault("cache.directory", "$HOME/.cache/mumbledj")
// Volume defaults.
viper.SetDefault("volume.default", 0.2)
viper.SetDefault("volume.lowest", 0.01)
viper.SetDefault("volume.highest", 0.8)
// Admins defaults.
viper.SetDefault("admins.enabled", true)
viper.SetDefault("admins.names", []string{"SuperUser"})
// Command defaults.
viper.SetDefault("commands.prefix", "!")
viper.SetDefault("commands.common_messages.no_tracks_error", "There are no tracks in the queue.")
viper.SetDefault("commands.common_messages.caching_disabled_error", "Caching is currently disabled.")
viper.SetDefault("commands.add.aliases", []string{"add", "a"})
viper.SetDefault("commands.add.is_admin", false)
viper.SetDefault("commands.add.description", "Adds a track or playlist from a media site to the queue.")
viper.SetDefault("commands.add.messages.no_url_error", "A URL must be supplied with the add command.")
viper.SetDefault("commands.add.messages.no_valid_tracks_error", "No valid tracks were found with the provided URL(s).")
viper.SetDefault("commands.add.messages.tracks_too_long_error", "Your track(s) were either too long or an error occurred while processing them. No track(s) have been added.")
viper.SetDefault("commands.add.messages.one_track_added", "<b>%s</b> added <b>1</b> track to the queue:<br><i>%s</i> from %s")
viper.SetDefault("commands.add.messages.many_tracks_added", "<b>%s</b> added <b>%d</b> tracks to the queue.")
viper.SetDefault("commands.add.messages.num_tracks_too_long", "<br><b>%d</b> tracks could not be added due to error or because they are too long.")
viper.SetDefault("commands.addnext.aliases", []string{"addnext", "an"})
viper.SetDefault("commands.addnext.is_admin", true)
viper.SetDefault("commands.addnext.description", "Adds a track or playlist from a media site as the next item in the queue.")
viper.SetDefault("commands.cachesize.aliases", []string{"cachesize", "cs"})
viper.SetDefault("commands.cachesize.is_admin", true)
viper.SetDefault("commands.cachesize.description", "Outputs the file size of the cache in MiB if caching is enabled.")
viper.SetDefault("commands.cachesize.messages.current_size", "The current size of the cache is <b>%.2v MiB</b>.")
viper.SetDefault("commands.currenttrack.aliases", []string{"currenttrack", "currentsong", "current"})
viper.SetDefault("commands.currenttrack.is_admin", false)
viper.SetDefault("commands.currenttrack.description", "Outputs information about the current track in the queue if one exists.")
viper.SetDefault("commands.currenttrack.messages.current_track", "The current track is <i>%s</i>, added by <b>%s</b>.")
viper.SetDefault("commands.forceskip.aliases", []string{"forceskip", "fs"})
viper.SetDefault("commands.forceskip.is_admin", true)
viper.SetDefault("commands.forceskip.description", "Immediately skips the current track.")
viper.SetDefault("commands.forceskip.messages.track_skipped", "The current track has been forcibly skipped by <b>%s</b>.")
viper.SetDefault("commands.forceskipplaylist.aliases", []string{"forceskipplaylist", "fsp"})
viper.SetDefault("commands.forceskipplaylist.is_admin", true)
viper.SetDefault("commands.forceskipplaylist.description", "Immediately skips the current playlist.")
viper.SetDefault("commands.forceskipplaylist.messages.no_playlist_error", "The current track is not part of a playlist.")
viper.SetDefault("commands.forceskipplaylist.messages.playlist_skipped", "The current playlist has been forcibly skipped by <b>%s</b>.")
viper.SetDefault("commands.help.aliases", []string{"help", "h"})
viper.SetDefault("commands.help.is_admin", false)
viper.SetDefault("commands.help.description", "Outputs this list of commands.")
viper.SetDefault("commands.help.messages.commands_header", "<br><b>Commands:</b><br>")
viper.SetDefault("commands.help.messages.admin_commands_header", "<br><b>Admin Commands:</b><br>")
viper.SetDefault("commands.joinme.aliases", []string{"joinme", "join"})
viper.SetDefault("commands.joinme.is_admin", true)
viper.SetDefault("commands.joinme.description", "Moves MumbleDJ into your current channel if not playing audio to someone else.")
viper.SetDefault("commands.joinme.messages.others_are_listening_error", "Users in another channel are listening to me.")
viper.SetDefault("commands.joinme.messages.in_your_channel", "I am now in your channel!")
viper.SetDefault("commands.kill.aliases", []string{"kill", "k"})
viper.SetDefault("commands.kill.is_admin", true)
viper.SetDefault("commands.kill.description", "Stops the bot and cleans its cache directory.")
viper.SetDefault("commands.listtracks.aliases", []string{"listtracks", "listsongs", "list", "l"})
viper.SetDefault("commands.listtracks.is_admin", false)
viper.SetDefault("commands.listtracks.description", "Outputs a list of the tracks currently in the queue.")
viper.SetDefault("commands.listtracks.messages.invalid_integer_error", "An invalid integer was supplied.")
viper.SetDefault("commands.listtracks.messages.track_listing", "<b>%d</b>: <i>%s</i>, added by <b>%s</b>.<br>")
viper.SetDefault("commands.move.aliases", []string{"move", "m"})
viper.SetDefault("commands.move.is_admin", true)
viper.SetDefault("commands.move.description", "Moves the bot into the Mumble channel provided via argument.")
viper.SetDefault("commands.move.messages.no_channel_provided_error", "A destination channel must be supplied to move the bot.")
viper.SetDefault("commands.move.messages.channel_doesnt_exist_error", "The provided channel does not exist.")
viper.SetDefault("commands.move.messages.move_successful", "You have successfully moved the bot to <b>%s</b>.")
viper.SetDefault("commands.nexttrack.aliases", []string{"nexttrack", "nextsong", "next"})
viper.SetDefault("commands.nexttrack.is_admin", false)
viper.SetDefault("commands.nexttrack.description", "Outputs information about the next track in the queue if one exists.")
viper.SetDefault("commands.nexttrack.messages.current_track_only_error", "The current track is the only track in the queue.")
viper.SetDefault("commands.nexttrack.messages.next_track", "The next track is <i>%s</i>, added by <b>%s</b>.")
viper.SetDefault("commands.numcached.aliases", []string{"numcached", "nc"})
viper.SetDefault("commands.numcached.is_admin", true)
viper.SetDefault("commands.numcached.description", "Outputs the number of tracks cached on disk if caching is enabled.")
viper.SetDefault("commands.numcached.messages.num_cached", "There are currently <b>%d</b> items stored in the cache.")
viper.SetDefault("commands.numtracks.aliases", []string{"numtracks", "numsongs", "nt"})
viper.SetDefault("commands.numtracks.is_admin", false)
viper.SetDefault("commands.numtracks.description", "Outputs the number of tracks currently in the queue.")
viper.SetDefault("commands.numtracks.messages.one_track", "There is currently <b>1</b> track in the queue.")
viper.SetDefault("commands.numtracks.messages.plural_tracks", "There are currently <b>%d</b> tracks in the queue.")
viper.SetDefault("commands.pause.aliases", []string{"pause"})
viper.SetDefault("commands.pause.is_admin", false)
viper.SetDefault("commands.pause.description", "Pauses audio playback.")
viper.SetDefault("commands.pause.messages.no_audio_error", "Either the audio is already paused, or there are no tracks in the queue.")
viper.SetDefault("commands.pause.messages.paused", "<b>%s</b> has paused audio playback.")
viper.SetDefault("commands.register.aliases", []string{"register", "reg"})
viper.SetDefault("commands.register.is_admin", true)
viper.SetDefault("commands.register.description", "Registers the bot on the server.")
viper.SetDefault("commands.register.messages.already_registered_error", "I am already registered on the server.")
viper.SetDefault("commands.register.messages.registered", "I am now registered on the server.")
viper.SetDefault("commands.reload.aliases", []string{"reload", "r"})
viper.SetDefault("commands.reload.is_admin", true)
viper.SetDefault("commands.reload.description", "Reloads the configuration file.")
viper.SetDefault("commands.reload.messages.reloaded", "The configuration file has been successfully reloaded.")
viper.SetDefault("commands.reset.aliases", []string{"reset", "re"})
viper.SetDefault("commands.reset.is_admin", true)
viper.SetDefault("commands.reset.description", "Resets the queue by removing all queue items.")
viper.SetDefault("commands.reset.messages.queue_reset", "<b>%s</b> has reset the queue.")
viper.SetDefault("commands.resume.aliases", []string{"resume"})
viper.SetDefault("commands.resume.is_admin", false)
viper.SetDefault("commands.resume.description", "Resumes audio playback.")
viper.SetDefault("commands.resume.messages.audio_error", "Either the audio is already playing, or there are no tracks in the queue.")
viper.SetDefault("commands.resume.messages.resumed", "<b>%s</b> has resumed audio playback.")
viper.SetDefault("commands.setcomment.aliases", []string{"setcomment", "comment", "sc"})
viper.SetDefault("commands.setcomment.is_admin", true)
viper.SetDefault("commands.setcomment.description", "Sets the comment displayed next to MumbleDJ's username in Mumble.")
viper.SetDefault("commands.setcomment.messages.comment_removed", "The comment for the bot has been successfully removed.")
viper.SetDefault("commands.setcomment.messages.comment_changed", "The comment for the bot has been successfully changed to the following: %s")
viper.SetDefault("commands.shuffle.aliases", []string{"shuffle", "shuf", "sh"})
viper.SetDefault("commands.shuffle.is_admin", true)
viper.SetDefault("commands.shuffle.description", "Randomizes the tracks currently in the queue.")
viper.SetDefault("commands.shuffle.messages.not_enough_tracks_error", "There are not enough tracks in the queue to execute a shuffle.")
viper.SetDefault("commands.shuffle.messages.shuffled", "The audio queue has been shuffled.")
viper.SetDefault("commands.skip.aliases", []string{"skip", "s"})
viper.SetDefault("commands.skip.is_admin", false)
viper.SetDefault("commands.skip.description", "Places a vote to skip the current track.")
viper.SetDefault("commands.skip.messages.already_voted_error", "You have already voted to skip this track.")
viper.SetDefault("commands.skip.messages.voted", "<b>%s</b> has voted to skip the current track.")
viper.SetDefault("commands.skip.messages.submitter_voted", "<b>%s</b>, the submitter of this track, has voted to skip. Skipping immediately.")
viper.SetDefault("commands.skipplaylist.aliases", []string{"skipplaylist", "sp"})
viper.SetDefault("commands.skipplaylist.is_admin", false)
viper.SetDefault("commands.skipplaylist.description", "Places a vote to skip the current playlist.")
viper.SetDefault("commands.skipplaylist.messages.no_playlist_error", "The current track is not part of a playlist.")
viper.SetDefault("commands.skipplaylist.messages.already_voted_error", "You have already voted to skip this playlist.")
viper.SetDefault("commands.skipplaylist.messages.voted", "<b>%s</b> has voted to skip the current playlist.")
viper.SetDefault("commands.skipplaylist.messages.submitter_voted", "<b>%s</b>, the submitter of this playlist, has voted to skip. Skipping immediately.")
viper.SetDefault("commands.toggleshuffle.aliases", []string{"toggleshuffle", "toggleshuf", "togshuf", "tsh"})
viper.SetDefault("commands.toggleshuffle.is_admin", true)
viper.SetDefault("commands.toggleshuffle.description", "Toggles automatic track shuffling on/off.")
viper.SetDefault("commands.toggleshuffle.messages.toggled_off", "Automatic shuffling has been toggled off.")
viper.SetDefault("commands.toggleshuffle.messages.toggled_on", "Automatic shuffling has been toggled on.")
viper.SetDefault("commands.version.aliases", []string{"version"})
viper.SetDefault("commands.version.is_admin", false)
viper.SetDefault("commands.version.description", "Outputs the current version of MumbleDJ.")
viper.SetDefault("commands.version.messages.version", "MumbleDJ version: <b>%s</b>")
viper.SetDefault("commands.volume.aliases", []string{"volume", "vol", "v"})
viper.SetDefault("commands.volume.is_admin", false)
viper.SetDefault("commands.volume.description", "Changes the volume if an argument is provided, outputs the current volume otherwise.")
viper.SetDefault("commands.volume.messages.parsing_error", "The requested volume could not be parsed.")
viper.SetDefault("commands.volume.messages.out_of_range_error", "Volumes must be between the values <b>%.2f</b> and <b>%.2f</b>.")
viper.SetDefault("commands.volume.messages.current_volume", "The current volume is <b>%.2f</b>.")
viper.SetDefault("commands.volume.messages.volume_changed", "<b>%s</b> has changed the volume to <b>%.2f</b>.")
}
// ReadConfigFile reads in the config file and updates the configuration accordingly.
func ReadConfigFile() error {
logrus.Infoln("Reading config...")
return viper.ReadInConfig()
}
// CheckForDuplicateAliases validates that all commands have unique aliases.
func CheckForDuplicateAliases() error {
var aliases []string
logrus.Infoln("Checking for duplicate aliases...")
// It would be preferred to use viper.Sub("aliases") here, but there are some
// nil pointer dereferencing issues.
for _, setting := range viper.AllKeys() {
if strings.HasSuffix(setting, "aliases") {
aliases = append(aliases, viper.GetStringSlice(setting)...)
}
}
// Sort the strings to allow us to fail faster in case there is a duplicate.
sort.Strings(aliases)
for i := 0; i < len(aliases)-1; i++ {
if aliases[i] == aliases[i+1] {
return fmt.Errorf("Duplicate alias found: %s", aliases[i])
}
}
return nil
}

56
bot/config_test.go Normal file
View file

@ -0,0 +1,56 @@
/*
* MumbleDJ
* By Matthieu Grieger
* bot/config_test.go
* Copyright (c) 2016 Matthieu Grieger (MIT License)
*/
package bot
import (
"testing"
"github.com/spf13/viper"
"github.com/stretchr/testify/suite"
)
type ConfigTestSuite struct {
suite.Suite
}
func (suite *ConfigTestSuite) SetupSuite() {
DJ = NewMumbleDJ()
}
func (suite *ConfigTestSuite) SetupTest() {
viper.Reset()
}
func (suite *ConfigTestSuite) TestCheckForDuplicateAliasesWhenNoDuplicatesExist() {
viper.Set("commands.add.aliases", []string{"add", "a"})
viper.Set("commands.addnext.aliases", []string{"addnext", "an"})
viper.Set("commands.skip.aliases", []string{"skip", "s"})
viper.Set("commands.skipplaylist.aliases", []string{"skipplaylist", "sp"})
err := CheckForDuplicateAliases()
suite.Nil(err, "No error should be returned as there are no duplicate aliases.")
}
func (suite *ConfigTestSuite) TestCheckForDuplicateAliasesWhenDuplicatesExist() {
viper.Set("commands.add.aliases", []string{"add", "a"})
viper.Set("commands.addnext.aliases", []string{"addnext", "an"})
viper.Set("commands.skip.aliases", []string{"skip", "s"})
viper.Set("commands.skipplaylist.aliases", []string{"skipplaylist", "sp"})
viper.Set("commands.version.aliases", []string{"version", "v"})
viper.Set("commands.volume.aliases", []string{"volume", "vol", "v"})
err := CheckForDuplicateAliases()
suite.NotNil(err, "An error should be returned as there are duplicate aliases.")
suite.Contains(err.Error(), "v", "The error message should contain the duplicate alias.")
}
func TestConfigTestSuite(t *testing.T) {
suite.Run(t, new(ConfigTestSuite))
}

320
bot/mumbledj.go Normal file
View file

@ -0,0 +1,320 @@
/*
* MumbleDJ
* By Matthieu Grieger
* bot/mumbledj.go
* Copyright (c) 2016 Matthieu Grieger (MIT License)
*/
package bot
import (
"crypto/tls"
"errors"
"fmt"
"io/ioutil"
"net"
"os"
"os/exec"
"strings"
"time"
"github.com/Sirupsen/logrus"
"github.com/layeh/gumble/gumble"
"github.com/layeh/gumble/gumbleffmpeg"
"github.com/layeh/gumble/gumbleutil"
"github.com/matthieugrieger/mumbledj/interfaces"
"github.com/spf13/viper"
)
// MumbleDJ is a struct that keeps track of all aspects of the bot's state.
type MumbleDJ struct {
AvailableServices []interfaces.Service
Client *gumble.Client
GumbleConfig *gumble.Config
TLSConfig *tls.Config
AudioStream *gumbleffmpeg.Stream
Queue interfaces.Queue
Cache *Cache
Skips interfaces.SkipTracker
Commands []interfaces.Command
Version string
Volume float32
YouTubeDL *YouTubeDL
KeepAlive chan bool
}
// DJ is a struct that keeps track of all aspects of MumbleDJ's environment.
var DJ *MumbleDJ
// NewMumbleDJ initializes and returns a MumbleDJ type.
func NewMumbleDJ() *MumbleDJ {
SetDefaultConfig()
return &MumbleDJ{
AvailableServices: make([]interfaces.Service, 0),
TLSConfig: new(tls.Config),
Queue: NewQueue(),
Cache: NewCache(),
Skips: NewSkipTracker(),
Commands: make([]interfaces.Command, 0),
YouTubeDL: new(YouTubeDL),
KeepAlive: make(chan bool),
}
}
// OnConnect event. First moves MumbleDJ into the default channel if one exists.
// The configuration is loaded and the audio stream is initialized.
func (dj *MumbleDJ) OnConnect(e *gumble.ConnectEvent) {
dj.AudioStream = nil
logrus.WithFields(logrus.Fields{
"volume": fmt.Sprintf("%.2f", viper.GetFloat64("volume.default")),
}).Infoln("Setting default volume...")
dj.Volume = float32(viper.GetFloat64("volume.default"))
if viper.GetBool("cache.enabled") {
logrus.Infoln("Caching enabled.")
dj.Cache.UpdateStatistics()
go dj.Cache.CleanPeriodically()
} else {
logrus.Infoln("Caching disabled.")
}
}
// OnDisconnect event. Terminates MumbleDJ process or retries connection if
// automatic connection retries are enabled.
func (dj *MumbleDJ) OnDisconnect(e *gumble.DisconnectEvent) {
dj.Queue.Reset()
if viper.GetBool("connection.retry_enabled") &&
(e.Type == gumble.DisconnectError || e.Type == gumble.DisconnectKicked) {
logrus.WithFields(logrus.Fields{
"interval_secs": fmt.Sprintf("%d", viper.GetInt("connection.retry_interval")),
"attempts": fmt.Sprintf("%d", viper.GetInt("connection.retry_attempts")),
}).Warnln("Disconnected from server. Retrying connection...")
success := false
for retries := 0; retries < viper.GetInt("connection.retry_attempts"); retries++ {
logrus.Infoln("Retrying connection...")
if client, err := gumble.DialWithDialer(new(net.Dialer), viper.GetString("connection.address")+":"+viper.GetString("connection.port"), dj.GumbleConfig, dj.TLSConfig); err == nil {
dj.Client = client
logrus.Infoln("Successfully reconnected to the server!")
success = true
break
}
time.Sleep(time.Duration(viper.GetInt("connection.retry_interval")) * time.Second)
}
if !success {
dj.KeepAlive <- true
logrus.Fatalln("Could not reconnect to server. Exiting...")
}
} else {
dj.KeepAlive <- true
logrus.Fatalln("Disconnected from server. No reconnect attempts will be made.")
}
}
// OnTextMessage event. Checks for command prefix and passes it to the Commander
// if it exists. Ignores the incoming message otherwise.
func (dj *MumbleDJ) OnTextMessage(e *gumble.TextMessageEvent) {
plainMessage := gumbleutil.PlainText(&e.TextMessage)
if len(plainMessage) != 0 {
if plainMessage[0] == viper.GetString("commands.prefix")[0] &&
plainMessage != viper.GetString("commands.prefix") {
go func() {
message, isPrivateMessage, err := dj.FindAndExecuteCommand(e.Sender, plainMessage[1:])
if err != nil {
logrus.WithFields(logrus.Fields{
"user": e.Sender.Name,
"message": err.Error(),
}).Warnln("Sending an error message...")
dj.SendPrivateMessage(e.Sender, fmt.Sprintf("<b>Error:</b> %s", err.Error()))
} else {
if isPrivateMessage {
logrus.WithFields(logrus.Fields{
"user": e.Sender.Name,
"message": message,
}).Infoln("Sending a private message...")
dj.SendPrivateMessage(e.Sender, message)
} else {
logrus.WithFields(logrus.Fields{
"channel": dj.Client.Self.Channel.Name,
"message": message,
}).Infoln("Sending a message to channel...")
dj.Client.Self.Channel.Send(message, false)
}
}
}()
}
}
}
// OnUserChange event. Checks UserChange type and adjusts skip trackers to
// reflect the current status of the users on the server.
func (dj *MumbleDJ) OnUserChange(e *gumble.UserChangeEvent) {
if e.Type.Has(gumble.UserChangeDisconnected) || e.Type.Has(gumble.UserChangeChannel) {
logrus.WithFields(logrus.Fields{
"user": e.User.Name,
}).Infoln("A user has disconnected or changed channels, updating skip trackers...")
dj.Skips.RemoveTrackSkip(e.User)
dj.Skips.RemovePlaylistSkip(e.User)
}
}
// SendPrivateMessage sends a private message to the specified user. This method
// verifies that the targeted user is still present in the server before attempting
// to send the message.
func (dj *MumbleDJ) SendPrivateMessage(user *gumble.User, message string) {
dj.Client.Do(func() {
if targetUser := dj.Client.Self.Channel.Users.Find(user.Name); targetUser != nil {
targetUser.Send(message)
}
})
}
// IsAdmin checks whether a particular Mumble user is a MumbleDJ admin.
// Returns true if the user is an admin, and false otherwise.
func (dj *MumbleDJ) IsAdmin(user *gumble.User) bool {
for _, admin := range viper.GetStringSlice("admins.names") {
if user.Name == admin {
return true
}
}
return false
}
// Connect starts the process for connecting to a Mumble server.
func (dj *MumbleDJ) Connect() error {
// Perform startup checks before connecting.
logrus.Infoln("Performing startup checks...")
PerformStartupChecks()
// Create Gumble config.
dj.GumbleConfig = gumble.NewConfig()
dj.GumbleConfig.Username = viper.GetString("connection.username")
dj.GumbleConfig.Password = viper.GetString("connection.password")
dj.GumbleConfig.Tokens = strings.Split(viper.GetString("connection.access_tokens"), ",")
// Initialize key pair if needed.
if viper.GetBool("connection.insecure") {
dj.TLSConfig.InsecureSkipVerify = true
} else {
dj.TLSConfig.ServerName = viper.GetString("connection.address")
if viper.GetString("connection.cert") != "" {
if viper.GetString("connection.key") == "" {
viper.Set("connection.key", viper.GetString("connection.cert"))
}
if certificate, err := tls.LoadX509KeyPair(viper.GetString("connection.cert"), viper.GetString("connection.key")); err == nil {
dj.TLSConfig.Certificates = append(dj.TLSConfig.Certificates, certificate)
} else {
return err
}
}
}
// Add user p12 cert if needed.
if viper.GetString("connection.user_p12") != "" {
if _, err := os.Stat(viper.GetString("connection.user_p12")); os.IsNotExist(err) {
return err
}
// Create temporary directory for converted p12 file.
dir, err := ioutil.TempDir("", "mumbledj")
if err != nil {
return err
}
defer os.RemoveAll(dir)
// Create temporary mumbledj.crt.pem from p12 file.
command := exec.Command("openssl", "pkcs12", "-password", "pass:", "-in", viper.GetString("connection.user_p12"), "-out", dir+"/mumbledj.crt.pem", "-clcerts", "-nokeys")
if err := command.Run(); err != nil {
return err
}
// Create temporary mumbledj.key.pem from p12 file.
command = exec.Command("openssl", "pkcs12", "-password", "pass:", "-in", viper.GetString("connection.user_p12"), "-out", dir+"/mumbledj.key.pem", "-nocerts", "-nodes")
if err := command.Run(); err != nil {
return err
}
if certificate, err := tls.LoadX509KeyPair(dir+"/mumbledj.crt.pem", dir+"/mumbledj.key.pem"); err == nil {
dj.TLSConfig.Certificates = append(dj.TLSConfig.Certificates, certificate)
} else {
return err
}
}
dj.GumbleConfig.Attach(gumbleutil.Listener{
Connect: dj.OnConnect,
Disconnect: dj.OnDisconnect,
TextMessage: dj.OnTextMessage,
UserChange: dj.OnUserChange,
})
dj.GumbleConfig.Attach(gumbleutil.AutoBitrate)
var connErr error
logrus.WithFields(logrus.Fields{
"address": viper.GetString("connection.address"),
"port": viper.GetString("connection.port"),
}).Infoln("Attempting connection to server...")
if dj.Client, connErr = gumble.DialWithDialer(new(net.Dialer), viper.GetString("connection.address")+":"+viper.GetString("connection.port"), dj.GumbleConfig, dj.TLSConfig); connErr != nil {
return connErr
}
return nil
}
// FindAndExecuteCommand attempts to find a reference to a command in an
// incoming message. If found, the command is executed and the resulting
// message/error is returned.
func (dj *MumbleDJ) FindAndExecuteCommand(user *gumble.User, message string) (string, bool, error) {
command, err := dj.findCommand(message)
if err != nil {
return "", true, errors.New("No command was found in this message")
}
return dj.executeCommand(user, message, command)
}
// GetService loops through the available services and determines if a URL
// matches a particular service. If a match is found, the service object is
// returned.
func (dj *MumbleDJ) GetService(url string) (interfaces.Service, error) {
for _, service := range dj.AvailableServices {
if service.CheckURL(url) {
return service, nil
}
}
return nil, errors.New("The provided URL does not match an enabled service")
}
func (dj *MumbleDJ) findCommand(message string) (interfaces.Command, error) {
var possibleCommand string
if strings.Contains(message, " ") {
possibleCommand = strings.ToLower(message[:strings.Index(message, " ")])
} else {
possibleCommand = strings.ToLower(message)
}
for _, command := range dj.Commands {
for _, alias := range command.Aliases() {
if possibleCommand == alias {
return command, nil
}
}
}
return nil, errors.New("No command was found in this message")
}
func (dj *MumbleDJ) executeCommand(user *gumble.User, message string, command interfaces.Command) (string, bool, error) {
canExecute := false
if viper.GetBool("admins.enabled") && command.IsAdminCommand() {
canExecute = dj.IsAdmin(user)
} else {
canExecute = true
}
if canExecute {
return command.Execute(user, strings.Split(message, " ")[1:]...)
}
return "", true, errors.New("You do not have permission to execute this command")
}

36
bot/playlist.go Normal file
View file

@ -0,0 +1,36 @@
/*
* MumbleDJ
* By Matthieu Grieger
* bot/playlist.go
* Copyright (c) 2016 Matthieu Grieger (MIT License)
*/
package bot
// Playlist stores all metadata related to a playlist of tracks.
type Playlist struct {
ID string
Title string
Submitter string
Service string
}
// GetID returns the ID of the playlist.
func (p *Playlist) GetID() string {
return p.ID
}
// GetTitle returns the title of the playlist.
func (p *Playlist) GetTitle() string {
return p.Title
}
// GetSubmitter returns the submitter of the playlist.
func (p *Playlist) GetSubmitter() string {
return p.Submitter
}
// GetService returns the name of the service from which the playlist was retrieved from.
func (p *Playlist) GetService() string {
return p.Service
}

48
bot/playlist_test.go Normal file
View file

@ -0,0 +1,48 @@
/*
* MumbleDJ
* By Matthieu Grieger
* bot/playlist_test.go
* Copyright (c) 2016 Matthieu Grieger (MIT License)
*/
package bot
import (
"testing"
"github.com/stretchr/testify/suite"
)
type PlaylistTestSuite struct {
Playlist Playlist
suite.Suite
}
func (suite *PlaylistTestSuite) SetupTest() {
suite.Playlist = Playlist{
ID: "id",
Title: "title",
Submitter: "submitter",
Service: "service",
}
}
func (suite *PlaylistTestSuite) TestGetID() {
suite.Equal("id", suite.Playlist.GetID())
}
func (suite *PlaylistTestSuite) TestGetTitle() {
suite.Equal("title", suite.Playlist.GetTitle())
}
func (suite *PlaylistTestSuite) TestGetSubmitter() {
suite.Equal("submitter", suite.Playlist.GetSubmitter())
}
func (suite *PlaylistTestSuite) TestGetService() {
suite.Equal("service", suite.Playlist.GetService())
}
func TestPlaylistTestSuite(t *testing.T) {
suite.Run(t, new(PlaylistTestSuite))
}

361
bot/queue.go Normal file
View file

@ -0,0 +1,361 @@
/*
* MumbleDJ
* By Matthieu Grieger
* bot/queue.go
* Copyright (c) 2016 Matthieu Grieger (MIT License)
*/
package bot
import (
"errors"
"fmt"
"math/rand"
"os"
"sync"
"time"
"github.com/layeh/gumble/gumbleffmpeg"
_ "github.com/layeh/gumble/opus"
"github.com/matthieugrieger/mumbledj/interfaces"
"github.com/spf13/viper"
)
// Queue holds the audio queue itself along with useful methods for
// performing actions on the queue.
type Queue struct {
Queue []interfaces.Track
mutex sync.RWMutex
}
func init() {
rand.Seed(time.Now().UTC().UnixNano())
}
// NewQueue initializes a new queue and returns it.
func NewQueue() *Queue {
return &Queue{
Queue: make([]interfaces.Track, 0),
}
}
// Length returns the length of the queue.
func (q *Queue) Length() int {
q.mutex.RLock()
length := len(q.Queue)
q.mutex.RUnlock()
return length
}
// Reset removes all tracks from the queue.
func (q *Queue) Reset() {
q.mutex.Lock()
q.Queue = q.Queue[:0]
q.mutex.Unlock()
}
// AppendTrack adds a track to the back of the queue.
func (q *Queue) AppendTrack(t interfaces.Track) error {
q.mutex.Lock()
beforeLen := len(q.Queue)
// An error should never occur here since maxTrackDuration is restricted to
// ints. Any error in the configuration will be caught during yaml load.
maxTrackDuration, _ := time.ParseDuration(fmt.Sprintf("%ds",
viper.GetInt("queue.max_track_duration")))
if viper.GetInt("queue.max_track_duration") == 0 ||
t.GetDuration() <= maxTrackDuration {
q.Queue = append(q.Queue, t)
} else {
q.mutex.Unlock()
return errors.New("The track is too long to add to the queue")
}
if len(q.Queue) == beforeLen+1 {
q.mutex.Unlock()
q.playIfNeeded()
return nil
}
q.mutex.Unlock()
return errors.New("Could not add track to queue")
}
// InsertTrack inserts track `t` at position `i` in the queue.
func (q *Queue) InsertTrack(i int, t interfaces.Track) error {
q.mutex.Lock()
beforeLen := len(q.Queue)
// An error should never occur here since maxTrackDuration is restricted to
// ints. Any error in the configuration will be caught during yaml load.
maxTrackDuration, _ := time.ParseDuration(fmt.Sprintf("%ds",
viper.GetInt("queue.max_track_duration")))
if viper.GetInt("queue.max_track_duration") == 0 ||
t.GetDuration() <= maxTrackDuration {
q.Queue = append(q.Queue, Track{})
copy(q.Queue[i+1:], q.Queue[i:])
q.Queue[i] = t
} else {
q.mutex.Unlock()
return errors.New("The track is too long to add to the queue")
}
if len(q.Queue) == beforeLen+1 {
q.mutex.Unlock()
q.playIfNeeded()
return nil
}
q.mutex.Unlock()
return errors.New("Could not add track to queue")
}
// CurrentTrack returns the current Track.
func (q *Queue) CurrentTrack() (interfaces.Track, error) {
q.mutex.RLock()
if len(q.Queue) != 0 {
current := q.Queue[0]
q.mutex.RUnlock()
return current, nil
}
q.mutex.RUnlock()
return nil, errors.New("There are no tracks currently in the queue")
}
// GetTrack takes an `index` argument to determine which track to return.
// If the track in position `index` exists, it is returned. Otherwise,
// nil is returned.
func (q *Queue) GetTrack(index int) interfaces.Track {
q.mutex.RLock()
if index >= len(q.Queue) {
q.mutex.RUnlock()
return nil
}
track := q.Queue[index]
q.mutex.RUnlock()
return track
}
// PeekNextTrack peeks at the next track and returns it.
func (q *Queue) PeekNextTrack() (interfaces.Track, error) {
q.mutex.RLock()
if len(q.Queue) > 1 {
if viper.GetBool("queue.automatic_shuffle_on") {
q.RandomNextTrack(false)
}
next := q.Queue[1]
q.mutex.RUnlock()
return next, nil
}
q.mutex.RUnlock()
return nil, errors.New("There is no track coming up next")
}
// Traverse is a traversal function for Queue. Allows a visit function to
// be passed in which performs the specified action on each queue item.
func (q *Queue) Traverse(visit func(i int, t interfaces.Track)) {
q.mutex.RLock()
if len(q.Queue) > 0 {
for queueIndex, queueTrack := range q.Queue {
visit(queueIndex, queueTrack)
}
}
q.mutex.RUnlock()
}
// ShuffleTracks shuffles the queue using an inside-out algorithm.
func (q *Queue) ShuffleTracks() {
q.mutex.Lock()
// Skip the first track, as it is likely playing.
for i := range q.Queue[1:] {
j := rand.Intn(i + 1)
q.Queue[i+1], q.Queue[j+1] = q.Queue[j+1], q.Queue[i+1]
}
q.mutex.Unlock()
}
// RandomNextTrack sets a random track as the next track to be played.
func (q *Queue) RandomNextTrack(queueWasEmpty bool) {
q.mutex.Lock()
if len(q.Queue) > 1 {
nextTrackIndex := 1
if queueWasEmpty {
nextTrackIndex = 0
}
swapIndex := nextTrackIndex + rand.Intn(len(q.Queue)-1)
q.Queue[nextTrackIndex], q.Queue[swapIndex] = q.Queue[swapIndex], q.Queue[nextTrackIndex]
}
q.mutex.Unlock()
}
// Skip performs the necessary actions that take place when a track is skipped
// via a command.
func (q *Queue) Skip() {
// Set AudioStream to nil if it isn't already.
if DJ.AudioStream != nil {
DJ.AudioStream = nil
}
// Remove all track skips.
DJ.Skips.ResetTrackSkips()
q.mutex.Lock()
// If caching is disabled, delete the track from disk.
if len(q.Queue) != 0 && !viper.GetBool("cache.enabled") {
DJ.YouTubeDL.Delete(q.Queue[0])
}
// If automatic track shuffling is enabled, assign a random track in the queue to be the next track.
if viper.GetBool("queue.automatic_shuffle_on") {
q.mutex.Unlock()
q.RandomNextTrack(false)
q.mutex.Lock()
}
// Remove all playlist skips if this is the last track of the playlist still in the queue.
if playlist := q.Queue[0].GetPlaylist(); playlist != nil {
id := playlist.GetID()
playlistIsFinished := true
q.mutex.Unlock()
q.Traverse(func(i int, t interfaces.Track) {
if i != 0 && t.GetPlaylist() != nil {
if t.GetPlaylist().GetID() == id {
playlistIsFinished = false
}
}
})
q.mutex.Lock()
if playlistIsFinished {
DJ.Skips.ResetPlaylistSkips()
}
}
// Skip the track.
length := len(q.Queue)
if length > 1 {
q.Queue = q.Queue[1:]
} else {
q.Queue = make([]interfaces.Track, 0)
}
q.mutex.Unlock()
if err := q.playIfNeeded(); err != nil {
q.Skip()
}
}
// SkipPlaylist performs the necessary actions that take place when a playlist
// is skipped via a command.
func (q *Queue) SkipPlaylist() {
q.mutex.Lock()
if playlist := q.Queue[0].GetPlaylist(); playlist != nil {
currentPlaylistID := playlist.GetID()
// We must loop backwards to prevent missing any elements after deletion.
// NOTE: We do not remove the first track of the playlist quite yet as that
// is removed properly with the following Skip() call.
for i := len(q.Queue) - 1; i >= 1; i-- {
if otherTrackPlaylist := q.Queue[i].GetPlaylist(); otherTrackPlaylist != nil {
if otherTrackPlaylist.GetID() == currentPlaylistID {
q.Queue = append(q.Queue[:i], q.Queue[i+1:]...)
}
}
}
}
q.mutex.Unlock()
q.StopCurrent()
}
// PlayCurrent creates a new audio stream and begins playing the current track.
func (q *Queue) PlayCurrent() error {
currentTrack := q.GetTrack(0)
filepath := os.ExpandEnv(viper.GetString("cache.directory") + "/" + currentTrack.GetFilename())
if _, err := os.Stat(filepath); os.IsNotExist(err) {
if err := DJ.YouTubeDL.Download(q.GetTrack(0)); err != nil {
return err
}
}
source := gumbleffmpeg.SourceFile(filepath)
DJ.AudioStream = gumbleffmpeg.New(DJ.Client, source)
DJ.AudioStream.Offset = currentTrack.GetPlaybackOffset()
DJ.AudioStream.Volume = DJ.Volume
if viper.GetString("defaults.player_command") == "avconv" {
DJ.AudioStream.Command = "avconv"
}
if viper.GetBool("queue.announce_new_tracks") {
message :=
`<table>
<tr>
<td align="center"><img src="%s" width=150 /></td>
</tr>
<tr>
<td align="center"><b><a href="%s">%s</a> (%s)</b></td>
</tr>
<tr>
<td align="center">Added by %s</td>
</tr>
`
message = fmt.Sprintf(message, currentTrack.GetThumbnailURL(), currentTrack.GetURL(),
currentTrack.GetTitle(), currentTrack.GetDuration().String(), currentTrack.GetSubmitter())
if currentTrack.GetPlaylist() != nil {
message = fmt.Sprintf(message+`<tr><td align="center">From playlist "%s"</td></tr>`, currentTrack.GetPlaylist().GetTitle())
}
message += `</table>`
DJ.Client.Self.Channel.Send(message, false)
}
DJ.AudioStream.Play()
go func() {
DJ.AudioStream.Wait()
q.Skip()
}()
return nil
}
// PauseCurrent pauses the current audio stream if it exists and is not already paused.
func (q *Queue) PauseCurrent() error {
if DJ.AudioStream == nil {
return errors.New("There is no track to pause")
}
if DJ.AudioStream.State() == gumbleffmpeg.StatePaused {
return errors.New("The track is already paused")
}
DJ.AudioStream.Pause()
return nil
}
// ResumeCurrent resumes playback of the current audio stream if it exists and is paused.
func (q *Queue) ResumeCurrent() error {
if DJ.AudioStream == nil {
return errors.New("There is no track to resume")
}
if DJ.AudioStream.State() == gumbleffmpeg.StatePlaying {
return errors.New("The track is already playing")
}
DJ.AudioStream.Play()
return nil
}
// StopCurrent stops the playback of the current audio stream if it exists.
func (q *Queue) StopCurrent() error {
if DJ.AudioStream == nil {
return errors.New("The audio stream is nil")
}
DJ.AudioStream.Stop()
return nil
}
func (q *Queue) playIfNeeded() error {
if DJ.AudioStream == nil && q.Length() > 0 {
if err := DJ.YouTubeDL.Download(q.GetTrack(0)); err != nil {
return err
}
if err := q.PlayCurrent(); err != nil {
return err
}
}
return nil
}

276
bot/queue_test.go Normal file
View file

@ -0,0 +1,276 @@
/*
* MumbleDJ
* By Matthieu Grieger
* bot/queue_test.go
* Copyright (c) 2016 Matthieu Grieger (MIT License)
*/
package bot
import (
"fmt"
"math/rand"
"testing"
"time"
"github.com/layeh/gumble/gumbleffmpeg"
"github.com/matthieugrieger/mumbledj/interfaces"
"github.com/spf13/viper"
"github.com/stretchr/testify/suite"
)
type QueueTestSuite struct {
suite.Suite
FirstTrack *Track
SecondTrack *Track
ThirdTrack *Track
}
func (suite *QueueTestSuite) SetupSuite() {
DJ = NewMumbleDJ()
// Trick the tests into thinking audio is already playing to avoid
// attempting to play tracks that don't exist.
DJ.AudioStream = new(gumbleffmpeg.Stream)
viper.Set("queue.automatic_shuffle_on", false)
suite.FirstTrack = &Track{ID: "first"}
suite.SecondTrack = &Track{ID: "second"}
suite.ThirdTrack = &Track{ID: "third"}
}
func (suite *QueueTestSuite) SetupTest() {
DJ.Queue = NewQueue()
viper.Set("queue.max_track_duration", 0)
// Override the initialized seed for consistent test results.
rand.Seed(1)
}
func (suite *QueueTestSuite) TestNewQueue() {
suite.Zero(DJ.Queue.Length(), "The new queue should be empty.")
}
func (suite *QueueTestSuite) TestAppendTrackWhenTrackIsValid() {
suite.Zero(DJ.Queue.Length(), "The queue should be empty.")
err := DJ.Queue.AppendTrack(suite.FirstTrack)
suite.Equal(1, DJ.Queue.Length(), "There should now be one track in the queue.")
suite.Nil(err, "No error should be returned.")
}
func (suite *QueueTestSuite) TestAppendTrackWhenTrackIsTooLong() {
// Set max track duration to 5 seconds
viper.Set("queue.max_track_duration", 5)
// Create duration longer than 5 seconds
duration, _ := time.ParseDuration("6s")
longTrack := &Track{}
longTrack.Duration = duration
suite.Zero(DJ.Queue.Length(), "The queue should be empty.")
err := DJ.Queue.AppendTrack(longTrack)
suite.Zero(DJ.Queue.Length(), "The queue should still be empty.")
suite.NotNil(err, "An error should be returned due to the track being too long.")
}
func (suite *QueueTestSuite) TestCurrentTrackWhenOneExists() {
DJ.Queue.AppendTrack(suite.FirstTrack)
track, err := DJ.Queue.CurrentTrack()
suite.NotNil(track, "The returned track should be non-nil.")
suite.Equal("first", track.GetID(), "The returned track should be the one just added to the queue.")
suite.Nil(err, "No error should be returned.")
}
func (suite *QueueTestSuite) TestCurrentTrackWhenOneDoesNotExist() {
track, err := DJ.Queue.CurrentTrack()
suite.Nil(track, "The returned track should be nil.")
suite.NotNil(err, "An error should be returned because there are no tracks in the queue.")
}
func (suite *QueueTestSuite) TestPeekNextTrackWhenOneExists() {
DJ.Queue.AppendTrack(suite.FirstTrack)
DJ.Queue.AppendTrack(suite.SecondTrack)
track, err := DJ.Queue.PeekNextTrack()
suite.NotNil(track, "The returned track should be non-nil.")
suite.Equal("second", track.GetID(), "The returned track should be the second one added to the queue.")
suite.Nil(err, "No error should be returned.")
}
func (suite *QueueTestSuite) TestPeekNextTrackWhenOneDoesNotExist() {
track, err := DJ.Queue.PeekNextTrack()
suite.Nil(track, "The returned track should be nil.")
suite.NotNil(err, "An error should be returned because there are no tracks in the queue.")
DJ.Queue.AppendTrack(suite.FirstTrack)
track, err = DJ.Queue.PeekNextTrack()
suite.Nil(track, "The returned track should be nil.")
suite.NotNil(err, "An error should be returned because there is only one track in the queue.")
}
func (suite *QueueTestSuite) TestTraverseWhenNoTracksExist() {
trackString := ""
DJ.Queue.Traverse(func(i int, t interfaces.Track) {
trackString += t.GetID() + ", "
})
suite.Equal("", trackString, "No tracks should be traversed as there are none in the queue.")
}
func (suite *QueueTestSuite) TestTraverseWhenTracksExist() {
trackString := ""
DJ.Queue.AppendTrack(suite.FirstTrack)
DJ.Queue.AppendTrack(suite.SecondTrack)
DJ.Queue.Traverse(func(i int, t interfaces.Track) {
trackString += t.GetID() + ", "
})
suite.NotEqual("", trackString, "The trackString should not be empty as there were tracks to traverse.")
suite.Contains(trackString, "first", "The traverse method should have visited the first track.")
suite.Contains(trackString, "second", "The traverse method should have visited the second track.")
}
func (suite *QueueTestSuite) TestShuffleTracks() {
DJ.Queue.AppendTrack(suite.FirstTrack)
DJ.Queue.ShuffleTracks()
suite.Equal(suite.FirstTrack, DJ.Queue.GetTrack(0), "Shuffle shouldn't do anything when only one track is in the queue.")
DJ.Queue.AppendTrack(suite.SecondTrack)
DJ.Queue.ShuffleTracks()
suite.Equal(suite.FirstTrack, DJ.Queue.GetTrack(0), "Shuffle shouldn't do anything when only two tracks are in the queue.")
suite.Equal(suite.SecondTrack, DJ.Queue.GetTrack(1), "Shuffle shouldn't do anything when only two tracks are in the queue.")
DJ.Queue.AppendTrack(suite.ThirdTrack)
for i := 0; i < 10; i++ {
DJ.Queue.AppendTrack(&Track{ID: fmt.Sprintf("%d", i+4)})
}
originalSecondTrack := DJ.Queue.GetTrack(1)
DJ.Queue.ShuffleTracks()
suite.NotEqual(originalSecondTrack, DJ.Queue.GetTrack(1), "The shuffled queue should not be the same as the original queue.")
}
func (suite *QueueTestSuite) TestRandomNextTrackWhenQueueWasEmpty() {
DJ.Queue.AppendTrack(suite.FirstTrack)
DJ.Queue.RandomNextTrack(true)
suite.Equal(suite.FirstTrack, DJ.Queue.GetTrack(0), "RandomNextTrack shouldn't do anything when there is only one track in the queue.")
for i := 0; i < 25; i++ {
DJ.Queue.AppendTrack(&Track{ID: fmt.Sprintf("%d", i+1)})
}
DJ.Queue.RandomNextTrack(true)
suite.NotEqual(suite.FirstTrack, DJ.Queue.GetTrack(0), "The first track should no longer be the same.")
}
func (suite *QueueTestSuite) TestRandomNextTrackWhenQueueWasNotEmpty() {
DJ.Queue.AppendTrack(suite.FirstTrack)
DJ.Queue.RandomNextTrack(false)
suite.Equal(suite.FirstTrack, DJ.Queue.GetTrack(0), "RandomNextTrack shouldn't do anything when there is only one track in the queue.")
DJ.Queue.AppendTrack(suite.SecondTrack)
DJ.Queue.RandomNextTrack(false)
suite.Equal(suite.FirstTrack, DJ.Queue.GetTrack(0), "RandomNextTrack shouldn't do anything when there is only two tracks in the queue and the queue was not previously empty.")
suite.Equal(suite.SecondTrack, DJ.Queue.GetTrack(1), "RandomNextTrack shouldn't do anything when there is only two tracks in the queue and the queue was not previously empty.")
for i := 0; i < 25; i++ {
DJ.Queue.AppendTrack(&Track{ID: fmt.Sprintf("%d", i+2)})
}
DJ.Queue.RandomNextTrack(false)
suite.Equal(suite.FirstTrack, DJ.Queue.GetTrack(0), "Since the queue was not previously empty the first track should not be touched.")
suite.NotEqual(suite.SecondTrack, DJ.Queue.GetTrack(1), "The next track should be randomized.")
}
// TODO: Fix these tests.
/*func (suite *QueueTestSuite) TestSkipWhenQueueHasLessThanTwoTracks() {
DJ.Queue.AppendTrack(suite.FirstTrack)
suite.Equal(1, DJ.Queue.Length(), "There should be one item in the queue.")
DJ.Queue.Skip()
suite.Zero(DJ.Queue.Length(), "There should now be zero items in the queue.")
}
func (suite *QueueTestSuite) TestSkipWhenQueueHasTwoOrMoreTracks() {
DJ.Queue.AppendTrack(suite.FirstTrack)
DJ.Queue.AppendTrack(suite.SecondTrack)
suite.Equal(suite.FirstTrack, DJ.Queue.GetTrack(0), "The track added first should be at the front of the queue.")
suite.Equal(2, DJ.Queue.Length(), "There should be two items in the queue.")
DJ.Queue.Skip()
suite.Equal(suite.SecondTrack, DJ.Queue.GetTrack(0), "The track added second should be at the front of the queue.")
suite.Equal(1, DJ.Queue.Length(), "There should be one item in the queue.")
}
func (suite *QueueTestSuite) TestSkipPlaylistWhenFirstTrackIsNotPartOfPlaylist() {
DJ.Queue.AppendTrack(suite.FirstTrack)
DJ.Queue.AppendTrack(suite.SecondTrack)
DJ.Queue.AppendTrack(suite.ThirdTrack)
DJ.Queue.SkipPlaylist()
suite.Equal(3, DJ.Queue.Length(), "No tracks should be skipped.")
}
func (suite *QueueTestSuite) TestSkipPlaylistWhenFirstTrackIsPartOfPlaylist() {
playlist := &Playlist{ID: "playlist"}
track1 := &Track{Playlist: playlist}
track2 := &Track{Playlist: playlist}
track3 := &Track{}
DJ.Queue.AppendTrack(track1)
DJ.Queue.AppendTrack(track2)
DJ.Queue.AppendTrack(track3)
suite.Equal(3, DJ.Queue.Length(), "There should be three tracks in the queue.")
DJ.Queue.SkipPlaylist()
suite.Equal(1, DJ.Queue.Length(), "There should be one track remaining in the queue.")
}
func (suite *QueueTestSuite) TestSkipPlaylistWhenPlaylistIsShuffled() {
playlist := &Playlist{ID: "playlist"}
otherPlaylist := &Playlist{ID: "otherplaylist"}
track1 := &Track{Playlist: playlist}
track2 := &Track{}
track3 := &Track{Playlist: otherPlaylist}
track4 := &Track{Playlist: playlist}
DJ.Queue.AppendTrack(track1)
DJ.Queue.AppendTrack(track2)
DJ.Queue.AppendTrack(track3)
DJ.Queue.AppendTrack(track4)
suite.Equal(4, DJ.Queue.Length(), "There should be four tracks in the queue.")
DJ.Queue.SkipPlaylist()
suite.Equal(2, DJ.Queue.Length(), "There should be two tracks remaining in the queue.")
}*/
func TestQueueTestSuite(t *testing.T) {
suite.Run(t, new(QueueTestSuite))
}

144
bot/skiptracker.go Normal file
View file

@ -0,0 +1,144 @@
/*
* MumbleDJ
* By Matthieu Grieger
* bot/skiptracker.go
* Copyright (c) 2016 Matthieu Grieger (MIT License)
*/
package bot
import (
"fmt"
"sync"
"github.com/layeh/gumble/gumble"
"github.com/spf13/viper"
)
// SkipTracker keeps track of the list of users who have skipped the current
// track or playlist.
type SkipTracker struct {
TrackSkips []*gumble.User
PlaylistSkips []*gumble.User
trackMutex sync.RWMutex
playlistMutex sync.RWMutex
}
// NewSkipTracker returns an empty SkipTracker.
func NewSkipTracker() *SkipTracker {
return &SkipTracker{
TrackSkips: make([]*gumble.User, 0),
PlaylistSkips: make([]*gumble.User, 0),
}
}
// AddTrackSkip adds a skip to the SkipTracker for the current track.
func (s *SkipTracker) AddTrackSkip(skipper *gumble.User) error {
s.trackMutex.Lock()
for _, user := range s.TrackSkips {
if user.Name == skipper.Name {
s.trackMutex.Unlock()
return fmt.Errorf("%s has already voted to skip the track", skipper.Name)
}
}
s.TrackSkips = append(s.TrackSkips, skipper)
s.trackMutex.Unlock()
s.evaluateTrackSkips()
return nil
}
// AddPlaylistSkip adds a skip to the SkipTracker for the current playlist.
func (s *SkipTracker) AddPlaylistSkip(skipper *gumble.User) error {
s.playlistMutex.Lock()
for _, user := range s.PlaylistSkips {
if user.Name == skipper.Name {
s.playlistMutex.Unlock()
return fmt.Errorf("%s has already voted to skip the playlist", skipper.Name)
}
}
s.PlaylistSkips = append(s.PlaylistSkips, skipper)
s.playlistMutex.Unlock()
s.evaluatePlaylistSkips()
return nil
}
// RemoveTrackSkip removes a skip from the SkipTracker for the current track.
func (s *SkipTracker) RemoveTrackSkip(skipper *gumble.User) error {
s.trackMutex.Lock()
for i, user := range s.TrackSkips {
if user.Name == skipper.Name {
s.TrackSkips = append(s.TrackSkips[:i], s.TrackSkips[i+1:]...)
s.trackMutex.Unlock()
return nil
}
}
s.trackMutex.Unlock()
return fmt.Errorf("%s did not previously vote to skip the track", skipper.Name)
}
// RemovePlaylistSkip removes a skip from the SkipTracker for the current playlist.
func (s *SkipTracker) RemovePlaylistSkip(skipper *gumble.User) error {
s.playlistMutex.Lock()
for i, user := range s.PlaylistSkips {
if user.Name == skipper.Name {
s.PlaylistSkips = append(s.PlaylistSkips[:i], s.PlaylistSkips[i+1:]...)
s.playlistMutex.Unlock()
return nil
}
}
s.playlistMutex.Unlock()
return fmt.Errorf("%s did not previously vote to skip the playlist", skipper.Name)
}
// NumTrackSkips returns the number of users who have skipped the current track.
func (s *SkipTracker) NumTrackSkips() int {
s.trackMutex.RLock()
length := len(s.TrackSkips)
s.trackMutex.RUnlock()
return length
}
// NumPlaylistSkips returns the number of users who have skipped the current playlist.
func (s *SkipTracker) NumPlaylistSkips() int {
s.playlistMutex.RLock()
length := len(s.PlaylistSkips)
s.playlistMutex.RUnlock()
return length
}
// ResetTrackSkips resets the skip slice for the current track.
func (s *SkipTracker) ResetTrackSkips() {
s.trackMutex.Lock()
s.TrackSkips = s.TrackSkips[:0]
s.trackMutex.Unlock()
}
// ResetPlaylistSkips resets the skip slice for the current playlist.
func (s *SkipTracker) ResetPlaylistSkips() {
s.playlistMutex.Lock()
s.PlaylistSkips = s.PlaylistSkips[:0]
s.playlistMutex.Unlock()
}
func (s *SkipTracker) evaluateTrackSkips() {
s.trackMutex.RLock()
skipRatio := viper.GetFloat64("queue.track_skip_ratio")
DJ.Client.Do(func() {
if float64(len(s.TrackSkips))/float64(len(DJ.Client.Self.Channel.Users)) >= skipRatio {
// Stopping an audio stream triggers a skip.
DJ.Queue.StopCurrent()
}
})
s.trackMutex.RUnlock()
}
func (s *SkipTracker) evaluatePlaylistSkips() {
s.playlistMutex.RLock()
skipRatio := viper.GetFloat64("queue.playlist_skip_ratio")
DJ.Client.Do(func() {
if float64(len(s.PlaylistSkips))/float64(len(DJ.Client.Self.Channel.Users)) >= skipRatio {
DJ.Queue.SkipPlaylist()
}
})
s.playlistMutex.RUnlock()
}

143
bot/skiptracker_test.go Normal file
View file

@ -0,0 +1,143 @@
/*
* MumbleDJ
* By Matthieu Grieger
* bot/skiptracker_test.go
* Copyright (c) 2016 Matthieu Grieger (MIT License)
*/
package bot
import (
"testing"
"github.com/layeh/gumble/gumble"
"github.com/stretchr/testify/suite"
)
type SkipTrackerTestSuite struct {
suite.Suite
Skips *SkipTracker
User1 *gumble.User
User2 *gumble.User
}
func (suite *SkipTrackerTestSuite) SetupSuite() {
suite.User1 = new(gumble.User)
suite.User1.Name = "User1"
suite.User2 = new(gumble.User)
suite.User2.Name = "User2"
}
func (suite *SkipTrackerTestSuite) SetupTest() {
suite.Skips = NewSkipTracker()
}
func (suite *SkipTrackerTestSuite) TestNewSkipTracker() {
suite.Zero(suite.Skips.NumTrackSkips(), "The track skip slice should be empty upon initialization.")
suite.Zero(suite.Skips.NumPlaylistSkips(), "The playlist skip slice should be empty upon initialization.")
}
// TODO: Fix these tests.
/*func (suite *SkipTrackerTestSuite) TestAddTrackSkip() {
err := suite.Skips.AddTrackSkip(suite.User1)
suite.Equal(1, suite.Skips.NumTrackSkips(), "There should now be one user in the track skip slice.")
suite.Zero(0, suite.Skips.NumPlaylistSkips(), "The playlist skip slice should be unaffected.")
suite.Nil(err, "No error should be returned.")
err = suite.Skips.AddTrackSkip(suite.User2)
suite.Equal(2, suite.Skips.NumTrackSkips(), "There should now be two users in the track skip slice.")
suite.Zero(0, suite.Skips.NumPlaylistSkips(), "The playlist skip slice should be unaffected.")
suite.Nil(err, "No error should be returned.")
err = suite.Skips.AddTrackSkip(suite.User1)
suite.Equal(2, suite.Skips.NumTrackSkips(), "This is a duplicate skip, so the track skip slice should still only have two users.")
suite.Zero(0, suite.Skips.NumPlaylistSkips(), "The playlist skip slice should be unaffected.")
suite.NotNil(err, "An error should be returned since this user has already voted to skip the current track.")
}
func (suite *SkipTrackerTestSuite) TestAddPlaylistSkip() {
err := suite.Skips.AddPlaylistSkip(suite.User1)
suite.Zero(suite.Skips.NumTrackSkips(), "The track skip slice should be unaffected.")
suite.Equal(1, suite.Skips.NumPlaylistSkips(), "There should now be one user in the playlist skip slice.")
suite.Nil(err, "No error should be returned.")
err = suite.Skips.AddPlaylistSkip(suite.User2)
suite.Zero(suite.Skips.NumTrackSkips(), "The track skip slice should be unaffected.")
suite.Equal(2, suite.Skips.NumPlaylistSkips(), "There should now be two users in the playlist skip slice.")
suite.Nil(err, "No error should be returned.")
err = suite.Skips.AddPlaylistSkip(suite.User1)
suite.Zero(suite.Skips.NumTrackSkips(), "The track skip slice should be unaffected.")
suite.Equal(2, suite.Skips.NumPlaylistSkips(), "This is a duplicate skip, so the playlist skip slice should still only have two users.")
suite.NotNil(err, "An error should be returned since this user has already voted to skip the current playlist.")
}
func (suite *SkipTrackerTestSuite) TestRemoveTrackSkip() {
suite.Skips.AddTrackSkip(suite.User1)
err := suite.Skips.RemoveTrackSkip(suite.User2)
suite.Equal(1, suite.Skips.NumTrackSkips(), "User2 has not skipped the track so the track skip slice should be unaffected.")
suite.Zero(suite.Skips.NumPlaylistSkips(), "The playlist skip slice should be unaffected.")
suite.NotNil(err, "An error should be returned since User2 has not skipped the track yet.")
err = suite.Skips.RemoveTrackSkip(suite.User1)
suite.Zero(suite.Skips.NumTrackSkips(), "User1 skipped the track, so their skip should be removed.")
suite.Zero(suite.Skips.NumPlaylistSkips(), "The playlist skip slice should be unaffected.")
suite.Nil(err, "No error should be returned.")
}
func (suite *SkipTrackerTestSuite) TestRemovePlaylistSkip() {
suite.Skips.AddPlaylistSkip(suite.User1)
err := suite.Skips.RemovePlaylistSkip(suite.User2)
suite.Zero(suite.Skips.NumTrackSkips(), "The track skip slice should be unaffected.")
suite.Equal(1, suite.Skips.NumPlaylistSkips(), "User2 has not skipped the playlist so the playlist skip slice should be unaffected.")
suite.NotNil(err, "An error should be returned since User2 has not skipped the playlist yet.")
err = suite.Skips.RemovePlaylistSkip(suite.User1)
suite.Zero(suite.Skips.NumTrackSkips(), "The track skip slice should be unaffected.")
suite.Zero(suite.Skips.NumPlaylistSkips(), "User1 skipped the playlist, so their skip should be removed.")
suite.Nil(err, "No error should be returned.")
}
func (suite *SkipTrackerTestSuite) TestResetTrackSkips() {
suite.Skips.AddTrackSkip(suite.User1)
suite.Skips.AddTrackSkip(suite.User2)
suite.Skips.AddPlaylistSkip(suite.User1)
suite.Skips.AddPlaylistSkip(suite.User2)
suite.Equal(2, suite.Skips.NumTrackSkips(), "There should be two users in the track skip slice.")
suite.Equal(2, suite.Skips.NumPlaylistSkips(), "There should be two users in the playlist skip slice.")
suite.Skips.ResetTrackSkips()
suite.Zero(suite.Skips.NumTrackSkips(), "The track skip slice has been reset, so the length should be zero.")
suite.Equal(2, suite.Skips.NumPlaylistSkips(), "The playlist skip slice should be unaffected.")
}
func (suite *SkipTrackerTestSuite) TestResetPlaylistSkips() {
suite.Skips.AddTrackSkip(suite.User1)
suite.Skips.AddTrackSkip(suite.User2)
suite.Skips.AddPlaylistSkip(suite.User1)
suite.Skips.AddPlaylistSkip(suite.User2)
suite.Equal(2, suite.Skips.NumTrackSkips(), "There should be two users in the track skip slice.")
suite.Equal(2, suite.Skips.NumPlaylistSkips(), "There should be two users in the playlist skip slice.")
suite.Skips.ResetPlaylistSkips()
suite.Equal(2, suite.Skips.NumTrackSkips(), "The track skip slice should be unaffected.")
suite.Zero(suite.Skips.NumPlaylistSkips(), "The playlist skip slice has been reset, so the length should be zero.")
}*/
func TestSkipTrackerTestSuite(t *testing.T) {
suite.Run(t, new(SkipTrackerTestSuite))
}

110
bot/startup.go Normal file
View file

@ -0,0 +1,110 @@
/*
* MumbleDJ
* By Matthieu Grieger
* bot/startup.go
* Copyright (c) 2016 Matthieu Grieger (MIT License)
*/
package bot
import (
"errors"
"fmt"
"os/exec"
"github.com/Sirupsen/logrus"
"github.com/spf13/viper"
)
// PerformStartupChecks executes the suite of startup checks that are run before the bot
// connects to the server.
func PerformStartupChecks() {
logrus.WithFields(logrus.Fields{
"num_services": fmt.Sprintf("%d", len(DJ.AvailableServices)),
}).Infoln("Checking for availability of services...")
for i := len(DJ.AvailableServices) - 1; i >= 0; i-- {
if err := DJ.AvailableServices[i].CheckAPIKey(); err != nil {
name := DJ.AvailableServices[i].GetReadableName()
logrus.WithFields(logrus.Fields{
"service": name,
"error": err.Error(),
}).Warnln("A startup check discovered an issue. The service will be disabled.")
// Remove service from enabled services.
DJ.AvailableServices = append(DJ.AvailableServices[:i], DJ.AvailableServices[i+1:]...)
}
}
if len(DJ.AvailableServices) == 0 {
logrus.Fatalln("The bot cannot continue as no services are enabled.")
}
if err := checkYouTubeDLInstallation(); err != nil {
logrus.Fatalln("youtube-dl is either not installed or is not discoverable in $PATH. youtube-dl is required to download audio.")
}
if viper.GetString("defaults.player_command") == "ffmpeg" {
if err := checkFfmpegInstallation(); err != nil {
logrus.Fatalln("ffmpeg is either not installed or is not discoverable in $PATH. If you would like to use avconv instead, change the defaults.player_command value in the configuration file.")
}
} else if viper.GetString("defaults.player_command") == "avconv" {
if err := checkAvconvInstallation(); err != nil {
logrus.Fatalln("avconv is either not installed or is not discoverable in $PATH. If you would like to use ffmpeg instead, change the defaults.player_command value in the configuration file.")
}
} else {
logrus.Fatalln("The player command provided in the configuration file is invalid. Valid choices are: \"ffmpeg\", \"avconv\".")
}
if err := checkAria2Installation(); err != nil {
logrus.Warnln("aria2 is not installed or is not discoverable in $PATH. The bot will still partially work, but some services will not work properly.")
}
if err := checkOpenSSLInstallation(); err != nil {
logrus.Warnln("openssl is not installed or is not discoverable in $PATH. p12 certificate files will not work.")
}
}
func checkYouTubeDLInstallation() error {
logrus.Infoln("Checking YouTubeDL installation...")
command := exec.Command("youtube-dl", "--version")
if err := command.Run(); err != nil {
return errors.New("youtube-dl is not properly installed")
}
return nil
}
func checkFfmpegInstallation() error {
logrus.Infoln("Checking ffmpeg installation...")
command := exec.Command("ffmpeg", "-version")
if err := command.Run(); err != nil {
return errors.New("ffmpeg is not properly installed")
}
return nil
}
func checkAvconvInstallation() error {
logrus.Infoln("Checking avconv installation...")
command := exec.Command("avconv", "-version")
if err := command.Run(); err != nil {
return errors.New("avconv is not properly installed")
}
return nil
}
func checkAria2Installation() error {
logrus.Infoln("Checking aria2c installation...")
command := exec.Command("aria2c", "-v")
if err := command.Run(); err != nil {
return errors.New("aria2c is not properly installed")
}
return nil
}
func checkOpenSSLInstallation() error {
logrus.Infoln("Checking openssl installation...")
command := exec.Command("openssl", "version")
if err := command.Run(); err != nil {
return errors.New("openssl is not properly installed")
}
return nil
}

94
bot/track.go Normal file
View file

@ -0,0 +1,94 @@
/*
* MumbleDJ
* By Matthieu Grieger
* bot/track.go
* Copyright (c) 2016 Matthieu Grieger (MIT License)
*/
package bot
import (
"time"
"github.com/matthieugrieger/mumbledj/interfaces"
)
// Track stores all metadata related to an audio track.
type Track struct {
ID string
URL string
Title string
Author string
AuthorURL string
Submitter string
Service string
Filename string
ThumbnailURL string
Duration time.Duration
PlaybackOffset time.Duration
Playlist interfaces.Playlist
}
// GetID returns the ID of the track.
func (t Track) GetID() string {
return t.ID
}
// GetURL returns the URL of the track.
func (t Track) GetURL() string {
return t.URL
}
// GetTitle returns the title of the track.
func (t Track) GetTitle() string {
return t.Title
}
// GetAuthor returns the author of the track.
func (t Track) GetAuthor() string {
return t.Author
}
// GetAuthorURL returns the URL that links to the author of the track.
func (t Track) GetAuthorURL() string {
return t.AuthorURL
}
// GetSubmitter returns the submitter of the track.
func (t Track) GetSubmitter() string {
return t.Submitter
}
// GetService returns the name of the service from which the track was retrieved from.
func (t Track) GetService() string {
return t.Service
}
// GetFilename returns the name of the file stored on disk, if it exists. If no
// file on disk exists an empty string and error are returned.
func (t Track) GetFilename() string {
return t.Filename
}
// GetThumbnailURL returns the URL to the thumbnail for the track. If no thumbnail
// exists an empty string and error are returned.
func (t Track) GetThumbnailURL() string {
return t.ThumbnailURL
}
// GetDuration returns the duration of the track.
func (t Track) GetDuration() time.Duration {
return t.Duration
}
// GetPlaybackOffset returns the playback offset for the track. A duration
// of 0 is given to tracks that do not specify an offset.
func (t Track) GetPlaybackOffset() time.Duration {
return t.PlaybackOffset
}
// GetPlaylist returns the playlist the track is associated with, if it exists. If
// the track is not associated with a playlist a nil playlist and error are returned.
func (t Track) GetPlaylist() interfaces.Playlist {
return t.Playlist
}

125
bot/track_test.go Normal file
View file

@ -0,0 +1,125 @@
/*
* MumbleDJ
* By Matthieu Grieger
* bot/track_test.go
* Copyright (c) 2016 Matthieu Grieger (MIT License)
*/
package bot
import (
"testing"
"time"
"github.com/stretchr/testify/suite"
)
type TrackTestSuite struct {
suite.Suite
Track Track
}
func (suite *TrackTestSuite) SetupTest() {
duration, _ := time.ParseDuration("1s")
offset, _ := time.ParseDuration("2ms")
suite.Track = Track{
ID: "id",
URL: "url",
Title: "title",
Author: "author",
AuthorURL: "author_url",
Submitter: "submitter",
Service: "service",
Filename: "filename",
ThumbnailURL: "thumbnailurl",
Duration: duration,
PlaybackOffset: offset,
Playlist: new(Playlist),
}
}
func (suite *TrackTestSuite) TestGetID() {
suite.Equal("id", suite.Track.GetID())
}
func (suite *TrackTestSuite) TestGetURL() {
suite.Equal("url", suite.Track.GetURL())
}
func (suite *TrackTestSuite) TestGetTitle() {
suite.Equal("title", suite.Track.GetTitle())
}
func (suite *TrackTestSuite) TestGetAuthor() {
suite.Equal("author", suite.Track.GetAuthor())
}
func (suite *TrackTestSuite) TestGetAuthorURL() {
suite.Equal("author_url", suite.Track.GetAuthorURL())
}
func (suite *TrackTestSuite) TestGetSubmitter() {
suite.Equal("submitter", suite.Track.GetSubmitter())
}
func (suite *TrackTestSuite) TestGetService() {
suite.Equal("service", suite.Track.GetService())
}
func (suite *TrackTestSuite) TestGetFilenameWhenExists() {
result := suite.Track.GetFilename()
suite.Equal("filename", result)
}
func (suite *TrackTestSuite) TestGetFilenameWhenNotExists() {
suite.Track.Filename = ""
result := suite.Track.GetFilename()
suite.Equal("", result)
}
func (suite *TrackTestSuite) TestGetThumbnailURLWhenExists() {
result := suite.Track.GetThumbnailURL()
suite.Equal("thumbnailurl", result)
}
func (suite *TrackTestSuite) TestGetThumbnailURLWhenNotExists() {
suite.Track.ThumbnailURL = ""
result := suite.Track.GetThumbnailURL()
suite.Equal("", result)
}
func (suite *TrackTestSuite) TestGetDuration() {
duration, _ := time.ParseDuration("1s")
suite.Equal(duration, suite.Track.GetDuration())
}
func (suite *TrackTestSuite) TestGetPlaybackOffset() {
duration, _ := time.ParseDuration("2ms")
suite.Equal(duration, suite.Track.GetPlaybackOffset())
}
func (suite *TrackTestSuite) TestGetPlaylistWhenExists() {
result := suite.Track.GetPlaylist()
suite.NotNil(result)
}
func (suite *TrackTestSuite) TestGetPlaylistWhenNotExists() {
suite.Track.Playlist = nil
result := suite.Track.GetPlaylist()
suite.Nil(result)
}
func TestTrackTestSuite(t *testing.T) {
suite.Run(t, new(TrackTestSuite))
}

81
bot/youtube_dl.go Normal file
View file

@ -0,0 +1,81 @@
/*
* MumbleDJ
* By Matthieu Grieger
* bot/youtube_dl.go
* Copyright (c) 2016 Matthieu Grieger (MIT License)
*/
package bot
import (
"errors"
"os"
"os/exec"
"github.com/Sirupsen/logrus"
"github.com/matthieugrieger/mumbledj/interfaces"
"github.com/spf13/viper"
)
// YouTubeDL is a struct that gathers all methods related to the youtube-dl
// software.
// youtube-dl: https://rg3.github.io/youtube-dl/
type YouTubeDL struct{}
// Download downloads the audio associated with the incoming `track` object
// and stores it `track.Filename`.
func (yt *YouTubeDL) Download(t interfaces.Track) error {
player := "--prefer-ffmpeg"
if viper.GetString("defaults.player_command") == "avconv" {
player = "--prefer-avconv"
}
filepath := os.ExpandEnv(viper.GetString("cache.directory") + "/" + t.GetFilename())
// Determine which format to use.
format := "bestaudio"
for _, service := range DJ.AvailableServices {
if service.GetReadableName() == t.GetService() {
format = service.GetFormat()
}
}
// Check to see if track is already downloaded.
if _, err := os.Stat(filepath); os.IsNotExist(err) {
var cmd *exec.Cmd
if t.GetService() == "Mixcloud" {
cmd = exec.Command("youtube-dl", "--verbose", "--no-mtime", "--output", filepath, "--format", format, "--external-downloader", "aria2c", player, t.GetURL())
} else {
cmd = exec.Command("youtube-dl", "--verbose", "--no-mtime", "--output", filepath, "--format", format, player, t.GetURL())
}
output, err := cmd.CombinedOutput()
if err != nil {
args := ""
for s := range cmd.Args {
args += cmd.Args[s] + " "
}
logrus.Warnf("%s\n%s\nyoutube-dl: %s", args, string(output), err.Error())
return errors.New("Track download failed")
}
if viper.GetBool("cache.enabled") {
DJ.Cache.CheckDirectorySize()
}
}
return nil
}
// Delete deletes the audio file associated with the incoming `track` object.
func (yt *YouTubeDL) Delete(t interfaces.Track) error {
if !viper.GetBool("cache.enabled") {
filePath := os.ExpandEnv(viper.GetString("cache.directory") + "/" + t.GetFilename())
if _, err := os.Stat(filePath); err == nil {
if err := os.Remove(filePath); err == nil {
return nil
}
return errors.New("An error occurred while deleting the audio file")
}
}
return nil
}

View file

@ -1,234 +0,0 @@
/*
* MumbleDJ
* By Matthieu Grieger
* commands.go
* Copyright (c) 2014 Matthieu Grieger (MIT License)
*/
package main
import (
"errors"
"fmt"
"github.com/kennygrant/sanitize"
"github.com/layeh/gumble/gumble"
"os"
"regexp"
"strconv"
"strings"
)
// Called on text message event. Checks the message for a command string, and processes it accordingly if
// it contains a command.
func parseCommand(user *gumble.User, username, command string) {
var com, argument string
if strings.Contains(command, " ") {
sanitizedCommand := sanitize.HTML(command)
parsedCommand := strings.Split(sanitizedCommand, " ")
com, argument = parsedCommand[0], parsedCommand[1]
} else {
com = command
argument = ""
}
switch com {
// Add command
case dj.conf.Aliases.AddAlias:
if dj.HasPermission(username, dj.conf.Permissions.AdminAdd) {
if argument == "" {
user.Send(NO_ARGUMENT_MSG)
} else {
if songTitle, err := add(username, argument); err == nil {
dj.client.Self().Channel().Send(fmt.Sprintf(SONG_ADDED_HTML, username, songTitle), false)
if dj.queue.Len() == 1 && !dj.audioStream.IsPlaying() {
dj.currentSong = dj.queue.NextSong()
if err := dj.currentSong.Download(); err == nil {
dj.currentSong.Play()
} else {
user.Send(AUDIO_FAIL_MSG)
dj.currentSong.Delete()
}
}
} else {
user.Send(INVALID_URL_MSG)
}
}
} else {
user.Send(NO_PERMISSION_MSG)
}
// Skip command
case dj.conf.Aliases.SkipAlias:
if dj.HasPermission(username, dj.conf.Permissions.AdminSkip) {
if err := skip(username, false); err == nil {
dj.client.Self().Channel().Send(fmt.Sprintf(SKIP_ADDED_HTML, username), false)
}
} else {
user.Send(NO_PERMISSION_MSG)
}
// Forceskip command
case dj.conf.Aliases.AdminSkipAlias:
if dj.HasPermission(username, true) {
if err := skip(username, true); err == nil {
dj.client.Self().Channel().Send(ADMIN_SONG_SKIP_MSG, false)
}
} else {
user.Send(NO_PERMISSION_MSG)
}
// Volume command
case dj.conf.Aliases.VolumeAlias:
if dj.HasPermission(username, dj.conf.Permissions.AdminVolume) {
if argument == "" {
dj.client.Self().Channel().Send(fmt.Sprintf(CUR_VOLUME_HTML, dj.audioStream.Volume()), false)
} else {
if err := volume(username, argument); err == nil {
dj.client.Self().Channel().Send(fmt.Sprintf(VOLUME_SUCCESS_HTML, username, argument), false)
} else {
user.Send(fmt.Sprintf(NOT_IN_VOLUME_RANGE_MSG, dj.conf.Volume.LowestVolume, dj.conf.Volume.HighestVolume))
}
}
} else {
user.Send(NO_PERMISSION_MSG)
}
// Move command
case dj.conf.Aliases.MoveAlias:
if dj.HasPermission(username, dj.conf.Permissions.AdminMove) {
if argument == "" {
user.Send(NO_ARGUMENT_MSG)
} else {
if err := move(argument); err == nil {
fmt.Printf("%s has been moved to %s.", dj.client.Self().Name(), argument)
} else {
user.Send(CHANNEL_DOES_NOT_EXIST_MSG)
}
}
} else {
user.Send(NO_PERMISSION_MSG)
}
// Reload command
case dj.conf.Aliases.ReloadAlias:
if dj.HasPermission(username, dj.conf.Permissions.AdminReload) {
err := loadConfiguration()
if err == nil {
user.Send(CONFIG_RELOAD_SUCCESS_MSG)
} else {
panic(err)
}
} else {
user.Send(NO_PERMISSION_MSG)
}
// Kill command
case dj.conf.Aliases.KillAlias:
if dj.HasPermission(username, dj.conf.Permissions.AdminKill) {
if err := kill(); err == nil {
fmt.Println("Kill successful. Goodbye!")
os.Exit(0)
} else {
user.Send(KILL_ERROR_MSG)
}
} else {
user.Send(NO_PERMISSION_MSG)
}
default:
user.Send(COMMAND_DOESNT_EXIST_MSG)
}
}
// Performs add functionality. Checks input URL for YouTube format, and adds
// the URL to the queue if the format matches.
func add(user, url string) (string, error) {
youtubePatterns := []string{
`https?:\/\/www\.youtube\.com\/watch\?v=([\w-]+)`,
`https?:\/\/youtube\.com\/watch\?v=([\w-]+)`,
`https?:\/\/youtu.be\/([\w-]+)`,
`https?:\/\/youtube.com\/v\/([\w-]+)`,
`https?:\/\/www.youtube.com\/v\/([\w-]+)`,
}
matchFound := false
shortUrl := ""
for _, pattern := range youtubePatterns {
if re, err := regexp.Compile(pattern); err == nil {
if re.MatchString(url) {
matchFound = true
shortUrl = re.FindStringSubmatch(url)[1]
break
}
}
}
if matchFound {
newSong := NewSong(user, shortUrl)
if err := dj.queue.AddSong(newSong); err == nil {
return newSong.title, nil
} else {
return "", errors.New("Could not add the Song to the queue.")
}
} else {
return "", errors.New("The URL provided did not match a YouTube URL.")
}
}
// Performs skip functionality. Adds a skip to the skippers slice for the current song, and then
// evaluates if a skip should be performed. Both skip and forceskip are implemented here.
func skip(user string, admin bool) error {
if err := dj.currentSong.AddSkip(user); err == nil {
if dj.currentSong.SkipReached(len(dj.client.Self().Channel().Users())) || admin {
if err := dj.audioStream.Stop(); err == nil {
dj.OnSongFinished()
return nil
} else {
return errors.New("An error occurred while stopping the current song.")
}
} else {
return nil
}
} else {
return errors.New("An error occurred while adding a skip to the current song.")
}
}
// Performs volume functionality. Checks input value against LowestVolume and HighestVolume from
// config to determine if the volume should be applied. If in the correct range, the new volume
// is applied and is immediately in effect.
func volume(user, value string) error {
if parsedVolume, err := strconv.ParseFloat(value, 32); err == nil {
newVolume := float32(parsedVolume)
if newVolume >= dj.conf.Volume.LowestVolume && newVolume <= dj.conf.Volume.HighestVolume {
dj.audioStream.SetVolume(newVolume)
return nil
} else {
return errors.New("The volume supplied was not in the allowed range.")
}
} else {
return errors.New("An error occurred while parsing the volume string.")
}
}
// Performs move functionality. Determines if the supplied channel is valid and moves the bot
// to the channel if it is.
func move(channel string) error {
if dj.client.Channels().Find(channel) != nil {
dj.client.Self().Move(dj.client.Channels().Find(channel))
return nil
} else {
return errors.New("The channel provided does not exist.")
}
}
// Performs kill functionality. First cleans the ~/.mumbledj/songs directory to get rid of any
// excess m4a files. The bot then safely disconnects from the server.
func kill() error {
songsDir := fmt.Sprintf("%s/.mumbledj/songs", dj.homeDir)
if err := os.RemoveAll(songsDir); err != nil {
return errors.New("An error occurred while deleting the audio files.")
} else {
if err := os.Mkdir(songsDir, 0777); err != nil {
return errors.New("An error occurred while recreating the songs directory.")
}
}
if err := dj.client.Disconnect(); err == nil {
return nil
} else {
return errors.New("An error occurred while disconnecting from the server.")
}
}

97
commands/add.go Normal file
View file

@ -0,0 +1,97 @@
/*
* MumbleDJ
* By Matthieu Grieger
* commands/add.go
* Copyright (c) 2016 Matthieu Grieger (MIT License)
*/
package commands
import (
"errors"
"fmt"
"github.com/layeh/gumble/gumble"
"github.com/matthieugrieger/mumbledj/interfaces"
"github.com/spf13/viper"
)
// AddCommand is a command that adds an audio track associated with a supported
// URL to the queue.
type AddCommand struct{}
// Aliases returns the current aliases for the command.
func (c *AddCommand) Aliases() []string {
return viper.GetStringSlice("commands.add.aliases")
}
// Description returns the description for the command.
func (c *AddCommand) Description() string {
return viper.GetString("commands.add.description")
}
// IsAdminCommand returns true if the command is only for admin use, and
// returns false otherwise.
func (c *AddCommand) IsAdminCommand() bool {
return viper.GetBool("commands.add.is_admin")
}
// Execute executes the command with the given user and arguments.
// Return value descriptions:
// string: A message to be returned to the user upon successful execution.
// bool: Whether the message should be private or not. true = private,
// false = public (sent to whole channel).
// error: An error message to be returned upon unsuccessful execution.
// If no error has occurred, pass nil instead.
// Example return statement:
// return "This is a private message!", true, nil
func (c *AddCommand) Execute(user *gumble.User, args ...string) (string, bool, error) {
var (
allTracks []interfaces.Track
tracks []interfaces.Track
service interfaces.Service
err error
lastTrackAdded interfaces.Track
)
if len(args) == 0 {
return "", true, errors.New(viper.GetString("commands.add.messages.no_url_error"))
}
for _, arg := range args {
if service, err = DJ.GetService(arg); err == nil {
tracks, err = service.GetTracks(arg, user)
if err == nil {
allTracks = append(allTracks, tracks...)
}
}
}
if len(allTracks) == 0 {
return "", true, errors.New(viper.GetString("commands.add.messages.no_valid_tracks_error"))
}
numTooLong := 0
numAdded := 0
for _, track := range allTracks {
if err = DJ.Queue.AppendTrack(track); err != nil {
numTooLong++
} else {
numAdded++
lastTrackAdded = track
}
}
if numAdded == 0 {
return "", true, errors.New(viper.GetString("commands.add.messages.tracks_too_long_error"))
} else if numAdded == 1 {
return fmt.Sprintf(viper.GetString("commands.add.messages.one_track_added"),
user.Name, lastTrackAdded.GetTitle(), lastTrackAdded.GetService()), false, nil
}
retString := fmt.Sprintf(viper.GetString("commands.add.messages.many_tracks_added"), user.Name, numAdded)
if numTooLong != 0 {
retString += fmt.Sprintf(viper.GetString("commands.add.messages.num_tracks_too_long"), numTooLong)
}
return retString, false, nil
}

83
commands/add_test.go Normal file
View file

@ -0,0 +1,83 @@
/*
* MumbleDJ
* By Matthieu Grieger
* commands/add_test.go
* Copyright (c) 2016 Matthieu Grieger (MIT License)
*/
package commands
import (
"testing"
"github.com/layeh/gumble/gumbleffmpeg"
"github.com/matthieugrieger/mumbledj/bot"
"github.com/spf13/viper"
"github.com/stretchr/testify/suite"
)
type AddCommandTestSuite struct {
Command AddCommand
suite.Suite
}
func (suite *AddCommandTestSuite) SetupSuite() {
DJ = bot.NewMumbleDJ()
bot.DJ = DJ
// Trick the tests into thinking audio is already playing to avoid
// attempting to play tracks that don't exist.
DJ.AudioStream = new(gumbleffmpeg.Stream)
viper.Set("commands.add.aliases", []string{"add", "a"})
viper.Set("commands.add.description", "add")
viper.Set("commands.add.is_admin", false)
}
func (suite *AddCommandTestSuite) SetupTest() {
DJ.Queue = bot.NewQueue()
}
func (suite *AddCommandTestSuite) TestAliases() {
suite.Equal([]string{"add", "a"}, suite.Command.Aliases())
}
func (suite *AddCommandTestSuite) TestDescription() {
suite.Equal("add", suite.Command.Description())
}
func (suite *AddCommandTestSuite) TestIsAdminCommand() {
suite.False(suite.Command.IsAdminCommand())
}
func (suite *AddCommandTestSuite) TestExecuteWithNoArgs() {
message, isPrivateMessage, err := suite.Command.Execute(nil)
suite.Equal("", message, "No message should be returned since an error occurred.")
suite.True(isPrivateMessage, "This should be a private message.")
suite.NotNil(err, "An error should be returned for attempting to add a track without providing a URL.")
}
// TODO: Implement this test.
func (suite *AddCommandTestSuite) TestExecuteWhenNoTracksFound() {
}
// TODO: Implement this test.
func (suite *AddCommandTestSuite) TestExecuteWhenTrackFound() {
}
// TODO: Implement this test.
func (suite *AddCommandTestSuite) TestExecuteWhenMultipleTracksFound() {
}
// TODO: Implement this test.
func (suite *AddCommandTestSuite) TestExecuteWithMultipleURLs() {
}
func TestAddCommandTestSuite(t *testing.T) {
suite.Run(t, new(AddCommandTestSuite))
}

98
commands/addnext.go Normal file
View file

@ -0,0 +1,98 @@
/*
* MumbleDJ
* By Matthieu Grieger
* commands/addnext.go
* Copyright (c) 2016 Matthieu Grieger (MIT License)
*/
package commands
import (
"errors"
"fmt"
"github.com/layeh/gumble/gumble"
"github.com/matthieugrieger/mumbledj/interfaces"
"github.com/spf13/viper"
)
// AddNextCommand is a command that adds an audio track associated with a supported
// URL to the queue as the next item.
type AddNextCommand struct{}
// Aliases returns the current aliases for the command.
func (c *AddNextCommand) Aliases() []string {
return viper.GetStringSlice("commands.addnext.aliases")
}
// Description returns the description for the command.
func (c *AddNextCommand) Description() string {
return viper.GetString("commands.addnext.description")
}
// IsAdminCommand returns true if the command is only for admin use, and
// returns false otherwise.
func (c *AddNextCommand) IsAdminCommand() bool {
return viper.GetBool("commands.addnext.is_admin")
}
// Execute executes the command with the given user and arguments.
// Return value descriptions:
// string: A message to be returned to the user upon successful execution.
// bool: Whether the message should be private or not. true = private,
// false = public (sent to whole channel).
// error: An error message to be returned upon unsuccessful execution.
// If no error has occurred, pass nil instead.
// Example return statement:
// return "This is a private message!", true, nil
func (c *AddNextCommand) Execute(user *gumble.User, args ...string) (string, bool, error) {
var (
allTracks []interfaces.Track
tracks []interfaces.Track
service interfaces.Service
err error
lastTrackAdded interfaces.Track
)
if len(args) == 0 {
return "", true, errors.New(viper.GetString("commands.add.messages.no_url_error"))
}
for _, arg := range args {
if service, err = DJ.GetService(arg); err == nil {
tracks, err = service.GetTracks(arg, user)
if err == nil {
allTracks = append(allTracks, tracks...)
}
}
}
if len(allTracks) == 0 {
return "", true, errors.New(viper.GetString("commands.add.messages.no_valid_tracks_error"))
}
numTooLong := 0
numAdded := 0
// We must loop backwards here to preserve the track order when inserting tracks.
for i := len(allTracks) - 1; i >= 0; i-- {
if err = DJ.Queue.InsertTrack(1, allTracks[i]); err != nil {
numTooLong++
} else {
numAdded++
lastTrackAdded = allTracks[i]
}
}
if numAdded == 0 {
return "", true, errors.New(viper.GetString("commands.add.messages.tracks_too_long_error"))
} else if numAdded == 1 {
return fmt.Sprintf(viper.GetString("commands.add.messages.one_track_added"),
user.Name, lastTrackAdded.GetTitle(), lastTrackAdded.GetService()), false, nil
}
retString := fmt.Sprintf(viper.GetString("commands.add.messages.many_tracks_added"), user.Name, numAdded)
if numTooLong != 0 {
retString += fmt.Sprintf(viper.GetString("commands.add.messages.num_tracks_too_long"), numTooLong)
}
return retString, false, nil
}

8
commands/addnext_test.go Normal file
View file

@ -0,0 +1,8 @@
/*
* MumbleDJ
* By Matthieu Grieger
* Copyright (c) 2016 Matthieu Grieger (MIT License)
* commands/addnext_test.go
*/
package commands

55
commands/cachesize.go Normal file
View file

@ -0,0 +1,55 @@
/*
* MumbleDJ
* By Matthieu Grieger
* commands/cachesize.go
* Copyright (c) 2016 Matthieu Grieger (MIT License)
*/
package commands
import (
"errors"
"fmt"
"github.com/layeh/gumble/gumble"
"github.com/spf13/viper"
)
// CacheSizeCommand is a command that outputs the current size of the cache.
type CacheSizeCommand struct{}
// Aliases returns the current aliases for the command.
func (c *CacheSizeCommand) Aliases() []string {
return viper.GetStringSlice("commands.cachesize.aliases")
}
// Description returns the description for the command.
func (c *CacheSizeCommand) Description() string {
return viper.GetString("commands.cachesize.description")
}
// IsAdminCommand returns true if the command is only for admin use, and
// returns false otherwise.
func (c *CacheSizeCommand) IsAdminCommand() bool {
return viper.GetBool("commands.cachesize.is_admin")
}
// Execute executes the command with the given user and arguments.
// Return value descriptions:
// string: A message to be returned to the user upon successful execution.
// bool: Whether the message should be private or not. true = private,
// false = public (sent to whole channel).
// error: An error message to be returned upon unsuccessful execution.
// If no error has occurred, pass nil instead.
// Example return statement:
// return "This is a private message!", true, nil
func (c *CacheSizeCommand) Execute(user *gumble.User, args ...string) (string, bool, error) {
const bytesInMiB = 1048576
if !viper.GetBool("cache.enabled") {
return "", true, errors.New(viper.GetString("commands.common_messages.caching_disabled_error"))
}
DJ.Cache.UpdateStatistics()
return fmt.Sprintf(viper.GetString("commands.cachesize.messages.current_size"), DJ.Cache.TotalFileSize/bytesInMiB), true, nil
}

View file

@ -0,0 +1,60 @@
/*
* MumbleDJ
* By Matthieu Grieger
* commands/cachesize_test.go
* Copyright (c) 2016 Matthieu Grieger (MIT License)
*/
package commands
import (
"testing"
"github.com/matthieugrieger/mumbledj/bot"
"github.com/spf13/viper"
"github.com/stretchr/testify/suite"
)
type CacheSizeCommandTestSuite struct {
Command CacheSizeCommand
suite.Suite
}
func (suite *CacheSizeCommandTestSuite) SetupSuite() {
DJ = bot.NewMumbleDJ()
viper.Set("commands.cachesize.aliases", []string{"cachesize", "cs"})
viper.Set("commands.cachesize.description", "cachesize")
viper.Set("commands.cachesize.is_admin", true)
}
func (suite *CacheSizeCommandTestSuite) TestAliases() {
suite.Equal([]string{"cachesize", "cs"}, suite.Command.Aliases())
}
func (suite *CacheSizeCommandTestSuite) TestDescription() {
suite.Equal("cachesize", suite.Command.Description())
}
func (suite *CacheSizeCommandTestSuite) TestIsAdminCommand() {
suite.True(suite.Command.IsAdminCommand())
}
func (suite *CacheSizeCommandTestSuite) TestExecuteWhenCachingIsDisabled() {
viper.Set("cache.enabled", false)
message, isPrivateMessage, err := suite.Command.Execute(nil)
suite.Equal("", message, "An error occurred so no message should be returned.")
suite.True(isPrivateMessage, "This should be a private message.")
suite.NotNil(err, "An error should be returned because caching is disabled.")
}
// TODO: Implement this test.
func (suite *CacheSizeCommandTestSuite) TestExecuteWhenCachingIsEnabled() {
}
func TestCacheSizeCommandTestSuite(t *testing.T) {
suite.Run(t, new(CacheSizeCommandTestSuite))
}

60
commands/currenttrack.go Normal file
View file

@ -0,0 +1,60 @@
/*
* MumbleDJ
* By Matthieu Grieger
* commands/currenttrack.go
* Copyright (c) 2016 Matthieu Grieger (MIT License)
*/
package commands
import (
"errors"
"fmt"
"github.com/layeh/gumble/gumble"
"github.com/matthieugrieger/mumbledj/interfaces"
"github.com/spf13/viper"
)
// CurrentTrackCommand is a command that outputs information related to
// the track that is currently playing (if one exists).
type CurrentTrackCommand struct{}
// Aliases returns the current aliases for the command.
func (c *CurrentTrackCommand) Aliases() []string {
return viper.GetStringSlice("commands.currenttrack.aliases")
}
// Description returns the description for the command.
func (c *CurrentTrackCommand) Description() string {
return viper.GetString("commands.currenttrack.description")
}
// IsAdminCommand returns true if the command is only for admin use, and
// returns false otherwise.
func (c *CurrentTrackCommand) IsAdminCommand() bool {
return viper.GetBool("commands.currenttrack.is_admin")
}
// Execute executes the command with the given user and arguments.
// Return value descriptions:
// string: A message to be returned to the user upon successful execution.
// bool: Whether the message should be private or not. true = private,
// false = public (sent to whole channel).
// error: An error message to be returned upon unsuccessful execution.
// If no error has occurred, pass nil instead.
// Example return statement:
// return "This is a private message!", true, nil
func (c *CurrentTrackCommand) Execute(user *gumble.User, args ...string) (string, bool, error) {
var (
currentTrack interfaces.Track
err error
)
if currentTrack, err = DJ.Queue.CurrentTrack(); err != nil {
return "", true, errors.New(viper.GetString("commands.common_messages.no_tracks_error"))
}
return fmt.Sprintf(viper.GetString("commands.currenttrack.messages.current_track"),
currentTrack.GetTitle(), currentTrack.GetSubmitter()), true, nil
}

View file

@ -0,0 +1,76 @@
/*
* MumbleDJ
* By Matthieu Grieger
* commands/currenttrack_test.go
* Copyright (c) 2016 Matthieu Grieger (MIT License)
*/
package commands
import (
"testing"
"github.com/layeh/gumble/gumbleffmpeg"
"github.com/matthieugrieger/mumbledj/bot"
"github.com/spf13/viper"
"github.com/stretchr/testify/suite"
)
type CurrentTrackCommandTestSuite struct {
Command CurrentTrackCommand
suite.Suite
}
func (suite *CurrentTrackCommandTestSuite) SetupSuite() {
DJ = bot.NewMumbleDJ()
// Trick the tests into thinking audio is already playing to avoid
// attempting to play tracks that don't exist.
DJ.AudioStream = new(gumbleffmpeg.Stream)
viper.Set("commands.currenttrack.aliases", []string{"currenttrack", "current"})
viper.Set("commands.currenttrack.description", "currenttrack")
viper.Set("commands.currenttrack.is_admin", false)
}
func (suite *CurrentTrackCommandTestSuite) SetupTest() {
DJ.Queue = bot.NewQueue()
}
func (suite *CurrentTrackCommandTestSuite) TestAliases() {
suite.Equal([]string{"currenttrack", "current"}, suite.Command.Aliases())
}
func (suite *CurrentTrackCommandTestSuite) TestDescription() {
suite.Equal("currenttrack", suite.Command.Description())
}
func (suite *CurrentTrackCommandTestSuite) TestIsAdminCommand() {
suite.False(suite.Command.IsAdminCommand())
}
func (suite *CurrentTrackCommandTestSuite) TestExecuteWhenQueueIsEmpty() {
message, isPrivateMessage, err := suite.Command.Execute(nil)
suite.Equal("", message, "No message should be returned since an error occurred.")
suite.True(isPrivateMessage, "This should be a private message.")
suite.NotNil(err, "An error should be returned since the queue is empty.")
}
func (suite *CurrentTrackCommandTestSuite) TestExecuteWhenQueueNotEmpty() {
track := new(bot.Track)
track.Submitter = "test"
track.Title = "test"
DJ.Queue.AppendTrack(track)
message, isPrivateMessage, err := suite.Command.Execute(nil)
suite.NotEqual("", message, "A message should be returned with the current track information.")
suite.True(isPrivateMessage, "This should be a private message.")
suite.Nil(err, "No error should be returned.")
}
func TestCurrentTrackCommandTestSuite(t *testing.T) {
suite.Run(t, new(CurrentTrackCommandTestSuite))
}

55
commands/forceskip.go Normal file
View file

@ -0,0 +1,55 @@
/*
* MumbleDJ
* By Matthieu Grieger
* commands/forceskip.go
* Copyright (c) 2016 Matthieu Grieger (MIT License)
*/
package commands
import (
"errors"
"fmt"
"github.com/layeh/gumble/gumble"
"github.com/spf13/viper"
)
// ForceSkipCommand is a command that immediately skips the current track.
type ForceSkipCommand struct{}
// Aliases returns the current aliases for the command.
func (c *ForceSkipCommand) Aliases() []string {
return viper.GetStringSlice("commands.forceskip.aliases")
}
// Description returns the description for the command.
func (c *ForceSkipCommand) Description() string {
return viper.GetString("commands.forceskip.description")
}
// IsAdminCommand returns true if the command is only for admin use, and
// returns false otherwise.
func (c *ForceSkipCommand) IsAdminCommand() bool {
return viper.GetBool("commands.forceskip.is_admin")
}
// Execute executes the command with the given user and arguments.
// Return value descriptions:
// string: A message to be returned to the user upon successful execution.
// bool: Whether the message should be private or not. true = private,
// false = public (sent to whole channel).
// error: An error message to be returned upon unsuccessful execution.
// If no error has occurred, pass nil instead.
// Example return statement:
// return "This is a private message!", true, nil
func (c *ForceSkipCommand) Execute(user *gumble.User, args ...string) (string, bool, error) {
if DJ.Queue.Length() == 0 {
return "", true, errors.New(viper.GetString("commands.common_messages.no_tracks_error"))
}
DJ.Queue.StopCurrent()
return fmt.Sprintf(viper.GetString("commands.forceskip.messages.track_skipped"),
user.Name), false, nil
}

View file

@ -0,0 +1,8 @@
/*
* MumbleDJ
* By Matthieu Grieger
* Copyright (c) 2016 Matthieu Grieger (MIT License)
* commands/forceskip_test.go
*/
package commands

View file

@ -0,0 +1,66 @@
/*
* MumbleDJ
* By Matthieu Grieger
* commands/forceskipplaylist.go
* Copyright (c) 2016 Matthieu Grieger (MIT License)
*/
package commands
import (
"errors"
"fmt"
"github.com/layeh/gumble/gumble"
"github.com/matthieugrieger/mumbledj/interfaces"
"github.com/spf13/viper"
)
// ForceSkipPlaylistCommand is a command that immediately skips the current
// playlist.
type ForceSkipPlaylistCommand struct{}
// Aliases returns the current aliases for the command.
func (c *ForceSkipPlaylistCommand) Aliases() []string {
return viper.GetStringSlice("commands.forceskipplaylist.aliases")
}
// Description returns the description for the command.
func (c *ForceSkipPlaylistCommand) Description() string {
return viper.GetString("commands.forceskipplaylist.description")
}
// IsAdminCommand returns true if the command is only for admin use, and
// returns false otherwise.
func (c *ForceSkipPlaylistCommand) IsAdminCommand() bool {
return viper.GetBool("commands.forceskipplaylist.is_admin")
}
// Execute executes the command with the given user and arguments.
// Return value descriptions:
// string: A message to be returned to the user upon successful execution.
// bool: Whether the message should be private or not. true = private,
// false = public (sent to whole channel).
// error: An error message to be returned upon unsuccessful execution.
// If no error has occurred, pass nil instead.
// Example return statement:
// return "This is a private message!", true, nil
func (c *ForceSkipPlaylistCommand) Execute(user *gumble.User, args ...string) (string, bool, error) {
var (
currentTrack interfaces.Track
err error
)
if currentTrack, err = DJ.Queue.CurrentTrack(); err != nil {
return "", true, errors.New(viper.GetString("commands.common_messages.no_tracks_error"))
}
if playlist := currentTrack.GetPlaylist(); playlist == nil {
return "", true, errors.New(viper.GetString("commands.forceskipplaylist.messages.no_playlist_error"))
}
DJ.Queue.SkipPlaylist()
return fmt.Sprintf(viper.GetString("commands.forceskipplaylist.messages.playlist_skipped"),
user.Name), false, nil
}

View file

@ -0,0 +1,8 @@
/*
* MumbleDJ
* By Matthieu Grieger
* Copyright (c) 2016 Matthieu Grieger (MIT License)
* commands/forceskipplaylist_test.go
*/
package commands

75
commands/help.go Normal file
View file

@ -0,0 +1,75 @@
/*
* MumbleDJ
* By Matthieu Grieger
* commands/help.go
* Copyright (c) 2016 Matthieu Grieger (MIT License)
*/
package commands
import (
"fmt"
"github.com/layeh/gumble/gumble"
"github.com/spf13/viper"
)
// HelpCommand is a command that outputs a help message that shows the
// available commands and their aliases.
type HelpCommand struct{}
// Aliases returns the current aliases for the command.
func (c *HelpCommand) Aliases() []string {
return viper.GetStringSlice("commands.help.aliases")
}
// Description returns the description for the command.
func (c *HelpCommand) Description() string {
return viper.GetString("commands.help.description")
}
// IsAdminCommand returns true if the command is only for admin use, and
// returns false otherwise.
func (c *HelpCommand) IsAdminCommand() bool {
return viper.GetBool("commands.help.is_admin")
}
// Execute executes the command with the given user and arguments.
// Return value descriptions:
// string: A message to be returned to the user upon successful execution.
// bool: Whether the message should be private or not. true = private,
// false = public (sent to whole channel).
// error: An error message to be returned upon unsuccessful execution.
// If no error has occurred, pass nil instead.
// Example return statement:
// return "This is a private message!", true, nil
func (c *HelpCommand) Execute(user *gumble.User, args ...string) (string, bool, error) {
commandString := "<b>%s</b> -- %s<br>"
regularCommands := ""
adminCommands := ""
totalString := ""
for _, command := range Commands {
currentString := fmt.Sprintf(commandString, command.Aliases(), command.Description())
if command.IsAdminCommand() {
adminCommands += currentString
} else {
regularCommands += currentString
}
}
totalString = viper.GetString("commands.help.messages.commands_header") + regularCommands
isAdmin := false
if viper.GetBool("admins.enabled") {
isAdmin = DJ.IsAdmin(user)
} else {
isAdmin = true
}
if isAdmin {
totalString += viper.GetString("commands.help.messages.admin_commands_header") + adminCommands
}
return totalString, true, nil
}

93
commands/help_test.go Normal file
View file

@ -0,0 +1,93 @@
/*
* MumbleDJ
* By Matthieu Grieger
* commands/help_test.go
* Copyright (c) 2016 Matthieu Grieger (MIT License)
*/
package commands
import (
"testing"
"github.com/layeh/gumble/gumble"
"github.com/matthieugrieger/mumbledj/bot"
"github.com/spf13/viper"
"github.com/stretchr/testify/suite"
)
type HelpCommandTestSuite struct {
Command HelpCommand
suite.Suite
}
func (suite *HelpCommandTestSuite) SetupSuite() {
DJ = bot.NewMumbleDJ()
viper.Set("commands.help.aliases", []string{"help", "h"})
viper.Set("commands.help.description", "help")
viper.Set("commands.help.is_admin", false)
}
func (suite *HelpCommandTestSuite) SetupTest() {
viper.Set("admins.enabled", true)
}
func (suite *HelpCommandTestSuite) TestAliases() {
suite.Equal([]string{"help", "h"}, suite.Command.Aliases())
}
func (suite *HelpCommandTestSuite) TestDescription() {
suite.Equal("help", suite.Command.Description())
}
func (suite *HelpCommandTestSuite) TestIsAdminCommand() {
suite.False(suite.Command.IsAdminCommand())
}
func (suite *HelpCommandTestSuite) TestExecuteWhenPermissionsEnabledAndUserIsNotAdmin() {
viper.Set("admins.names", []string{"SuperUser"})
user := new(gumble.User)
user.Name = "Test"
message, isPrivateMessage, err := suite.Command.Execute(user)
suite.NotEqual("", message, "A message should be returned.")
suite.True(isPrivateMessage, "This should be a private message.")
suite.Nil(err, "No error should be returned.")
suite.Contains(message, "help", "The returned message should contain command descriptions.")
suite.Contains(message, "add", "The returned message should contain command descriptions.")
suite.NotContains(message, "Admin Commands", "The returned message should not contain admin command descriptions.")
}
func (suite *HelpCommandTestSuite) TestExecuteWhenPermissionsEnabledAndUserIsAdmin() {
viper.Set("admins.names", []string{"SuperUser"})
user := new(gumble.User)
user.Name = "SuperUser"
message, isPrivateMessage, err := suite.Command.Execute(user)
suite.NotEqual("", message, "A message should be returned.")
suite.True(isPrivateMessage, "This should be a private message.")
suite.Nil(err, "No error should be returned.")
suite.Contains(message, "help", "The returned message should contain command descriptions.")
suite.Contains(message, "add", "The returned message should contain command descriptions.")
suite.Contains(message, "Admin Commands", "The returned message should contain admin command descriptions.")
}
func (suite *HelpCommandTestSuite) TestExecuteWhenPermissionsDisabled() {
viper.Set("admins.enabled", false)
message, isPrivateMessage, err := suite.Command.Execute(nil)
suite.NotEqual("", message, "A message should be returned.")
suite.True(isPrivateMessage, "This should be a private message.")
suite.Nil(err, "No error should be returned.")
suite.Contains(message, "help", "The returned message should contain command descriptions.")
suite.Contains(message, "add", "The returned message should contain command descriptions.")
suite.Contains(message, "Admin Commands", "The returned message should contain admin command descriptions.")
}
func TestHelpCommandTestSuite(t *testing.T) {
suite.Run(t, new(HelpCommandTestSuite))
}

58
commands/joinme.go Normal file
View file

@ -0,0 +1,58 @@
/*
* MumbleDJ
* By Matthieu Grieger
* commands/joinme.go
* Copyright (c) 2016 Matthieu Grieger (MIT License)
*/
package commands
import (
"errors"
"github.com/layeh/gumble/gumble"
"github.com/layeh/gumble/gumbleffmpeg"
"github.com/spf13/viper"
)
// JoinMeCommand is a command that moves the bot to the channel of the user
// who issued the command.
type JoinMeCommand struct{}
// Aliases returns the current aliases for the command.
func (c *JoinMeCommand) Aliases() []string {
return viper.GetStringSlice("commands.joinme.aliases")
}
// Description returns the description for the command.
func (c *JoinMeCommand) Description() string {
return viper.GetString("commands.joinme.description")
}
// IsAdminCommand returns true if the command is only for admin use, and
// returns false otherwise.
func (c *JoinMeCommand) IsAdminCommand() bool {
return viper.GetBool("commands.joinme.is_admin")
}
// Execute executes the command with the given user and arguments.
// Return value descriptions:
// string: A message to be returned to the user upon successful execution.
// bool: Whether the message should be private or not. true = private,
// false = public (sent to whole channel).
// error: An error message to be returned upon unsuccessful execution.
// If no error has occurred, pass nil instead.
// Example return statement:
// return "This is a private message!", true, nil
func (c *JoinMeCommand) Execute(user *gumble.User, args ...string) (string, bool, error) {
if DJ.AudioStream != nil && DJ.AudioStream.State() == gumbleffmpeg.StatePlaying &&
len(DJ.Client.Self.Channel.Users) > 1 {
return "", true, errors.New(viper.GetString("commands.joinme.messages.others_are_listening_error"))
}
DJ.Client.Do(func() {
DJ.Client.Self.Move(user.Channel)
})
return viper.GetString("commands.joinme.messages.in_your_channel"), true, nil
}

8
commands/joinme_test.go Normal file
View file

@ -0,0 +1,8 @@
/*
* MumbleDJ
* By Matthieu Grieger
* Copyright (c) 2016 Matthieu Grieger (MIT License)
* commands/joinme_test.go
*/
package commands

55
commands/kill.go Normal file
View file

@ -0,0 +1,55 @@
/*
* MumbleDJ
* By Matthieu Grieger
* commands/currenttrack.go
* Copyright (c) 2016 Matthieu Grieger (MIT License)
*/
package commands
import (
"os"
"github.com/layeh/gumble/gumble"
"github.com/spf13/viper"
)
// KillCommand is a command that safely kills the bot.
type KillCommand struct{}
// Aliases returns the current aliases for the command.
func (c *KillCommand) Aliases() []string {
return viper.GetStringSlice("commands.kill.aliases")
}
// Description returns the description for the command.
func (c *KillCommand) Description() string {
return viper.GetString("commands.kill.description")
}
// IsAdminCommand returns true if the command is only for admin use, and
// returns false otherwise.
func (c *KillCommand) IsAdminCommand() bool {
return viper.GetBool("commands.kill.is_admin")
}
// Execute executes the command with the given user and arguments.
// Return value descriptions:
// string: A message to be returned to the user upon successful execution.
// bool: Whether the message should be private or not. true = private,
// false = public (sent to whole channel).
// error: An error message to be returned upon unsuccessful execution.
// If no error has occurred, pass nil instead.
// Example return statement:
// return "This is a private message!", true, nil
func (c *KillCommand) Execute(user *gumble.User, args ...string) (string, bool, error) {
if err := DJ.Cache.DeleteAll(); err != nil {
return "", true, err
}
if err := DJ.Client.Disconnect(); err != nil {
return "", true, err
}
os.Exit(0)
return "", true, nil
}

8
commands/kill_test.go Normal file
View file

@ -0,0 +1,8 @@
/*
* MumbleDJ
* By Matthieu Grieger
* Copyright (c) 2016 Matthieu Grieger (MIT License)
* commands/kill_test.go
*/
package commands

73
commands/listtracks.go Normal file
View file

@ -0,0 +1,73 @@
/*
* MumbleDJ
* By Matthieu Grieger
* commands/currenttrack.go
* Copyright (c) 2016 Matthieu Grieger (MIT License)
*/
package commands
import (
"bytes"
"errors"
"fmt"
"strconv"
"github.com/layeh/gumble/gumble"
"github.com/matthieugrieger/mumbledj/interfaces"
"github.com/spf13/viper"
)
// ListTracksCommand is a command that lists the tracks that are currently
// in the queue.
type ListTracksCommand struct{}
// Aliases returns the current aliases for the command.
func (c *ListTracksCommand) Aliases() []string {
return viper.GetStringSlice("commands.listtracks.aliases")
}
// Description returns the description for the command.
func (c *ListTracksCommand) Description() string {
return viper.GetString("commands.listtracks.description")
}
// IsAdminCommand returns true if the command is only for admin use, and
// returns false otherwise.
func (c *ListTracksCommand) IsAdminCommand() bool {
return viper.GetBool("commands.listtracks.is_admin")
}
// Execute executes the command with the given user and arguments.
// Return value descriptions:
// string: A message to be returned to the user upon successful execution.
// bool: Whether the message should be private or not. true = private,
// false = public (sent to whole channel).
// error: An error message to be returned upon unsuccessful execution.
// If no error has occurred, pass nil instead.
// Example return statement:
// return "This is a private message!", true, nil
func (c *ListTracksCommand) Execute(user *gumble.User, args ...string) (string, bool, error) {
if DJ.Queue.Length() == 0 {
return "", true, errors.New(viper.GetString("commands.common_messages.no_tracks_error"))
}
numTracksToList := DJ.Queue.Length()
if len(args) != 0 {
if parsedNum, err := strconv.Atoi(args[0]); err == nil {
numTracksToList = parsedNum
} else {
return "", true, errors.New(viper.GetString("commands.listtracks.messages.invalid_integer_error"))
}
}
var buffer bytes.Buffer
DJ.Queue.Traverse(func(i int, track interfaces.Track) {
if i < numTracksToList {
buffer.WriteString(fmt.Sprintf(viper.GetString("commands.listtracks.messages.track_listing"),
i+1, track.GetTitle(), track.GetSubmitter()))
}
})
return buffer.String(), true, nil
}

136
commands/listtracks_test.go Normal file
View file

@ -0,0 +1,136 @@
/*
* MumbleDJ
* By Matthieu Grieger
* commands/listtracks_test.go
* Copyright (c) 2016 Matthieu Grieger (MIT License)
*/
package commands
import (
"testing"
"github.com/layeh/gumble/gumbleffmpeg"
"github.com/matthieugrieger/mumbledj/bot"
"github.com/spf13/viper"
"github.com/stretchr/testify/suite"
)
type ListTracksCommandTestSuite struct {
Command ListTracksCommand
suite.Suite
}
func (suite *ListTracksCommandTestSuite) SetupSuite() {
DJ = bot.NewMumbleDJ()
bot.DJ = DJ
// Trick the tests into thinking audio is already playing to avoid
// attempting to play tracks that don't exist.
DJ.AudioStream = new(gumbleffmpeg.Stream)
viper.Set("commands.listtracks.aliases", []string{"listtracks", "list"})
viper.Set("commands.listtracks.description", "listtracks")
viper.Set("commands.listtracks.is_admin", false)
}
func (suite *ListTracksCommandTestSuite) SetupTest() {
DJ.Queue = bot.NewQueue()
}
func (suite *ListTracksCommandTestSuite) TestAliases() {
suite.Equal([]string{"listtracks", "list"}, suite.Command.Aliases())
}
func (suite *ListTracksCommandTestSuite) TestDescription() {
suite.Equal("listtracks", suite.Command.Description())
}
func (suite *ListTracksCommandTestSuite) TestIsAdminCommand() {
suite.False(suite.Command.IsAdminCommand())
}
func (suite *ListTracksCommandTestSuite) TestExecuteWithNoTracks() {
message, isPrivateMessage, err := suite.Command.Execute(nil)
suite.Equal("", message, "No message should be returned.")
suite.True(isPrivateMessage, "This should be a private message.")
suite.NotNil(err, "An error should be returned as there are no tracks to list.")
}
func (suite *ListTracksCommandTestSuite) TestExecuteWithNoArg() {
track := new(bot.Track)
track.Title = "title"
track.Submitter = "test"
DJ.Queue.AppendTrack(track)
message, isPrivateMessage, err := suite.Command.Execute(nil)
suite.NotEqual("", message, "A message containing track information should be returned.")
suite.Contains(message, "title", "The returned message should contain the track title.")
suite.Contains(message, "test", "The returned message should contain the track submitter.")
suite.True(isPrivateMessage, "This should be a private message.")
suite.Nil(err, "No error should be returned.")
}
func (suite *ListTracksCommandTestSuite) TestExecuteWithValidArg() {
track1 := new(bot.Track)
track1.Title = "first"
track1.Submitter = "test"
track2 := new(bot.Track)
track2.Title = "second"
track2.Submitter = "test"
track3 := new(bot.Track)
track3.Title = "third"
track3.Submitter = "test"
DJ.Queue.AppendTrack(track1)
DJ.Queue.AppendTrack(track2)
DJ.Queue.AppendTrack(track3)
message, isPrivateMessage, err := suite.Command.Execute(nil, "2")
suite.NotEqual("", message, "A message containing track information should be returned.")
suite.Contains(message, "first", "The returned message should contain the first track.")
suite.Contains(message, "second", "The returned message should contain the second track.")
suite.NotContains(message, "third", "The returned message should not contain the third track.")
suite.True(isPrivateMessage, "This should be a private message.")
suite.Nil(err, "No error should be returned.")
}
func (suite *ListTracksCommandTestSuite) TestExecuteWithArgLargerThanQueueLength() {
track := new(bot.Track)
track.Title = "track"
track.Submitter = "test"
DJ.Queue.AppendTrack(track)
message, isPrivateMessage, err := suite.Command.Execute(nil, "2")
suite.NotEqual("", message, "A message containing track information should be returned.")
suite.Contains(message, "1", "The returned message should contain the first track.")
suite.NotContains(message, "2", "The returned message should not contain any further tracks.")
suite.True(isPrivateMessage, "This should be a private message.")
suite.Nil(err, "No error should be returned.")
}
func (suite *ListTracksCommandTestSuite) TestExecuteWithInvalidArg() {
track := new(bot.Track)
track.Title = "track"
track.Submitter = "test"
DJ.Queue.AppendTrack(track)
message, isPrivateMessage, err := suite.Command.Execute(nil, "test")
suite.Equal("", message, "No message should be returned.")
suite.True(isPrivateMessage, "This should be a private message.")
suite.NotNil(err, "An error should be returned due to an invalid argument being supplied.")
}
func TestListTracksCommandTestSuite(t *testing.T) {
suite.Run(t, new(ListTracksCommandTestSuite))
}

65
commands/move.go Normal file
View file

@ -0,0 +1,65 @@
/*
* MumbleDJ
* By Matthieu Grieger
* commands/move.go
* Copyright (c) 2016 Matthieu Grieger (MIT License)
*/
package commands
import (
"errors"
"fmt"
"strings"
"github.com/layeh/gumble/gumble"
"github.com/spf13/viper"
)
// MoveCommand is a command that moves the bot from one channel to another.
type MoveCommand struct{}
// Aliases returns the current aliases for the command.
func (c *MoveCommand) Aliases() []string {
return viper.GetStringSlice("commands.move.aliases")
}
// Description returns the description for the command.
func (c *MoveCommand) Description() string {
return viper.GetString("commands.move.description")
}
// IsAdminCommand returns true if the command is only for admin use, and
// returns false otherwise.
func (c *MoveCommand) IsAdminCommand() bool {
return viper.GetBool("commands.move.is_admin")
}
// Execute executes the command with the given user and arguments.
// Return value descriptions:
// string: A message to be returned to the user upon successful execution.
// bool: Whether the message should be private or not. true = private,
// false = public (sent to whole channel).
// error: An error message to be returned upon unsuccessful execution.
// If no error has occurred, pass nil instead.
// Example return statement:
// return "This is a private message!", true, nil
func (c *MoveCommand) Execute(user *gumble.User, args ...string) (string, bool, error) {
if len(args) == 0 {
return "", true, errors.New(viper.GetString("commands.move.messages.no_channel_provided_error"))
}
channel := ""
for _, arg := range args {
channel += arg + " "
}
channel = strings.TrimSpace(channel)
if channels := strings.Split(channel, "/"); DJ.Client.Channels.Find(channels...) != nil {
DJ.Client.Do(func() {
DJ.Client.Self.Move(DJ.Client.Channels.Find(channels...))
})
} else {
return "", true, errors.New(viper.GetString("commands.move.messages.channel_doesnt_exist_error"))
}
return fmt.Sprintf(viper.GetString("commands.move.messages.move_successful"), channel), true, nil
}

8
commands/move_test.go Normal file
View file

@ -0,0 +1,8 @@
/*
* MumbleDJ
* By Matthieu Grieger
* Copyright (c) 2016 Matthieu Grieger (MIT License)
* commands/move_test.go
*/
package commands

60
commands/nexttrack.go Normal file
View file

@ -0,0 +1,60 @@
/*
* MumbleDJ
* By Matthieu Grieger
* commands/nexttrack.go
* Copyright (c) 2016 Matthieu Grieger (MIT License)
*/
package commands
import (
"errors"
"fmt"
"github.com/layeh/gumble/gumble"
"github.com/spf13/viper"
)
// NextTrackCommand is a command that outputs information related to the next
// track in the queue (if one exists).
type NextTrackCommand struct{}
// Aliases returns the current aliases for the command.
func (c *NextTrackCommand) Aliases() []string {
return viper.GetStringSlice("commands.nexttrack.aliases")
}
// Description returns the description for the command.
func (c *NextTrackCommand) Description() string {
return viper.GetString("commands.nexttrack.description")
}
// IsAdminCommand returns true if the command is only for admin use, and
// returns false otherwise.
func (c *NextTrackCommand) IsAdminCommand() bool {
return viper.GetBool("commands.nexttrack.is_admin")
}
// Execute executes the command with the given user and arguments.
// Return value descriptions:
// string: A message to be returned to the user upon successful execution.
// bool: Whether the message should be private or not. true = private,
// false = public (sent to whole channel).
// error: An error message to be returned upon unsuccessful execution.
// If no error has occurred, pass nil instead.
// Example return statement:
// return "This is a private message!", true, nil
func (c *NextTrackCommand) Execute(user *gumble.User, args ...string) (string, bool, error) {
length := DJ.Queue.Length()
if length == 0 {
return "", true, errors.New(viper.GetString("commands.common_messages.no_tracks_error"))
}
if length == 1 {
return "", true, errors.New(viper.GetString("commands.nexttrack.messages.current_track_only_error"))
}
nextTrack, _ := DJ.Queue.PeekNextTrack()
return fmt.Sprintf(viper.GetString("commands.nexttrack.messages.next_track"),
nextTrack.GetTitle(), nextTrack.GetSubmitter()), true, nil
}

View file

@ -0,0 +1,97 @@
/*
* MumbleDJ
* By Matthieu Grieger
* commands/nexttrack_test.go
* Copyright (c) 2016 Matthieu Grieger (MIT License)
*/
package commands
import (
"testing"
"github.com/layeh/gumble/gumbleffmpeg"
"github.com/matthieugrieger/mumbledj/bot"
"github.com/spf13/viper"
"github.com/stretchr/testify/suite"
)
type NextTrackCommandTestSuite struct {
Command NextTrackCommand
suite.Suite
}
func (suite *NextTrackCommandTestSuite) SetupSuite() {
DJ = bot.NewMumbleDJ()
bot.DJ = DJ
// Trick the tests into thinking audio is already playing to avoid
// attempting to play tracks that don't exist.
DJ.AudioStream = new(gumbleffmpeg.Stream)
viper.Set("commands.nexttrack.aliases", []string{"nexttrack", "next"})
viper.Set("commands.nexttrack.description", "nexttrack")
viper.Set("commands.nexttrack.is_admin", false)
}
func (suite *NextTrackCommandTestSuite) SetupTest() {
DJ.Queue = bot.NewQueue()
}
func (suite *NextTrackCommandTestSuite) TestAliases() {
suite.Equal([]string{"nexttrack", "next"}, suite.Command.Aliases())
}
func (suite *NextTrackCommandTestSuite) TestDescription() {
suite.Equal("nexttrack", suite.Command.Description())
}
func (suite *NextTrackCommandTestSuite) TestIsAdminCommand() {
suite.False(suite.Command.IsAdminCommand())
}
func (suite *NextTrackCommandTestSuite) TestExecuteWhenQueueIsEmpty() {
message, isPrivateMessage, err := suite.Command.Execute(nil)
suite.Equal("", message, "No message should be returned.")
suite.True(isPrivateMessage, "This should be a private message.")
suite.NotNil(err, "An error should be returned due to the queue being empty.")
}
func (suite *NextTrackCommandTestSuite) TestExecuteWhenQueueHasOneTrack() {
track := new(bot.Track)
track.Title = "test"
track.Submitter = "test"
DJ.Queue.AppendTrack(track)
message, isPrivateMessage, err := suite.Command.Execute(nil)
suite.Equal("", message, "No message should be returned.")
suite.True(isPrivateMessage, "This should be a private message.")
suite.NotNil(err, "An error should be returned due to the queue having only one track.")
}
func (suite *NextTrackCommandTestSuite) TestExecuteWhenQueueHasTwoOrMoreTracks() {
track1 := new(bot.Track)
track1.Title = "first"
track1.Submitter = "test"
track2 := new(bot.Track)
track2.Title = "second"
track2.Submitter = "test"
DJ.Queue.AppendTrack(track1)
DJ.Queue.AppendTrack(track2)
message, isPrivateMessage, err := suite.Command.Execute(nil)
suite.NotEqual("", "A message containing information for the next track should be returned.")
suite.Contains(message, "second", "The returned message should contain information about the second track in the queue.")
suite.True(isPrivateMessage, "This should be a private message.")
suite.Nil(err, "No error should be returned.")
}
func TestNextTrackCommandTestSuite(t *testing.T) {
suite.Run(t, new(NextTrackCommandTestSuite))
}

55
commands/numcached.go Normal file
View file

@ -0,0 +1,55 @@
/*
* MumbleDJ
* By Matthieu Grieger
* commands/numcached.go
* Copyright (c) 2016 Matthieu Grieger (MIT License)
*/
package commands
import (
"errors"
"fmt"
"github.com/layeh/gumble/gumble"
"github.com/spf13/viper"
)
// NumCachedCommand is a command that outputs the number of tracks that
// are currently cached on disk (if caching is enabled).
type NumCachedCommand struct{}
// Aliases returns the current aliases for the command.
func (c *NumCachedCommand) Aliases() []string {
return viper.GetStringSlice("commands.numcached.aliases")
}
// Description returns the description for the command.
func (c *NumCachedCommand) Description() string {
return viper.GetString("commands.numcached.description")
}
// IsAdminCommand returns true if the command is only for admin use, and
// returns false otherwise.
func (c *NumCachedCommand) IsAdminCommand() bool {
return viper.GetBool("commands.numcached.is_admin")
}
// Execute executes the command with the given user and arguments.
// Return value descriptions:
// string: A message to be returned to the user upon successful execution.
// bool: Whether the message should be private or not. true = private,
// false = public (sent to whole channel).
// error: An error message to be returned upon unsuccessful execution.
// If no error has occurred, pass nil instead.
// Example return statement:
// return "This is a private message!", true, nil
func (c *NumCachedCommand) Execute(user *gumble.User, args ...string) (string, bool, error) {
if !viper.GetBool("cache.enabled") {
return "", true, errors.New(viper.GetString("commands.common_messages.caching_disabled_error"))
}
DJ.Cache.UpdateStatistics()
return fmt.Sprintf(viper.GetString("commands.numcached.messages.num_cached"),
DJ.Cache.NumAudioFiles), true, nil
}

View file

@ -0,0 +1,8 @@
/*
* MumbleDJ
* By Matthieu Grieger
* Copyright (c) 2016 Matthieu Grieger (MIT License)
* commands/numcached_test.go
*/
package commands

53
commands/numtracks.go Normal file
View file

@ -0,0 +1,53 @@
/*
* MumbleDJ
* By Matthieu Grieger
* commands/numtracks.go
* Copyright (c) 2016 Matthieu Grieger (MIT License)
*/
package commands
import (
"fmt"
"github.com/layeh/gumble/gumble"
"github.com/spf13/viper"
)
// NumTracksCommand is a command that outputs the current number of tracks
// in the queue.
type NumTracksCommand struct{}
// Aliases returns the current aliases for the command.
func (c *NumTracksCommand) Aliases() []string {
return viper.GetStringSlice("commands.numtracks.aliases")
}
// Description returns the description for the command.
func (c *NumTracksCommand) Description() string {
return viper.GetString("commands.numtracks.description")
}
// IsAdminCommand returns true if the command is only for admin use, and
// returns false otherwise.
func (c *NumTracksCommand) IsAdminCommand() bool {
return viper.GetBool("commands.numtracks.is_admin")
}
// Execute executes the command with the given user and arguments.
// Return value descriptions:
// string: A message to be returned to the user upon successful execution.
// bool: Whether the message should be private or not. true = private,
// false = public (sent to whole channel).
// error: An error message to be returned upon unsuccessful execution.
// If no error has occurred, pass nil instead.
// Example return statement:
// return "This is a private message!", true, nil
func (c *NumTracksCommand) Execute(user *gumble.User, args ...string) (string, bool, error) {
length := DJ.Queue.Length()
if length == 1 {
return viper.GetString("commands.numtracks.messages.one_track"), true, nil
}
return fmt.Sprintf(viper.GetString("commands.numtracks.messages.plural_tracks"), length), true, nil
}

View file

@ -0,0 +1,99 @@
/*
* MumbleDJ
* By Matthieu Grieger
* commands/numtracks_test.go
* Copyright (c) 2016 Matthieu Grieger (MIT License)
*/
package commands
import (
"testing"
"github.com/layeh/gumble/gumbleffmpeg"
"github.com/matthieugrieger/mumbledj/bot"
"github.com/spf13/viper"
"github.com/stretchr/testify/suite"
)
type NumTracksCommandTestSuite struct {
Command NumTracksCommand
suite.Suite
}
func (suite *NumTracksCommandTestSuite) SetupSuite() {
DJ = bot.NewMumbleDJ()
bot.DJ = DJ
// Trick the tests into thinking audio is already playing to avoid
// attempting to play tracks that don't exist.
DJ.AudioStream = new(gumbleffmpeg.Stream)
viper.Set("commands.numtracks.aliases", []string{"numtracks", "num"})
viper.Set("commands.numtracks.description", "numtracks")
viper.Set("commands.numtracks.is_admin", false)
}
func (suite *NumTracksCommandTestSuite) SetupTest() {
DJ.Queue = bot.NewQueue()
}
func (suite *NumTracksCommandTestSuite) TestAliases() {
suite.Equal([]string{"numtracks", "num"}, suite.Command.Aliases())
}
func (suite *NumTracksCommandTestSuite) TestDescription() {
suite.Equal("numtracks", suite.Command.Description())
}
func (suite *NumTracksCommandTestSuite) TestIsAdminCommand() {
suite.False(suite.Command.IsAdminCommand())
}
func (suite *NumTracksCommandTestSuite) TestExecuteWhenZeroTracksAreInQueue() {
message, isPrivateMessage, err := suite.Command.Execute(nil)
suite.NotEqual("", message, "A message should be returned.")
suite.Contains(message, "<b>0</b> tracks", "The returned message should state that there are no tracks in the queue.")
suite.True(isPrivateMessage, "This should be a private message.")
suite.Nil(err, "No error should be returned.")
}
func (suite *NumTracksCommandTestSuite) TestExecuteWhenOneTrackIsInQueue() {
track := new(bot.Track)
track.Title = "test"
track.Submitter = "test"
DJ.Queue.AppendTrack(track)
message, isPrivateMessage, err := suite.Command.Execute(nil)
suite.NotEqual("", message, "A message should be returned.")
suite.Contains(message, "<b>1</b> track", "The returned message should state that there is one track in the queue.")
suite.True(isPrivateMessage, "This should be a private message.")
suite.Nil(err, "No error should be returned.")
}
func (suite *NumTracksCommandTestSuite) TestExecuteWhenTwoOrMoreTracksAreInQueue() {
track1 := new(bot.Track)
track1.Title = "test"
track1.Submitter = "test"
track2 := new(bot.Track)
track2.Title = "test"
track2.Submitter = "test"
DJ.Queue.AppendTrack(track1)
DJ.Queue.AppendTrack(track2)
message, isPrivateMessage, err := suite.Command.Execute(nil)
suite.NotEqual("", "A message should be returned.")
suite.Contains(message, "tracks", "The returned message should use the plural form of the word track.")
suite.True(isPrivateMessage, "This should be a private message.")
suite.Nil(err, "No error should be returned.")
}
func TestNumTracksCommandTestSuite(t *testing.T) {
suite.Run(t, new(NumTracksCommandTestSuite))
}

52
commands/pause.go Normal file
View file

@ -0,0 +1,52 @@
/*
* MumbleDJ
* By Matthieu Grieger
* commands/pause.go
* Copyright (c) 2016 Matthieu Grieger (MIT License)
*/
package commands
import (
"errors"
"fmt"
"github.com/layeh/gumble/gumble"
"github.com/spf13/viper"
)
// PauseCommand is a command that pauses audio playback.
type PauseCommand struct{}
// Aliases returns the current aliases for the command.
func (c *PauseCommand) Aliases() []string {
return viper.GetStringSlice("commands.pause.aliases")
}
// Description returns the description for the command.
func (c *PauseCommand) Description() string {
return viper.GetString("commands.pause.description")
}
// IsAdminCommand returns true if the command is only for admin use, and
// returns false otherwise.
func (c *PauseCommand) IsAdminCommand() bool {
return viper.GetBool("commands.pause.is_admin")
}
// Execute executes the command with the given user and arguments.
// Return value descriptions:
// string: A message to be returned to the user upon successful execution.
// bool: Whether the message should be private or not. true = private,
// false = public (sent to whole channel).
// error: An error message to be returned upon unsuccessful execution.
// If no error has occurred, pass nil instead.
// Example return statement:
// return "This is a private message!", true, nil
func (c *PauseCommand) Execute(user *gumble.User, args ...string) (string, bool, error) {
err := DJ.Queue.PauseCurrent()
if err != nil {
return "", true, errors.New(viper.GetString("commands.pause.messages.no_audio_error"))
}
return fmt.Sprintf(viper.GetString("commands.pause.messages.paused"), user.Name), false, nil
}

8
commands/pause_test.go Normal file
View file

@ -0,0 +1,8 @@
/*
* MumbleDJ
* By Matthieu Grieger
* Copyright (c) 2016 Matthieu Grieger (MIT License)
* commands/pause_test.go
*/
package commands

50
commands/pkg_init.go Normal file
View file

@ -0,0 +1,50 @@
/*
* MumbleDJ
* By Matthieu Grieger
* commands/pkg_init.go
* Copyright (c) 2016 Matthieu Grieger (MIT License)
*/
package commands
import (
"github.com/matthieugrieger/mumbledj/bot"
"github.com/matthieugrieger/mumbledj/interfaces"
)
// DJ is an injected MumbleDJ struct.
var DJ *bot.MumbleDJ
// Commands is a slice of all enabled commands.
var Commands []interfaces.Command
func init() {
Commands = []interfaces.Command{
new(AddCommand),
new(AddNextCommand),
new(CacheSizeCommand),
new(CurrentTrackCommand),
new(ForceSkipCommand),
new(ForceSkipPlaylistCommand),
new(HelpCommand),
new(JoinMeCommand),
new(KillCommand),
new(ListTracksCommand),
new(MoveCommand),
new(NextTrackCommand),
new(NumCachedCommand),
new(NumTracksCommand),
new(PauseCommand),
new(RegisterCommand),
new(ReloadCommand),
new(ResetCommand),
new(ResumeCommand),
new(SetCommentCommand),
new(ShuffleCommand),
new(SkipCommand),
new(SkipPlaylistCommand),
new(ToggleShuffleCommand),
new(VersionCommand),
new(VolumeCommand),
}
}

53
commands/register.go Normal file
View file

@ -0,0 +1,53 @@
/*
* MumbleDJ
* By Matthieu Grieger
* commands/register.go
* Copyright (c) 2016 Matthieu Grieger (MIT License)
*/
package commands
import (
"errors"
"github.com/layeh/gumble/gumble"
"github.com/spf13/viper"
)
// RegisterCommand is a command that registers the bot on the server.
type RegisterCommand struct{}
// Aliases returns the current aliases for the command.
func (c *RegisterCommand) Aliases() []string {
return viper.GetStringSlice("commands.register.aliases")
}
// Description returns the description for the command.
func (c *RegisterCommand) Description() string {
return viper.GetString("commands.register.description")
}
// IsAdminCommand returns true if the command is only for admin use, and
// returns false otherwise.
func (c *RegisterCommand) IsAdminCommand() bool {
return viper.GetBool("commands.register.is_admin")
}
// Execute executes the command with the given user and arguments.
// Return value descriptions:
// string: A message to be returned to the user upon successful execution.
// bool: Whether the message should be private or not. true = private,
// false = public (sent to whole channel).
// error: An error message to be returned upon unsuccessful execution.
// If no error has occurred, pass nil instead.
// Example return statement:
// return "This is a private message!", true, nil
func (c *RegisterCommand) Execute(user *gumble.User, args ...string) (string, bool, error) {
if DJ.Client.Self.IsRegistered() {
return "", true, errors.New(viper.GetString("commands.register.messages.already_registered_error"))
}
DJ.Client.Self.Register()
return viper.GetString("commands.register.messages.registered"), true, nil
}

View file

@ -0,0 +1,8 @@
/*
* MumbleDJ
* By Matthieu Grieger
* commands/register_test.go
* Copyright (c) 2016 Matthieu Grieger (MIT License)
*/
package commands

52
commands/reload.go Normal file
View file

@ -0,0 +1,52 @@
/*
* MumbleDJ
* By Matthieu Grieger
* commands/reload.go
* Copyright (c) 2016 Matthieu Grieger (MIT License)
*/
package commands
import (
"github.com/layeh/gumble/gumble"
"github.com/matthieugrieger/mumbledj/bot"
"github.com/spf13/viper"
)
// ReloadCommand is a command that reloads the configuration values for the bot
// from a config file.
type ReloadCommand struct{}
// Aliases returns the current aliases for the command.
func (c *ReloadCommand) Aliases() []string {
return viper.GetStringSlice("commands.reload.aliases")
}
// Description returns the description for the command.
func (c *ReloadCommand) Description() string {
return viper.GetString("commands.reload.description")
}
// IsAdminCommand returns true if the command is only for admin use, and
// returns false otherwise.
func (c *ReloadCommand) IsAdminCommand() bool {
return viper.GetBool("commands.reload.is_admin")
}
// Execute executes the command with the given user and arguments.
// Return value descriptions:
// string: A message to be returned to the user upon successful execution.
// bool: Whether the message should be private or not. true = private,
// false = public (sent to whole channel).
// error: An error message to be returned upon unsuccessful execution.
// If no error has occurred, pass nil instead.
// Example return statement:
// return "This is a private message!", true, nil
func (c *ReloadCommand) Execute(user *gumble.User, args ...string) (string, bool, error) {
if err := bot.ReadConfigFile(); err != nil {
return "", true, err
}
return viper.GetString("commands.reload.messages.reloaded"),
true, nil
}

8
commands/reload_test.go Normal file
View file

@ -0,0 +1,8 @@
/*
* MumbleDJ
* By Matthieu Grieger
* Copyright (c) 2016 Matthieu Grieger (MIT License)
* commands/reload_test.go
*/
package commands

63
commands/reset.go Normal file
View file

@ -0,0 +1,63 @@
/*
* MumbleDJ
* By Matthieu Grieger
* commands/reset.go
* Copyright (c) 2016 Matthieu Grieger (MIT License)
*/
package commands
import (
"errors"
"fmt"
"github.com/layeh/gumble/gumble"
"github.com/spf13/viper"
)
// ResetCommand is a command that resets the queue and cache.
type ResetCommand struct{}
// Aliases returns the current aliases for the command.
func (c *ResetCommand) Aliases() []string {
return viper.GetStringSlice("commands.reset.aliases")
}
// Description returns the description for the command.
func (c *ResetCommand) Description() string {
return viper.GetString("commands.reset.description")
}
// IsAdminCommand returns true if the command is only for admin use, and
// returns false otherwise.
func (c *ResetCommand) IsAdminCommand() bool {
return viper.GetBool("commands.reset.is_admin")
}
// Execute executes the command with the given user and arguments.
// Return value descriptions:
// string: A message to be returned to the user upon successful execution.
// bool: Whether the message should be private or not. true = private,
// false = public (sent to whole channel).
// error: An error message to be returned upon unsuccessful execution.
// If no error has occurred, pass nil instead.
// Example return statement:
// return "This is a private message!", true, nil
func (c *ResetCommand) Execute(user *gumble.User, args ...string) (string, bool, error) {
if DJ.Queue.Length() == 0 {
return "", true, errors.New(viper.GetString("commands.common_messages.no_tracks_error"))
}
if DJ.AudioStream != nil {
DJ.AudioStream.Stop()
DJ.AudioStream = nil
}
DJ.Queue.Reset()
if err := DJ.Cache.DeleteAll(); err != nil {
return "", true, err
}
return fmt.Sprintf(viper.GetString("commands.reset.messages.queue_reset"), user.Name), false, nil
}

8
commands/reset_test.go Normal file
View file

@ -0,0 +1,8 @@
/*
* MumbleDJ
* By Matthieu Grieger
* Copyright (c) 2016 Matthieu Grieger (MIT License)
* commands/reset_test.go
*/
package commands

52
commands/resume.go Normal file
View file

@ -0,0 +1,52 @@
/*
* MumbleDJ
* By Matthieu Grieger
* commands/resume.go
* Copyright (c) 2016 Matthieu Grieger (MIT License)
*/
package commands
import (
"errors"
"fmt"
"github.com/layeh/gumble/gumble"
"github.com/spf13/viper"
)
// ResumeCommand is a command that resumes audio playback.
type ResumeCommand struct{}
// Aliases returns the current aliases for the command.
func (c *ResumeCommand) Aliases() []string {
return viper.GetStringSlice("commands.resume.aliases")
}
// Description returns the description for the command.
func (c *ResumeCommand) Description() string {
return viper.GetString("commands.resume.description")
}
// IsAdminCommand returns true if the command is only for admin use, and
// returns false otherwise.
func (c *ResumeCommand) IsAdminCommand() bool {
return viper.GetBool("commands.resume.is_admin")
}
// Execute executes the command with the given user and arguments.
// Return value descriptions:
// string: A message to be returned to the user upon successful execution.
// bool: Whether the message should be private or not. true = private,
// false = public (sent to whole channel).
// error: An error message to be returned upon unsuccessful execution.
// If no error has occurred, pass nil instead.
// Example return statement:
// return "This is a private message!", true, nil
func (c *ResumeCommand) Execute(user *gumble.User, args ...string) (string, bool, error) {
err := DJ.Queue.ResumeCurrent()
if err != nil {
return "", true, errors.New(viper.GetString("commands.resume.messages.audio_error"))
}
return fmt.Sprintf(viper.GetString("commands.resume.messages.resumed"), user.Name), false, nil
}

8
commands/resume_test.go Normal file
View file

@ -0,0 +1,8 @@
/*
* MumbleDJ
* By Matthieu Grieger
* Copyright (c) 2016 Matthieu Grieger (MIT License)
* commands/resume_test.go
*/
package commands

66
commands/setcomment.go Normal file
View file

@ -0,0 +1,66 @@
/*
* MumbleDJ
* By Matthieu Grieger
* commands/setcomment.go
* Copyright (c) 2016 Matthieu Grieger (MIT License)
*/
package commands
import (
"fmt"
"strings"
"github.com/layeh/gumble/gumble"
"github.com/spf13/viper"
)
// SetCommentCommand is a command that changes the Mumble comment of the bot.
type SetCommentCommand struct{}
// Aliases returns the current aliases for the command.
func (c *SetCommentCommand) Aliases() []string {
return viper.GetStringSlice("commands.setcomment.aliases")
}
// Description returns the description for the command.
func (c *SetCommentCommand) Description() string {
return viper.GetString("commands.setcomment.description")
}
// IsAdminCommand returns true if the command is only for admin use, and
// returns false otherwise.
func (c *SetCommentCommand) IsAdminCommand() bool {
return viper.GetBool("commands.setcomment.is_admin")
}
// Execute executes the command with the given user and arguments.
// Return value descriptions:
// string: A message to be returned to the user upon successful execution.
// bool: Whether the message should be private or not. true = private,
// false = public (sent to whole channel).
// error: An error message to be returned upon unsuccessful execution.
// If no error has occurred, pass nil instead.
// Example return statement:
// return "This is a private message!", true, nil
func (c *SetCommentCommand) Execute(user *gumble.User, args ...string) (string, bool, error) {
if len(args) == 0 {
DJ.Client.Do(func() {
DJ.Client.Self.SetComment("")
})
return viper.GetString("commands.setcomment.messages.comment_removed"), true, nil
}
var newComment string
for _, arg := range args {
newComment += arg + " "
}
strings.TrimSpace(newComment)
DJ.Client.Do(func() {
DJ.Client.Self.SetComment(newComment)
})
return fmt.Sprintf(viper.GetString("commands.setcomment.messages.comment_changed"),
newComment), true, nil
}

View file

@ -0,0 +1,8 @@
/*
* MumbleDJ
* By Matthieu Grieger
* Copyright (c) 2016 Matthieu Grieger (MIT License)
* commands/setcomment_test.go
*/
package commands

57
commands/shuffle.go Normal file
View file

@ -0,0 +1,57 @@
/*
* MumbleDJ
* By Matthieu Grieger
* commands/shuffle.go
* Copyright (c) 2016 Matthieu Grieger (MIT License)
*/
package commands
import (
"errors"
"github.com/layeh/gumble/gumble"
"github.com/spf13/viper"
)
// ShuffleCommand is a command that shuffles the audio queue.
type ShuffleCommand struct{}
// Aliases returns the current aliases for the command.
func (c *ShuffleCommand) Aliases() []string {
return viper.GetStringSlice("commands.shuffle.aliases")
}
// Description returns the description for the command.
func (c *ShuffleCommand) Description() string {
return viper.GetString("commands.shuffle.description")
}
// IsAdminCommand returns true if the command is only for admin use, and
// returns false otherwise.
func (c *ShuffleCommand) IsAdminCommand() bool {
return viper.GetBool("commands.shuffle.is_admin")
}
// Execute executes the command with the given user and arguments.
// Return value descriptions:
// string: A message to be returned to the user upon successful execution.
// bool: Whether the message should be private or not. true = private,
// false = public (sent to whole channel).
// error: An error message to be returned upon unsuccessful execution.
// If no error has occurred, pass nil instead.
// Example return statement:
// return "This is a private message!", true, nil
func (c *ShuffleCommand) Execute(user *gumble.User, args ...string) (string, bool, error) {
length := DJ.Queue.Length()
if length == 0 {
return "", true, errors.New(viper.GetString("commands.common_messages.no_tracks_error"))
}
if length <= 2 {
return "", true, errors.New(viper.GetString("commands.shuffle.messages.not_enough_tracks_error"))
}
DJ.Queue.ShuffleTracks()
return viper.GetString("commands.shuffle.messages.shuffled"), false, nil
}

8
commands/shuffle_test.go Normal file
View file

@ -0,0 +1,8 @@
/*
* MumbleDJ
* By Matthieu Grieger
* Copyright (c) 2016 Matthieu Grieger (MIT License)
* commands/shuffle_test.go
*/
package commands

60
commands/skip.go Normal file
View file

@ -0,0 +1,60 @@
/*
* MumbleDJ
* By Matthieu Grieger
* commands/skip.go
* Copyright (c) 2016 Matthieu Grieger (MIT License)
*/
package commands
import (
"errors"
"fmt"
"github.com/layeh/gumble/gumble"
"github.com/spf13/viper"
)
// SkipCommand is a command that places a vote to skip the current track.
type SkipCommand struct{}
// Aliases returns the current aliases for the command.
func (c *SkipCommand) Aliases() []string {
return viper.GetStringSlice("commands.skip.aliases")
}
// Description returns the description for the command.
func (c *SkipCommand) Description() string {
return viper.GetString("commands.skip.description")
}
// IsAdminCommand returns true if the command is only for admin use, and
// returns false otherwise.
func (c *SkipCommand) IsAdminCommand() bool {
return viper.GetBool("commands.skip.is_admin")
}
// Execute executes the command with the given user and arguments.
// Return value descriptions:
// string: A message to be returned to the user upon successful execution.
// bool: Whether the message should be private or not. true = private,
// false = public (sent to whole channel).
// error: An error message to be returned upon unsuccessful execution.
// If no error has occurred, pass nil instead.
// Example return statement:
// return "This is a private message!", true, nil
func (c *SkipCommand) Execute(user *gumble.User, args ...string) (string, bool, error) {
if DJ.Queue.Length() == 0 {
return "", true, errors.New(viper.GetString("commands.common_messages.no_tracks_error"))
}
if DJ.Queue.GetTrack(0).GetSubmitter() == user.Name {
// The user who submitted the track is skipping, this means we skip this track immediately.
DJ.Queue.StopCurrent()
return fmt.Sprintf(viper.GetString("commands.skip.messages.submitter_voted"), user.Name), false, nil
}
if err := DJ.Skips.AddTrackSkip(user); err != nil {
return "", true, errors.New(viper.GetString("commands.skip.messages.already_voted_error"))
}
return fmt.Sprintf(viper.GetString("commands.skip.messages.voted"), user.Name), false, nil
}

8
commands/skip_test.go Normal file
View file

@ -0,0 +1,8 @@
/*
* MumbleDJ
* By Matthieu Grieger
* Copyright (c) 2016 Matthieu Grieger (MIT License)
* commands/skip_test.go
*/
package commands

70
commands/skipplaylist.go Normal file
View file

@ -0,0 +1,70 @@
/*
* MumbleDJ
* By Matthieu Grieger
* commands/skipplaylist.go
* Copyright (c) 2016 Matthieu Grieger (MIT License)
*/
package commands
import (
"errors"
"fmt"
"github.com/layeh/gumble/gumble"
"github.com/matthieugrieger/mumbledj/interfaces"
"github.com/spf13/viper"
)
// SkipPlaylistCommand is a command that places a vote to skip the current
// playlist.
type SkipPlaylistCommand struct{}
// Aliases returns the current aliases for the command.
func (c *SkipPlaylistCommand) Aliases() []string {
return viper.GetStringSlice("commands.skipplaylist.aliases")
}
// Description returns the description for the command.
func (c *SkipPlaylistCommand) Description() string {
return viper.GetString("commands.skipplaylist.description")
}
// IsAdminCommand returns true if the command is only for admin use, and
// returns false otherwise.
func (c *SkipPlaylistCommand) IsAdminCommand() bool {
return viper.GetBool("commands.skipplaylist.is_admin")
}
// Execute executes the command with the given user and arguments.
// Return value descriptions:
// string: A message to be returned to the user upon successful execution.
// bool: Whether the message should be private or not. true = private,
// false = public (sent to whole channel).
// error: An error message to be returned upon unsuccessful execution.
// If no error has occurred, pass nil instead.
// Example return statement:
// return "This is a private message!", true, nil
func (c *SkipPlaylistCommand) Execute(user *gumble.User, args ...string) (string, bool, error) {
var (
currentTrack interfaces.Track
err error
)
if currentTrack, err = DJ.Queue.CurrentTrack(); err != nil {
return "", true, errors.New(viper.GetString("commands.common_messages.no_tracks_error"))
}
if playlist := currentTrack.GetPlaylist(); playlist == nil {
return "", true, errors.New(viper.GetString("commands.skipplaylist.messages.no_playlist_error"))
}
if currentTrack.GetPlaylist().GetSubmitter() == user.Name {
DJ.Queue.SkipPlaylist()
return fmt.Sprintf(viper.GetString("commands.skipplaylist.messages.submitter_voted"), user.Name), false, nil
}
if err := DJ.Skips.AddPlaylistSkip(user); err != nil {
return "", true, errors.New(viper.GetString("commands.skipplaylist.messages.already_voted_error"))
}
return fmt.Sprintf(viper.GetString("commands.skipplaylist.messages.voted"), user.Name), false, nil
}

View file

@ -0,0 +1,8 @@
/*
* MumbleDJ
* By Matthieu Grieger
* Copyright (c) 2016 Matthieu Grieger (MIT License)
* commands/skipplaylist_test.go
*/
package commands

50
commands/toggleshuffle.go Normal file
View file

@ -0,0 +1,50 @@
/*
* MumbleDJ
* By Matthieu Grieger
* commands/toggleshuffle.go
* Copyright (c) 2016 Matthieu Grieger (MIT License)
*/
package commands
import (
"github.com/layeh/gumble/gumble"
"github.com/spf13/viper"
)
// ToggleShuffleCommand is a command that changes the Mumble comment of the bot.
type ToggleShuffleCommand struct{}
// Aliases returns the current aliases for the command.
func (c *ToggleShuffleCommand) Aliases() []string {
return viper.GetStringSlice("commands.toggleshuffle.aliases")
}
// Description returns the description for the command.
func (c *ToggleShuffleCommand) Description() string {
return viper.GetString("commands.toggleshuffle.description")
}
// IsAdminCommand returns true if the command is only for admin use, and
// returns false otherwise.
func (c *ToggleShuffleCommand) IsAdminCommand() bool {
return viper.GetBool("commands.toggleshuffle.is_admin")
}
// Execute executes the command with the given user and arguments.
// Return value descriptions:
// string: A message to be returned to the user upon successful execution.
// bool: Whether the message should be private or not. true = private,
// false = public (sent to whole channel).
// error: An error message to be returned upon unsuccessful execution.
// If no error has occurred, pass nil instead.
// Example return statement:
// return "This is a private message!", true, nil
func (c *ToggleShuffleCommand) Execute(user *gumble.User, args ...string) (string, bool, error) {
if viper.GetBool("queue.automatic_shuffle_on") {
viper.Set("queue.automatic_shuffle_on", false)
return viper.GetString("commands.toggleshuffle.messages.toggled_off"), false, nil
}
viper.Set("queue.automatic_shuffle_on", true)
return viper.GetString("commands.toggleshuffle.messages.toggled_on"), false, nil
}

View file

@ -0,0 +1,67 @@
/*
* MumbleDJ
* By Matthieu Grieger
* commands/toggleshuffle_test.go
* Copyright (c) 2016 Matthieu Grieger (MIT License)
*/
package commands
import (
"testing"
"github.com/matthieugrieger/mumbledj/bot"
"github.com/spf13/viper"
"github.com/stretchr/testify/suite"
)
type ToggleShuffleCommandTestSuite struct {
Command ToggleShuffleCommand
suite.Suite
}
func (suite *ToggleShuffleCommandTestSuite) SetupSuite() {
DJ = bot.NewMumbleDJ()
viper.Set("commands.toggleshuffle.aliases", []string{"toggleshuffle", "ts"})
viper.Set("commands.toggleshuffle.description", "toggleshuffle")
viper.Set("commands.toggleshuffle.is_admin", true)
}
func (suite *ToggleShuffleCommandTestSuite) TestAliases() {
suite.Equal([]string{"toggleshuffle", "ts"}, suite.Command.Aliases())
}
func (suite *ToggleShuffleCommandTestSuite) TestDescription() {
suite.Equal("toggleshuffle", suite.Command.Description())
}
func (suite *ToggleShuffleCommandTestSuite) TestIsAdminCommand() {
suite.True(suite.Command.IsAdminCommand())
}
func (suite *ToggleShuffleCommandTestSuite) TestExecuteWhenShuffleIsOff() {
viper.Set("queue.automatic_shuffle_on", false)
message, isPrivateMessage, err := suite.Command.Execute(nil)
suite.NotEqual("", message, "A message should be returned.")
suite.False(isPrivateMessage, "This should not be a private message.")
suite.Nil(err, "No error should be returned.")
suite.True(viper.GetBool("queue.automatic_shuffle_on"), "Automatic shuffling should now be on.")
}
func (suite *ToggleShuffleCommandTestSuite) TestExecuteWhenShuffleIsOn() {
viper.Set("queue.automatic_shuffle_on", true)
message, isPrivateMessage, err := suite.Command.Execute(nil)
suite.NotEqual("", message, "A message should be returned.")
suite.False(isPrivateMessage, "This should not be a private message.")
suite.Nil(err, "No error should be returned.")
suite.False(viper.GetBool("queue.automatic_shuffle_on"), "Automatic shuffling should now be off.")
}
func TestToggleShuffleCommandTestSuite(t *testing.T) {
suite.Run(t, new(ToggleShuffleCommandTestSuite))
}

47
commands/version.go Normal file
View file

@ -0,0 +1,47 @@
/*
* MumbleDJ
* By Matthieu Grieger
* commands/version.go
* Copyright (c) 2016 Matthieu Grieger (MIT License)
*/
package commands
import (
"fmt"
"github.com/layeh/gumble/gumble"
"github.com/spf13/viper"
)
// VersionCommand is a command that outputs the local MumbleDJ version.
type VersionCommand struct{}
// Aliases returns the current aliases for the command.
func (c *VersionCommand) Aliases() []string {
return viper.GetStringSlice("commands.version.aliases")
}
// Description returns the description for the command.
func (c *VersionCommand) Description() string {
return viper.GetString("commands.version.description")
}
// IsAdminCommand returns true if the command is only for admin use, and
// returns false otherwise.
func (c *VersionCommand) IsAdminCommand() bool {
return viper.GetBool("commands.version.is_admin")
}
// Execute executes the command with the given user and arguments.
// Return value descriptions:
// string: A message to be returned to the user upon successful execution.
// bool: Whether the message should be private or not. true = private,
// false = public (sent to whole channel).
// error: An error message to be returned upon unsuccessful execution.
// If no error has occurred, pass nil instead.
// Example return statement:
// return "This is a private message!", true, nil
func (c *VersionCommand) Execute(user *gumble.User, args ...string) (string, bool, error) {
return fmt.Sprintf(viper.GetString("commands.version.messages.version"), DJ.Version), true, nil
}

55
commands/version_test.go Normal file
View file

@ -0,0 +1,55 @@
/*
* MumbleDJ
* By Matthieu Grieger
* commands/version_test.go
* Copyright (c) 2016 Matthieu Grieger (MIT License)
*/
package commands
import (
"testing"
"github.com/matthieugrieger/mumbledj/bot"
"github.com/spf13/viper"
"github.com/stretchr/testify/suite"
)
type VersionCommandTestSuite struct {
Command VersionCommand
suite.Suite
}
func (suite *VersionCommandTestSuite) SetupSuite() {
DJ = bot.NewMumbleDJ()
viper.Set("commands.version.aliases", []string{"version", "v"})
viper.Set("commands.version.description", "version")
viper.Set("commands.version.is_admin", false)
DJ.Version = "test"
}
func (suite *VersionCommandTestSuite) TestAliases() {
suite.Equal([]string{"version", "v"}, suite.Command.Aliases())
}
func (suite *VersionCommandTestSuite) TestDescription() {
suite.Equal("version", suite.Command.Description())
}
func (suite *VersionCommandTestSuite) TestIsAdminCommand() {
suite.False(suite.Command.IsAdminCommand())
}
func (suite *VersionCommandTestSuite) TestExecute() {
message, isPrivateMessage, err := suite.Command.Execute(nil)
suite.NotEqual("", message, "A message should be returned.")
suite.Contains(message, "MumbleDJ", "The message should contain a MumbleDJ version string.")
suite.True(isPrivateMessage, "This should be a private message.")
suite.Nil(err, "No error should be returned.")
}
func TestVersionCommandTestSuite(t *testing.T) {
suite.Run(t, new(VersionCommandTestSuite))
}

72
commands/volume.go Normal file
View file

@ -0,0 +1,72 @@
/*
* MumbleDJ
* By Matthieu Grieger
* commands/volume.go
* Copyright (c) 2016 Matthieu Grieger (MIT License)
*/
package commands
import (
"errors"
"fmt"
"strconv"
"github.com/layeh/gumble/gumble"
"github.com/spf13/viper"
)
// VolumeCommand is a command that changes the volume of the audio output.
type VolumeCommand struct{}
// Aliases returns the current aliases for the command.
func (c *VolumeCommand) Aliases() []string {
return viper.GetStringSlice("commands.volume.aliases")
}
// Description returns the description for the command.
func (c *VolumeCommand) Description() string {
return viper.GetString("commands.volume.description")
}
// IsAdminCommand returns true if the command is only for admin use, and
// returns false otherwise.
func (c *VolumeCommand) IsAdminCommand() bool {
return viper.GetBool("commands.volume.is_admin")
}
// Execute executes the command with the given user and arguments.
// Return value descriptions:
// string: A message to be returned to the user upon successful execution.
// bool: Whether the message should be private or not. true = private,
// false = public (sent to whole channel).
// error: An error message to be returned upon unsuccessful execution.
// If no error has occurred, pass nil instead.
// Example return statement:
// return "This is a private message!", true, nil
func (c *VolumeCommand) Execute(user *gumble.User, args ...string) (string, bool, error) {
if len(args) == 0 {
// Send the user the current volume level.
return fmt.Sprintf(viper.GetString("commands.volume.messages.current_volume"), DJ.Volume), true, nil
}
newVolume, err := strconv.ParseFloat(args[0], 32)
if err != nil {
return "", true, errors.New(viper.GetString("commands.volume.messages.parsing_error"))
}
if newVolume <= viper.GetFloat64("volume.lowest") || newVolume >= viper.GetFloat64("volume.highest") {
return "", true, fmt.Errorf(viper.GetString("commands.volume.messages.out_of_range_error"),
viper.GetFloat64("volume.lowest"), viper.GetFloat64("volume.highest"))
}
newVolume32 := float32(newVolume)
if DJ.AudioStream != nil {
DJ.AudioStream.Volume = newVolume32
}
DJ.Volume = newVolume32
return fmt.Sprintf(viper.GetString("commands.volume.messages.volume_changed"),
user.Name, newVolume32), false, nil
}

110
commands/volume_test.go Normal file
View file

@ -0,0 +1,110 @@
/*
* MumbleDJ
* By Matthieu Grieger
* commands/volume_test.go
* Copyright (c) 2016 Matthieu Grieger (MIT License)
*/
package commands
import (
"fmt"
"testing"
"github.com/layeh/gumble/gumble"
"github.com/layeh/gumble/gumbleffmpeg"
"github.com/matthieugrieger/mumbledj/bot"
"github.com/spf13/viper"
"github.com/stretchr/testify/suite"
)
type VolumeCommandTestSuite struct {
Command VolumeCommand
suite.Suite
}
func (suite *VolumeCommandTestSuite) SetupSuite() {
DJ = bot.NewMumbleDJ()
viper.Set("commands.volume.aliases", []string{"volume", "vol"})
viper.Set("commands.volume.description", "volume")
viper.Set("commands.volume.is_admin", false)
viper.Set("volume.lowest", 0.2)
viper.Set("volume.highest", 1)
viper.Set("volume.default", 0.4)
}
func (suite *VolumeCommandTestSuite) SetupTest() {
DJ.Volume = float32(viper.GetFloat64("volume.default"))
}
func (suite *VolumeCommandTestSuite) TestAliases() {
suite.Equal([]string{"volume", "vol"}, suite.Command.Aliases())
}
func (suite *VolumeCommandTestSuite) TestDescription() {
suite.Equal("volume", suite.Command.Description())
}
func (suite *VolumeCommandTestSuite) TestIsAdminCommand() {
suite.False(suite.Command.IsAdminCommand())
}
func (suite *VolumeCommandTestSuite) TestExecuteWithNoArgs() {
message, isPrivateMessage, err := suite.Command.Execute(nil)
suite.NotEqual("", message, "A message should be returned.")
suite.True(isPrivateMessage, "This should be a private message.")
suite.Nil(err, "No error should be returned.")
suite.Contains(message, "0.4", "The returned string should contain the current volume.")
}
func (suite *VolumeCommandTestSuite) TestExecuteWithValidArg() {
dummyUser := &gumble.User{
Name: "test",
}
message, isPrivateMessage, err := suite.Command.Execute(dummyUser, "0.6")
suite.NotEqual("", message, "A message should be returned.")
suite.False(isPrivateMessage, "This should not be a private message.")
suite.Nil(err, "No error should be returned.")
suite.Contains(message, "0.6", "The returned string should contain the new volume.")
suite.Contains(message, "test", "The returned string should contain the username of whomever changed the volume.")
}
func (suite *VolumeCommandTestSuite) TestExecuteWithValidArgAndNonNilStream() {
dummyUser := &gumble.User{
Name: "test",
}
DJ.AudioStream = new(gumbleffmpeg.Stream)
DJ.AudioStream.Volume = 0.2
message, isPrivateMessage, err := suite.Command.Execute(dummyUser, "0.6")
suite.NotEqual("", message, "A message should be returned.")
suite.False(isPrivateMessage, "This should not be a private message.")
suite.Nil(err, "No error should be returned.")
suite.Contains(message, "0.6", "The returned string should contain the new volume.")
suite.Contains(message, "test", "The returned string should contain the username of whomever changed the volume.")
suite.Equal("0.60", fmt.Sprintf("%.2f", DJ.AudioStream.Volume), "The audio stream value should match the new volume.")
}
func (suite *VolumeCommandTestSuite) TestExecuteWithArgOutOfRange() {
message, isPrivateMessage, err := suite.Command.Execute(nil, "1.4")
suite.Equal("", message, "No message should be returned as an error occurred.")
suite.True(isPrivateMessage, "This should be a private message.")
suite.NotNil(err, "An error should be returned since the provided argument was outside of the valid range.")
}
func (suite *VolumeCommandTestSuite) TestExecuteWithInvalidArg() {
message, isPrivateMessage, err := suite.Command.Execute(nil, "test")
suite.Equal("", message, "No message should be returned as an error occurred.")
suite.True(isPrivateMessage, "This should be a private message.")
suite.NotNil(err, "An error should be returned as a non-floating-point argument was provided.")
}
func TestVolumeCommandTestSuite(t *testing.T) {
suite.Run(t, new(VolumeCommandTestSuite))
}

423
config.yaml Normal file
View file

@ -0,0 +1,423 @@
# MumbleDJ
# By Matthieu Grieger
# Copyright (c) 2016 Matthieu Grieger (MIT License)
# config.yaml
api_keys:
# YouTube API key.
youtube: ""
# SoundCloud API key.
# NOTE: The API key is your client ID.
soundcloud: ""
defaults:
# Default comment to be applied to bot.
# NOTE: If you do not want a comment by default, set to empty string ("").
comment: "Hello! I am a bot. Type !help for a list of commands."
# Default channel for the bot to enter upon connection.
# NOTE: If you wish for the bot to connect to the root channel, set to empty string ("").
channel: ""
# Command to use to play audio files. The two supported choices are "ffmpeg" and "avconv".
player_command: "ffmpeg"
queue:
# Ratio that must be met or exceeded to trigger a track skip.
track_skip_ratio: 0.5
# Ratio that must be met or exceeded to trigger a playlist skip.
playlist_skip_ratio: 0.5
# Maximum track duration in seconds. Set to 0 for unrestricted duration.
max_track_duration: 0
# Maximum tracks per playlist. Set to 0 for unrestricted playlists.
max_tracks_per_playlist: 50
# Is shuffling enabled when the bot starts?
automatic_shuffle_on: false
# Announce track information at the beginning of audio playback?
announce_new_tracks: true
connection:
# Address bot should attempt to connect to.
address: "127.0.0.1"
# Port bot should attempt to connect to.
port: "64738"
# Password for connecting to server.
# NOTE: If no password, set to empty string ("").
password: ""
# Username for MumbleDJ.
username: "MumbleDJ"
# Filepath to user p12 file for authenticating as a registered user.
# NOTE: If no p12 file is needed, set to empty string ("").
user_p12: ""
# Should the bot attempt an insecure connection?
# An insecure connection does not verify the certificate of the server for
# consistency. It is best to leave this on, but disable it if you are having
# issues connecting to a server or are running multiple instances of MumbleDJ.
insecure: false
# Filepath to certificate file.
# NOTE: If no certificate file is needed, set to empty string ("").
cert: ""
# Filepath to certificate key file.
# NOTE: If no key is needed, set to empty string ("").
key: ""
# Access tokens to initialize the bot with, separated by commas.
# NOTE: If no access tokens are needed, set to empty string ("").
access_tokens: ""
# Should the bot automatically attempt to retry connection to a server after disconnecting?
retry_enabled: true
# How many times should the bot attempt to reconnect to the server?
retry_attempts: 10
# How many seconds should the bot wait in-between connection retry attempts?
retry_interval: 5
cache:
# Cache songs as they are downloaded?
enabled: false
# Maximum total file size of cache directory in MiB.
maximum_size: 512
# Period of time that should elapse before a song is cleared from the cache, in hours.
expire_time: 24
# Period of time between each check of the cache for expired items, in minutes.
check_interval: 5
# Directory to store cached items. Environment variables are able to be used here.
directory: "$HOME/.cache/mumbledj"
volume:
# Default volume.
default: 0.2
# Lowest volume allowed.
lowest: 0.01
# Highest volume allowed.
highest: 0.8
admins:
# Enable admins?
# NOTE: If this is set to false, any command can be executed by any user.
enabled: true
# List of admin names.
# NOTE: It is recommended that the names in this list are registered on the
# server so that imposters cannot execute admin commands.
names:
- "SuperUser"
commands:
# Character used to designate commands from normal text messages.
# NOTE: Only one character (the first) is used.
prefix: "!"
common_messages:
no_tracks_error: "There are no tracks in the queue."
caching_disabled_error: "Caching is currently disabled."
# Below is a list of the commands supported by MumbleDJ. Each command has
# three configurable options:
# aliases: A list of names that can be used to execute the command.
# is_admin: true = only admins can execute the command, false = anyone can execute the command.
# description: Description shown for the command when the help command is executed.
# messages: Various messages that may be sent as a text message from the command. Useful for translating
# strings to other languages. Do NOT remove strings that begin with "%" (such as "%s", "%d", etc.)
# as they substituted with runtime data. Removing these strings will cause the bot to misbehave.
add:
aliases:
- "add"
- "a"
is_admin: false
description: "Adds a track or playlist from a media site to the queue."
messages:
no_url_error: "A URL must be supplied with the add command."
no_valid_tracks_error: "No valid tracks were found with the provided URL(s)."
tracks_too_long_error: "Your track(s) were either too long or an error occurred while processing them. No track(s) have been added."
one_track_added: "<b>%s</b> added <b>1</b> track to the queue:<br><i>%s</i> from %s"
many_tracks_added: "<b>%s</b> added <b>%d</b> tracks to the queue."
num_tracks_too_long: "<br><b>%d</b> tracks could not be added due to error or because they are too long."
addnext:
aliases:
- "addnext"
- "an"
is_admin: true
description: "Adds a track or playlist from a media site as the next item in the queue."
# addnext uses the messages defined for add.
cachesize:
aliases:
- "cachesize"
- "cs"
is_admin: true
description: "Outputs the file size of the cache in MiB if caching is enabled."
messages:
current_size: "The current size of the cache is <b>%.2v MiB</b>."
currenttrack:
aliases:
- "currenttrack"
- "currentsong"
- "current"
is_admin: false
description: "Outputs information about the current track in the queue if one exists."
messages:
current_track: "The current track is <i>%s</i>, added by <b>%s</b>."
forceskip:
aliases:
- "forceskip"
- "fs"
is_admin: true
description: "Immediately skips the current track."
messages:
track_skipped: "The current track has been forcibly skipped by <b>%s</b>."
forceskipplaylist:
aliases:
- "forceskipplaylist"
- "fsp"
is_admin: true
description: "Immediately skips the current playlist."
messages:
no_playlist_error: "The current track is not part of a playlist."
playlist_skipped: "The current playlist has been forcibly skipped by <b>%s</b>."
help:
aliases:
- "help"
- "h"
is_admin: false
description: "Outputs this list of commands."
messages:
commands_header: "<br><b>Commands:</b><br>"
admin_commands_header: "<br><b>Admin Commands:</b><br>"
joinme:
aliases:
- "joinme"
- "join"
is_admin: true
description: "Moves MumbleDJ into your current channel if not playing audio to someone else."
messages:
others_are_listening_error: "Users in another channel are listening to me."
in_your_channel: "I am now in your channel!"
kill:
aliases:
- "kill"
- "k"
is_admin: true
description: "Stops the bot and cleans its cache directory."
listtracks:
aliases:
- "listtracks"
- "listsongs"
- "list"
- "l"
is_admin: false
description: "Outputs a list of the tracks currently in the queue."
messages:
invalid_integer_error: "An invalid integer was supplied."
track_listing: "<b>%d</b>: <i>%s</i>, added by <b>%s</b>.<br>"
move:
aliases:
- "move"
- "m"
is_admin: true
description: "Moves the bot into the Mumble channel provided via argument."
messages:
no_channel_provided_error: "A destination channel must be supplied to move the bot."
channel_doesnt_exist_error: "The provided channel does not exist."
move_successful: "You have successfully moved the bot to <b>%s</b>."
nexttrack:
aliases:
- "nexttrack"
- "nextsong"
- "next"
is_admin: false
description: "Outputs information about the next track in the queue if one exists."
messages:
current_track_only_error: "The current track is the only track in the queue."
next_track: "The next track is <i>%s</i>, added by <b>%s</b>."
numcached:
aliases:
- "numcached"
- "nc"
is_admin: true
description: "Outputs the number of tracks cached on disk if caching is enabled."
messages:
num_cached: "There are currently <b>%d</b> items stored in the cache."
numtracks:
aliases:
- "numtracks"
- "numsongs"
- "nt"
is_admin: false
description: "Outputs the number of tracks currently in the queue."
messages:
one_track: "There is currently <b>1</b> track in the queue."
plural_tracks: "There are currently <b>%d</b> tracks in the queue."
pause:
aliases:
- "pause"
is_admin: false
description: "Pauses audio playback."
messages:
no_audio_error: "Either the audio is already paused, or there are no tracks in the queue."
paused: "<b>%s</b> has paused audio playback."
register:
aliases:
- "register"
- "reg"
is_admin: true
description: "Registers the bot on the server."
messages:
already_registered_error: "I am already registered on the server."
registered: "I am now registered on the server."
reload:
aliases:
- "reload"
- "r"
is_admin: true
description: "Reloads the configuration file."
messages:
reloaded: "The configuration file has been successfully reloaded."
reset:
aliases:
- "reset"
- "re"
is_admin: true
description: "Resets the queue by removing all queue items."
messages:
queue_reset: "<b>%s</b> has reset the queue."
resume:
aliases:
- "resume"
is_admin: false
description: "Resumes audio playback."
messages:
audio_error: "Either the audio is already playing, or there are no tracks in the queue."
resumed: "<b>%s</b> has resumed audio playback."
setcomment:
aliases:
- "setcomment"
- "comment"
- "sc"
is_admin: true
description: "Sets the comment displayed next to MumbleDJ's username in Mumble."
messages:
comment_removed: "The comment for the bot has been successfully removed."
comment_changed: "The comment for the bot has been successfully changed to the following: %s"
shuffle:
aliases:
- "shuffle"
- "shuf"
- "sh"
is_admin: true
description: "Randomizes the tracks currently in the queue."
messages:
not_enough_tracks_error: "There are not enough tracks in the queue to execute a shuffle."
shuffled: "The audio queue has been shuffled."
skip:
aliases:
- "skip"
- "s"
is_admin: false
description: "Places a vote to skip the current track."
messages:
already_voted_error: "You have already voted to skip this track."
voted: "<b>%s</b> has voted to skip the current track."
submitter_voted: "<b>%s</b>, the submitter of this track, has voted to skip. Skipping immediately."
skipplaylist:
aliases:
- "skipplaylist"
- "sp"
is_admin: false
description: "Places a vote to skip the current playlist."
messages:
no_playlist_error: "The current track is not part of a playlist."
already_voted_error: "You have already voted to skip this playlist."
voted: "<b>%s</b> has voted to skip the current playlist."
submitter_voted: "<b>%s</b>, the submitter of this playlist, has voted to skip. Skipping immediately."
toggleshuffle:
aliases:
- "toggleshuffle"
- "toggleshuf"
- "togshuf"
- "tsh"
is_admin: true
description: "Toggles automatic track shuffling on/off."
messages:
toggled_off: "Automatic shuffling has been toggled off."
toggled_on: "Automatic shuffling has been toggled on."
version:
aliases:
- "version"
- "v"
is_admin: false
description: "Outputs the current version of MumbleDJ."
messages:
version: "MumbleDJ version: <b>%s</b>"
volume:
aliases:
- "volume"
- "vol"
is_admin: false
description: "Changes the volume if an argument is provided, outputs the current volume otherwise."
messages:
parsing_error: "The requested volume could not be parsed."
out_of_range_error: "Volumes must be between the values <b>%.2f</b> and <b>%.2f</b>."
current_volume: "The current volume is <b>%.2f</b>."
volume_changed: "<b>%s</b> has changed the volume to <b>%.2f</b>."

62
glide.lock generated Normal file
View file

@ -0,0 +1,62 @@
hash: b68f7c8a3b59d7dac3e12321ed6a2265b553c2856ae70e0ed5e960ba8412f8d8
updated: 2016-07-11T16:01:20.19606261-07:00
imports:
- name: github.com/antonholmquist/jason
version: c23cef7eaa75a6a5b8810120e167bd590d8fd2ab
- name: github.com/BurntSushi/toml
version: bbd5bb678321a0d6e58f1099321dfa73391c1b6f
- name: github.com/ChannelMeter/iso8601duration
version: 8da3af7a2a61a4eb5ae9bddec06bf637fa9593da
- name: github.com/fsnotify/fsnotify
version: a8a77c9133d2d6fd8334f3260d06f60e8d80a5fb
- name: github.com/golang/protobuf
version: 3852dcfda249c2097355a6aabb199a28d97b30df
subpackages:
- proto
- name: github.com/hashicorp/hcl
version: 364df430845abef160a0bfb3a59979f746bf4956
subpackages:
- hcl/ast
- hcl/parser
- hcl/token
- json/parser
- hcl/scanner
- hcl/strconv
- json/scanner
- json/token
- name: github.com/layeh/gopus
version: 867541549ca5f8b4db2b92fd1dded8711256a27d
- name: github.com/layeh/gumble
version: f0a4a2992fa9a969ef90d673374bc63a9b7948ad
subpackages:
- gumble
- gumbleffmpeg
- gumbleutil
- opus
- gumble/MumbleProto
- gumble/varint
- name: github.com/magiconair/properties
version: e2f061ecfdaca9f35b2e2c12346ffc526f138137
- name: github.com/mitchellh/mapstructure
version: d2dd0262208475919e1a362f675cfc0e7c10e905
- name: github.com/Sirupsen/logrus
version: 4b6ea7319e214d98c938f12692336f7ca9348d6b
- name: github.com/spf13/cast
version: 27b586b42e29bec072fe7379259cc719e1289da6
- name: github.com/spf13/jwalterweatherman
version: 33c24e77fb80341fe7130ee7c594256ff08ccc46
- name: github.com/spf13/pflag
version: 367864438f1b1a3c7db4da06a2f55b144e6784e0
- name: github.com/spf13/viper
version: c1ccc378a054ea8d4e38d8c67f6938d4760b53dd
- name: github.com/stretchr/testify
version: f390dcf405f7b83c997eac1b06768bb9f44dec18
- name: github.com/urfave/cli
version: 01857ac33766ce0c93856370626f9799281c14f4
- name: golang.org/x/sys
version: a408501be4d17ee978c04a618e7a1b22af058c0e
subpackages:
- unix
- name: gopkg.in/yaml.v2
version: a83829b6f1293c91addabc89d0571c246397bbf4
devImports: []

26
glide.yaml Normal file
View file

@ -0,0 +1,26 @@
package: github.com/matthieugrieger/mumbledj
homepage: https://github.com/matthieugrieger/mumbledj
license: MIT
owners:
- name: Matthieu Grieger
email: me@matthieugrieger.com
homepage: https://matthieugrieger.com
import:
- package: github.com/ChannelMeter/iso8601duration
- package: github.com/Sirupsen/logrus
version: v0.10.0
- package: github.com/antonholmquist/jason
version: v1.0.0
- package: github.com/layeh/gumble
subpackages:
- gumble
- gumbleffmpeg
- gumbleutil
- opus
- package: github.com/spf13/viper
- package: github.com/urfave/cli
version: v1.17.0
- package: github.com/stretchr/testify
version: v1.1.3
- package: github.com/BurntSushi/toml
version: v0.2.0

18
interfaces/command.go Normal file
View file

@ -0,0 +1,18 @@
/*
* MumbleDJ
* By Matthieu Grieger
* interfaces/command.go
* Copyright (c) 2016 Matthieu Grieger (MIT License)
*/
package interfaces
import "github.com/layeh/gumble/gumble"
// Command is an interface that all commands must implement.
type Command interface {
Aliases() []string
Description() string
IsAdminCommand() bool
Execute(user *gumble.User, args ...string) (string, bool, error)
}

16
interfaces/playlist.go Normal file
View file

@ -0,0 +1,16 @@
/*
* MumbleDJ
* By Matthieu Grieger
* interfaces/playlist.go
* Copyright (c) 2016 Matthieu Grieger (MIT License)
*/
package interfaces
// Playlist is an interface of methods that must be implemented by playlists.
type Playlist interface {
GetID() string
GetTitle() string
GetSubmitter() string
GetService() string
}

29
interfaces/queue.go Normal file
View file

@ -0,0 +1,29 @@
/*
* MumbleDJ
* By Matthieu Grieger
* interfaces/queue.go
* Copyright (c) 2016 Matthieu Grieger (MIT License)
*/
package interfaces
// Queue is the interface which should be interacted with for queue operations.
// Using the Queue interface ensures thread safety.
type Queue interface {
Length() int
Reset()
AppendTrack(Track) error
InsertTrack(int, Track) error
CurrentTrack() (Track, error)
GetTrack(int) Track
PeekNextTrack() (Track, error)
Traverse(func(int, Track))
ShuffleTracks()
RandomNextTrack(bool)
Skip()
SkipPlaylist()
PlayCurrent() error
PauseCurrent() error
ResumeCurrent() error
StopCurrent() error
}

20
interfaces/service.go Normal file
View file

@ -0,0 +1,20 @@
/*
* MumbleDJ
* By Matthieu Grieger
* interfaces/service.go
* Copyright (c) 2016 Matthieu Grieger (MIT License)
*/
package interfaces
import "github.com/layeh/gumble/gumble"
// Service is an interface of methods to be implemented
// by various service types, such as YouTube or SoundCloud.
type Service interface {
GetReadableName() string
GetFormat() string
CheckAPIKey() error
CheckURL(string) bool
GetTracks(string, *gumble.User) ([]Track, error)
}

23
interfaces/skiptracker.go Normal file
View file

@ -0,0 +1,23 @@
/*
* MumbleDJ
* By Matthieu Grieger
* interfaces/skiptracker.go
* Copyright (c) 2016 Matthieu Grieger (MIT License)
*/
package interfaces
import "github.com/layeh/gumble/gumble"
// SkipTracker is the interface which should be interacted with for skip operations.
// Using the SkipTracker interface ensures thread safety.
type SkipTracker interface {
AddTrackSkip(*gumble.User) error
AddPlaylistSkip(*gumble.User) error
RemoveTrackSkip(*gumble.User) error
RemovePlaylistSkip(*gumble.User) error
NumTrackSkips() int
NumPlaylistSkips() int
ResetTrackSkips()
ResetPlaylistSkips()
}

26
interfaces/track.go Normal file
View file

@ -0,0 +1,26 @@
/*
* MumbleDJ
* By Matthieu Grieger
* interfaces/track.go
* Copyright (c) 2016 Matthieu Grieger (MIT License)
*/
package interfaces
import "time"
// Track is an interface of methods that must be implemented by tracks.
type Track interface {
GetID() string
GetURL() string
GetTitle() string
GetAuthor() string
GetAuthorURL() string
GetSubmitter() string
GetService() string
GetFilename() string
GetThumbnailURL() string
GetDuration() time.Duration
GetPlaybackOffset() time.Duration
GetPlaylist() Playlist
}

344
main.go
View file

@ -2,147 +2,253 @@
* MumbleDJ
* By Matthieu Grieger
* main.go
* Copyright (c) 2014 Matthieu Grieger (MIT License)
* Copyright (c) 2016 Matthieu Grieger (MIT License)
*/
package main
import (
"flag"
"fmt"
"github.com/layeh/gumble/gumble"
"github.com/layeh/gumble/gumble_ffmpeg"
"github.com/layeh/gumble/gumbleutil"
"os/user"
"io/ioutil"
"os"
"strings"
"github.com/Sirupsen/logrus"
"github.com/matthieugrieger/mumbledj/bot"
"github.com/matthieugrieger/mumbledj/commands"
"github.com/matthieugrieger/mumbledj/services"
"github.com/spf13/viper"
"github.com/urfave/cli"
)
// MumbleDJ type declaration
type mumbledj struct {
config gumble.Config
client *gumble.Client
keepAlive chan bool
defaultChannel string
conf DjConfig
queue *SongQueue
currentSong *Song
audioStream *gumble_ffmpeg.Stream
homeDir string
// DJ is a global variable that holds various details about the bot's state.
var DJ = bot.NewMumbleDJ()
func init() {
DJ.Commands = commands.Commands
DJ.AvailableServices = services.Services
// Injection into sub-packages.
commands.DJ = DJ
services.DJ = DJ
bot.DJ = DJ
DJ.Version = "v3.2.1"
logrus.SetLevel(logrus.WarnLevel)
}
// OnConnect event. First moves MumbleDJ into the default channel specified
// via commandline args, and moves to root channel if the channel does not exist. The current
// user's homedir path is stored, configuration is loaded, and the audio stream is set up.
func (dj *mumbledj) OnConnect(e *gumble.ConnectEvent) {
if dj.client.Channels().Find(dj.defaultChannel) != nil {
dj.client.Self().Move(dj.client.Channels().Find(dj.defaultChannel))
} else {
fmt.Println("Channel doesn't exist or one was not provided, staying in root channel...")
func main() {
app := cli.NewApp()
app.Name = "MumbleDJ"
app.Usage = "A Mumble bot that plays audio from various media sites."
app.Version = DJ.Version
app.Flags = []cli.Flag{
cli.StringFlag{
Name: "config, c",
Value: os.ExpandEnv("$HOME/.config/mumbledj/config.yaml"),
Usage: "location of MumbleDJ configuration file",
},
cli.StringFlag{
Name: "server, s",
Value: "127.0.0.1",
Usage: "address of Mumble server to connect to",
},
cli.StringFlag{
Name: "port, o",
Value: "64738",
Usage: "port of Mumble server to connect to",
},
cli.StringFlag{
Name: "username, u",
Value: "MumbleDJ",
Usage: "username for the bot",
},
cli.StringFlag{
Name: "password, p",
Value: "",
Usage: "password for the Mumble server",
},
cli.StringFlag{
Name: "channel, n",
Value: "",
Usage: "channel the bot enters after connecting to the Mumble server",
},
cli.StringFlag{
Name: "p12",
Value: "",
Usage: "path to user p12 file for authenticating as a registered user",
},
cli.StringFlag{
Name: "cert, e",
Value: "",
Usage: "path to PEM certificate",
},
cli.StringFlag{
Name: "key, k",
Value: "",
Usage: "path to PEM key",
},
cli.StringFlag{
Name: "accesstokens, a",
Value: "",
Usage: "list of access tokens separated by spaces",
},
cli.BoolFlag{
Name: "insecure, i",
Usage: "if present, the bot will not check Mumble certs for consistency",
},
cli.BoolFlag{
Name: "debug, d",
Usage: "if present, all debug messages will be shown",
},
}
if currentUser, err := user.Current(); err == nil {
dj.homeDir = currentUser.HomeDir
}
if err := loadConfiguration(); err == nil {
fmt.Println("Configuration successfully loaded!")
} else {
panic(err)
}
if audioStream, err := gumble_ffmpeg.New(dj.client); err == nil {
dj.audioStream = audioStream
dj.audioStream.Done = dj.OnSongFinished
dj.audioStream.SetVolume(dj.conf.Volume.DefaultVolume)
} else {
panic(err)
}
}
// OnDisconnect event. Terminates MumbleDJ thread.
func (dj *mumbledj) OnDisconnect(e *gumble.DisconnectEvent) {
dj.keepAlive <- true
}
// OnTextMessage event. Checks for command prefix, and calls parseCommand if it exists. Ignores
// the incoming message otherwise.
func (dj *mumbledj) OnTextMessage(e *gumble.TextMessageEvent) {
if e.Message[0] == dj.conf.General.CommandPrefix[0] {
parseCommand(e.Sender, e.Sender.Name(), e.Message[1:])
}
}
// Checks if username has the permissions to execute a command. Permissions are specified in
// mumbledj.gcfg.
func (dj *mumbledj) HasPermission(username string, command bool) bool {
if dj.conf.Permissions.AdminsEnabled && command {
for _, adminName := range dj.conf.Permissions.Admins {
if username == adminName {
return true
}
hiddenFlags := make([]cli.Flag, len(viper.AllKeys()))
for i, configValue := range viper.AllKeys() {
hiddenFlags[i] = cli.StringFlag{
Name: configValue,
Hidden: true,
}
return false
} else {
return true
}
}
app.Flags = append(app.Flags, hiddenFlags...)
// OnSongFinished event. Deletes song that just finished playing, then queues, downloads, and plays
// the next song if it exists.
func (dj *mumbledj) OnSongFinished() {
if err := dj.currentSong.Delete(); err == nil {
if dj.queue.Len() != 0 {
dj.currentSong = dj.queue.NextSong()
if dj.currentSong != nil {
if err := dj.currentSong.Download(); err == nil {
dj.currentSong.Play()
app.Action = func(c *cli.Context) error {
if c.Bool("debug") {
logrus.SetLevel(logrus.InfoLevel)
}
for _, configValue := range viper.AllKeys() {
if c.GlobalIsSet(configValue) {
if strings.Contains(c.String(configValue), ",") {
viper.Set(configValue, strings.Split(c.String(configValue), ","))
} else {
panic(err)
viper.Set(configValue, c.String(configValue))
}
}
}
viper.SetConfigFile(c.String("config"))
if err := viper.ReadInConfig(); err != nil {
logrus.WithFields(logrus.Fields{
"file": c.String("config"),
"error": err.Error(),
}).Warnln("An error occurred while reading the configuration file. Using default configuration...")
if _, err := os.Stat(c.String("config")); os.IsNotExist(err) {
createConfigWhenNotExists()
}
} else {
if duplicateErr := bot.CheckForDuplicateAliases(); duplicateErr != nil {
logrus.WithFields(logrus.Fields{
"issue": duplicateErr.Error(),
}).Fatalln("An issue was discoverd in your configuration.")
}
createNewConfigIfNeeded()
viper.WatchConfig()
}
if c.GlobalIsSet("server") {
viper.Set("connection.address", c.String("server"))
}
if c.GlobalIsSet("port") {
viper.Set("connection.port", c.String("port"))
}
if c.GlobalIsSet("username") {
viper.Set("connection.username", c.String("username"))
}
if c.GlobalIsSet("password") {
viper.Set("connection.password", c.String("password"))
}
if c.GlobalIsSet("channel") {
viper.Set("defaults.channel", c.String("channel"))
}
if c.GlobalIsSet("p12") {
viper.Set("connection.user_p12", c.String("p12"))
}
if c.GlobalIsSet("cert") {
viper.Set("connection.cert", c.String("cert"))
}
if c.GlobalIsSet("key") {
viper.Set("connection.key", c.String("key"))
}
if c.GlobalIsSet("accesstokens") {
viper.Set("connection.access_tokens", c.String("accesstokens"))
}
if c.GlobalIsSet("insecure") {
viper.Set("connection.insecure", c.Bool("insecure"))
}
if err := DJ.Connect(); err != nil {
logrus.WithFields(logrus.Fields{
"error": err.Error(),
}).Fatalln("An error occurred while connecting to the server.")
}
if viper.GetString("defaults.channel") != "" {
defaultChannel := strings.Split(viper.GetString("defaults.channel"), "/")
DJ.Client.Do(func() {
DJ.Client.Self.Move(DJ.Client.Channels.Find(defaultChannel...))
})
}
DJ.Client.Do(func() {
DJ.Client.Self.SetComment(viper.GetString("defaults.comment"))
})
<-DJ.KeepAlive
return nil
}
app.Run(os.Args)
}
func createConfigWhenNotExists() {
configFile, err := Asset("config.yaml")
if err != nil {
logrus.Warnln("An error occurred while accessing config binary data. A new config file will not be written.")
} else {
panic(err)
filePath := os.ExpandEnv("$HOME/.config/mumbledj/config.yaml")
os.MkdirAll(os.ExpandEnv("$HOME/.config/mumbledj"), 0777)
writeErr := ioutil.WriteFile(filePath, configFile, 0644)
if writeErr == nil {
logrus.WithFields(logrus.Fields{
"file_path": filePath,
}).Infoln("A default configuration file has been written.")
} else {
logrus.WithFields(logrus.Fields{
"error": writeErr.Error(),
}).Warnln("An error occurred while writing a new config file.")
}
}
}
// dj variable declaration. This is done outside of main() to allow global use.
var dj = mumbledj{
keepAlive: make(chan bool),
queue: NewSongQueue(),
}
func createNewConfigIfNeeded() {
newConfigPath := os.ExpandEnv("$HOME/.config/mumbledj/config.yaml.new")
// Main function, but only really performs startup tasks. Grabs and parses commandline
// args, sets up the gumble client and its listeners, and then connects to the server.
func main() {
var address, port, username, password, channel string
flag.StringVar(&address, "server", "localhost", "address for Mumble server")
flag.StringVar(&port, "port", "64738", "port for Mumble server")
flag.StringVar(&username, "username", "MumbleDJ", "username of MumbleDJ on server")
flag.StringVar(&password, "password", "", "password for Mumble server (if needed)")
flag.StringVar(&channel, "channel", "root", "default channel for MumbleDJ")
flag.Parse()
dj.client = gumble.NewClient(&dj.config)
dj.config = gumble.Config{
Username: username,
Password: password,
Address: address + ":" + port,
// Check if we should write an updated config file to config.yaml.new.
if assetInfo, err := AssetInfo("config.yaml"); err == nil {
asset, _ := Asset("config.yaml")
if configFile, err := os.Open(os.ExpandEnv("$HOME/.config/mumbledj/config.yaml")); err == nil {
configInfo, _ := configFile.Stat()
defer configFile.Close()
if configNewFile, err := os.Open(newConfigPath); err == nil {
defer configNewFile.Close()
configNewInfo, _ := configNewFile.Stat()
if assetInfo.ModTime().Unix() > configNewInfo.ModTime().Unix() {
// The config asset is newer than the config.yaml.new file.
// Write a new config.yaml.new file.
ioutil.WriteFile(os.ExpandEnv(newConfigPath), asset, 0644)
logrus.WithFields(logrus.Fields{
"file_path": newConfigPath,
}).Infoln("An updated default configuration file has been written.")
}
} else if assetInfo.ModTime().Unix() > configInfo.ModTime().Unix() {
// The config asset is newer than the existing config file.
// Write a config.yaml.new file.
ioutil.WriteFile(os.ExpandEnv(newConfigPath), asset, 0644)
logrus.WithFields(logrus.Fields{
"file_path": newConfigPath,
}).Infoln("An updated default configuration file has been written.")
}
}
}
dj.defaultChannel = channel
dj.client.Attach(gumbleutil.Listener{
Connect: dj.OnConnect,
Disconnect: dj.OnDisconnect,
TextMessage: dj.OnTextMessage,
})
// IMPORTANT NOTE: This will be changed later once released. Not really safe at the
// moment.
dj.config.TLSConfig.InsecureSkipVerify = true
if err := dj.client.Connect(); err != nil {
panic(err)
}
<-dj.keepAlive
}

View file

@ -1,100 +0,0 @@
# MumbleDJ
# By Matthieu Grieger
# config.toml
# Copyright (c) 2014 Matthieu Grieger (MIT License)
[General]
# Command prefix
# DEFAULT VALUE: "!"
CommandPrefix = "!"
# Ratio that must be met or exceeded to trigger a song skip
# DEFAULT VALUE: 0.5
SkipRatio = 0.5
[Volume]
# Default volume
# DEFAULT VALUE: 0.2
DefaultVolume = 0.2
# Lowest volume allowed
# DEFAULT VALUE: 0.01
LowestVolume = 0.01
# Highest volume allowed
# DEFAULT VALUE: 0.8
HighestVolume = 0.8
[Aliases]
# Alias used for add command
# DEFAULT VALUE: "add"
AddAlias = "add"
# Alias used for skip command
# DEFAULT VALUE: "skip"
SkipAlias = "skip"
# Alias used for admin skip command
# DEFAULT VALUE: "forceskip"
AdminSkipAlias = "forceskip"
# Alias used for volume command
# DEFAULT VALUE: "volume"
VolumeAlias = "volume"
# Alias used for move command
# DEFAULT VALUE: "move"
MoveAlias = "move"
# Alias used for reload command
# DEFAULT VALUE: "reload"
ReloadAlias = "reload"
# Alias used for kill command
# DEFAULT VALUE: "kill"
KillAlias = "kill"
[Permissions]
# Enable admins
# DEFAULT VALUE: true
AdminsEnabled = true
# List of admins
# NOTE: I recommend only giving users admin privileges if they are registered
# on the server. Otherwise people can just take their username and issue admin
# commands.
# SYNTAX: In order to specify multiple admins, repeat the Admins="username"
# line of code. Each line has one username, and an unlimited amount of usernames may
# be entered in this matter.
Admins = "Matt"
# Make add an admin command?
# DEFAULT VALUE: false
AdminAdd = false
# Make skip an admin command?
# DEFAULT VALUE: false
AdminSkip = false
# Make volume an admin command?
# DEFAULT VALUE: false
AdminVolume = false
# Make move an admin command?
# DEFAULT VALUE: true
AdminMove = true
# Make reload an admin command?
# DEFAULT VALUE: true
AdminReload = true
# Make kill an admin command?
# DEFAULT VALUE: true (I recommend never changing this to false)
AdminKill = true

View file

@ -1,55 +0,0 @@
/*
* MumbleDJ
* By Matthieu Grieger
* parseconfig.go
* Copyright (c) 2014 Matthieu Grieger (MIT License)
*/
package main
import (
"code.google.com/p/gcfg"
"errors"
"fmt"
)
// Golang struct representation of mumbledj.gcfg file structure for parsing.
type DjConfig struct {
General struct {
CommandPrefix string
SkipRatio float32
}
Volume struct {
DefaultVolume float32
LowestVolume float32
HighestVolume float32
}
Aliases struct {
AddAlias string
SkipAlias string
AdminSkipAlias string
VolumeAlias string
MoveAlias string
ReloadAlias string
KillAlias string
}
Permissions struct {
AdminsEnabled bool
Admins []string
AdminAdd bool
AdminSkip bool
AdminVolume bool
AdminMove bool
AdminReload bool
AdminKill bool
}
}
// Loads mumbledj.gcfg into dj.conf, a variable of type DjConfig.
func loadConfiguration() error {
if gcfg.ReadFileInto(&dj.conf, fmt.Sprintf("%s/.mumbledj/config/mumbledj.gcfg", dj.homeDir)) == nil {
return nil
} else {
return errors.New("Configuration load failed.")
}
}

117
queue.go
View file

@ -1,117 +0,0 @@
//
// queue.go
//
// Created by Hicham Bouabdallah
// Copyright (c) 2012 SimpleRocket LLC
//
// Permission is hereby granted, free of charge, to any person
// obtaining a copy of this software and associated documentation
// files (the "Software"), to deal in the Software without
// restriction, including without limitation the rights to use,
// copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following
// conditions:
//
// The above copyright notice and this permission notice shall be
// included in all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
// OTHER DEALINGS IN THE SOFTWARE.
//
package main
import "sync"
type queuenode struct {
data interface{}
next *queuenode
}
// A go-routine safe FIFO (first in first out) data stucture.
type Queue struct {
head *queuenode
tail *queuenode
count int
lock *sync.Mutex
}
// Creates a new pointer to a new queue.
func NewQueue() *Queue {
q := &Queue{}
q.lock = &sync.Mutex{}
return q
}
// Returns the number of elements in the queue (i.e. size/length)
// go-routine safe.
func (q *Queue) Len() int {
q.lock.Lock()
defer q.lock.Unlock()
return q.count
}
// Pushes/inserts a value at the end/tail of the queue.
// Note: this function does mutate the queue.
// go-routine safe.
func (q *Queue) Push(item interface{}) {
q.lock.Lock()
defer q.lock.Unlock()
n := &queuenode{data: item}
if q.tail == nil {
q.tail = n
q.head = n
} else {
q.tail.next = n
q.tail = n
}
q.count++
}
// Returns the value at the front of the queue.
// i.e. the oldest value in the queue.
// Note: this function does mutate the queue.
// go-routine safe.
func (q *Queue) Poll() interface{} {
q.lock.Lock()
defer q.lock.Unlock()
if q.head == nil {
return nil
}
n := q.head
q.head = n.next
if q.head == nil {
q.tail = nil
}
q.count--
return n.data
}
// Returns a read value at the front of the queue.
// i.e. the oldest value in the queue.
// Note: this function does NOT mutate the queue.
// go-routine safe.
func (q *Queue) Peek() interface{} {
q.lock.Lock()
defer q.lock.Unlock()
n := q.head
if n == nil || n.data == nil {
return nil
}
return n.data
}

20
raspberry.Dockerfile Normal file
View file

@ -0,0 +1,20 @@
FROM hypriot/rpi-alpine-scratch:v3.3
ENV GOPATH=/
RUN echo "https://dl-cdn.alpinelinux.org/alpine/v3.3/community" >> /etc/apk/repositories
RUN apk add --update ca-certificates go ffmpeg make build-base opus-dev python aria2
RUN apk upgrade
RUN wget https://yt-dl.org/downloads/latest/youtube-dl -O /bin/youtube-dl && chmod a+x /bin/youtube-dl
COPY . /src/github.com/matthieugrieger/mumbledj
COPY config.yaml /root/.config/mumbledj/config.yaml
WORKDIR /src/github.com/matthieugrieger/mumbledj
RUN make
RUN make install
RUN apk del go make build-base && rm -rf /var/cache/apk/*
ENTRYPOINT ["/usr/local/bin/mumbledj"]

View file

@ -0,0 +1,88 @@
/*
* MumbleDJ
* By Matthieu Grieger
* services/generic_service.go
* Copyright (c) 2016 Matthieu Grieger (MIT License)
*/
package services
import (
"errors"
"regexp"
)
// GenericService is a generic struct that should be embedded
// in other service structs, as it provides useful helper
// methods and properties.
type GenericService struct {
ReadableName string
Format string
TrackRegex []*regexp.Regexp
PlaylistRegex []*regexp.Regexp
}
// GetReadableName returns the readable name for the service.
func (gs *GenericService) GetReadableName() string {
return gs.ReadableName
}
// GetFormat returns the youtube-dl format for the service.
func (gs *GenericService) GetFormat() string {
return gs.Format
}
// CheckURL matches the passed URL with a list of regex patterns
// for valid URLs associated with this service. Returns true if a
// match is found, false otherwise.
func (gs *GenericService) CheckURL(url string) bool {
if gs.isTrack(url) || gs.isPlaylist(url) {
return true
}
return false
}
func (gs *GenericService) isTrack(url string) bool {
for _, regex := range gs.TrackRegex {
if regex.MatchString(url) {
return true
}
}
return false
}
func (gs *GenericService) isPlaylist(url string) bool {
for _, regex := range gs.PlaylistRegex {
if regex.MatchString(url) {
return true
}
}
return false
}
func (gs *GenericService) getID(url string) (string, error) {
var allRegex []*regexp.Regexp
if gs.PlaylistRegex != nil {
allRegex = append(gs.TrackRegex, gs.PlaylistRegex...)
} else {
allRegex = gs.TrackRegex
}
for _, regex := range allRegex {
match := regex.FindStringSubmatch(url)
if match == nil {
// Move on to next regex, this one didn't match.
continue
}
result := make(map[string]string)
for i, name := range regex.SubexpNames() {
if i < len(match) {
result[name] = match[i]
}
}
return result["id"], nil
}
return "", errors.New("No match found for URL")
}

114
services/mixcloud.go Normal file
View file

@ -0,0 +1,114 @@
/*
* MumbleDJ
* By Matthieu Grieger
* services/mixcloud.go
* Copyright (c) 2016 Matthieu Grieger (MIT License)
*/
package services
import (
"fmt"
"net/http"
"regexp"
"strings"
"time"
"github.com/antonholmquist/jason"
"github.com/layeh/gumble/gumble"
"github.com/matthieugrieger/mumbledj/bot"
"github.com/matthieugrieger/mumbledj/interfaces"
)
// Mixcloud is a wrapper around the Mixcloud API.
// https://www.mixcloud.com/developers/
type Mixcloud struct {
*GenericService
}
// NewMixcloudService returns an initialized Mixcloud service object.
func NewMixcloudService() *Mixcloud {
return &Mixcloud{
&GenericService{
ReadableName: "Mixcloud",
Format: "m4a",
TrackRegex: []*regexp.Regexp{
regexp.MustCompile(`https?:\/\/(www\.)?mixcloud\.com\/([\w-]+)\/([\w-]+)(#t=\n\n?(:\n\n)*)?`),
},
// Playlists are currently unsupported by Mixcloud's API.
PlaylistRegex: nil,
},
}
}
// CheckAPIKey performs a test API call with the API key
// provided in the configuration file to determine if the
// service should be enabled.
func (mc *Mixcloud) CheckAPIKey() error {
// Mixcloud (at the moment) does not require an API key,
// so we can just return nil.
return nil
}
// GetTracks uses the passed URL to find and return
// tracks associated with the URL. An error is returned
// if any error occurs during the API call.
func (mc *Mixcloud) GetTracks(url string, submitter *gumble.User) ([]interfaces.Track, error) {
var (
apiURL string
err error
resp *http.Response
v *jason.Object
tracks []interfaces.Track
)
apiURL = strings.Replace(url, "www", "api", 1)
// Track playback offset is not present in Mixcloud URLs,
// so we can safely assume that users will not request
// a playback offset in the URL.
offset, _ := time.ParseDuration("0s")
resp, err = http.Get(apiURL)
defer resp.Body.Close()
if err != nil {
return nil, err
}
v, err = jason.NewObjectFromReader(resp.Body)
if err != nil {
return nil, err
}
id, _ := v.GetString("slug")
trackURL, _ := v.GetString("url")
title, _ := v.GetString("name")
author, _ := v.GetString("user", "username")
authorURL, _ := v.GetString("user", "url")
durationSecs, _ := v.GetInt64("audio_length")
duration, _ := time.ParseDuration(fmt.Sprintf("%ds", durationSecs))
thumbnail, err := v.GetString("pictures", "large")
if err != nil {
// Track has no artwork, using profile avatar instead.
thumbnail, _ = v.GetString("user", "pictures", "large")
}
track := bot.Track{
ID: id,
URL: trackURL,
Title: title,
Author: author,
AuthorURL: authorURL,
Submitter: submitter.Name,
Service: mc.ReadableName,
ThumbnailURL: thumbnail,
Filename: id + ".track",
Duration: duration,
PlaybackOffset: offset,
Playlist: nil,
}
tracks = append(tracks, track)
return tracks, nil
}

27
services/pkg_init.go Normal file
View file

@ -0,0 +1,27 @@
/*
* MumbleDJ
* By Matthieu Grieger
* services/pkg_init.go
* Copyright (c) 2016 Matthieu Grieger (MIT License)
*/
package services
import (
"github.com/matthieugrieger/mumbledj/bot"
"github.com/matthieugrieger/mumbledj/interfaces"
)
// DJ is an injected MumbleDJ struct.
var DJ *bot.MumbleDJ
// Services is a slice of enabled MumbleDJ services.
var Services []interfaces.Service
func init() {
Services = []interfaces.Service{
NewMixcloudService(),
NewSoundCloudService(),
NewYouTubeService(),
}
}

192
services/soundcloud.go Normal file
View file

@ -0,0 +1,192 @@
/*
* MumbleDJ
* By Matthieu Grieger
* services/soundcloud.go
* Copyright (c) 2016 Matthieu Grieger (MIT License)
*/
package services
import (
"errors"
"fmt"
"net/http"
"regexp"
"strconv"
"strings"
"time"
"github.com/antonholmquist/jason"
"github.com/layeh/gumble/gumble"
"github.com/matthieugrieger/mumbledj/bot"
"github.com/matthieugrieger/mumbledj/interfaces"
"github.com/spf13/viper"
)
// SoundCloud is a wrapper around the SoundCloud API.
// https://developers.soundcloud.com/docs/api/reference
type SoundCloud struct {
*GenericService
}
// NewSoundCloudService returns an initialized SoundCloud service object.
func NewSoundCloudService() *SoundCloud {
return &SoundCloud{
&GenericService{
ReadableName: "SoundCloud",
Format: "bestaudio",
TrackRegex: []*regexp.Regexp{
regexp.MustCompile(`https?:\/\/(www\.)?soundcloud\.com\/([\w-]+)\/([\w-]+)(#t=\n\n?(:\n\n)*)?`),
},
PlaylistRegex: []*regexp.Regexp{
regexp.MustCompile(`https?:\/\/(www\.)?soundcloud\.com\/([\w-]+)\/sets\/([\w-]+)`),
},
},
}
}
// CheckAPIKey performs a test API call with the API key
// provided in the configuration file to determine if the
// service should be enabled.
func (sc *SoundCloud) CheckAPIKey() error {
if viper.GetString("api_keys.soundcloud") == "" {
return errors.New("No SoundCloud API key has been provided")
}
url := "http://api.soundcloud.com/tracks/13158665?client_id=%s"
response, err := http.Get(fmt.Sprintf(url, viper.GetString("api_keys.soundcloud")))
defer response.Body.Close()
if err != nil {
return err
}
if response.StatusCode != 200 {
return errors.New(response.Status)
}
return nil
}
// GetTracks uses the passed URL to find and return
// tracks associated with the URL. An error is returned
// if any error occurs during the API call.
func (sc *SoundCloud) GetTracks(url string, submitter *gumble.User) ([]interfaces.Track, error) {
var (
apiURL string
err error
resp *http.Response
v *jason.Object
track bot.Track
tracks []interfaces.Track
)
urlSplit := strings.Split(url, "#t=")
apiURL = "http://api.soundcloud.com/resolve?url=%s&client_id=%s"
if sc.isPlaylist(url) {
// Submitter has added a playlist!
resp, err = http.Get(fmt.Sprintf(apiURL, urlSplit[0], viper.GetString("api_keys.soundcloud")))
defer resp.Body.Close()
if err != nil {
return nil, err
}
v, err = jason.NewObjectFromReader(resp.Body)
if err != nil {
return nil, err
}
title, _ := v.GetString("title")
permalink, _ := v.GetString("permalink_url")
playlist := &bot.Playlist{
ID: permalink,
Title: title,
Submitter: submitter.Name,
Service: sc.ReadableName,
}
var scTracks []*jason.Object
scTracks, err = v.GetObjectArray("tracks")
if err != nil {
return nil, err
}
dummyOffset, _ := time.ParseDuration("0s")
for _, t := range scTracks {
track, err = sc.getTrack(t, dummyOffset, submitter)
if err != nil {
// Skip this track.
continue
}
track.Playlist = playlist
tracks = append(tracks, track)
}
if len(tracks) == 0 {
return nil, errors.New("Invalid playlist. No tracks were added")
}
return tracks, nil
}
// Submitter has added a track!
offset := 0
// Calculate track offset if needed
if len(urlSplit) == 2 {
timeSplit := strings.Split(urlSplit[1], ":")
multiplier := 1
for i := len(timeSplit) - 1; i >= 0; i-- {
time, _ := strconv.Atoi(timeSplit[i])
offset += time * multiplier
multiplier *= 60
}
}
playbackOffset, _ := time.ParseDuration(fmt.Sprintf("%ds", offset))
resp, err = http.Get(fmt.Sprintf(apiURL, urlSplit[0], viper.GetString("api_keys.soundcloud")))
defer resp.Body.Close()
if err != nil {
return nil, err
}
v, err = jason.NewObjectFromReader(resp.Body)
if err != nil {
return nil, err
}
track, err = sc.getTrack(v, playbackOffset, submitter)
if err != nil {
return nil, err
}
tracks = append(tracks, track)
return tracks, nil
}
func (sc *SoundCloud) getTrack(obj *jason.Object, offset time.Duration, submitter *gumble.User) (bot.Track, error) {
title, _ := obj.GetString("title")
idInt, _ := obj.GetInt64("id")
id := strconv.FormatInt(idInt, 10)
url, _ := obj.GetString("permalink_url")
author, _ := obj.GetString("user", "username")
authorURL, _ := obj.GetString("user", "permalink_url")
durationMS, _ := obj.GetInt64("duration")
duration, _ := time.ParseDuration(fmt.Sprintf("%dms", durationMS))
thumbnail, err := obj.GetString("artwork_url")
if err != nil {
// Track has no artwork, using profile avatar instead.
thumbnail, _ = obj.GetString("user", "avatar_url")
}
return bot.Track{
ID: id,
URL: url,
Title: title,
Author: author,
AuthorURL: authorURL,
Submitter: submitter.Name,
Service: sc.ReadableName,
Filename: id + ".track",
ThumbnailURL: thumbnail,
Duration: duration,
PlaybackOffset: offset,
Playlist: nil,
}, nil
}

247
services/youtube.go Normal file
View file

@ -0,0 +1,247 @@
/*
* MumbleDJ
* By Matthieu Grieger
* services/youtube.go
* Copyright (c) 2016 Matthieu Grieger (MIT License)
*/
package services
import (
"errors"
"fmt"
"math"
"net/http"
"regexp"
"strings"
"time"
"github.com/ChannelMeter/iso8601duration"
"github.com/antonholmquist/jason"
"github.com/layeh/gumble/gumble"
"github.com/matthieugrieger/mumbledj/bot"
"github.com/matthieugrieger/mumbledj/interfaces"
"github.com/spf13/viper"
)
// YouTube is a wrapper around the YouTube Data API.
// https://developers.google.com/youtube/v3/docs/
type YouTube struct {
*GenericService
}
// NewYouTubeService returns an initialized YouTube service object.
func NewYouTubeService() *YouTube {
return &YouTube{
&GenericService{
ReadableName: "YouTube",
Format: "bestaudio",
TrackRegex: []*regexp.Regexp{
regexp.MustCompile(`https?:\/\/www.youtube.com\/watch\?v=(?P<id>[\w-]+)(?P<timestamp>\&t=\d*m?\d*s?)?`),
regexp.MustCompile(`https?:\/\/youtube.com\/watch\?v=(?P<id>[\w-]+)(?P<timestamp>\&t=\d*m?\d*s?)?`),
regexp.MustCompile(`https?:\/\/youtu.be\/(?P<id>[\w-]+)(?P<timestamp>\?t=\d*m?\d*s?)?`),
regexp.MustCompile(`https?:\/\/youtube.com\/v\/(?P<id>[\w-]+)(?P<timestamp>\?t=\d*m?\d*s?)?`),
regexp.MustCompile(`https?:\/\/www.youtube.com\/v\/(?P<id>[\w-]+)(?P<timestamp>\?t=\d*m?\d*s?)?`),
},
PlaylistRegex: []*regexp.Regexp{
regexp.MustCompile(`https?:\/\/www\.youtube\.com\/playlist\?list=(?P<id>[\w-]+)`),
},
},
}
}
// CheckAPIKey performs a test API call with the API key
// provided in the configuration file to determine if the
// service should be enabled.
func (yt *YouTube) CheckAPIKey() error {
var (
response *http.Response
v *jason.Object
err error
)
if viper.GetString("api_keys.youtube") == "" {
return errors.New("No YouTube API key has been provided")
}
url := "https://www.googleapis.com/youtube/v3/videos?part=snippet&id=KQY9zrjPBjo&key=%s"
response, err = http.Get(fmt.Sprintf(url, viper.GetString("api_keys.youtube")))
defer response.Body.Close()
if err != nil {
return err
}
if v, err = jason.NewObjectFromReader(response.Body); err != nil {
return err
}
if v, err = v.GetObject("error"); err == nil {
message, _ := v.GetString("message")
code, _ := v.GetInt64("code")
errArray, _ := v.GetObjectArray("errors")
reason, _ := errArray[0].GetString("reason")
return fmt.Errorf("%d: %s (reason: %s)", code, message, reason)
}
return nil
}
// GetTracks uses the passed URL to find and return
// tracks associated with the URL. An error is returned
// if any error occurs during the API call.
func (yt *YouTube) GetTracks(url string, submitter *gumble.User) ([]interfaces.Track, error) {
var (
playlistURL string
playlistItemsURL string
id string
err error
resp *http.Response
v *jason.Object
track bot.Track
tracks []interfaces.Track
)
dummyOffset, _ := time.ParseDuration("0s")
urlSplit := strings.Split(url, "?t=")
playlistURL = "https://www.googleapis.com/youtube/v3/playlists?part=snippet&id=%s&key=%s"
playlistItemsURL = "https://www.googleapis.com/youtube/v3/playlistItems?part=snippet,contentDetails&playlistId=%s&maxResults=%d&key=%s&pageToken=%s"
id, err = yt.getID(urlSplit[0])
if err != nil {
return nil, err
}
if yt.isPlaylist(url) {
resp, err = http.Get(fmt.Sprintf(playlistURL, id, viper.GetString("api_keys.youtube")))
defer resp.Body.Close()
if err != nil {
return nil, err
}
v, err = jason.NewObjectFromReader(resp.Body)
if err != nil {
return nil, err
}
items, _ := v.GetObjectArray("items")
item := items[0]
title, _ := item.GetString("snippet", "title")
playlist := &bot.Playlist{
ID: id,
Title: title,
Submitter: submitter.Name,
Service: yt.ReadableName,
}
maxItems := math.MaxInt32
if viper.GetInt("queue.max_tracks_per_playlist") > 0 {
maxItems = viper.GetInt("queue.max_tracks_per_playlist")
}
// YouTube playlist searches return a max of 50 results per page
maxResults := 50
if maxResults > maxItems {
maxResults = maxItems
}
pageToken := ""
for len(tracks) < maxItems {
curResp, curErr := http.Get(fmt.Sprintf(playlistItemsURL, id, maxResults, viper.GetString("api_keys.youtube"), pageToken))
defer curResp.Body.Close()
if curErr != nil {
// An error occurred, simply skip this track.
continue
}
v, err = jason.NewObjectFromReader(curResp.Body)
if err != nil {
// An error occurred, simply skip this track.
continue
}
curTracks, _ := v.GetObjectArray("items")
for _, track := range curTracks {
videoID, _ := track.GetString("snippet", "resourceId", "videoId")
// Unfortunately we have to execute another API call for each video as the YouTube API does not
// return video durations from the playlistItems endpoint...
newTrack, _ := yt.getTrack(videoID, submitter, dummyOffset)
newTrack.Playlist = playlist
tracks = append(tracks, newTrack)
if len(tracks) >= maxItems {
break
}
}
pageToken, _ = v.GetString("nextPageToken")
if pageToken == "" {
break
}
}
if len(tracks) == 0 {
return nil, errors.New("Invalid playlist. No tracks were added")
}
return tracks, nil
}
// Submitter added a track!
offset := dummyOffset
if len(urlSplit) == 2 {
offset, _ = time.ParseDuration(urlSplit[1])
}
track, err = yt.getTrack(id, submitter, offset)
if err != nil {
return nil, err
}
tracks = append(tracks, track)
return tracks, nil
}
func (yt *YouTube) getTrack(id string, submitter *gumble.User, offset time.Duration) (bot.Track, error) {
var (
resp *http.Response
err error
v *jason.Object
)
videoURL := "https://www.googleapis.com/youtube/v3/videos?part=snippet,contentDetails&id=%s&key=%s"
resp, err = http.Get(fmt.Sprintf(videoURL, id, viper.GetString("api_keys.youtube")))
defer resp.Body.Close()
if err != nil {
return bot.Track{}, err
}
v, err = jason.NewObjectFromReader(resp.Body)
if err != nil {
return bot.Track{}, err
}
items, _ := v.GetObjectArray("items")
if len(items) == 0 {
return bot.Track{}, errors.New("This YouTube video is private")
}
item := items[0]
title, _ := item.GetString("snippet", "title")
thumbnail, _ := item.GetString("snippet", "thumbnails", "high", "url")
author, _ := item.GetString("snippet", "channelTitle")
durationString, _ := item.GetString("contentDetails", "duration")
durationConverted, _ := duration.FromString(durationString)
duration := durationConverted.ToDuration()
return bot.Track{
ID: id,
URL: "https://youtube.com/watch?v=" + id,
Title: title,
Author: author,
Submitter: submitter.Name,
Service: yt.ReadableName,
Filename: id + ".track",
ThumbnailURL: thumbnail,
Duration: duration,
PlaybackOffset: offset,
Playlist: nil,
}, nil
}

Some files were not shown because too many files have changed in this diff Show more