├── .gitignore ├── LICENSE ├── Pipfile ├── Pipfile.lock ├── README.md ├── contributors.md └── musicbot ├── __main__.py ├── bot.py ├── cogs ├── error.py ├── meta.py ├── music.py └── tips.py ├── config.py ├── util.py └── video.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | .static_storage/ 56 | .media/ 57 | local_settings.py 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | 106 | # config file 107 | config.toml 108 | **/config.toml 109 | 110 | # .vscode editor files 111 | .vscode 112 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Joe Kerrigan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | name = "pypi" 3 | url = "https://pypi.org/simple" 4 | verify_ssl = true 5 | 6 | [dev-packages] 7 | autopep8 = "*" 8 | 9 | [packages] 10 | toml = "*" 11 | youtube-dl = "*" 12 | "discord.py" = "*" 13 | pynacl = "*" 14 | 15 | [requires] 16 | python_version = "3.8" 17 | -------------------------------------------------------------------------------- /Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "8945b7495a6b67bbd1c61f1840165a9cff30aa853e5057a8719192e59d7d7b08" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": { 8 | "python_version": "3.8" 9 | }, 10 | "sources": [ 11 | { 12 | "name": "pypi", 13 | "url": "https://pypi.org/simple", 14 | "verify_ssl": true 15 | } 16 | ] 17 | }, 18 | "default": { 19 | "aiohttp": { 20 | "hashes": [ 21 | "sha256:119feb2bd551e58d83d1b38bfa4cb921af8ddedec9fad7183132db334c3133e0", 22 | "sha256:16d0683ef8a6d803207f02b899c928223eb219111bd52420ef3d7a8aa76227b6", 23 | "sha256:2eb3efe243e0f4ecbb654b08444ae6ffab37ac0ef8f69d3a2ffb958905379daf", 24 | "sha256:2ffea7904e70350da429568113ae422c88d2234ae776519549513c8f217f58a9", 25 | "sha256:40bd1b101b71a18a528ffce812cc14ff77d4a2a1272dfb8b11b200967489ef3e", 26 | "sha256:418597633b5cd9639e514b1d748f358832c08cd5d9ef0870026535bd5eaefdd0", 27 | "sha256:481d4b96969fbfdcc3ff35eea5305d8565a8300410d3d269ccac69e7256b1329", 28 | "sha256:4c1bdbfdd231a20eee3e56bd0ac1cd88c4ff41b64ab679ed65b75c9c74b6c5c2", 29 | "sha256:5563ad7fde451b1986d42b9bb9140e2599ecf4f8e42241f6da0d3d624b776f40", 30 | "sha256:58c62152c4c8731a3152e7e650b29ace18304d086cb5552d317a54ff2749d32a", 31 | "sha256:5b50e0b9460100fe05d7472264d1975f21ac007b35dcd6fd50279b72925a27f4", 32 | "sha256:5d84ecc73141d0a0d61ece0742bb7ff5751b0657dab8405f899d3ceb104cc7de", 33 | "sha256:5dde6d24bacac480be03f4f864e9a67faac5032e28841b00533cd168ab39cad9", 34 | "sha256:5e91e927003d1ed9283dee9abcb989334fc8e72cf89ebe94dc3e07e3ff0b11e9", 35 | "sha256:62bc216eafac3204877241569209d9ba6226185aa6d561c19159f2e1cbb6abfb", 36 | "sha256:6c8200abc9dc5f27203986100579fc19ccad7a832c07d2bc151ce4ff17190076", 37 | "sha256:6ca56bdfaf825f4439e9e3673775e1032d8b6ea63b8953d3812c71bd6a8b81de", 38 | "sha256:71680321a8a7176a58dfbc230789790639db78dad61a6e120b39f314f43f1907", 39 | "sha256:7c7820099e8b3171e54e7eedc33e9450afe7cd08172632d32128bd527f8cb77d", 40 | "sha256:7dbd087ff2f4046b9b37ba28ed73f15fd0bc9f4fdc8ef6781913da7f808d9536", 41 | "sha256:822bd4fd21abaa7b28d65fc9871ecabaddc42767884a626317ef5b75c20e8a2d", 42 | "sha256:8ec1a38074f68d66ccb467ed9a673a726bb397142c273f90d4ba954666e87d54", 43 | "sha256:950b7ef08b2afdab2488ee2edaff92a03ca500a48f1e1aaa5900e73d6cf992bc", 44 | "sha256:99c5a5bf7135607959441b7d720d96c8e5c46a1f96e9d6d4c9498be8d5f24212", 45 | "sha256:b84ad94868e1e6a5e30d30ec419956042815dfaea1b1df1cef623e4564c374d9", 46 | "sha256:bc3d14bf71a3fb94e5acf5bbf67331ab335467129af6416a437bd6024e4f743d", 47 | "sha256:c2a80fd9a8d7e41b4e38ea9fe149deed0d6aaede255c497e66b8213274d6d61b", 48 | "sha256:c44d3c82a933c6cbc21039326767e778eface44fca55c65719921c4b9661a3f7", 49 | "sha256:cc31e906be1cc121ee201adbdf844522ea3349600dd0a40366611ca18cd40e81", 50 | "sha256:d5d102e945ecca93bcd9801a7bb2fa703e37ad188a2f81b1e65e4abe4b51b00c", 51 | "sha256:dd7936f2a6daa861143e376b3a1fb56e9b802f4980923594edd9ca5670974895", 52 | "sha256:dee68ec462ff10c1d836c0ea2642116aba6151c6880b688e56b4c0246770f297", 53 | "sha256:e76e78863a4eaec3aee5722d85d04dcbd9844bc6cd3bfa6aa880ff46ad16bfcb", 54 | "sha256:eab51036cac2da8a50d7ff0ea30be47750547c9aa1aa2cf1a1b710a1827e7dbe", 55 | "sha256:f4496d8d04da2e98cc9133e238ccebf6a13ef39a93da2e87146c8c8ac9768242", 56 | "sha256:fbd3b5e18d34683decc00d9a360179ac1e7a320a5fee10ab8053ffd6deab76e0", 57 | "sha256:feb24ff1226beeb056e247cf2e24bba5232519efb5645121c4aea5b6ad74c1f2" 58 | ], 59 | "index": "pypi", 60 | "version": "==3.7.4" 61 | }, 62 | "async-timeout": { 63 | "hashes": [ 64 | "sha256:0c3c816a028d47f659d6ff5c745cb2acf1f966da1fe5c19c77a70282b25f4c5f", 65 | "sha256:4291ca197d287d274d0b6cb5d6f8f8f82d434ed288f962539ff18cc9012f9ea3" 66 | ], 67 | "version": "==3.0.1" 68 | }, 69 | "attrs": { 70 | "hashes": [ 71 | "sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6", 72 | "sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700" 73 | ], 74 | "version": "==20.3.0" 75 | }, 76 | "cffi": { 77 | "hashes": [ 78 | "sha256:005a36f41773e148deac64b08f233873a4d0c18b053d37da83f6af4d9087b813", 79 | "sha256:0857f0ae312d855239a55c81ef453ee8fd24136eaba8e87a2eceba644c0d4c06", 80 | "sha256:1071534bbbf8cbb31b498d5d9db0f274f2f7a865adca4ae429e147ba40f73dea", 81 | "sha256:158d0d15119b4b7ff6b926536763dc0714313aa59e320ddf787502c70c4d4bee", 82 | "sha256:1f436816fc868b098b0d63b8920de7d208c90a67212546d02f84fe78a9c26396", 83 | "sha256:2894f2df484ff56d717bead0a5c2abb6b9d2bf26d6960c4604d5c48bbc30ee73", 84 | "sha256:29314480e958fd8aab22e4a58b355b629c59bf5f2ac2492b61e3dc06d8c7a315", 85 | "sha256:34eff4b97f3d982fb93e2831e6750127d1355a923ebaeeb565407b3d2f8d41a1", 86 | "sha256:35f27e6eb43380fa080dccf676dece30bef72e4a67617ffda586641cd4508d49", 87 | "sha256:3d3dd4c9e559eb172ecf00a2a7517e97d1e96de2a5e610bd9b68cea3925b4892", 88 | "sha256:43e0b9d9e2c9e5d152946b9c5fe062c151614b262fda2e7b201204de0b99e482", 89 | "sha256:48e1c69bbacfc3d932221851b39d49e81567a4d4aac3b21258d9c24578280058", 90 | "sha256:51182f8927c5af975fece87b1b369f722c570fe169f9880764b1ee3bca8347b5", 91 | "sha256:58e3f59d583d413809d60779492342801d6e82fefb89c86a38e040c16883be53", 92 | "sha256:5de7970188bb46b7bf9858eb6890aad302577a5f6f75091fd7cdd3ef13ef3045", 93 | "sha256:65fa59693c62cf06e45ddbb822165394a288edce9e276647f0046e1ec26920f3", 94 | "sha256:69e395c24fc60aad6bb4fa7e583698ea6cc684648e1ffb7fe85e3c1ca131a7d5", 95 | "sha256:6c97d7350133666fbb5cf4abdc1178c812cb205dc6f41d174a7b0f18fb93337e", 96 | "sha256:6e4714cc64f474e4d6e37cfff31a814b509a35cb17de4fb1999907575684479c", 97 | "sha256:72d8d3ef52c208ee1c7b2e341f7d71c6fd3157138abf1a95166e6165dd5d4369", 98 | "sha256:8ae6299f6c68de06f136f1f9e69458eae58f1dacf10af5c17353eae03aa0d827", 99 | "sha256:8b198cec6c72df5289c05b05b8b0969819783f9418e0409865dac47288d2a053", 100 | "sha256:99cd03ae7988a93dd00bcd9d0b75e1f6c426063d6f03d2f90b89e29b25b82dfa", 101 | "sha256:9cf8022fb8d07a97c178b02327b284521c7708d7c71a9c9c355c178ac4bbd3d4", 102 | "sha256:9de2e279153a443c656f2defd67769e6d1e4163952b3c622dcea5b08a6405322", 103 | "sha256:9e93e79c2551ff263400e1e4be085a1210e12073a31c2011dbbda14bda0c6132", 104 | "sha256:9ff227395193126d82e60319a673a037d5de84633f11279e336f9c0f189ecc62", 105 | "sha256:a465da611f6fa124963b91bf432d960a555563efe4ed1cc403ba5077b15370aa", 106 | "sha256:ad17025d226ee5beec591b52800c11680fca3df50b8b29fe51d882576e039ee0", 107 | "sha256:afb29c1ba2e5a3736f1c301d9d0abe3ec8b86957d04ddfa9d7a6a42b9367e396", 108 | "sha256:b85eb46a81787c50650f2392b9b4ef23e1f126313b9e0e9013b35c15e4288e2e", 109 | "sha256:bb89f306e5da99f4d922728ddcd6f7fcebb3241fc40edebcb7284d7514741991", 110 | "sha256:cbde590d4faaa07c72bf979734738f328d239913ba3e043b1e98fe9a39f8b2b6", 111 | "sha256:cd2868886d547469123fadc46eac7ea5253ea7fcb139f12e1dfc2bbd406427d1", 112 | "sha256:d42b11d692e11b6634f7613ad8df5d6d5f8875f5d48939520d351007b3c13406", 113 | "sha256:f2d45f97ab6bb54753eab54fffe75aaf3de4ff2341c9daee1987ee1837636f1d", 114 | "sha256:fd78e5fee591709f32ef6edb9a015b4aa1a5022598e36227500c8f4e02328d9c" 115 | ], 116 | "version": "==1.14.5" 117 | }, 118 | "chardet": { 119 | "hashes": [ 120 | "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", 121 | "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" 122 | ], 123 | "version": "==3.0.4" 124 | }, 125 | "discord.py": { 126 | "hashes": [ 127 | "sha256:3df148daf6fbcc7ab5b11042368a3cd5f7b730b62f09fb5d3cbceff59bcfbb12", 128 | "sha256:ba8be99ff1b8c616f7b6dcb700460d0222b29d4c11048e74366954c465fdd05f" 129 | ], 130 | "index": "pypi", 131 | "version": "==1.6.0" 132 | }, 133 | "idna": { 134 | "hashes": [ 135 | "sha256:5205d03e7bcbb919cc9c19885f9920d622ca52448306f2377daede5cf3faac16", 136 | "sha256:c5b02147e01ea9920e6b0a3f1f7bb833612d507592c837a6c49552768f4054e1" 137 | ], 138 | "version": "==3.1" 139 | }, 140 | "multidict": { 141 | "hashes": [ 142 | "sha256:018132dbd8688c7a69ad89c4a3f39ea2f9f33302ebe567a879da8f4ca73f0d0a", 143 | "sha256:051012ccee979b2b06be928a6150d237aec75dd6bf2d1eeeb190baf2b05abc93", 144 | "sha256:05c20b68e512166fddba59a918773ba002fdd77800cad9f55b59790030bab632", 145 | "sha256:07b42215124aedecc6083f1ce6b7e5ec5b50047afa701f3442054373a6deb656", 146 | "sha256:0e3c84e6c67eba89c2dbcee08504ba8644ab4284863452450520dad8f1e89b79", 147 | "sha256:0e929169f9c090dae0646a011c8b058e5e5fb391466016b39d21745b48817fd7", 148 | "sha256:1ab820665e67373de5802acae069a6a05567ae234ddb129f31d290fc3d1aa56d", 149 | "sha256:25b4e5f22d3a37ddf3effc0710ba692cfc792c2b9edfb9c05aefe823256e84d5", 150 | "sha256:2e68965192c4ea61fff1b81c14ff712fc7dc15d2bd120602e4a3494ea6584224", 151 | "sha256:2f1a132f1c88724674271d636e6b7351477c27722f2ed789f719f9e3545a3d26", 152 | "sha256:37e5438e1c78931df5d3c0c78ae049092877e5e9c02dd1ff5abb9cf27a5914ea", 153 | "sha256:3a041b76d13706b7fff23b9fc83117c7b8fe8d5fe9e6be45eee72b9baa75f348", 154 | "sha256:3a4f32116f8f72ecf2a29dabfb27b23ab7cdc0ba807e8459e59a93a9be9506f6", 155 | "sha256:46c73e09ad374a6d876c599f2328161bcd95e280f84d2060cf57991dec5cfe76", 156 | "sha256:46dd362c2f045095c920162e9307de5ffd0a1bfbba0a6e990b344366f55a30c1", 157 | "sha256:4b186eb7d6ae7c06eb4392411189469e6a820da81447f46c0072a41c748ab73f", 158 | "sha256:54fd1e83a184e19c598d5e70ba508196fd0bbdd676ce159feb412a4a6664f952", 159 | "sha256:585fd452dd7782130d112f7ddf3473ffdd521414674c33876187e101b588738a", 160 | "sha256:5cf3443199b83ed9e955f511b5b241fd3ae004e3cb81c58ec10f4fe47c7dce37", 161 | "sha256:6a4d5ce640e37b0efcc8441caeea8f43a06addace2335bd11151bc02d2ee31f9", 162 | "sha256:7df80d07818b385f3129180369079bd6934cf70469f99daaebfac89dca288359", 163 | "sha256:806068d4f86cb06af37cd65821554f98240a19ce646d3cd24e1c33587f313eb8", 164 | "sha256:830f57206cc96ed0ccf68304141fec9481a096c4d2e2831f311bde1c404401da", 165 | "sha256:929006d3c2d923788ba153ad0de8ed2e5ed39fdbe8e7be21e2f22ed06c6783d3", 166 | "sha256:9436dc58c123f07b230383083855593550c4d301d2532045a17ccf6eca505f6d", 167 | "sha256:9dd6e9b1a913d096ac95d0399bd737e00f2af1e1594a787e00f7975778c8b2bf", 168 | "sha256:ace010325c787c378afd7f7c1ac66b26313b3344628652eacd149bdd23c68841", 169 | "sha256:b47a43177a5e65b771b80db71e7be76c0ba23cc8aa73eeeb089ed5219cdbe27d", 170 | "sha256:b797515be8743b771aa868f83563f789bbd4b236659ba52243b735d80b29ed93", 171 | "sha256:b7993704f1a4b204e71debe6095150d43b2ee6150fa4f44d6d966ec356a8d61f", 172 | "sha256:d5c65bdf4484872c4af3150aeebe101ba560dcfb34488d9a8ff8dbcd21079647", 173 | "sha256:d81eddcb12d608cc08081fa88d046c78afb1bf8107e6feab5d43503fea74a635", 174 | "sha256:dc862056f76443a0db4509116c5cd480fe1b6a2d45512a653f9a855cc0517456", 175 | "sha256:ecc771ab628ea281517e24fd2c52e8f31c41e66652d07599ad8818abaad38cda", 176 | "sha256:f200755768dc19c6f4e2b672421e0ebb3dd54c38d5a4f262b872d8cfcc9e93b5", 177 | "sha256:f21756997ad8ef815d8ef3d34edd98804ab5ea337feedcd62fb52d22bf531281", 178 | "sha256:fc13a9524bc18b6fb6e0dbec3533ba0496bbed167c56d0aabefd965584557d80" 179 | ], 180 | "version": "==5.1.0" 181 | }, 182 | "pycparser": { 183 | "hashes": [ 184 | "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0", 185 | "sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705" 186 | ], 187 | "version": "==2.20" 188 | }, 189 | "pynacl": { 190 | "hashes": [ 191 | "sha256:06cbb4d9b2c4bd3c8dc0d267416aaed79906e7b33f114ddbf0911969794b1cc4", 192 | "sha256:11335f09060af52c97137d4ac54285bcb7df0cef29014a1a4efe64ac065434c4", 193 | "sha256:2fe0fc5a2480361dcaf4e6e7cea00e078fcda07ba45f811b167e3f99e8cff574", 194 | "sha256:30f9b96db44e09b3304f9ea95079b1b7316b2b4f3744fe3aaecccd95d547063d", 195 | "sha256:4e10569f8cbed81cb7526ae137049759d2a8d57726d52c1a000a3ce366779634", 196 | "sha256:511d269ee845037b95c9781aa702f90ccc36036f95d0f31373a6a79bd8242e25", 197 | "sha256:537a7ccbea22905a0ab36ea58577b39d1fa9b1884869d173b5cf111f006f689f", 198 | "sha256:54e9a2c849c742006516ad56a88f5c74bf2ce92c9f67435187c3c5953b346505", 199 | "sha256:757250ddb3bff1eecd7e41e65f7f833a8405fede0194319f87899690624f2122", 200 | "sha256:7757ae33dae81c300487591c68790dfb5145c7d03324000433d9a2c141f82af7", 201 | "sha256:7c6092102219f59ff29788860ccb021e80fffd953920c4a8653889c029b2d420", 202 | "sha256:8122ba5f2a2169ca5da936b2e5a511740ffb73979381b4229d9188f6dcb22f1f", 203 | "sha256:9c4a7ea4fb81536c1b1f5cc44d54a296f96ae78c1ebd2311bd0b60be45a48d96", 204 | "sha256:c914f78da4953b33d4685e3cdc7ce63401247a21425c16a39760e282075ac4a6", 205 | "sha256:cd401ccbc2a249a47a3a1724c2918fcd04be1f7b54eb2a5a71ff915db0ac51c6", 206 | "sha256:d452a6746f0a7e11121e64625109bc4468fc3100452817001dbe018bb8b08514", 207 | "sha256:ea6841bc3a76fa4942ce00f3bda7d436fda21e2d91602b9e21b7ca9ecab8f3ff", 208 | "sha256:f8851ab9041756003119368c1e6cd0b9c631f46d686b3904b18c0139f4419f80" 209 | ], 210 | "index": "pypi", 211 | "version": "==1.4.0" 212 | }, 213 | "six": { 214 | "hashes": [ 215 | "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", 216 | "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" 217 | ], 218 | "version": "==1.15.0" 219 | }, 220 | "toml": { 221 | "hashes": [ 222 | "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", 223 | "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f" 224 | ], 225 | "index": "pypi", 226 | "version": "==0.10.2" 227 | }, 228 | "typing-extensions": { 229 | "hashes": [ 230 | "sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918", 231 | "sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c", 232 | "sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f" 233 | ], 234 | "version": "==3.7.4.3" 235 | }, 236 | "yarl": { 237 | "hashes": [ 238 | "sha256:00d7ad91b6583602eb9c1d085a2cf281ada267e9a197e8b7cae487dadbfa293e", 239 | "sha256:0355a701b3998dcd832d0dc47cc5dedf3874f966ac7f870e0f3a6788d802d434", 240 | "sha256:15263c3b0b47968c1d90daa89f21fcc889bb4b1aac5555580d74565de6836366", 241 | "sha256:2ce4c621d21326a4a5500c25031e102af589edb50c09b321049e388b3934eec3", 242 | "sha256:31ede6e8c4329fb81c86706ba8f6bf661a924b53ba191b27aa5fcee5714d18ec", 243 | "sha256:324ba3d3c6fee56e2e0b0d09bf5c73824b9f08234339d2b788af65e60040c959", 244 | "sha256:329412812ecfc94a57cd37c9d547579510a9e83c516bc069470db5f75684629e", 245 | "sha256:4736eaee5626db8d9cda9eb5282028cc834e2aeb194e0d8b50217d707e98bb5c", 246 | "sha256:4953fb0b4fdb7e08b2f3b3be80a00d28c5c8a2056bb066169de00e6501b986b6", 247 | "sha256:4c5bcfc3ed226bf6419f7a33982fb4b8ec2e45785a0561eb99274ebbf09fdd6a", 248 | "sha256:547f7665ad50fa8563150ed079f8e805e63dd85def6674c97efd78eed6c224a6", 249 | "sha256:5b883e458058f8d6099e4420f0cc2567989032b5f34b271c0827de9f1079a424", 250 | "sha256:63f90b20ca654b3ecc7a8d62c03ffa46999595f0167d6450fa8383bab252987e", 251 | "sha256:68dc568889b1c13f1e4745c96b931cc94fdd0defe92a72c2b8ce01091b22e35f", 252 | "sha256:69ee97c71fee1f63d04c945f56d5d726483c4762845400a6795a3b75d56b6c50", 253 | "sha256:6d6283d8e0631b617edf0fd726353cb76630b83a089a40933043894e7f6721e2", 254 | "sha256:72a660bdd24497e3e84f5519e57a9ee9220b6f3ac4d45056961bf22838ce20cc", 255 | "sha256:73494d5b71099ae8cb8754f1df131c11d433b387efab7b51849e7e1e851f07a4", 256 | "sha256:7356644cbed76119d0b6bd32ffba704d30d747e0c217109d7979a7bc36c4d970", 257 | "sha256:8a9066529240171b68893d60dca86a763eae2139dd42f42106b03cf4b426bf10", 258 | "sha256:8aa3decd5e0e852dc68335abf5478a518b41bf2ab2f330fe44916399efedfae0", 259 | "sha256:97b5bdc450d63c3ba30a127d018b866ea94e65655efaf889ebeabc20f7d12406", 260 | "sha256:9ede61b0854e267fd565e7527e2f2eb3ef8858b301319be0604177690e1a3896", 261 | "sha256:b2e9a456c121e26d13c29251f8267541bd75e6a1ccf9e859179701c36a078643", 262 | "sha256:b5dfc9a40c198334f4f3f55880ecf910adebdcb2a0b9a9c23c9345faa9185721", 263 | "sha256:bafb450deef6861815ed579c7a6113a879a6ef58aed4c3a4be54400ae8871478", 264 | "sha256:c49ff66d479d38ab863c50f7bb27dee97c6627c5fe60697de15529da9c3de724", 265 | "sha256:ce3beb46a72d9f2190f9e1027886bfc513702d748047b548b05dab7dfb584d2e", 266 | "sha256:d26608cf178efb8faa5ff0f2d2e77c208f471c5a3709e577a7b3fd0445703ac8", 267 | "sha256:d597767fcd2c3dc49d6eea360c458b65643d1e4dbed91361cf5e36e53c1f8c96", 268 | "sha256:d5c32c82990e4ac4d8150fd7652b972216b204de4e83a122546dce571c1bdf25", 269 | "sha256:d8d07d102f17b68966e2de0e07bfd6e139c7c02ef06d3a0f8d2f0f055e13bb76", 270 | "sha256:e46fba844f4895b36f4c398c5af062a9808d1f26b2999c58909517384d5deda2", 271 | "sha256:e6b5460dc5ad42ad2b36cca524491dfcaffbfd9c8df50508bddc354e787b8dc2", 272 | "sha256:f040bcc6725c821a4c0665f3aa96a4d0805a7aaf2caf266d256b8ed71b9f041c", 273 | "sha256:f0b059678fd549c66b89bed03efcabb009075bd131c248ecdf087bdb6faba24a", 274 | "sha256:fcbb48a93e8699eae920f8d92f7160c03567b421bc17362a9ffbbd706a816f71" 275 | ], 276 | "version": "==1.6.3" 277 | }, 278 | "youtube-dl": { 279 | "hashes": [ 280 | "sha256:65968065e66966955dc79fad9251565fcc982566118756da624bd21467f3a04c", 281 | "sha256:eaa859f15b6897bec21474b7787dc958118c8088e1f24d4ef1d58eab13188958" 282 | ], 283 | "index": "pypi", 284 | "version": "==2020.12.14" 285 | } 286 | }, 287 | "develop": { 288 | "autopep8": { 289 | "hashes": [ 290 | "sha256:d21d3901cb0da6ebd1e83fc9b0dfbde8b46afc2ede4fe32fbda0c7c6118ca094" 291 | ], 292 | "index": "pypi", 293 | "version": "==1.5.4" 294 | }, 295 | "pycodestyle": { 296 | "hashes": [ 297 | "sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367", 298 | "sha256:c58a7d2815e0e8d7972bf1803331fb0152f867bd89adf8a01dfd55085434192e" 299 | ], 300 | "version": "==2.6.0" 301 | }, 302 | "toml": { 303 | "hashes": [ 304 | "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", 305 | "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f" 306 | ], 307 | "index": "pypi", 308 | "version": "==0.10.2" 309 | } 310 | } 311 | } 312 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # discord.py MusicBot 2 | 3 | A simple, unintrusive musicbot written in Discord.py that utilizes YoutubeDL and ffmpeg to stream audio. Use the `help` command to get a list of commands! 4 | 5 | ## Getting started 6 | If you just want to get up and running with the bot quickly: 7 | 8 | 1. Clone this repository using the GitHub website or GitHub/git CLI. 9 | 2. Install `pipenv`, the Python dependency manager, if necessary. Also, ensure `opus` and `ffmpeg` are installed on your machine and available in your environment. Both are used for media streaming. 10 | 3. Navigate into the project directory and run `pipenv install` to install dependencies. 11 | 4. Activate the Pipenv using `pipenv shell`. Run the bot using `python -m musicbot`. 12 | 5. On first startup, a default `config.toml` will be generated without an API token, so the bot will abort complaining that `No token has been provided.` Fill your bot's token into `config.toml`. 13 | 6. Use something like the [Discord API Permissions calculator](https://discordapi.com/permissions.html) to generate an invite link and invite your bot to your server, if necessary. 14 | 7. Run the bot using `python -m musicbot`. 15 | 16 | ## Additional Dependencies 17 | 18 | Make sure that [pipenv](https://pipenv.pypa.io/en/latest/) is installed. Navigate to the project directory, and run `pipenv install` to install the Python dependencies. 19 | 20 | To allow for streaming of media, make sure `opus` and `ffmpeg` are installed and in your environment. 21 | 22 | To run the bot, activate the virtual environment with `pipenv shell` and then `python -m musicbot` to start the bot. 23 | 24 | ## Configuring 25 | 26 | When you run the bot for the first time, a default configuration file will be generated called `config.toml`. You can enter that file and add your token, etc. The default file looks like this: 27 | 28 | ```toml 29 | "token"="" # the bot's token 30 | "prefix"="!" # prefix used to denote commands 31 | 32 | [music] 33 | # Options for the music commands 34 | "max_volume"=250 # Max audio volume. Set to -1 for unlimited. 35 | "vote_skip"=true # whether vote-skipping is enabled 36 | "vote_skip_ratio"=0.5 # the minimum ratio of votes needed to skip a song 37 | [tips] 38 | "github_url"="https://github.com/joek13/py-music-bot" 39 | ``` 40 | 41 | If you ever wish to restore the bot to default configuration, you can simply delete (or rename) your config file. A new one will be generated upon startup. 42 | 43 | ## Commands 44 | From the bot's `help` command: 45 | ``` 46 | Meta: 47 | uptime Tells how long the bot has been running. 48 | Music: 49 | leave Leaves the voice channel, if currently in one. 50 | play Plays audio from . 51 | Tips: 52 | tip Get a random tip about using the bot. 53 | ​No Category: 54 | help Shows this message 55 | ``` 56 | 57 | ## Contributing 58 | Issues and pull requests are welcomed and appreciated. I can't guarantee that I will respond to all issues in a timely manner, but I will try my best to respond to any issues that arise. -------------------------------------------------------------------------------- /contributors.md: -------------------------------------------------------------------------------- 1 | # Contributors 2 | 3 | The following users have contributed work to the project. Thank you, everyone! I really appreciate the work you put in. 4 | 5 | - [plutphil](https://github.com/plutphil) 6 | - [RedstonedLife](https://github.com/RedstonedLife) 7 | - [RedKrieg](https://github.com/RedKrieg) 8 | -------------------------------------------------------------------------------- /musicbot/__main__.py: -------------------------------------------------------------------------------- 1 | from . import bot 2 | import logging 3 | from . import config 4 | 5 | if __name__ == "__main__": 6 | formatter = logging.Formatter( 7 | fmt="[%(asctime)s] %(levelname)s [%(name)s.%(funcName)s:%(lineno)d] %(message)s" 8 | ) 9 | logger = logging.getLogger() 10 | logger.setLevel(logging.INFO) 11 | 12 | file_handler = logging.FileHandler("bot.log") 13 | file_handler.setFormatter(formatter) 14 | logger.addHandler(file_handler) 15 | 16 | console_handler = logging.StreamHandler() 17 | console_handler.setFormatter(formatter) 18 | logger.addHandler(console_handler) 19 | 20 | bot.run() 21 | -------------------------------------------------------------------------------- /musicbot/bot.py: -------------------------------------------------------------------------------- 1 | import discord 2 | import logging 3 | import sys 4 | from discord.ext import commands 5 | from .cogs import music, error, meta, tips 6 | from . import config 7 | 8 | cfg = config.load_config() 9 | 10 | bot = commands.Bot(command_prefix=cfg["prefix"]) 11 | 12 | 13 | @bot.event 14 | async def on_ready(): 15 | logging.info(f"Logged in as {bot.user.name}") 16 | 17 | 18 | COGS = [music.Music, error.CommandErrorHandler, meta.Meta, tips.Tips] 19 | 20 | 21 | def add_cogs(bot): 22 | for cog in COGS: 23 | bot.add_cog(cog(bot, cfg)) # Initialize the cog and add it to the bot 24 | 25 | 26 | def run(): 27 | add_cogs(bot) 28 | if cfg["token"] == "": 29 | raise ValueError( 30 | "No token has been provided. Please ensure that config.toml contains the bot token." 31 | ) 32 | sys.exit(1) 33 | bot.run(cfg["token"]) 34 | -------------------------------------------------------------------------------- /musicbot/cogs/error.py: -------------------------------------------------------------------------------- 1 | import discord 2 | import sys 3 | import traceback 4 | import logging 5 | from discord.ext import commands 6 | 7 | 8 | class CommandErrorHandler(commands.Cog): 9 | def __init__(self, bot, config): 10 | self.bot = bot 11 | self.config = config 12 | self.bot.add_listener(self.on_command_error, "on_command_error") 13 | 14 | async def on_command_error(self, ctx, error): 15 | if hasattr(ctx.command, "on_error"): 16 | return # Don't interfere with custom error handlers 17 | 18 | error = getattr(error, "original", error) # get original error 19 | 20 | if isinstance(error, commands.CommandNotFound): 21 | return await ctx.send( 22 | f"That command does not exist. Please use `{self.bot.command_prefix}help` for a list of commands." 23 | ) 24 | 25 | if isinstance(error, commands.CommandError): 26 | return await ctx.send( 27 | f"Error executing command `{ctx.command.name}`: {str(error)}") 28 | 29 | await ctx.send( 30 | "An unexpected error occurred while running that command.") 31 | logging.warn("Ignoring exception in command {}:".format(ctx.command)) 32 | logging.warn("\n" + "".join( 33 | traceback.format_exception( 34 | type(error), error, error.__traceback__))) 35 | -------------------------------------------------------------------------------- /musicbot/cogs/meta.py: -------------------------------------------------------------------------------- 1 | from discord.ext import commands 2 | import discord 3 | from datetime import datetime 4 | from .. import util 5 | 6 | 7 | class Meta(commands.Cog): 8 | """Commands relating to the bot itself.""" 9 | 10 | def __init__(self, bot, config): 11 | self.bot = bot 12 | self.start_time = datetime.now() 13 | self.config = config 14 | 15 | @commands.command() 16 | async def uptime(self, ctx): 17 | """Tells how long the bot has been running.""" 18 | uptime_seconds = round( 19 | (datetime.now() - self.start_time).total_seconds()) 20 | await ctx.send(f"Current Uptime: {util.format_seconds(uptime_seconds)}" 21 | ) 22 | -------------------------------------------------------------------------------- /musicbot/cogs/music.py: -------------------------------------------------------------------------------- 1 | from discord.ext import commands 2 | import discord 3 | import asyncio 4 | import youtube_dl 5 | import logging 6 | import math 7 | from urllib import request 8 | from ..video import Video 9 | 10 | # TODO: abstract FFMPEG options into their own file? 11 | FFMPEG_BEFORE_OPTS = '-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5' 12 | """ 13 | Command line options to pass to `ffmpeg` before the `-i`. 14 | 15 | See https://stackoverflow.com/questions/43218292/youtubedl-read-error-with-discord-py/44490434#44490434 for more information. 16 | Also, https://ffmpeg.org/ffmpeg-protocols.html for command line option reference. 17 | """ 18 | 19 | 20 | async def audio_playing(ctx): 21 | """Checks that audio is currently playing before continuing.""" 22 | client = ctx.guild.voice_client 23 | if client and client.channel and client.source: 24 | return True 25 | else: 26 | raise commands.CommandError("Not currently playing any audio.") 27 | 28 | 29 | async def in_voice_channel(ctx): 30 | """Checks that the command sender is in the same voice channel as the bot.""" 31 | voice = ctx.author.voice 32 | bot_voice = ctx.guild.voice_client 33 | if voice and bot_voice and voice.channel and bot_voice.channel and voice.channel == bot_voice.channel: 34 | return True 35 | else: 36 | raise commands.CommandError( 37 | "You need to be in the channel to do that.") 38 | 39 | 40 | async def is_audio_requester(ctx): 41 | """Checks that the command sender is the song requester.""" 42 | music = ctx.bot.get_cog("Music") 43 | state = music.get_state(ctx.guild) 44 | permissions = ctx.channel.permissions_for(ctx.author) 45 | if permissions.administrator or state.is_requester(ctx.author): 46 | return True 47 | else: 48 | raise commands.CommandError( 49 | "You need to be the song requester to do that.") 50 | 51 | 52 | class Music(commands.Cog): 53 | """Bot commands to help play music.""" 54 | 55 | def __init__(self, bot, config): 56 | self.bot = bot 57 | self.config = config[__name__.split(".")[ 58 | -1]] # retrieve module name, find config entry 59 | self.states = {} 60 | self.bot.add_listener(self.on_reaction_add, "on_reaction_add") 61 | 62 | def get_state(self, guild): 63 | """Gets the state for `guild`, creating it if it does not exist.""" 64 | if guild.id in self.states: 65 | return self.states[guild.id] 66 | else: 67 | self.states[guild.id] = GuildState() 68 | return self.states[guild.id] 69 | 70 | @commands.command(aliases=["stop"]) 71 | @commands.guild_only() 72 | @commands.has_permissions(administrator=True) 73 | async def leave(self, ctx): 74 | """Leaves the voice channel, if currently in one.""" 75 | client = ctx.guild.voice_client 76 | state = self.get_state(ctx.guild) 77 | if client and client.channel: 78 | await client.disconnect() 79 | state.playlist = [] 80 | state.now_playing = None 81 | else: 82 | raise commands.CommandError("Not in a voice channel.") 83 | 84 | @commands.command(aliases=["resume", "p"]) 85 | @commands.guild_only() 86 | @commands.check(audio_playing) 87 | @commands.check(in_voice_channel) 88 | @commands.check(is_audio_requester) 89 | async def pause(self, ctx): 90 | """Pauses any currently playing audio.""" 91 | client = ctx.guild.voice_client 92 | self._pause_audio(client) 93 | 94 | def _pause_audio(self, client): 95 | if client.is_paused(): 96 | client.resume() 97 | else: 98 | client.pause() 99 | 100 | @commands.command(aliases=["vol", "v"]) 101 | @commands.guild_only() 102 | @commands.check(audio_playing) 103 | @commands.check(in_voice_channel) 104 | @commands.check(is_audio_requester) 105 | async def volume(self, ctx, volume: int): 106 | """Change the volume of currently playing audio (values 0-250).""" 107 | state = self.get_state(ctx.guild) 108 | 109 | # make sure volume is nonnegative 110 | if volume < 0: 111 | volume = 0 112 | 113 | max_vol = self.config["max_volume"] 114 | if max_vol > -1: # check if max volume is set 115 | # clamp volume to [0, max_vol] 116 | if volume > max_vol: 117 | volume = max_vol 118 | 119 | client = ctx.guild.voice_client 120 | 121 | state.volume = float(volume) / 100.0 122 | client.source.volume = state.volume # update the AudioSource's volume to match 123 | 124 | @commands.command() 125 | @commands.guild_only() 126 | @commands.check(audio_playing) 127 | @commands.check(in_voice_channel) 128 | async def skip(self, ctx): 129 | """Skips the currently playing song, or votes to skip it.""" 130 | state = self.get_state(ctx.guild) 131 | client = ctx.guild.voice_client 132 | if ctx.channel.permissions_for( 133 | ctx.author).administrator or state.is_requester(ctx.author): 134 | # immediately skip if requester or admin 135 | client.stop() 136 | elif self.config["vote_skip"]: 137 | # vote to skip song 138 | channel = client.channel 139 | self._vote_skip(channel, ctx.author) 140 | # announce vote 141 | users_in_channel = len([ 142 | member for member in channel.members if not member.bot 143 | ]) # don't count bots 144 | required_votes = math.ceil( 145 | self.config["vote_skip_ratio"] * users_in_channel) 146 | await ctx.send( 147 | f"{ctx.author.mention} voted to skip ({len(state.skip_votes)}/{required_votes} votes)" 148 | ) 149 | else: 150 | raise commands.CommandError("Sorry, vote skipping is disabled.") 151 | 152 | def _vote_skip(self, channel, member): 153 | """Register a vote for `member` to skip the song playing.""" 154 | logging.info(f"{member.name} votes to skip") 155 | state = self.get_state(channel.guild) 156 | state.skip_votes.add(member) 157 | users_in_channel = len([ 158 | member for member in channel.members if not member.bot 159 | ]) # don't count bots 160 | if (float(len(state.skip_votes)) / 161 | users_in_channel) >= self.config["vote_skip_ratio"]: 162 | # enough members have voted to skip, so skip the song 163 | logging.info(f"Enough votes, skipping...") 164 | channel.guild.voice_client.stop() 165 | 166 | def _play_song(self, client, state, song): 167 | state.now_playing = song 168 | state.skip_votes = set() # clear skip votes 169 | source = discord.PCMVolumeTransformer( 170 | discord.FFmpegPCMAudio(song.stream_url, before_options=FFMPEG_BEFORE_OPTS), volume=state.volume) 171 | 172 | def after_playing(err): 173 | if len(state.playlist) > 0: 174 | next_song = state.playlist.pop(0) 175 | self._play_song(client, state, next_song) 176 | else: 177 | asyncio.run_coroutine_threadsafe(client.disconnect(), 178 | self.bot.loop) 179 | 180 | client.play(source, after=after_playing) 181 | 182 | @commands.command(aliases=["np"]) 183 | @commands.guild_only() 184 | @commands.check(audio_playing) 185 | async def nowplaying(self, ctx): 186 | """Displays information about the current song.""" 187 | state = self.get_state(ctx.guild) 188 | message = await ctx.send("", embed=state.now_playing.get_embed()) 189 | await self._add_reaction_controls(message) 190 | 191 | @commands.command(aliases=["q", "playlist"]) 192 | @commands.guild_only() 193 | @commands.check(audio_playing) 194 | async def queue(self, ctx): 195 | """Display the current play queue.""" 196 | state = self.get_state(ctx.guild) 197 | await ctx.send(self._queue_text(state.playlist)) 198 | 199 | def _queue_text(self, queue): 200 | """Returns a block of text describing a given song queue.""" 201 | if len(queue) > 0: 202 | message = [f"{len(queue)} songs in queue:"] 203 | message += [ 204 | f" {index+1}. **{song.title}** (requested by **{song.requested_by.name}**)" 205 | for (index, song) in enumerate(queue) 206 | ] # add individual songs 207 | return "\n".join(message) 208 | else: 209 | return "The play queue is empty." 210 | 211 | @commands.command(aliases=["cq"]) 212 | @commands.guild_only() 213 | @commands.check(audio_playing) 214 | @commands.has_permissions(administrator=True) 215 | async def clearqueue(self, ctx): 216 | """Clears the play queue without leaving the channel.""" 217 | state = self.get_state(ctx.guild) 218 | state.playlist = [] 219 | 220 | @commands.command(aliases=["jq"]) 221 | @commands.guild_only() 222 | @commands.check(audio_playing) 223 | @commands.has_permissions(administrator=True) 224 | async def jumpqueue(self, ctx, song: int, new_index: int): 225 | """Moves song at an index to `new_index` in queue.""" 226 | state = self.get_state(ctx.guild) # get state for this guild 227 | if 1 <= song <= len(state.playlist) and 1 <= new_index: 228 | song = state.playlist.pop(song - 1) # take song at index... 229 | state.playlist.insert(new_index - 1, song) # and insert it. 230 | 231 | await ctx.send(self._queue_text(state.playlist)) 232 | else: 233 | raise commands.CommandError("You must use a valid index.") 234 | 235 | @commands.command(brief="Plays audio from .") 236 | @commands.guild_only() 237 | async def play(self, ctx, *, url): 238 | """Plays audio hosted at (or performs a search for and plays the first result).""" 239 | 240 | client = ctx.guild.voice_client 241 | state = self.get_state(ctx.guild) # get the guild's state 242 | 243 | if client and client.channel: 244 | try: 245 | video = Video(url, ctx.author) 246 | except youtube_dl.DownloadError as e: 247 | logging.warn(f"Error downloading video: {e}") 248 | await ctx.send( 249 | "There was an error downloading your video, sorry.") 250 | return 251 | state.playlist.append(video) 252 | message = await ctx.send( 253 | "Added to queue.", embed=video.get_embed()) 254 | await self._add_reaction_controls(message) 255 | else: 256 | if ctx.author.voice is not None and ctx.author.voice.channel is not None: 257 | channel = ctx.author.voice.channel 258 | try: 259 | video = Video(url, ctx.author) 260 | except youtube_dl.DownloadError as e: 261 | await ctx.send( 262 | "There was an error downloading your video, sorry.") 263 | return 264 | client = await channel.connect() 265 | self._play_song(client, state, video) 266 | message = await ctx.send("", embed=video.get_embed()) 267 | await self._add_reaction_controls(message) 268 | logging.info(f"Now playing '{video.title}'") 269 | else: 270 | raise commands.CommandError( 271 | "You need to be in a voice channel to do that.") 272 | 273 | async def on_reaction_add(self, reaction, user): 274 | """Respods to reactions added to the bot's messages, allowing reactions to control playback.""" 275 | message = reaction.message 276 | if user != self.bot.user and message.author == self.bot.user: 277 | await message.remove_reaction(reaction, user) 278 | if message.guild and message.guild.voice_client: 279 | user_in_channel = user.voice and user.voice.channel and user.voice.channel == message.guild.voice_client.channel 280 | permissions = message.channel.permissions_for(user) 281 | guild = message.guild 282 | state = self.get_state(guild) 283 | if permissions.administrator or ( 284 | user_in_channel and state.is_requester(user)): 285 | client = message.guild.voice_client 286 | if reaction.emoji == "⏯": 287 | # pause audio 288 | self._pause_audio(client) 289 | elif reaction.emoji == "⏭": 290 | # skip audio 291 | client.stop() 292 | elif reaction.emoji == "⏮": 293 | state.playlist.insert( 294 | 0, state.now_playing 295 | ) # insert current song at beginning of playlist 296 | client.stop() # skip ahead 297 | elif reaction.emoji == "⏭" and self.config["vote_skip"] and user_in_channel and message.guild.voice_client and message.guild.voice_client.channel: 298 | # ensure that skip was pressed, that vote skipping is 299 | # enabled, the user is in the channel, and that the bot is 300 | # in a voice channel 301 | voice_channel = message.guild.voice_client.channel 302 | self._vote_skip(voice_channel, user) 303 | # announce vote 304 | channel = message.channel 305 | users_in_channel = len([ 306 | member for member in voice_channel.members 307 | if not member.bot 308 | ]) # don't count bots 309 | required_votes = math.ceil( 310 | self.config["vote_skip_ratio"] * users_in_channel) 311 | await channel.send( 312 | f"{user.mention} voted to skip ({len(state.skip_votes)}/{required_votes} votes)" 313 | ) 314 | 315 | async def _add_reaction_controls(self, message): 316 | """Adds a 'control-panel' of reactions to a message that can be used to control the bot.""" 317 | CONTROLS = ["⏮", "⏯", "⏭"] 318 | for control in CONTROLS: 319 | await message.add_reaction(control) 320 | 321 | 322 | class GuildState: 323 | """Helper class managing per-guild state.""" 324 | 325 | def __init__(self): 326 | self.volume = 1.0 327 | self.playlist = [] 328 | self.skip_votes = set() 329 | self.now_playing = None 330 | 331 | def is_requester(self, user): 332 | return self.now_playing.requested_by == user 333 | -------------------------------------------------------------------------------- /musicbot/cogs/tips.py: -------------------------------------------------------------------------------- 1 | from discord.ext import commands 2 | import discord 3 | import random 4 | 5 | 6 | class Tips(commands.Cog): 7 | """Commands for providing tips about using the bot.""" 8 | 9 | def __init__(self, bot, config): 10 | self.bot = bot 11 | self.config = config[__name__.split(".")[-1]] 12 | self.tips = ["Only admins and the song requester can immediately skip songs. Everybody else will have to vote!", 13 | f"You can check out my source code here: {self.config['github_url']}"] 14 | 15 | @commands.command() 16 | async def tip(self, ctx): 17 | """Get a random tip about using the bot.""" 18 | index = random.randrange(len(self.tips)) 19 | await ctx.send(f"**Tip #{index+1}:** {self.tips[index]}") 20 | -------------------------------------------------------------------------------- /musicbot/config.py: -------------------------------------------------------------------------------- 1 | import toml 2 | import logging 3 | import os 4 | 5 | EXAMPLE_CONFIG = """\"token\"=\"\" # the bot's token 6 | \"prefix\"=\"!\" # prefix used to denote commands 7 | 8 | [music] 9 | # Options for the music commands 10 | "max_volume"=250 # Max audio volume. Set to -1 for unlimited. 11 | "vote_skip"=true # whether vote-skipping is enabled 12 | "vote_skip_ratio"=0.5 # the minimum ratio of votes needed to skip a song 13 | [tips] 14 | "github_url"="https://github.com/joek13/py-music-bot" 15 | """ 16 | 17 | 18 | def load_config(path="./config.toml"): 19 | """Loads the config from `path`""" 20 | if os.path.exists(path) and os.path.isfile(path): 21 | config = toml.load(path) 22 | return config 23 | else: 24 | with open(path, "w") as config: 25 | config.write(EXAMPLE_CONFIG) 26 | logging.warn( 27 | f"No config file found. Creating a default config file at {path}" 28 | ) 29 | return load_config(path=path) 30 | -------------------------------------------------------------------------------- /musicbot/util.py: -------------------------------------------------------------------------------- 1 | # Some generic utility commands. 2 | 3 | 4 | def format_seconds(time_seconds): 5 | """Formats some number of seconds into a string of format d days, x hours, y minutes, z seconds""" 6 | seconds = time_seconds 7 | hours = 0 8 | minutes = 0 9 | days = 0 10 | while seconds >= 60: 11 | if seconds >= 60 * 60 * 24: 12 | seconds -= 60 * 60 * 24 13 | days += 1 14 | elif seconds >= 60 * 60: 15 | seconds -= 60 * 60 16 | hours += 1 17 | elif seconds >= 60: 18 | seconds -= 60 19 | minutes += 1 20 | 21 | return f"{days}d {hours}h {minutes}m {seconds}s" 22 | -------------------------------------------------------------------------------- /musicbot/video.py: -------------------------------------------------------------------------------- 1 | import youtube_dl as ytdl 2 | import discord 3 | 4 | YTDL_OPTS = { 5 | "default_search": "ytsearch", 6 | "format": "bestaudio/best", 7 | "quiet": True, 8 | "extract_flat": "in_playlist" 9 | } 10 | 11 | 12 | class Video: 13 | """Class containing information about a particular video.""" 14 | 15 | def __init__(self, url_or_search, requested_by): 16 | """Plays audio from (or searches for) a URL.""" 17 | with ytdl.YoutubeDL(YTDL_OPTS) as ydl: 18 | video = self._get_info(url_or_search) 19 | video_format = video["formats"][0] 20 | self.stream_url = video_format["url"] 21 | self.video_url = video["webpage_url"] 22 | self.title = video["title"] 23 | self.uploader = video["uploader"] if "uploader" in video else "" 24 | self.thumbnail = video[ 25 | "thumbnail"] if "thumbnail" in video else None 26 | self.requested_by = requested_by 27 | 28 | def _get_info(self, video_url): 29 | with ytdl.YoutubeDL(YTDL_OPTS) as ydl: 30 | info = ydl.extract_info(video_url, download=False) 31 | video = None 32 | if "_type" in info and info["_type"] == "playlist": 33 | return self._get_info( 34 | info["entries"][0]["url"]) # get info for first video 35 | else: 36 | video = info 37 | return video 38 | 39 | def get_embed(self): 40 | """Makes an embed out of this Video's information.""" 41 | embed = discord.Embed( 42 | title=self.title, description=self.uploader, url=self.video_url) 43 | embed.set_footer( 44 | text=f"Requested by {self.requested_by.name}", 45 | icon_url=self.requested_by.avatar_url) 46 | if self.thumbnail: 47 | embed.set_thumbnail(url=self.thumbnail) 48 | return embed 49 | --------------------------------------------------------------------------------