From ce288668e1d86fd18320426e6dcd74cce28bc242 Mon Sep 17 00:00:00 2001 From: Jared Dantis Date: Sat, 30 Mar 2024 16:08:58 +0800 Subject: [PATCH 01/33] dev_server: Use new event loop --- dev_server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev_server.py b/dev_server.py index fd75664..bb78e85 100644 --- a/dev_server.py +++ b/dev_server.py @@ -37,6 +37,6 @@ def run_tailwind(): thread.start() db = Database(config.db_file) - loop = asyncio.get_event_loop() + loop = asyncio.new_event_loop() loop.create_task(run_app(db, config)) loop.run_forever() From 158e28454786d56c24e35260a5d3cf0911300c82 Mon Sep 17 00:00:00 2001 From: Jared Dantis Date: Sat, 30 Mar 2024 16:09:31 +0800 Subject: [PATCH 02/33] chore: Migrate from pip to poetry --- Makefile | 11 + poetry.lock | 1508 ++++++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 33 + requirements.txt | 43 -- 4 files changed, 1552 insertions(+), 43 deletions(-) create mode 100644 Makefile create mode 100644 poetry.lock create mode 100644 pyproject.toml delete mode 100644 requirements.txt diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..397163c --- /dev/null +++ b/Makefile @@ -0,0 +1,11 @@ +.PHONY: install dev + +install: + poetry env use 3.12 + poetry install + +dev-frontend: config.yml blanco.db + poetry run python dev_server.py + +dev: config.yml blanco.db + poetry run python main.py diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..6f12eeb --- /dev/null +++ b/poetry.lock @@ -0,0 +1,1508 @@ +# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. + +[[package]] +name = "aiohttp" +version = "3.9.3" +description = "Async http client/server framework (asyncio)" +optional = false +python-versions = ">=3.8" +files = [ + {file = "aiohttp-3.9.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:939677b61f9d72a4fa2a042a5eee2a99a24001a67c13da113b2e30396567db54"}, + {file = "aiohttp-3.9.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1f5cd333fcf7590a18334c90f8c9147c837a6ec8a178e88d90a9b96ea03194cc"}, + {file = "aiohttp-3.9.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:82e6aa28dd46374f72093eda8bcd142f7771ee1eb9d1e223ff0fa7177a96b4a5"}, + {file = "aiohttp-3.9.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f56455b0c2c7cc3b0c584815264461d07b177f903a04481dfc33e08a89f0c26b"}, + {file = "aiohttp-3.9.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bca77a198bb6e69795ef2f09a5f4c12758487f83f33d63acde5f0d4919815768"}, + {file = "aiohttp-3.9.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e083c285857b78ee21a96ba1eb1b5339733c3563f72980728ca2b08b53826ca5"}, + {file = "aiohttp-3.9.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab40e6251c3873d86ea9b30a1ac6d7478c09277b32e14745d0d3c6e76e3c7e29"}, + {file = "aiohttp-3.9.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:df822ee7feaaeffb99c1a9e5e608800bd8eda6e5f18f5cfb0dc7eeb2eaa6bbec"}, + {file = "aiohttp-3.9.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:acef0899fea7492145d2bbaaaec7b345c87753168589cc7faf0afec9afe9b747"}, + {file = "aiohttp-3.9.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:cd73265a9e5ea618014802ab01babf1940cecb90c9762d8b9e7d2cc1e1969ec6"}, + {file = "aiohttp-3.9.3-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:a78ed8a53a1221393d9637c01870248a6f4ea5b214a59a92a36f18151739452c"}, + {file = "aiohttp-3.9.3-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:6b0e029353361f1746bac2e4cc19b32f972ec03f0f943b390c4ab3371840aabf"}, + {file = "aiohttp-3.9.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7cf5c9458e1e90e3c390c2639f1017a0379a99a94fdfad3a1fd966a2874bba52"}, + {file = "aiohttp-3.9.3-cp310-cp310-win32.whl", hash = "sha256:3e59c23c52765951b69ec45ddbbc9403a8761ee6f57253250c6e1536cacc758b"}, + {file = "aiohttp-3.9.3-cp310-cp310-win_amd64.whl", hash = "sha256:055ce4f74b82551678291473f66dc9fb9048a50d8324278751926ff0ae7715e5"}, + {file = "aiohttp-3.9.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6b88f9386ff1ad91ace19d2a1c0225896e28815ee09fc6a8932fded8cda97c3d"}, + {file = "aiohttp-3.9.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c46956ed82961e31557b6857a5ca153c67e5476972e5f7190015018760938da2"}, + {file = "aiohttp-3.9.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:07b837ef0d2f252f96009e9b8435ec1fef68ef8b1461933253d318748ec1acdc"}, + {file = "aiohttp-3.9.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad46e6f620574b3b4801c68255492e0159d1712271cc99d8bdf35f2043ec266"}, + {file = "aiohttp-3.9.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5ed3e046ea7b14938112ccd53d91c1539af3e6679b222f9469981e3dac7ba1ce"}, + {file = "aiohttp-3.9.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:039df344b45ae0b34ac885ab5b53940b174530d4dd8a14ed8b0e2155b9dddccb"}, + {file = "aiohttp-3.9.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7943c414d3a8d9235f5f15c22ace69787c140c80b718dcd57caaade95f7cd93b"}, + {file = "aiohttp-3.9.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:84871a243359bb42c12728f04d181a389718710129b36b6aad0fc4655a7647d4"}, + {file = "aiohttp-3.9.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:5eafe2c065df5401ba06821b9a054d9cb2848867f3c59801b5d07a0be3a380ae"}, + {file = "aiohttp-3.9.3-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:9d3c9b50f19704552f23b4eaea1fc082fdd82c63429a6506446cbd8737823da3"}, + {file = "aiohttp-3.9.3-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:f033d80bc6283092613882dfe40419c6a6a1527e04fc69350e87a9df02bbc283"}, + {file = "aiohttp-3.9.3-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:2c895a656dd7e061b2fd6bb77d971cc38f2afc277229ce7dd3552de8313a483e"}, + {file = "aiohttp-3.9.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1f5a71d25cd8106eab05f8704cd9167b6e5187bcdf8f090a66c6d88b634802b4"}, + {file = "aiohttp-3.9.3-cp311-cp311-win32.whl", hash = "sha256:50fca156d718f8ced687a373f9e140c1bb765ca16e3d6f4fe116e3df7c05b2c5"}, + {file = "aiohttp-3.9.3-cp311-cp311-win_amd64.whl", hash = "sha256:5fe9ce6c09668063b8447f85d43b8d1c4e5d3d7e92c63173e6180b2ac5d46dd8"}, + {file = "aiohttp-3.9.3-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:38a19bc3b686ad55804ae931012f78f7a534cce165d089a2059f658f6c91fa60"}, + {file = "aiohttp-3.9.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:770d015888c2a598b377bd2f663adfd947d78c0124cfe7b959e1ef39f5b13869"}, + {file = "aiohttp-3.9.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ee43080e75fc92bf36219926c8e6de497f9b247301bbf88c5c7593d931426679"}, + {file = "aiohttp-3.9.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:52df73f14ed99cee84865b95a3d9e044f226320a87af208f068ecc33e0c35b96"}, + {file = "aiohttp-3.9.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc9b311743a78043b26ffaeeb9715dc360335e5517832f5a8e339f8a43581e4d"}, + {file = "aiohttp-3.9.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b955ed993491f1a5da7f92e98d5dad3c1e14dc175f74517c4e610b1f2456fb11"}, + {file = "aiohttp-3.9.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:504b6981675ace64c28bf4a05a508af5cde526e36492c98916127f5a02354d53"}, + {file = "aiohttp-3.9.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a6fe5571784af92b6bc2fda8d1925cccdf24642d49546d3144948a6a1ed58ca5"}, + {file = "aiohttp-3.9.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ba39e9c8627edc56544c8628cc180d88605df3892beeb2b94c9bc857774848ca"}, + {file = "aiohttp-3.9.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:e5e46b578c0e9db71d04c4b506a2121c0cb371dd89af17a0586ff6769d4c58c1"}, + {file = "aiohttp-3.9.3-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:938a9653e1e0c592053f815f7028e41a3062e902095e5a7dc84617c87267ebd5"}, + {file = "aiohttp-3.9.3-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:c3452ea726c76e92f3b9fae4b34a151981a9ec0a4847a627c43d71a15ac32aa6"}, + {file = "aiohttp-3.9.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ff30218887e62209942f91ac1be902cc80cddb86bf00fbc6783b7a43b2bea26f"}, + {file = "aiohttp-3.9.3-cp312-cp312-win32.whl", hash = "sha256:38f307b41e0bea3294a9a2a87833191e4bcf89bb0365e83a8be3a58b31fb7f38"}, + {file = "aiohttp-3.9.3-cp312-cp312-win_amd64.whl", hash = "sha256:b791a3143681a520c0a17e26ae7465f1b6f99461a28019d1a2f425236e6eedb5"}, + {file = "aiohttp-3.9.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:0ed621426d961df79aa3b963ac7af0d40392956ffa9be022024cd16297b30c8c"}, + {file = "aiohttp-3.9.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:7f46acd6a194287b7e41e87957bfe2ad1ad88318d447caf5b090012f2c5bb528"}, + {file = "aiohttp-3.9.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:feeb18a801aacb098220e2c3eea59a512362eb408d4afd0c242044c33ad6d542"}, + {file = "aiohttp-3.9.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f734e38fd8666f53da904c52a23ce517f1b07722118d750405af7e4123933511"}, + {file = "aiohttp-3.9.3-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b40670ec7e2156d8e57f70aec34a7216407848dfe6c693ef131ddf6e76feb672"}, + {file = "aiohttp-3.9.3-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fdd215b7b7fd4a53994f238d0f46b7ba4ac4c0adb12452beee724ddd0743ae5d"}, + {file = "aiohttp-3.9.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:017a21b0df49039c8f46ca0971b3a7fdc1f56741ab1240cb90ca408049766168"}, + {file = "aiohttp-3.9.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e99abf0bba688259a496f966211c49a514e65afa9b3073a1fcee08856e04425b"}, + {file = "aiohttp-3.9.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:648056db9a9fa565d3fa851880f99f45e3f9a771dd3ff3bb0c048ea83fb28194"}, + {file = "aiohttp-3.9.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:8aacb477dc26797ee089721536a292a664846489c49d3ef9725f992449eda5a8"}, + {file = "aiohttp-3.9.3-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:522a11c934ea660ff8953eda090dcd2154d367dec1ae3c540aff9f8a5c109ab4"}, + {file = "aiohttp-3.9.3-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:5bce0dc147ca85caa5d33debc4f4d65e8e8b5c97c7f9f660f215fa74fc49a321"}, + {file = "aiohttp-3.9.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:4b4af9f25b49a7be47c0972139e59ec0e8285c371049df1a63b6ca81fdd216a2"}, + {file = "aiohttp-3.9.3-cp38-cp38-win32.whl", hash = "sha256:298abd678033b8571995650ccee753d9458dfa0377be4dba91e4491da3f2be63"}, + {file = "aiohttp-3.9.3-cp38-cp38-win_amd64.whl", hash = "sha256:69361bfdca5468c0488d7017b9b1e5ce769d40b46a9f4a2eed26b78619e9396c"}, + {file = "aiohttp-3.9.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:0fa43c32d1643f518491d9d3a730f85f5bbaedcbd7fbcae27435bb8b7a061b29"}, + {file = "aiohttp-3.9.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:835a55b7ca49468aaaac0b217092dfdff370e6c215c9224c52f30daaa735c1c1"}, + {file = "aiohttp-3.9.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:06a9b2c8837d9a94fae16c6223acc14b4dfdff216ab9b7202e07a9a09541168f"}, + {file = "aiohttp-3.9.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:abf151955990d23f84205286938796c55ff11bbfb4ccfada8c9c83ae6b3c89a3"}, + {file = "aiohttp-3.9.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:59c26c95975f26e662ca78fdf543d4eeaef70e533a672b4113dd888bd2423caa"}, + {file = "aiohttp-3.9.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f95511dd5d0e05fd9728bac4096319f80615aaef4acbecb35a990afebe953b0e"}, + {file = "aiohttp-3.9.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:595f105710293e76b9dc09f52e0dd896bd064a79346234b521f6b968ffdd8e58"}, + {file = "aiohttp-3.9.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7c8b816c2b5af5c8a436df44ca08258fc1a13b449393a91484225fcb7545533"}, + {file = "aiohttp-3.9.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:f1088fa100bf46e7b398ffd9904f4808a0612e1d966b4aa43baa535d1b6341eb"}, + {file = "aiohttp-3.9.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f59dfe57bb1ec82ac0698ebfcdb7bcd0e99c255bd637ff613760d5f33e7c81b3"}, + {file = "aiohttp-3.9.3-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:361a1026c9dd4aba0109e4040e2aecf9884f5cfe1b1b1bd3d09419c205e2e53d"}, + {file = "aiohttp-3.9.3-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:363afe77cfcbe3a36353d8ea133e904b108feea505aa4792dad6585a8192c55a"}, + {file = "aiohttp-3.9.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8e2c45c208c62e955e8256949eb225bd8b66a4c9b6865729a786f2aa79b72e9d"}, + {file = "aiohttp-3.9.3-cp39-cp39-win32.whl", hash = "sha256:f7217af2e14da0856e082e96ff637f14ae45c10a5714b63c77f26d8884cf1051"}, + {file = "aiohttp-3.9.3-cp39-cp39-win_amd64.whl", hash = "sha256:27468897f628c627230dba07ec65dc8d0db566923c48f29e084ce382119802bc"}, + {file = "aiohttp-3.9.3.tar.gz", hash = "sha256:90842933e5d1ff760fae6caca4b2b3edba53ba8f4b71e95dacf2818a2aca06f7"}, +] + +[package.dependencies] +aiosignal = ">=1.1.2" +attrs = ">=17.3.0" +frozenlist = ">=1.1.1" +multidict = ">=4.5,<7.0" +yarl = ">=1.0,<2.0" + +[package.extras] +speedups = ["Brotli", "aiodns", "brotlicffi"] + +[[package]] +name = "aiohttp-jinja2" +version = "1.6" +description = "jinja2 template renderer for aiohttp.web (http server for asyncio)" +optional = false +python-versions = ">=3.8" +files = [ + {file = "aiohttp-jinja2-1.6.tar.gz", hash = "sha256:a3a7ff5264e5bca52e8ae547bbfd0761b72495230d438d05b6c0915be619b0e2"}, + {file = "aiohttp_jinja2-1.6-py3-none-any.whl", hash = "sha256:0df405ee6ad1b58e5a068a105407dc7dcc1704544c559f1938babde954f945c7"}, +] + +[package.dependencies] +aiohttp = ">=3.9.0" +jinja2 = ">=3.0.0" + +[[package]] +name = "aiohttp-session" +version = "2.12.0" +description = "sessions for aiohttp.web" +optional = false +python-versions = ">=3.7" +files = [ + {file = "aiohttp-session-2.12.0.tar.gz", hash = "sha256:0ccd11a7c77cb9e5a61f4daacdc9170d561112f9cfaf9e9a2d9867c0587d1950"}, + {file = "aiohttp_session-2.12.0-py3-none-any.whl", hash = "sha256:f0bf0caa2f5b5a56cb50a45f98d61f60d8523322099a2857410530149706f5e5"}, +] + +[package.dependencies] +aiohttp = ">=3.8" + +[package.extras] +aiomcache = ["aiomcache (>=0.5.2)"] +aioredis = ["redis (>=4.3.1)"] +pycrypto = ["cryptography"] +pynacl = ["pynacl"] +secure = ["cryptography"] + +[[package]] +name = "aiosignal" +version = "1.3.1" +description = "aiosignal: a list of registered asynchronous callbacks" +optional = false +python-versions = ">=3.7" +files = [ + {file = "aiosignal-1.3.1-py3-none-any.whl", hash = "sha256:f8376fb07dd1e86a584e4fcdec80b36b7f81aac666ebc724e2c090300dd83b17"}, + {file = "aiosignal-1.3.1.tar.gz", hash = "sha256:54cd96e15e1649b75d6c87526a6ff0b6c1b0dd3459f43d9ca11d48c339b68cfc"}, +] + +[package.dependencies] +frozenlist = ">=1.1.0" + +[[package]] +name = "anyio" +version = "4.3.0" +description = "High level compatibility layer for multiple asynchronous event loop implementations" +optional = false +python-versions = ">=3.8" +files = [ + {file = "anyio-4.3.0-py3-none-any.whl", hash = "sha256:048e05d0f6caeed70d731f3db756d35dcc1f35747c8c403364a8332c630441b8"}, + {file = "anyio-4.3.0.tar.gz", hash = "sha256:f75253795a87df48568485fd18cdd2a3fa5c4f7c5be8e5e36637733fce06fed6"}, +] + +[package.dependencies] +idna = ">=2.8" +sniffio = ">=1.1" + +[package.extras] +doc = ["Sphinx (>=7)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] +test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"] +trio = ["trio (>=0.23)"] + +[[package]] +name = "attrs" +version = "23.2.0" +description = "Classes Without Boilerplate" +optional = false +python-versions = ">=3.7" +files = [ + {file = "attrs-23.2.0-py3-none-any.whl", hash = "sha256:99b87a485a5820b23b879f04c2305b44b951b502fd64be915879d77a7e8fc6f1"}, + {file = "attrs-23.2.0.tar.gz", hash = "sha256:935dc3b529c262f6cf76e50877d35a4bd3c1de194fd41f47a2b7ae8f19971f30"}, +] + +[package.extras] +cov = ["attrs[tests]", "coverage[toml] (>=5.3)"] +dev = ["attrs[tests]", "pre-commit"] +docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier", "zope-interface"] +tests = ["attrs[tests-no-zope]", "zope-interface"] +tests-mypy = ["mypy (>=1.6)", "pytest-mypy-plugins"] +tests-no-zope = ["attrs[tests-mypy]", "cloudpickle", "hypothesis", "pympler", "pytest (>=4.3.0)", "pytest-xdist[psutil]"] + +[[package]] +name = "certifi" +version = "2024.2.2" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.6" +files = [ + {file = "certifi-2024.2.2-py3-none-any.whl", hash = "sha256:dc383c07b76109f368f6106eee2b593b04a011ea4d55f652c6ca24a754d1cdd1"}, + {file = "certifi-2024.2.2.tar.gz", hash = "sha256:0569859f95fc761b18b45ef421b1290a0f65f147e92a1e5eb3e635f9a5e4e66f"}, +] + +[[package]] +name = "cffi" +version = "1.16.0" +description = "Foreign Function Interface for Python calling C code." +optional = false +python-versions = ">=3.8" +files = [ + {file = "cffi-1.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6b3d6606d369fc1da4fd8c357d026317fbb9c9b75d36dc16e90e84c26854b088"}, + {file = "cffi-1.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ac0f5edd2360eea2f1daa9e26a41db02dd4b0451b48f7c318e217ee092a213e9"}, + {file = "cffi-1.16.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7e61e3e4fa664a8588aa25c883eab612a188c725755afff6289454d6362b9673"}, + {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a72e8961a86d19bdb45851d8f1f08b041ea37d2bd8d4fd19903bc3083d80c896"}, + {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b50bf3f55561dac5438f8e70bfcdfd74543fd60df5fa5f62d94e5867deca684"}, + {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7651c50c8c5ef7bdb41108b7b8c5a83013bfaa8a935590c5d74627c047a583c7"}, + {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4108df7fe9b707191e55f33efbcb2d81928e10cea45527879a4749cbe472614"}, + {file = "cffi-1.16.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:32c68ef735dbe5857c810328cb2481e24722a59a2003018885514d4c09af9743"}, + {file = "cffi-1.16.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:673739cb539f8cdaa07d92d02efa93c9ccf87e345b9a0b556e3ecc666718468d"}, + {file = "cffi-1.16.0-cp310-cp310-win32.whl", hash = "sha256:9f90389693731ff1f659e55c7d1640e2ec43ff725cc61b04b2f9c6d8d017df6a"}, + {file = "cffi-1.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:e6024675e67af929088fda399b2094574609396b1decb609c55fa58b028a32a1"}, + {file = "cffi-1.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b84834d0cf97e7d27dd5b7f3aca7b6e9263c56308ab9dc8aae9784abb774d404"}, + {file = "cffi-1.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1b8ebc27c014c59692bb2664c7d13ce7a6e9a629be20e54e7271fa696ff2b417"}, + {file = "cffi-1.16.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ee07e47c12890ef248766a6e55bd38ebfb2bb8edd4142d56db91b21ea68b7627"}, + {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8a9d3ebe49f084ad71f9269834ceccbf398253c9fac910c4fd7053ff1386936"}, + {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e70f54f1796669ef691ca07d046cd81a29cb4deb1e5f942003f401c0c4a2695d"}, + {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5bf44d66cdf9e893637896c7faa22298baebcd18d1ddb6d2626a6e39793a1d56"}, + {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7b78010e7b97fef4bee1e896df8a4bbb6712b7f05b7ef630f9d1da00f6444d2e"}, + {file = "cffi-1.16.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c6a164aa47843fb1b01e941d385aab7215563bb8816d80ff3a363a9f8448a8dc"}, + {file = "cffi-1.16.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e09f3ff613345df5e8c3667da1d918f9149bd623cd9070c983c013792a9a62eb"}, + {file = "cffi-1.16.0-cp311-cp311-win32.whl", hash = "sha256:2c56b361916f390cd758a57f2e16233eb4f64bcbeee88a4881ea90fca14dc6ab"}, + {file = "cffi-1.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:db8e577c19c0fda0beb7e0d4e09e0ba74b1e4c092e0e40bfa12fe05b6f6d75ba"}, + {file = "cffi-1.16.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:fa3a0128b152627161ce47201262d3140edb5a5c3da88d73a1b790a959126956"}, + {file = "cffi-1.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:68e7c44931cc171c54ccb702482e9fc723192e88d25a0e133edd7aff8fcd1f6e"}, + {file = "cffi-1.16.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:abd808f9c129ba2beda4cfc53bde801e5bcf9d6e0f22f095e45327c038bfe68e"}, + {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88e2b3c14bdb32e440be531ade29d3c50a1a59cd4e51b1dd8b0865c54ea5d2e2"}, + {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcc8eb6d5902bb1cf6dc4f187ee3ea80a1eba0a89aba40a5cb20a5087d961357"}, + {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b7be2d771cdba2942e13215c4e340bfd76398e9227ad10402a8767ab1865d2e6"}, + {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e715596e683d2ce000574bae5d07bd522c781a822866c20495e52520564f0969"}, + {file = "cffi-1.16.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2d92b25dbf6cae33f65005baf472d2c245c050b1ce709cc4588cdcdd5495b520"}, + {file = "cffi-1.16.0-cp312-cp312-win32.whl", hash = "sha256:b2ca4e77f9f47c55c194982e10f058db063937845bb2b7a86c84a6cfe0aefa8b"}, + {file = "cffi-1.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:68678abf380b42ce21a5f2abde8efee05c114c2fdb2e9eef2efdb0257fba1235"}, + {file = "cffi-1.16.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0c9ef6ff37e974b73c25eecc13952c55bceed9112be2d9d938ded8e856138bcc"}, + {file = "cffi-1.16.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a09582f178759ee8128d9270cd1344154fd473bb77d94ce0aeb2a93ebf0feaf0"}, + {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e760191dd42581e023a68b758769e2da259b5d52e3103c6060ddc02c9edb8d7b"}, + {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:80876338e19c951fdfed6198e70bc88f1c9758b94578d5a7c4c91a87af3cf31c"}, + {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a6a14b17d7e17fa0d207ac08642c8820f84f25ce17a442fd15e27ea18d67c59b"}, + {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6602bc8dc6f3a9e02b6c22c4fc1e47aa50f8f8e6d3f78a5e16ac33ef5fefa324"}, + {file = "cffi-1.16.0-cp38-cp38-win32.whl", hash = "sha256:131fd094d1065b19540c3d72594260f118b231090295d8c34e19a7bbcf2e860a"}, + {file = "cffi-1.16.0-cp38-cp38-win_amd64.whl", hash = "sha256:31d13b0f99e0836b7ff893d37af07366ebc90b678b6664c955b54561fc36ef36"}, + {file = "cffi-1.16.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:582215a0e9adbe0e379761260553ba11c58943e4bbe9c36430c4ca6ac74b15ed"}, + {file = "cffi-1.16.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b29ebffcf550f9da55bec9e02ad430c992a87e5f512cd63388abb76f1036d8d2"}, + {file = "cffi-1.16.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dc9b18bf40cc75f66f40a7379f6a9513244fe33c0e8aa72e2d56b0196a7ef872"}, + {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cb4a35b3642fc5c005a6755a5d17c6c8b6bcb6981baf81cea8bfbc8903e8ba8"}, + {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b86851a328eedc692acf81fb05444bdf1891747c25af7529e39ddafaf68a4f3f"}, + {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c0f31130ebc2d37cdd8e44605fb5fa7ad59049298b3f745c74fa74c62fbfcfc4"}, + {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f8e709127c6c77446a8c0a8c8bf3c8ee706a06cd44b1e827c3e6a2ee6b8c098"}, + {file = "cffi-1.16.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:748dcd1e3d3d7cd5443ef03ce8685043294ad6bd7c02a38d1bd367cfd968e000"}, + {file = "cffi-1.16.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8895613bcc094d4a1b2dbe179d88d7fb4a15cee43c052e8885783fac397d91fe"}, + {file = "cffi-1.16.0-cp39-cp39-win32.whl", hash = "sha256:ed86a35631f7bfbb28e108dd96773b9d5a6ce4811cf6ea468bb6a359b256b1e4"}, + {file = "cffi-1.16.0-cp39-cp39-win_amd64.whl", hash = "sha256:3686dffb02459559c74dd3d81748269ffb0eb027c39a6fc99502de37d501faa8"}, + {file = "cffi-1.16.0.tar.gz", hash = "sha256:bcb3ef43e58665bbda2fb198698fcae6776483e0c4a631aa5647806c25e02cc0"}, +] + +[package.dependencies] +pycparser = "*" + +[[package]] +name = "cfgv" +version = "3.4.0" +description = "Validate configuration and produce human readable error messages." +optional = false +python-versions = ">=3.8" +files = [ + {file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"}, + {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"}, +] + +[[package]] +name = "charset-normalizer" +version = "3.3.2" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +optional = false +python-versions = ">=3.7.0" +files = [ + {file = "charset-normalizer-3.3.2.tar.gz", hash = "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-win32.whl", hash = "sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-win32.whl", hash = "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-win32.whl", hash = "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c002b4ffc0be611f0d9da932eb0f704fe2602a9a949d1f738e4c34c75b0863d5"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a981a536974bbc7a512cf44ed14938cf01030a99e9b3a06dd59578882f06f985"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3287761bc4ee9e33561a7e058c72ac0938c4f57fe49a09eae428fd88aafe7bb6"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42cb296636fcc8b0644486d15c12376cb9fa75443e00fb25de0b8602e64c1714"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a55554a2fa0d408816b3b5cedf0045f4b8e1a6065aec45849de2d6f3f8e9786"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:c083af607d2515612056a31f0a8d9e0fcb5876b7bfc0abad3ecd275bc4ebc2d5"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:87d1351268731db79e0f8e745d92493ee2841c974128ef629dc518b937d9194c"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:bd8f7df7d12c2db9fab40bdd87a7c09b1530128315d047a086fa3ae3435cb3a8"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:c180f51afb394e165eafe4ac2936a14bee3eb10debc9d9e4db8958fe36afe711"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8c622a5fe39a48f78944a87d4fb8a53ee07344641b0562c540d840748571b811"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-win32.whl", hash = "sha256:db364eca23f876da6f9e16c9da0df51aa4f104a972735574842618b8c6d999d4"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-win_amd64.whl", hash = "sha256:86216b5cee4b06df986d214f664305142d9c76df9b6512be2738aa72a2048f99"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-win32.whl", hash = "sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-win32.whl", hash = "sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d"}, + {file = "charset_normalizer-3.3.2-py3-none-any.whl", hash = "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc"}, +] + +[[package]] +name = "cryptography" +version = "42.0.5" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +optional = false +python-versions = ">=3.7" +files = [ + {file = "cryptography-42.0.5-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:a30596bae9403a342c978fb47d9b0ee277699fa53bbafad14706af51fe543d16"}, + {file = "cryptography-42.0.5-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:b7ffe927ee6531c78f81aa17e684e2ff617daeba7f189f911065b2ea2d526dec"}, + {file = "cryptography-42.0.5-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2424ff4c4ac7f6b8177b53c17ed5d8fa74ae5955656867f5a8affaca36a27abb"}, + {file = "cryptography-42.0.5-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:329906dcc7b20ff3cad13c069a78124ed8247adcac44b10bea1130e36caae0b4"}, + {file = "cryptography-42.0.5-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:b03c2ae5d2f0fc05f9a2c0c997e1bc18c8229f392234e8a0194f202169ccd278"}, + {file = "cryptography-42.0.5-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8837fe1d6ac4a8052a9a8ddab256bc006242696f03368a4009be7ee3075cdb7"}, + {file = "cryptography-42.0.5-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:0270572b8bd2c833c3981724b8ee9747b3ec96f699a9665470018594301439ee"}, + {file = "cryptography-42.0.5-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:b8cac287fafc4ad485b8a9b67d0ee80c66bf3574f655d3b97ef2e1082360faf1"}, + {file = "cryptography-42.0.5-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:16a48c23a62a2f4a285699dba2e4ff2d1cff3115b9df052cdd976a18856d8e3d"}, + {file = "cryptography-42.0.5-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2bce03af1ce5a5567ab89bd90d11e7bbdff56b8af3acbbec1faded8f44cb06da"}, + {file = "cryptography-42.0.5-cp37-abi3-win32.whl", hash = "sha256:b6cd2203306b63e41acdf39aa93b86fb566049aeb6dc489b70e34bcd07adca74"}, + {file = "cryptography-42.0.5-cp37-abi3-win_amd64.whl", hash = "sha256:98d8dc6d012b82287f2c3d26ce1d2dd130ec200c8679b6213b3c73c08b2b7940"}, + {file = "cryptography-42.0.5-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:5e6275c09d2badf57aea3afa80d975444f4be8d3bc58f7f80d2a484c6f9485c8"}, + {file = "cryptography-42.0.5-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4985a790f921508f36f81831817cbc03b102d643b5fcb81cd33df3fa291a1a1"}, + {file = "cryptography-42.0.5-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7cde5f38e614f55e28d831754e8a3bacf9ace5d1566235e39d91b35502d6936e"}, + {file = "cryptography-42.0.5-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:7367d7b2eca6513681127ebad53b2582911d1736dc2ffc19f2c3ae49997496bc"}, + {file = "cryptography-42.0.5-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:cd2030f6650c089aeb304cf093f3244d34745ce0cfcc39f20c6fbfe030102e2a"}, + {file = "cryptography-42.0.5-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:a2913c5375154b6ef2e91c10b5720ea6e21007412f6437504ffea2109b5a33d7"}, + {file = "cryptography-42.0.5-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:c41fb5e6a5fe9ebcd58ca3abfeb51dffb5d83d6775405305bfa8715b76521922"}, + {file = "cryptography-42.0.5-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3eaafe47ec0d0ffcc9349e1708be2aaea4c6dd4978d76bf6eb0cb2c13636c6fc"}, + {file = "cryptography-42.0.5-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1b95b98b0d2af784078fa69f637135e3c317091b615cd0905f8b8a087e86fa30"}, + {file = "cryptography-42.0.5-cp39-abi3-win32.whl", hash = "sha256:1f71c10d1e88467126f0efd484bd44bca5e14c664ec2ede64c32f20875c0d413"}, + {file = "cryptography-42.0.5-cp39-abi3-win_amd64.whl", hash = "sha256:a011a644f6d7d03736214d38832e030d8268bcff4a41f728e6030325fea3e400"}, + {file = "cryptography-42.0.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:9481ffe3cf013b71b2428b905c4f7a9a4f76ec03065b05ff499bb5682a8d9ad8"}, + {file = "cryptography-42.0.5-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:ba334e6e4b1d92442b75ddacc615c5476d4ad55cc29b15d590cc6b86efa487e2"}, + {file = "cryptography-42.0.5-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:ba3e4a42397c25b7ff88cdec6e2a16c2be18720f317506ee25210f6d31925f9c"}, + {file = "cryptography-42.0.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:111a0d8553afcf8eb02a4fea6ca4f59d48ddb34497aa8706a6cf536f1a5ec576"}, + {file = "cryptography-42.0.5-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:cd65d75953847815962c84a4654a84850b2bb4aed3f26fadcc1c13892e1e29f6"}, + {file = "cryptography-42.0.5-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:e807b3188f9eb0eaa7bbb579b462c5ace579f1cedb28107ce8b48a9f7ad3679e"}, + {file = "cryptography-42.0.5-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:f12764b8fffc7a123f641d7d049d382b73f96a34117e0b637b80643169cec8ac"}, + {file = "cryptography-42.0.5-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:37dd623507659e08be98eec89323469e8c7b4c1407c85112634ae3dbdb926fdd"}, + {file = "cryptography-42.0.5.tar.gz", hash = "sha256:6fe07eec95dfd477eb9530aef5bead34fec819b3aaf6c5bd6d20565da607bfe1"}, +] + +[package.dependencies] +cffi = {version = ">=1.12", markers = "platform_python_implementation != \"PyPy\""} + +[package.extras] +docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=1.1.1)"] +docstest = ["pyenchant (>=1.6.11)", "readme-renderer", "sphinxcontrib-spelling (>=4.0.1)"] +nox = ["nox"] +pep8test = ["check-sdist", "click", "mypy", "ruff"] +sdist = ["build"] +ssh = ["bcrypt (>=3.1.5)"] +test = ["certifi", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] +test-randomorder = ["pytest-randomly"] + +[[package]] +name = "distlib" +version = "0.3.8" +description = "Distribution utilities" +optional = false +python-versions = "*" +files = [ + {file = "distlib-0.3.8-py2.py3-none-any.whl", hash = "sha256:034db59a0b96f8ca18035f36290806a9a6e6bd9d1ff91e45a7f172eb17e51784"}, + {file = "distlib-0.3.8.tar.gz", hash = "sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64"}, +] + +[[package]] +name = "filelock" +version = "3.13.3" +description = "A platform independent file lock." +optional = false +python-versions = ">=3.8" +files = [ + {file = "filelock-3.13.3-py3-none-any.whl", hash = "sha256:5ffa845303983e7a0b7ae17636509bc97997d58afeafa72fb141a17b152284cb"}, + {file = "filelock-3.13.3.tar.gz", hash = "sha256:a79895a25bbefdf55d1a2a0a80968f7dbb28edcd6d4234a0afb3f37ecde4b546"}, +] + +[package.extras] +docs = ["furo (>=2023.9.10)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"] +testing = ["covdefaults (>=2.3)", "coverage (>=7.3.2)", "diff-cover (>=8.0.1)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-timeout (>=2.2)"] +typing = ["typing-extensions (>=4.8)"] + +[[package]] +name = "frozenlist" +version = "1.4.1" +description = "A list-like structure which implements collections.abc.MutableSequence" +optional = false +python-versions = ">=3.8" +files = [ + {file = "frozenlist-1.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f9aa1878d1083b276b0196f2dfbe00c9b7e752475ed3b682025ff20c1c1f51ac"}, + {file = "frozenlist-1.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:29acab3f66f0f24674b7dc4736477bcd4bc3ad4b896f5f45379a67bce8b96868"}, + {file = "frozenlist-1.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:74fb4bee6880b529a0c6560885fce4dc95936920f9f20f53d99a213f7bf66776"}, + {file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:590344787a90ae57d62511dd7c736ed56b428f04cd8c161fcc5e7232c130c69a"}, + {file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:068b63f23b17df8569b7fdca5517edef76171cf3897eb68beb01341131fbd2ad"}, + {file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5c849d495bf5154cd8da18a9eb15db127d4dba2968d88831aff6f0331ea9bd4c"}, + {file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9750cc7fe1ae3b1611bb8cfc3f9ec11d532244235d75901fb6b8e42ce9229dfe"}, + {file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a9b2de4cf0cdd5bd2dee4c4f63a653c61d2408055ab77b151c1957f221cabf2a"}, + {file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:0633c8d5337cb5c77acbccc6357ac49a1770b8c487e5b3505c57b949b4b82e98"}, + {file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:27657df69e8801be6c3638054e202a135c7f299267f1a55ed3a598934f6c0d75"}, + {file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:f9a3ea26252bd92f570600098783d1371354d89d5f6b7dfd87359d669f2109b5"}, + {file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:4f57dab5fe3407b6c0c1cc907ac98e8a189f9e418f3b6e54d65a718aaafe3950"}, + {file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:e02a0e11cf6597299b9f3bbd3f93d79217cb90cfd1411aec33848b13f5c656cc"}, + {file = "frozenlist-1.4.1-cp310-cp310-win32.whl", hash = "sha256:a828c57f00f729620a442881cc60e57cfcec6842ba38e1b19fd3e47ac0ff8dc1"}, + {file = "frozenlist-1.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:f56e2333dda1fe0f909e7cc59f021eba0d2307bc6f012a1ccf2beca6ba362439"}, + {file = "frozenlist-1.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a0cb6f11204443f27a1628b0e460f37fb30f624be6051d490fa7d7e26d4af3d0"}, + {file = "frozenlist-1.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b46c8ae3a8f1f41a0d2ef350c0b6e65822d80772fe46b653ab6b6274f61d4a49"}, + {file = "frozenlist-1.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fde5bd59ab5357e3853313127f4d3565fc7dad314a74d7b5d43c22c6a5ed2ced"}, + {file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:722e1124aec435320ae01ee3ac7bec11a5d47f25d0ed6328f2273d287bc3abb0"}, + {file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2471c201b70d58a0f0c1f91261542a03d9a5e088ed3dc6c160d614c01649c106"}, + {file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c757a9dd70d72b076d6f68efdbb9bc943665ae954dad2801b874c8c69e185068"}, + {file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f146e0911cb2f1da549fc58fc7bcd2b836a44b79ef871980d605ec392ff6b0d2"}, + {file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f9c515e7914626b2a2e1e311794b4c35720a0be87af52b79ff8e1429fc25f19"}, + {file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:c302220494f5c1ebeb0912ea782bcd5e2f8308037b3c7553fad0e48ebad6ad82"}, + {file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:442acde1e068288a4ba7acfe05f5f343e19fac87bfc96d89eb886b0363e977ec"}, + {file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:1b280e6507ea8a4fa0c0a7150b4e526a8d113989e28eaaef946cc77ffd7efc0a"}, + {file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:fe1a06da377e3a1062ae5fe0926e12b84eceb8a50b350ddca72dc85015873f74"}, + {file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:db9e724bebd621d9beca794f2a4ff1d26eed5965b004a97f1f1685a173b869c2"}, + {file = "frozenlist-1.4.1-cp311-cp311-win32.whl", hash = "sha256:e774d53b1a477a67838a904131c4b0eef6b3d8a651f8b138b04f748fccfefe17"}, + {file = "frozenlist-1.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:fb3c2db03683b5767dedb5769b8a40ebb47d6f7f45b1b3e3b4b51ec8ad9d9825"}, + {file = "frozenlist-1.4.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:1979bc0aeb89b33b588c51c54ab0161791149f2461ea7c7c946d95d5f93b56ae"}, + {file = "frozenlist-1.4.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:cc7b01b3754ea68a62bd77ce6020afaffb44a590c2289089289363472d13aedb"}, + {file = "frozenlist-1.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c9c92be9fd329ac801cc420e08452b70e7aeab94ea4233a4804f0915c14eba9b"}, + {file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c3894db91f5a489fc8fa6a9991820f368f0b3cbdb9cd8849547ccfab3392d86"}, + {file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ba60bb19387e13597fb059f32cd4d59445d7b18b69a745b8f8e5db0346f33480"}, + {file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8aefbba5f69d42246543407ed2461db31006b0f76c4e32dfd6f42215a2c41d09"}, + {file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:780d3a35680ced9ce682fbcf4cb9c2bad3136eeff760ab33707b71db84664e3a"}, + {file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9acbb16f06fe7f52f441bb6f413ebae6c37baa6ef9edd49cdd567216da8600cd"}, + {file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:23b701e65c7b36e4bf15546a89279bd4d8675faabc287d06bbcfac7d3c33e1e6"}, + {file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:3e0153a805a98f5ada7e09826255ba99fb4f7524bb81bf6b47fb702666484ae1"}, + {file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:dd9b1baec094d91bf36ec729445f7769d0d0cf6b64d04d86e45baf89e2b9059b"}, + {file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:1a4471094e146b6790f61b98616ab8e44f72661879cc63fa1049d13ef711e71e"}, + {file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5667ed53d68d91920defdf4035d1cdaa3c3121dc0b113255124bcfada1cfa1b8"}, + {file = "frozenlist-1.4.1-cp312-cp312-win32.whl", hash = "sha256:beee944ae828747fd7cb216a70f120767fc9f4f00bacae8543c14a6831673f89"}, + {file = "frozenlist-1.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:64536573d0a2cb6e625cf309984e2d873979709f2cf22839bf2d61790b448ad5"}, + {file = "frozenlist-1.4.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:20b51fa3f588ff2fe658663db52a41a4f7aa6c04f6201449c6c7c476bd255c0d"}, + {file = "frozenlist-1.4.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:410478a0c562d1a5bcc2f7ea448359fcb050ed48b3c6f6f4f18c313a9bdb1826"}, + {file = "frozenlist-1.4.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:c6321c9efe29975232da3bd0af0ad216800a47e93d763ce64f291917a381b8eb"}, + {file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48f6a4533887e189dae092f1cf981f2e3885175f7a0f33c91fb5b7b682b6bab6"}, + {file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6eb73fa5426ea69ee0e012fb59cdc76a15b1283d6e32e4f8dc4482ec67d1194d"}, + {file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fbeb989b5cc29e8daf7f976b421c220f1b8c731cbf22b9130d8815418ea45887"}, + {file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:32453c1de775c889eb4e22f1197fe3bdfe457d16476ea407472b9442e6295f7a"}, + {file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:693945278a31f2086d9bf3df0fe8254bbeaef1fe71e1351c3bd730aa7d31c41b"}, + {file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:1d0ce09d36d53bbbe566fe296965b23b961764c0bcf3ce2fa45f463745c04701"}, + {file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:3a670dc61eb0d0eb7080890c13de3066790f9049b47b0de04007090807c776b0"}, + {file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:dca69045298ce5c11fd539682cff879cc1e664c245d1c64da929813e54241d11"}, + {file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:a06339f38e9ed3a64e4c4e43aec7f59084033647f908e4259d279a52d3757d09"}, + {file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b7f2f9f912dca3934c1baec2e4585a674ef16fe00218d833856408c48d5beee7"}, + {file = "frozenlist-1.4.1-cp38-cp38-win32.whl", hash = "sha256:e7004be74cbb7d9f34553a5ce5fb08be14fb33bc86f332fb71cbe5216362a497"}, + {file = "frozenlist-1.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:5a7d70357e7cee13f470c7883a063aae5fe209a493c57d86eb7f5a6f910fae09"}, + {file = "frozenlist-1.4.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:bfa4a17e17ce9abf47a74ae02f32d014c5e9404b6d9ac7f729e01562bbee601e"}, + {file = "frozenlist-1.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b7e3ed87d4138356775346e6845cccbe66cd9e207f3cd11d2f0b9fd13681359d"}, + {file = "frozenlist-1.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c99169d4ff810155ca50b4da3b075cbde79752443117d89429595c2e8e37fed8"}, + {file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:edb678da49d9f72c9f6c609fbe41a5dfb9a9282f9e6a2253d5a91e0fc382d7c0"}, + {file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6db4667b187a6742b33afbbaf05a7bc551ffcf1ced0000a571aedbb4aa42fc7b"}, + {file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:55fdc093b5a3cb41d420884cdaf37a1e74c3c37a31f46e66286d9145d2063bd0"}, + {file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:82e8211d69a4f4bc360ea22cd6555f8e61a1bd211d1d5d39d3d228b48c83a897"}, + {file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:89aa2c2eeb20957be2d950b85974b30a01a762f3308cd02bb15e1ad632e22dc7"}, + {file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9d3e0c25a2350080e9319724dede4f31f43a6c9779be48021a7f4ebde8b2d742"}, + {file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:7268252af60904bf52c26173cbadc3a071cece75f873705419c8681f24d3edea"}, + {file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:0c250a29735d4f15321007fb02865f0e6b6a41a6b88f1f523ca1596ab5f50bd5"}, + {file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:96ec70beabbd3b10e8bfe52616a13561e58fe84c0101dd031dc78f250d5128b9"}, + {file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:23b2d7679b73fe0e5a4560b672a39f98dfc6f60df63823b0a9970525325b95f6"}, + {file = "frozenlist-1.4.1-cp39-cp39-win32.whl", hash = "sha256:a7496bfe1da7fb1a4e1cc23bb67c58fab69311cc7d32b5a99c2007b4b2a0e932"}, + {file = "frozenlist-1.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:e6a20a581f9ce92d389a8c7d7c3dd47c81fd5d6e655c8dddf341e14aa48659d0"}, + {file = "frozenlist-1.4.1-py3-none-any.whl", hash = "sha256:04ced3e6a46b4cfffe20f9ae482818e34eba9b5fb0ce4056e4cc9b6e212d09b7"}, + {file = "frozenlist-1.4.1.tar.gz", hash = "sha256:c037a86e8513059a2613aaba4d817bb90b9d9b6b69aace3ce9c877e8c8ed402b"}, +] + +[[package]] +name = "h11" +version = "0.14.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +optional = false +python-versions = ">=3.7" +files = [ + {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, + {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, +] + +[[package]] +name = "httpcore" +version = "1.0.5" +description = "A minimal low-level HTTP client." +optional = false +python-versions = ">=3.8" +files = [ + {file = "httpcore-1.0.5-py3-none-any.whl", hash = "sha256:421f18bac248b25d310f3cacd198d55b8e6125c107797b609ff9b7a6ba7991b5"}, + {file = "httpcore-1.0.5.tar.gz", hash = "sha256:34a38e2f9291467ee3b44e89dd52615370e152954ba21721378a87b2960f7a61"}, +] + +[package.dependencies] +certifi = "*" +h11 = ">=0.13,<0.15" + +[package.extras] +asyncio = ["anyio (>=4.0,<5.0)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +trio = ["trio (>=0.22.0,<0.26.0)"] + +[[package]] +name = "httpx" +version = "0.27.0" +description = "The next generation HTTP client." +optional = false +python-versions = ">=3.8" +files = [ + {file = "httpx-0.27.0-py3-none-any.whl", hash = "sha256:71d5465162c13681bff01ad59b2cc68dd838ea1f10e51574bac27103f00c91a5"}, + {file = "httpx-0.27.0.tar.gz", hash = "sha256:a0cb88a46f32dc874e04ee956e4c2764aba2aa228f650b06788ba6bda2962ab5"}, +] + +[package.dependencies] +anyio = "*" +certifi = "*" +httpcore = "==1.*" +idna = "*" +sniffio = "*" + +[package.extras] +brotli = ["brotli", "brotlicffi"] +cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] + +[[package]] +name = "identify" +version = "2.5.35" +description = "File identification library for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "identify-2.5.35-py2.py3-none-any.whl", hash = "sha256:c4de0081837b211594f8e877a6b4fad7ca32bbfc1a9307fdd61c28bfe923f13e"}, + {file = "identify-2.5.35.tar.gz", hash = "sha256:10a7ca245cfcd756a554a7288159f72ff105ad233c7c4b9c6f0f4d108f5f6791"}, +] + +[package.extras] +license = ["ukkonen"] + +[[package]] +name = "idna" +version = "3.6" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.5" +files = [ + {file = "idna-3.6-py3-none-any.whl", hash = "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f"}, + {file = "idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca"}, +] + +[[package]] +name = "jinja2" +version = "3.1.3" +description = "A very fast and expressive template engine." +optional = false +python-versions = ">=3.7" +files = [ + {file = "Jinja2-3.1.3-py3-none-any.whl", hash = "sha256:7d6d50dd97d52cbc355597bd845fabfbac3f551e1f99619e39a35ce8c370b5fa"}, + {file = "Jinja2-3.1.3.tar.gz", hash = "sha256:ac8bd6544d4bb2c9792bf3a159e80bba8fda7f07e81bc3aed565432d5925ba90"}, +] + +[package.dependencies] +MarkupSafe = ">=2.0" + +[package.extras] +i18n = ["Babel (>=2.7)"] + +[[package]] +name = "mafic" +version = "2.10.0" +description = "A properly typehinted lavalink client for discord.py, nextcord, disnake and py-cord." +optional = false +python-versions = ">=3.8,<4.0" +files = [ + {file = "mafic-2.10.0-py3-none-any.whl", hash = "sha256:001214abaa92ebcaef321b297bd5b3386ae4ac29fc0f798770c403318062fcf8"}, + {file = "mafic-2.10.0.tar.gz", hash = "sha256:dddec9e97808d231379583188286232d408face8b7b4eb3f16bf3c1d8ae8b5b0"}, +] + +[package.dependencies] +aiohttp = ">=3.6.0,<4.0.0" +yarl = ">=1.0.0,<2.0.0" + +[package.extras] +speedups = ["orjson (>=3.8.0,<4.0.0)"] + +[[package]] +name = "markupsafe" +version = "2.1.5" +description = "Safely add untrusted strings to HTML/XML markup." +optional = false +python-versions = ">=3.7" +files = [ + {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61659ba32cf2cf1481e575d0462554625196a1f2fc06a1c777d3f48e8865d46"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2174c595a0d73a3080ca3257b40096db99799265e1c27cc5a610743acd86d62f"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae2ad8ae6ebee9d2d94b17fb62763125f3f374c25618198f40cbb8b525411900"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:075202fa5b72c86ad32dc7d0b56024ebdbcf2048c0ba09f1cde31bfdd57bcfff"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:598e3276b64aff0e7b3451b72e94fa3c238d452e7ddcd893c3ab324717456bad"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fce659a462a1be54d2ffcacea5e3ba2d74daa74f30f5f143fe0c58636e355fdd"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-win32.whl", hash = "sha256:d9fad5155d72433c921b782e58892377c44bd6252b5af2f67f16b194987338a4"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-win_amd64.whl", hash = "sha256:bf50cd79a75d181c9181df03572cdce0fbb75cc353bc350712073108cba98de5"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:629ddd2ca402ae6dbedfceeba9c46d5f7b2a61d9749597d4307f943ef198fc1f"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5b7b716f97b52c5a14bffdf688f971b2d5ef4029127f1ad7a513973cfd818df2"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ec585f69cec0aa07d945b20805be741395e28ac1627333b1c5b0105962ffced"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b91c037585eba9095565a3556f611e3cbfaa42ca1e865f7b8015fe5c7336d5a5"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7502934a33b54030eaf1194c21c692a534196063db72176b0c4028e140f8f32c"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0e397ac966fdf721b2c528cf028494e86172b4feba51d65f81ffd65c63798f3f"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c061bb86a71b42465156a3ee7bd58c8c2ceacdbeb95d05a99893e08b8467359a"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3a57fdd7ce31c7ff06cdfbf31dafa96cc533c21e443d57f5b1ecc6cdc668ec7f"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-win32.whl", hash = "sha256:397081c1a0bfb5124355710fe79478cdbeb39626492b15d399526ae53422b906"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-win_amd64.whl", hash = "sha256:2b7c57a4dfc4f16f7142221afe5ba4e093e09e728ca65c51f5620c9aaeb9a617"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-win32.whl", hash = "sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-win_amd64.whl", hash = "sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c8b29db45f8fe46ad280a7294f5c3ec36dbac9491f2d1c17345be8e69cc5928f"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec6a563cff360b50eed26f13adc43e61bc0c04d94b8be985e6fb24b81f6dcfdf"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a549b9c31bec33820e885335b451286e2969a2d9e24879f83fe904a5ce59d70a"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4f11aa001c540f62c6166c7726f71f7573b52c68c31f014c25cc7901deea0b52"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:7b2e5a267c855eea6b4283940daa6e88a285f5f2a67f2220203786dfa59b37e9"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:2d2d793e36e230fd32babe143b04cec8a8b3eb8a3122d2aceb4a371e6b09b8df"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:ce409136744f6521e39fd8e2a24c53fa18ad67aa5bc7c2cf83645cce5b5c4e50"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-win32.whl", hash = "sha256:4096e9de5c6fdf43fb4f04c26fb114f61ef0bf2e5604b6ee3019d51b69e8c371"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-win_amd64.whl", hash = "sha256:4275d846e41ecefa46e2015117a9f491e57a71ddd59bbead77e904dc02b1bed2"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:656f7526c69fac7f600bd1f400991cc282b417d17539a1b228617081106feb4a"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:97cafb1f3cbcd3fd2b6fbfb99ae11cdb14deea0736fc2b0952ee177f2b813a46"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f3fbcb7ef1f16e48246f704ab79d79da8a46891e2da03f8783a5b6fa41a9532"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa9db3f79de01457b03d4f01b34cf91bc0048eb2c3846ff26f66687c2f6d16ab"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffee1f21e5ef0d712f9033568f8344d5da8cc2869dbd08d87c84656e6a2d2f68"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:5dedb4db619ba5a2787a94d877bc8ffc0566f92a01c0ef214865e54ecc9ee5e0"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:30b600cf0a7ac9234b2638fbc0fb6158ba5bdcdf46aeb631ead21248b9affbc4"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8dd717634f5a044f860435c1d8c16a270ddf0ef8588d4887037c5028b859b0c3"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-win32.whl", hash = "sha256:daa4ee5a243f0f20d528d939d06670a298dd39b1ad5f8a72a4275124a7819eff"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-win_amd64.whl", hash = "sha256:619bc166c4f2de5caa5a633b8b7326fbe98e0ccbfacabd87268a2b15ff73a029"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7a68b554d356a91cce1236aa7682dc01df0edba8d043fd1ce607c49dd3c1edcf"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:db0b55e0f3cc0be60c1f19efdde9a637c32740486004f20d1cff53c3c0ece4d2"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e53af139f8579a6d5f7b76549125f0d94d7e630761a2111bc431fd820e163b8"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17b950fccb810b3293638215058e432159d2b71005c74371d784862b7e4683f3"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c31f53cdae6ecfa91a77820e8b151dba54ab528ba65dfd235c80b086d68a465"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:bff1b4290a66b490a2f4719358c0cdcd9bafb6b8f061e45c7a2460866bf50c2e"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:bc1667f8b83f48511b94671e0e441401371dfd0f0a795c7daa4a3cd1dde55bea"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5049256f536511ee3f7e1b3f87d1d1209d327e818e6ae1365e8653d7e3abb6a6"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-win32.whl", hash = "sha256:00e046b6dd71aa03a41079792f8473dc494d564611a8f89bbbd7cb93295ebdcf"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-win_amd64.whl", hash = "sha256:fa173ec60341d6bb97a89f5ea19c85c5643c1e7dedebc22f5181eb73573142c5"}, + {file = "MarkupSafe-2.1.5.tar.gz", hash = "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b"}, +] + +[[package]] +name = "multidict" +version = "6.0.5" +description = "multidict implementation" +optional = false +python-versions = ">=3.7" +files = [ + {file = "multidict-6.0.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:228b644ae063c10e7f324ab1ab6b548bdf6f8b47f3ec234fef1093bc2735e5f9"}, + {file = "multidict-6.0.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:896ebdcf62683551312c30e20614305f53125750803b614e9e6ce74a96232604"}, + {file = "multidict-6.0.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:411bf8515f3be9813d06004cac41ccf7d1cd46dfe233705933dd163b60e37600"}, + {file = "multidict-6.0.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d147090048129ce3c453f0292e7697d333db95e52616b3793922945804a433c"}, + {file = "multidict-6.0.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:215ed703caf15f578dca76ee6f6b21b7603791ae090fbf1ef9d865571039ade5"}, + {file = "multidict-6.0.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c6390cf87ff6234643428991b7359b5f59cc15155695deb4eda5c777d2b880f"}, + {file = "multidict-6.0.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21fd81c4ebdb4f214161be351eb5bcf385426bf023041da2fd9e60681f3cebae"}, + {file = "multidict-6.0.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3cc2ad10255f903656017363cd59436f2111443a76f996584d1077e43ee51182"}, + {file = "multidict-6.0.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:6939c95381e003f54cd4c5516740faba40cf5ad3eeff460c3ad1d3e0ea2549bf"}, + {file = "multidict-6.0.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:220dd781e3f7af2c2c1053da9fa96d9cf3072ca58f057f4c5adaaa1cab8fc442"}, + {file = "multidict-6.0.5-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:766c8f7511df26d9f11cd3a8be623e59cca73d44643abab3f8c8c07620524e4a"}, + {file = "multidict-6.0.5-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:fe5d7785250541f7f5019ab9cba2c71169dc7d74d0f45253f8313f436458a4ef"}, + {file = "multidict-6.0.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1c1496e73051918fcd4f58ff2e0f2f3066d1c76a0c6aeffd9b45d53243702cc"}, + {file = "multidict-6.0.5-cp310-cp310-win32.whl", hash = "sha256:7afcdd1fc07befad18ec4523a782cde4e93e0a2bf71239894b8d61ee578c1319"}, + {file = "multidict-6.0.5-cp310-cp310-win_amd64.whl", hash = "sha256:99f60d34c048c5c2fabc766108c103612344c46e35d4ed9ae0673d33c8fb26e8"}, + {file = "multidict-6.0.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f285e862d2f153a70586579c15c44656f888806ed0e5b56b64489afe4a2dbfba"}, + {file = "multidict-6.0.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:53689bb4e102200a4fafa9de9c7c3c212ab40a7ab2c8e474491914d2305f187e"}, + {file = "multidict-6.0.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:612d1156111ae11d14afaf3a0669ebf6c170dbb735e510a7438ffe2369a847fd"}, + {file = "multidict-6.0.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7be7047bd08accdb7487737631d25735c9a04327911de89ff1b26b81745bd4e3"}, + {file = "multidict-6.0.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de170c7b4fe6859beb8926e84f7d7d6c693dfe8e27372ce3b76f01c46e489fcf"}, + {file = "multidict-6.0.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04bde7a7b3de05732a4eb39c94574db1ec99abb56162d6c520ad26f83267de29"}, + {file = "multidict-6.0.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85f67aed7bb647f93e7520633d8f51d3cbc6ab96957c71272b286b2f30dc70ed"}, + {file = "multidict-6.0.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:425bf820055005bfc8aa9a0b99ccb52cc2f4070153e34b701acc98d201693733"}, + {file = "multidict-6.0.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d3eb1ceec286eba8220c26f3b0096cf189aea7057b6e7b7a2e60ed36b373b77f"}, + {file = "multidict-6.0.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:7901c05ead4b3fb75113fb1dd33eb1253c6d3ee37ce93305acd9d38e0b5f21a4"}, + {file = "multidict-6.0.5-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:e0e79d91e71b9867c73323a3444724d496c037e578a0e1755ae159ba14f4f3d1"}, + {file = "multidict-6.0.5-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:29bfeb0dff5cb5fdab2023a7a9947b3b4af63e9c47cae2a10ad58394b517fddc"}, + {file = "multidict-6.0.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e030047e85cbcedbfc073f71836d62dd5dadfbe7531cae27789ff66bc551bd5e"}, + {file = "multidict-6.0.5-cp311-cp311-win32.whl", hash = "sha256:2f4848aa3baa109e6ab81fe2006c77ed4d3cd1e0ac2c1fbddb7b1277c168788c"}, + {file = "multidict-6.0.5-cp311-cp311-win_amd64.whl", hash = "sha256:2faa5ae9376faba05f630d7e5e6be05be22913782b927b19d12b8145968a85ea"}, + {file = "multidict-6.0.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:51d035609b86722963404f711db441cf7134f1889107fb171a970c9701f92e1e"}, + {file = "multidict-6.0.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:cbebcd5bcaf1eaf302617c114aa67569dd3f090dd0ce8ba9e35e9985b41ac35b"}, + {file = "multidict-6.0.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2ffc42c922dbfddb4a4c3b438eb056828719f07608af27d163191cb3e3aa6cc5"}, + {file = "multidict-6.0.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ceb3b7e6a0135e092de86110c5a74e46bda4bd4fbfeeb3a3bcec79c0f861e450"}, + {file = "multidict-6.0.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:79660376075cfd4b2c80f295528aa6beb2058fd289f4c9252f986751a4cd0496"}, + {file = "multidict-6.0.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e4428b29611e989719874670fd152b6625500ad6c686d464e99f5aaeeaca175a"}, + {file = "multidict-6.0.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d84a5c3a5f7ce6db1f999fb9438f686bc2e09d38143f2d93d8406ed2dd6b9226"}, + {file = "multidict-6.0.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:76c0de87358b192de7ea9649beb392f107dcad9ad27276324c24c91774ca5271"}, + {file = "multidict-6.0.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:79a6d2ba910adb2cbafc95dad936f8b9386e77c84c35bc0add315b856d7c3abb"}, + {file = "multidict-6.0.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:92d16a3e275e38293623ebf639c471d3e03bb20b8ebb845237e0d3664914caef"}, + {file = "multidict-6.0.5-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:fb616be3538599e797a2017cccca78e354c767165e8858ab5116813146041a24"}, + {file = "multidict-6.0.5-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:14c2976aa9038c2629efa2c148022ed5eb4cb939e15ec7aace7ca932f48f9ba6"}, + {file = "multidict-6.0.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:435a0984199d81ca178b9ae2c26ec3d49692d20ee29bc4c11a2a8d4514c67eda"}, + {file = "multidict-6.0.5-cp312-cp312-win32.whl", hash = "sha256:9fe7b0653ba3d9d65cbe7698cca585bf0f8c83dbbcc710db9c90f478e175f2d5"}, + {file = "multidict-6.0.5-cp312-cp312-win_amd64.whl", hash = "sha256:01265f5e40f5a17f8241d52656ed27192be03bfa8764d88e8220141d1e4b3556"}, + {file = "multidict-6.0.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:19fe01cea168585ba0f678cad6f58133db2aa14eccaf22f88e4a6dccadfad8b3"}, + {file = "multidict-6.0.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6bf7a982604375a8d49b6cc1b781c1747f243d91b81035a9b43a2126c04766f5"}, + {file = "multidict-6.0.5-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:107c0cdefe028703fb5dafe640a409cb146d44a6ae201e55b35a4af8e95457dd"}, + {file = "multidict-6.0.5-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:403c0911cd5d5791605808b942c88a8155c2592e05332d2bf78f18697a5fa15e"}, + {file = "multidict-6.0.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aeaf541ddbad8311a87dd695ed9642401131ea39ad7bc8cf3ef3967fd093b626"}, + {file = "multidict-6.0.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e4972624066095e52b569e02b5ca97dbd7a7ddd4294bf4e7247d52635630dd83"}, + {file = "multidict-6.0.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d946b0a9eb8aaa590df1fe082cee553ceab173e6cb5b03239716338629c50c7a"}, + {file = "multidict-6.0.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:b55358304d7a73d7bdf5de62494aaf70bd33015831ffd98bc498b433dfe5b10c"}, + {file = "multidict-6.0.5-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:a3145cb08d8625b2d3fee1b2d596a8766352979c9bffe5d7833e0503d0f0b5e5"}, + {file = "multidict-6.0.5-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:d65f25da8e248202bd47445cec78e0025c0fe7582b23ec69c3b27a640dd7a8e3"}, + {file = "multidict-6.0.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:c9bf56195c6bbd293340ea82eafd0071cb3d450c703d2c93afb89f93b8386ccc"}, + {file = "multidict-6.0.5-cp37-cp37m-win32.whl", hash = "sha256:69db76c09796b313331bb7048229e3bee7928eb62bab5e071e9f7fcc4879caee"}, + {file = "multidict-6.0.5-cp37-cp37m-win_amd64.whl", hash = "sha256:fce28b3c8a81b6b36dfac9feb1de115bab619b3c13905b419ec71d03a3fc1423"}, + {file = "multidict-6.0.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:76f067f5121dcecf0d63a67f29080b26c43c71a98b10c701b0677e4a065fbd54"}, + {file = "multidict-6.0.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b82cc8ace10ab5bd93235dfaab2021c70637005e1ac787031f4d1da63d493c1d"}, + {file = "multidict-6.0.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:5cb241881eefd96b46f89b1a056187ea8e9ba14ab88ba632e68d7a2ecb7aadf7"}, + {file = "multidict-6.0.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8e94e6912639a02ce173341ff62cc1201232ab86b8a8fcc05572741a5dc7d93"}, + {file = "multidict-6.0.5-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09a892e4a9fb47331da06948690ae38eaa2426de97b4ccbfafbdcbe5c8f37ff8"}, + {file = "multidict-6.0.5-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:55205d03e8a598cfc688c71ca8ea5f66447164efff8869517f175ea632c7cb7b"}, + {file = "multidict-6.0.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:37b15024f864916b4951adb95d3a80c9431299080341ab9544ed148091b53f50"}, + {file = "multidict-6.0.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2a1dee728b52b33eebff5072817176c172050d44d67befd681609b4746e1c2e"}, + {file = "multidict-6.0.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:edd08e6f2f1a390bf137080507e44ccc086353c8e98c657e666c017718561b89"}, + {file = "multidict-6.0.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:60d698e8179a42ec85172d12f50b1668254628425a6bd611aba022257cac1386"}, + {file = "multidict-6.0.5-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:3d25f19500588cbc47dc19081d78131c32637c25804df8414463ec908631e453"}, + {file = "multidict-6.0.5-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:4cc0ef8b962ac7a5e62b9e826bd0cd5040e7d401bc45a6835910ed699037a461"}, + {file = "multidict-6.0.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:eca2e9d0cc5a889850e9bbd68e98314ada174ff6ccd1129500103df7a94a7a44"}, + {file = "multidict-6.0.5-cp38-cp38-win32.whl", hash = "sha256:4a6a4f196f08c58c59e0b8ef8ec441d12aee4125a7d4f4fef000ccb22f8d7241"}, + {file = "multidict-6.0.5-cp38-cp38-win_amd64.whl", hash = "sha256:0275e35209c27a3f7951e1ce7aaf93ce0d163b28948444bec61dd7badc6d3f8c"}, + {file = "multidict-6.0.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e7be68734bd8c9a513f2b0cfd508802d6609da068f40dc57d4e3494cefc92929"}, + {file = "multidict-6.0.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1d9ea7a7e779d7a3561aade7d596649fbecfa5c08a7674b11b423783217933f9"}, + {file = "multidict-6.0.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ea1456df2a27c73ce51120fa2f519f1bea2f4a03a917f4a43c8707cf4cbbae1a"}, + {file = "multidict-6.0.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf590b134eb70629e350691ecca88eac3e3b8b3c86992042fb82e3cb1830d5e1"}, + {file = "multidict-6.0.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5c0631926c4f58e9a5ccce555ad7747d9a9f8b10619621f22f9635f069f6233e"}, + {file = "multidict-6.0.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dce1c6912ab9ff5f179eaf6efe7365c1f425ed690b03341911bf4939ef2f3046"}, + {file = "multidict-6.0.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0868d64af83169e4d4152ec612637a543f7a336e4a307b119e98042e852ad9c"}, + {file = "multidict-6.0.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:141b43360bfd3bdd75f15ed811850763555a251e38b2405967f8e25fb43f7d40"}, + {file = "multidict-6.0.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:7df704ca8cf4a073334e0427ae2345323613e4df18cc224f647f251e5e75a527"}, + {file = "multidict-6.0.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:6214c5a5571802c33f80e6c84713b2c79e024995b9c5897f794b43e714daeec9"}, + {file = "multidict-6.0.5-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:cd6c8fca38178e12c00418de737aef1261576bd1b6e8c6134d3e729a4e858b38"}, + {file = "multidict-6.0.5-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:e02021f87a5b6932fa6ce916ca004c4d441509d33bbdbeca70d05dff5e9d2479"}, + {file = "multidict-6.0.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ebd8d160f91a764652d3e51ce0d2956b38efe37c9231cd82cfc0bed2e40b581c"}, + {file = "multidict-6.0.5-cp39-cp39-win32.whl", hash = "sha256:04da1bb8c8dbadf2a18a452639771951c662c5ad03aefe4884775454be322c9b"}, + {file = "multidict-6.0.5-cp39-cp39-win_amd64.whl", hash = "sha256:d6f6d4f185481c9669b9447bf9d9cf3b95a0e9df9d169bbc17e363b7d5487755"}, + {file = "multidict-6.0.5-py3-none-any.whl", hash = "sha256:0d63c74e3d7ab26de115c49bffc92cc77ed23395303d496eae515d4204a625e7"}, + {file = "multidict-6.0.5.tar.gz", hash = "sha256:f7e301075edaf50500f0b341543c41194d8df3ae5caf4702f2095f3ca73dd8da"}, +] + +[[package]] +name = "mypy" +version = "1.9.0" +description = "Optional static typing for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "mypy-1.9.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f8a67616990062232ee4c3952f41c779afac41405806042a8126fe96e098419f"}, + {file = "mypy-1.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d357423fa57a489e8c47b7c85dfb96698caba13d66e086b412298a1a0ea3b0ed"}, + {file = "mypy-1.9.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49c87c15aed320de9b438ae7b00c1ac91cd393c1b854c2ce538e2a72d55df150"}, + {file = "mypy-1.9.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:48533cdd345c3c2e5ef48ba3b0d3880b257b423e7995dada04248725c6f77374"}, + {file = "mypy-1.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:4d3dbd346cfec7cb98e6cbb6e0f3c23618af826316188d587d1c1bc34f0ede03"}, + {file = "mypy-1.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:653265f9a2784db65bfca694d1edd23093ce49740b2244cde583aeb134c008f3"}, + {file = "mypy-1.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3a3c007ff3ee90f69cf0a15cbcdf0995749569b86b6d2f327af01fd1b8aee9dc"}, + {file = "mypy-1.9.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2418488264eb41f69cc64a69a745fad4a8f86649af4b1041a4c64ee61fc61129"}, + {file = "mypy-1.9.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:68edad3dc7d70f2f17ae4c6c1b9471a56138ca22722487eebacfd1eb5321d612"}, + {file = "mypy-1.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:85ca5fcc24f0b4aeedc1d02f93707bccc04733f21d41c88334c5482219b1ccb3"}, + {file = "mypy-1.9.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aceb1db093b04db5cd390821464504111b8ec3e351eb85afd1433490163d60cd"}, + {file = "mypy-1.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0235391f1c6f6ce487b23b9dbd1327b4ec33bb93934aa986efe8a9563d9349e6"}, + {file = "mypy-1.9.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d4d5ddc13421ba3e2e082a6c2d74c2ddb3979c39b582dacd53dd5d9431237185"}, + {file = "mypy-1.9.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:190da1ee69b427d7efa8aa0d5e5ccd67a4fb04038c380237a0d96829cb157913"}, + {file = "mypy-1.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:fe28657de3bfec596bbeef01cb219833ad9d38dd5393fc649f4b366840baefe6"}, + {file = "mypy-1.9.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e54396d70be04b34f31d2edf3362c1edd023246c82f1730bbf8768c28db5361b"}, + {file = "mypy-1.9.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:5e6061f44f2313b94f920e91b204ec600982961e07a17e0f6cd83371cb23f5c2"}, + {file = "mypy-1.9.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:81a10926e5473c5fc3da8abb04119a1f5811a236dc3a38d92015cb1e6ba4cb9e"}, + {file = "mypy-1.9.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b685154e22e4e9199fc95f298661deea28aaede5ae16ccc8cbb1045e716b3e04"}, + {file = "mypy-1.9.0-cp38-cp38-win_amd64.whl", hash = "sha256:5d741d3fc7c4da608764073089e5f58ef6352bedc223ff58f2f038c2c4698a89"}, + {file = "mypy-1.9.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:587ce887f75dd9700252a3abbc9c97bbe165a4a630597845c61279cf32dfbf02"}, + {file = "mypy-1.9.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f88566144752999351725ac623471661c9d1cd8caa0134ff98cceeea181789f4"}, + {file = "mypy-1.9.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61758fabd58ce4b0720ae1e2fea5cfd4431591d6d590b197775329264f86311d"}, + {file = "mypy-1.9.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:e49499be624dead83927e70c756970a0bc8240e9f769389cdf5714b0784ca6bf"}, + {file = "mypy-1.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:571741dc4194b4f82d344b15e8837e8c5fcc462d66d076748142327626a1b6e9"}, + {file = "mypy-1.9.0-py3-none-any.whl", hash = "sha256:a260627a570559181a9ea5de61ac6297aa5af202f06fd7ab093ce74e7181e43e"}, + {file = "mypy-1.9.0.tar.gz", hash = "sha256:3cc5da0127e6a478cddd906068496a97a7618a21ce9b54bde5bf7e539c7af974"}, +] + +[package.dependencies] +mypy-extensions = ">=1.0.0" +typing-extensions = ">=4.1.0" + +[package.extras] +dmypy = ["psutil (>=4.0)"] +install-types = ["pip"] +mypyc = ["setuptools (>=50)"] +reports = ["lxml"] + +[[package]] +name = "mypy-extensions" +version = "1.0.0" +description = "Type system extensions for programs checked with the mypy type checker." +optional = false +python-versions = ">=3.5" +files = [ + {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, + {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, +] + +[[package]] +name = "nextcord" +version = "2.6.0" +description = "A Python wrapper for the Discord API forked from discord.py" +optional = false +python-versions = ">=3.8.0" +files = [ + {file = "nextcord-2.6.0.tar.gz", hash = "sha256:ccf461157be682cbceaa474e8df4afa302351b8d6ced632f9ccb7aa647b092e7"}, +] + +[package.dependencies] +aiohttp = ">=3.8.0,<4.0.0" +typing_extensions = ">=4.2.0,<5" + +[package.extras] +docs = ["sphinx (==5.2.3)", "sphinxcontrib-websupport", "sphinxcontrib_trio (==1.1.2)", "typing_extensions (>=4.2.0,<5)"] +speed = ["aiohttp[speedups]", "orjson (>=3.5.4)"] +voice = ["PyNaCl (>=1.3.0,<1.5)"] + +[[package]] +name = "nodeenv" +version = "1.8.0" +description = "Node.js virtual environment builder" +optional = false +python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*" +files = [ + {file = "nodeenv-1.8.0-py2.py3-none-any.whl", hash = "sha256:df865724bb3c3adc86b3876fa209771517b0cfe596beff01a92700e0e8be4cec"}, + {file = "nodeenv-1.8.0.tar.gz", hash = "sha256:d51e0c37e64fbf47d017feac3145cdbb58836d7eee8c6f6d3b6880c5456227d2"}, +] + +[package.dependencies] +setuptools = "*" + +[[package]] +name = "platformdirs" +version = "4.2.0" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +optional = false +python-versions = ">=3.8" +files = [ + {file = "platformdirs-4.2.0-py3-none-any.whl", hash = "sha256:0614df2a2f37e1a662acbd8e2b25b92ccf8632929bc6d43467e17fe89c75e068"}, + {file = "platformdirs-4.2.0.tar.gz", hash = "sha256:ef0cc731df711022c174543cb70a9b5bd22e5a9337c8624ef2c2ceb8ddad8768"}, +] + +[package.extras] +docs = ["furo (>=2023.9.10)", "proselint (>=0.13)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)"] + +[[package]] +name = "pre-commit" +version = "3.7.0" +description = "A framework for managing and maintaining multi-language pre-commit hooks." +optional = false +python-versions = ">=3.9" +files = [ + {file = "pre_commit-3.7.0-py2.py3-none-any.whl", hash = "sha256:5eae9e10c2b5ac51577c3452ec0a490455c45a0533f7960f993a0d01e59decab"}, + {file = "pre_commit-3.7.0.tar.gz", hash = "sha256:e209d61b8acdcf742404408531f0c37d49d2c734fd7cff2d6076083d191cb060"}, +] + +[package.dependencies] +cfgv = ">=2.0.0" +identify = ">=1.0.0" +nodeenv = ">=0.11.1" +pyyaml = ">=5.1" +virtualenv = ">=20.10.0" + +[[package]] +name = "pycparser" +version = "2.21" +description = "C parser in Python" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"}, + {file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"}, +] + +[[package]] +name = "pylast" +version = "5.2.0" +description = "A Python interface to Last.fm and Libre.fm" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pylast-5.2.0-py3-none-any.whl", hash = "sha256:89c7c01ea9f08c83865999d8907835157a8096e77dd9dc23420246eb66cfcff5"}, + {file = "pylast-5.2.0.tar.gz", hash = "sha256:bb046804ef56a0c18072c750d61a282d47ac102a3b0b9c44a023eaf5b0934b0a"}, +] + +[package.dependencies] +httpx = "*" + +[package.extras] +tests = ["flaky", "pytest", "pytest-cov", "pytest-random-order", "pyyaml"] + +[[package]] +name = "pyyaml" +version = "6.0.1" +description = "YAML parser and emitter for Python" +optional = false +python-versions = ">=3.6" +files = [ + {file = "PyYAML-6.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a"}, + {file = "PyYAML-6.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f"}, + {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, + {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, + {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, + {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, + {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, + {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, + {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, + {file = "PyYAML-6.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab"}, + {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, + {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, + {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, + {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, + {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, + {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, + {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"}, + {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, + {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, + {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, + {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, + {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, + {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, + {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, + {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd"}, + {file = "PyYAML-6.0.1-cp36-cp36m-win32.whl", hash = "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585"}, + {file = "PyYAML-6.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa"}, + {file = "PyYAML-6.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3"}, + {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27"}, + {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3"}, + {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c"}, + {file = "PyYAML-6.0.1-cp37-cp37m-win32.whl", hash = "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba"}, + {file = "PyYAML-6.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867"}, + {file = "PyYAML-6.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595"}, + {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, + {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, + {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, + {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, + {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, + {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, + {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, + {file = "PyYAML-6.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859"}, + {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, + {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, + {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, + {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, + {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, + {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, + {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, +] + +[[package]] +name = "rapidfuzz" +version = "3.7.0" +description = "rapid fuzzy string matching" +optional = false +python-versions = ">=3.8" +files = [ + {file = "rapidfuzz-3.7.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:860f438238f1807532aa5c5c25e74c284232ccc115fe84697b78e25d48f364f7"}, + {file = "rapidfuzz-3.7.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4bb9285abeb0477cdb2f8ea0cf7fd4b5f72ed5a9a7d3f0c0bb4a5239db2fc1ed"}, + {file = "rapidfuzz-3.7.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:08671280e0c04d2bb3f39511f13cae5914e6690036fd1eefc3d47a47f9fae634"}, + {file = "rapidfuzz-3.7.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:04bae4d9c16ce1bab6447d196fb8258d98139ed8f9b288a38b84887985e4227b"}, + {file = "rapidfuzz-3.7.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1efa2268b51b68156fb84d18ca1720311698a58051c4a19c40d670057ce60519"}, + {file = "rapidfuzz-3.7.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:600b4d4315f33ec0356c0dab3991a5d5761102420bcff29e0773706aa48936e8"}, + {file = "rapidfuzz-3.7.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:18bc2f13c73d5d34499ff6ada55b052c445d3aa64d22c2639e5ab45472568046"}, + {file = "rapidfuzz-3.7.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e11c5e6593be41a555475c9c20320342c1f5585d635a064924956944c465ad4"}, + {file = "rapidfuzz-3.7.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d7878025248b99ccca3285891899373f98548f2ca13835d83619ffc42241c626"}, + {file = "rapidfuzz-3.7.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:b4a7e37fe136022d944374fcd8a2f72b8a19f7b648d2cdfb946667e9ede97f9f"}, + {file = "rapidfuzz-3.7.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:b5881856f830351aaabd869151124f64a80bf61560546d9588a630a4e933a5de"}, + {file = "rapidfuzz-3.7.0-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:c788b11565cc176fab8fab6dfcd469031e906927db94bf7e422afd8ef8f88a5a"}, + {file = "rapidfuzz-3.7.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:9e17a3092e74025d896ef1d67ac236c83494da37a78ef84c712e4e2273c115f1"}, + {file = "rapidfuzz-3.7.0-cp310-cp310-win32.whl", hash = "sha256:e499c823206c9ffd9d89aa11f813a4babdb9219417d4efe4c8a6f8272da00e98"}, + {file = "rapidfuzz-3.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:91f798cc00cd94a0def43e9befc6e867c9bd8fa8f882d1eaa40042f528b7e2c7"}, + {file = "rapidfuzz-3.7.0-cp310-cp310-win_arm64.whl", hash = "sha256:d5a3872f35bec89f07b993fa1c5401d11b9e68bcdc1b9737494e279308a38a5f"}, + {file = "rapidfuzz-3.7.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ef6b6ab64c4c91c57a6b58e1d690b59453bfa1f1e9757a7e52e59b4079e36631"}, + {file = "rapidfuzz-3.7.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2f9070b42c0ba030b045bba16a35bdb498a0d6acb0bdb3ff4e325960e685e290"}, + {file = "rapidfuzz-3.7.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:63044c63565f50818d885bfcd40ac369947da4197de56b4d6c26408989d48edf"}, + {file = "rapidfuzz-3.7.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:49b0c47860c733a3d73a4b70b97b35c8cbf24ef24f8743732f0d1c412a8c85de"}, + {file = "rapidfuzz-3.7.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d1b14489b038f007f425a06fcf28ac6313c02cb603b54e3a28d9cfae82198cc0"}, + {file = "rapidfuzz-3.7.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be08f39e397a618aab907887465d7fabc2d1a4d15d1a67cb8b526a7fb5202a3e"}, + {file = "rapidfuzz-3.7.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:16895dc62a7b92028f9c8b6d22830f1cbc77306ee794f461afc6028e1a8d7539"}, + {file = "rapidfuzz-3.7.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:579cce49dfa57ffd8c8227b3fb53cced54b4df70cec502e63e9799b4d1f44004"}, + {file = "rapidfuzz-3.7.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:40998c8dc35fdd221790b8b5134a8d7499adbfab9a5dd9ec626c7e92e17a43ed"}, + {file = "rapidfuzz-3.7.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:dc3fdb4738a6b83ae27f1d8923b00d3a9c2b5c50da75b9f8b81841839c6e3e1f"}, + {file = "rapidfuzz-3.7.0-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:92b8146fbfb37ac358ef7e0f6b79619e4f793fbbe894b99ea87920f9c0a9d77d"}, + {file = "rapidfuzz-3.7.0-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:1dfceaa7c2914585bb8a043265c39ec09078f13fbf53b5525722fc074306b6fa"}, + {file = "rapidfuzz-3.7.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f332d61f51b0b9c8b55a0fb052b4764b6ad599ea8ce948ac47a4388e9083c35e"}, + {file = "rapidfuzz-3.7.0-cp311-cp311-win32.whl", hash = "sha256:dfd1e4819f1f3c47141f86159b44b7360ecb19bf675080b3b40437bf97273ab9"}, + {file = "rapidfuzz-3.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:594b9c33fc1a86784962043ee3fbaaed875fbaadff72e467c2f7a83cd6c5d69d"}, + {file = "rapidfuzz-3.7.0-cp311-cp311-win_arm64.whl", hash = "sha256:0b13a6823a1b83ae43f8bf35955df35032bee7bec0daf9b5ab836e0286067434"}, + {file = "rapidfuzz-3.7.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:075a419a0ec29be44b3d7f4bcfa5cb7e91e419379a85fc05eb33de68315bd96f"}, + {file = "rapidfuzz-3.7.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:51a5b96d2081c3afbef1842a61d63e55d0a5a201473e6975a80190ff2d6f22ca"}, + {file = "rapidfuzz-3.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a9460d8fddac7ea46dff9298eee9aa950dbfe79f2eb509a9f18fbaefcd10894c"}, + {file = "rapidfuzz-3.7.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f39eb1513ee139ba6b5c01fe47ddf2d87e9560dd7fdee1068f7f6efbae70de34"}, + {file = "rapidfuzz-3.7.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eace9fdde58a425d4c9a93021b24a0cac830df167a5b2fc73299e2acf9f41493"}, + {file = "rapidfuzz-3.7.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cc77237242303733de47829028a0a8b6ab9188b23ec9d9ff0a674fdcd3c8e7f"}, + {file = "rapidfuzz-3.7.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:74e692357dd324dff691d379ef2c094c9ec526c0ce83ed43a066e4e68fe70bf6"}, + {file = "rapidfuzz-3.7.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5f2075ac9ee5c15d33d24a1efc8368d095602b5fd9634c5b5f24d83e41903528"}, + {file = "rapidfuzz-3.7.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:5a8ba64d72329a940ff6c74b721268c2004eecc48558f648a38e96915b5d1c1b"}, + {file = "rapidfuzz-3.7.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:a1f268a2a37cd22573b4a06eccd481c04504b246d3cadc2d8e8dfa64b575636d"}, + {file = "rapidfuzz-3.7.0-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:42c2e8a2341363c7caf276efdbe1a673fc5267a02568c47c8e980f12e9bc8727"}, + {file = "rapidfuzz-3.7.0-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:a9acca34b34fb895ee6a84c436bb919f3b9cd8f43e7003d43e9573a1d990ff74"}, + {file = "rapidfuzz-3.7.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:9bad6a0fe3bc1753dacaa6229a8ba7d9844eb7ae24d44d17c5f4c51c91a8a95e"}, + {file = "rapidfuzz-3.7.0-cp312-cp312-win32.whl", hash = "sha256:c86bc4b1d2380739e6485396195e30021df509b4923f3f757914e171587bce7c"}, + {file = "rapidfuzz-3.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:d7361608c8e73a1dc0203a87d151cddebdade0098a047c46da43c469c07df964"}, + {file = "rapidfuzz-3.7.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fdc26e7863e0f63c2185d53bb61f5173ad4451c1c8287b535b30ea25a419a5a"}, + {file = "rapidfuzz-3.7.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:9b6167468f76779a14b9af66210f68741af94d32d086f19118de4e919f00585c"}, + {file = "rapidfuzz-3.7.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:5bd394e28ff221557ea4d8152fcec3e66d9f620557feca5f2bedc4c21f8cf2f9"}, + {file = "rapidfuzz-3.7.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:8e70f876ca89a6df344f8157ac60384e8c05a0dfb442da2490c3f1c45238ccf5"}, + {file = "rapidfuzz-3.7.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7c837f89d86a5affe9ee6574dad6b195475676a6ab171a67920fc99966f2ab2c"}, + {file = "rapidfuzz-3.7.0-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cda4550a98658f9a8bcdc03d0498ed1565c1563880e3564603a9eaae28d51b2a"}, + {file = "rapidfuzz-3.7.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ecd70212fd9f1f8b1d3bdd8bcb05acc143defebd41148bdab43e573b043bb241"}, + {file = "rapidfuzz-3.7.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:187db4cc8fb54f8c49c67b7f38ef3a122ce23be273032fa2ff34112a2694c3d8"}, + {file = "rapidfuzz-3.7.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4604dfc1098920c4eb6d0c6b5cc7bdd4bf95b48633e790c1d3f100a25870691d"}, + {file = "rapidfuzz-3.7.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:01581b688c5f4f6665b779135e32db0edab1d78028abf914bb91469928efa383"}, + {file = "rapidfuzz-3.7.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:0828b55ec8ad084febdf4ab0c942eb1f81c97c0935f1cb0be0b4ea84ce755988"}, + {file = "rapidfuzz-3.7.0-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:150c98b65faff17b917b9d36bff8a4d37b6173579c6bc2e38ff2044e209d37a4"}, + {file = "rapidfuzz-3.7.0-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:7e4eea225d2bff1aff4c85fcc44716596d3699374d99eb5906b7a7560297460e"}, + {file = "rapidfuzz-3.7.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:7bc944d7e830cfce0f8b4813875f05904207017b66e25ab7ee757507001310a9"}, + {file = "rapidfuzz-3.7.0-cp38-cp38-win32.whl", hash = "sha256:3e55f02105c451ab6ff0edaaba57cab1b6c0a0241cfb2b306d4e8e1503adba50"}, + {file = "rapidfuzz-3.7.0-cp38-cp38-win_amd64.whl", hash = "sha256:41851620d2900791d66d9b6092fc163441d7dd91a460c73b07957ff1c517bc30"}, + {file = "rapidfuzz-3.7.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e8041c6b2d339766efe6298fa272f79d6dd799965df364ef4e50f488c101c899"}, + {file = "rapidfuzz-3.7.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:4e09d81008e212fc824ea23603ff5270d75886e72372fa6c7c41c1880bcb57ed"}, + {file = "rapidfuzz-3.7.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:419c8961e861fb5fc5590056c66a279623d1ea27809baea17e00cdc313f1217a"}, + {file = "rapidfuzz-3.7.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1522eaab91b9400b3ef16eebe445940a19e70035b5bc5d98aef23d66e9ac1df0"}, + {file = "rapidfuzz-3.7.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:611278ce3136f4544d596af18ab8849827d64372e1d8888d9a8d071bf4a3f44d"}, + {file = "rapidfuzz-3.7.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4efa9bfc5b955b6474ee077eee154e240441842fa304f280b06e6b6aa58a1d1e"}, + {file = "rapidfuzz-3.7.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c0cc9d3c8261457af3f8756b1f71a9fdc4892978a9e8b967976d2803e08bf972"}, + {file = "rapidfuzz-3.7.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce728e2b582fd396bc2559160ee2e391e6a4b5d2e455624044699d96abe8a396"}, + {file = "rapidfuzz-3.7.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:3a6a36c9299e059e0bee3409218bc5235a46570c20fc980cdee5ed21ea6110ad"}, + {file = "rapidfuzz-3.7.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9ea720db8def684c1eb71dadad1f61c9b52f4d979263eb5d443f2b22b0d5430a"}, + {file = "rapidfuzz-3.7.0-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:358692f1df3f8aebcd48e69c77c948c9283b44c0efbaf1eeea01739efe3cd9a6"}, + {file = "rapidfuzz-3.7.0-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:faded69ffe79adcefa8da08f414a0fd52375e2b47f57be79471691dad9656b5a"}, + {file = "rapidfuzz-3.7.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:7f9f3dc14fadbd553975f824ac48c381f42192cec9d7e5711b528357662a8d8e"}, + {file = "rapidfuzz-3.7.0-cp39-cp39-win32.whl", hash = "sha256:7be5f460ff42d7d27729115bfe8a02e83fa0284536d8630ee900d17b75c29e65"}, + {file = "rapidfuzz-3.7.0-cp39-cp39-win_amd64.whl", hash = "sha256:dd5ad2c12dab2b98340c4b7b9592c8f349730bda9a2e49675ea592bbcbc1360b"}, + {file = "rapidfuzz-3.7.0-cp39-cp39-win_arm64.whl", hash = "sha256:aa163257a0ac4e70f9009d25e5030bdd83a8541dfa3ba78dc86b35c9e16a80b4"}, + {file = "rapidfuzz-3.7.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:4e50840a8a8e0229563eeaf22e21a203359859557db8829f4d0285c17126c5fb"}, + {file = "rapidfuzz-3.7.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:632f09e19365ace5ff2670008adc8bf23d03d668b03a30230e5b60ff9317ee93"}, + {file = "rapidfuzz-3.7.0-pp38-pypy38_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:209dda6ae66b702f74a78cef555397cdc2a83d7f48771774a20d2fc30808b28c"}, + {file = "rapidfuzz-3.7.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bc0b78572626af6ab134895e4dbfe4f4d615d18dcc43b8d902d8e45471aabba"}, + {file = "rapidfuzz-3.7.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:7ba14850cc8258b3764ea16b8a4409ac2ba16d229bde7a5f495dd479cd9ccd56"}, + {file = "rapidfuzz-3.7.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:b917764fd2b267addc9d03a96d26f751f6117a95f617428c44a069057653b528"}, + {file = "rapidfuzz-3.7.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1252ca156e1b053e84e5ae1c8e9e062ee80468faf23aa5c543708212a42795fd"}, + {file = "rapidfuzz-3.7.0-pp39-pypy39_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:86c7676a32d7524e40bc73546e511a408bc831ae5b163029d325ea3a2027d089"}, + {file = "rapidfuzz-3.7.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:20e7d729af2e5abb29caa070ec048aba042f134091923d9ca2ac662b5604577e"}, + {file = "rapidfuzz-3.7.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:86eea3e6c314a9238de568254a9c591ec73c2985f125675ed5f171d869c47773"}, + {file = "rapidfuzz-3.7.0.tar.gz", hash = "sha256:620df112c39c6d27316dc1e22046dc0382d6d91fd60d7c51bd41ca0333d867e9"}, +] + +[package.extras] +full = ["numpy"] + +[[package]] +name = "ratelimit" +version = "2.2.1" +description = "API rate limit decorator" +optional = false +python-versions = "*" +files = [ + {file = "ratelimit-2.2.1.tar.gz", hash = "sha256:af8a9b64b821529aca09ebaf6d8d279100d766f19e90b5059ac6a718ca6dee42"}, +] + +[[package]] +name = "redis" +version = "5.0.3" +description = "Python client for Redis database and key-value store" +optional = false +python-versions = ">=3.7" +files = [ + {file = "redis-5.0.3-py3-none-any.whl", hash = "sha256:5da9b8fe9e1254293756c16c008e8620b3d15fcc6dde6babde9541850e72a32d"}, + {file = "redis-5.0.3.tar.gz", hash = "sha256:4973bae7444c0fbed64a06b87446f79361cb7e4ec1538c022d696ed7a5015580"}, +] + +[package.extras] +hiredis = ["hiredis (>=1.0.0)"] +ocsp = ["cryptography (>=36.0.1)", "pyopenssl (==20.0.1)", "requests (>=2.26.0)"] + +[[package]] +name = "requests" +version = "2.31.0" +description = "Python HTTP for Humans." +optional = false +python-versions = ">=3.7" +files = [ + {file = "requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f"}, + {file = "requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1"}, +] + +[package.dependencies] +certifi = ">=2017.4.17" +charset-normalizer = ">=2,<4" +idna = ">=2.5,<4" +urllib3 = ">=1.21.1,<3" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] + +[[package]] +name = "ruff" +version = "0.3.4" +description = "An extremely fast Python linter and code formatter, written in Rust." +optional = false +python-versions = ">=3.7" +files = [ + {file = "ruff-0.3.4-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:60c870a7d46efcbc8385d27ec07fe534ac32f3b251e4fc44b3cbfd9e09609ef4"}, + {file = "ruff-0.3.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6fc14fa742e1d8f24910e1fff0bd5e26d395b0e0e04cc1b15c7c5e5fe5b4af91"}, + {file = "ruff-0.3.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d3ee7880f653cc03749a3bfea720cf2a192e4f884925b0cf7eecce82f0ce5854"}, + {file = "ruff-0.3.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cf133dd744f2470b347f602452a88e70dadfbe0fcfb5fd46e093d55da65f82f7"}, + {file = "ruff-0.3.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3f3860057590e810c7ffea75669bdc6927bfd91e29b4baa9258fd48b540a4365"}, + {file = "ruff-0.3.4-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:986f2377f7cf12efac1f515fc1a5b753c000ed1e0a6de96747cdf2da20a1b369"}, + {file = "ruff-0.3.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c4fd98e85869603e65f554fdc5cddf0712e352fe6e61d29d5a6fe087ec82b76c"}, + {file = "ruff-0.3.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:64abeed785dad51801b423fa51840b1764b35d6c461ea8caef9cf9e5e5ab34d9"}, + {file = "ruff-0.3.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:df52972138318bc7546d92348a1ee58449bc3f9eaf0db278906eb511889c4b50"}, + {file = "ruff-0.3.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:98e98300056445ba2cc27d0b325fd044dc17fcc38e4e4d2c7711585bd0a958ed"}, + {file = "ruff-0.3.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:519cf6a0ebed244dce1dc8aecd3dc99add7a2ee15bb68cf19588bb5bf58e0488"}, + {file = "ruff-0.3.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:bb0acfb921030d00070539c038cd24bb1df73a2981e9f55942514af8b17be94e"}, + {file = "ruff-0.3.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:cf187a7e7098233d0d0c71175375c5162f880126c4c716fa28a8ac418dcf3378"}, + {file = "ruff-0.3.4-py3-none-win32.whl", hash = "sha256:af27ac187c0a331e8ef91d84bf1c3c6a5dea97e912a7560ac0cef25c526a4102"}, + {file = "ruff-0.3.4-py3-none-win_amd64.whl", hash = "sha256:de0d5069b165e5a32b3c6ffbb81c350b1e3d3483347196ffdf86dc0ef9e37dd6"}, + {file = "ruff-0.3.4-py3-none-win_arm64.whl", hash = "sha256:6810563cc08ad0096b57c717bd78aeac888a1bfd38654d9113cb3dc4d3f74232"}, + {file = "ruff-0.3.4.tar.gz", hash = "sha256:f0f4484c6541a99862b693e13a151435a279b271cff20e37101116a21e2a1ad1"}, +] + +[[package]] +name = "sentry-sdk" +version = "1.44.0" +description = "Python client for Sentry (https://sentry.io)" +optional = false +python-versions = "*" +files = [ + {file = "sentry-sdk-1.44.0.tar.gz", hash = "sha256:f7125a9235795811962d52ff796dc032cd1d0dd98b59beaced8380371cd9c13c"}, + {file = "sentry_sdk-1.44.0-py2.py3-none-any.whl", hash = "sha256:eb65289da013ca92fad2694851ad2f086aa3825e808dc285bd7dcaf63602bb18"}, +] + +[package.dependencies] +certifi = "*" +urllib3 = {version = ">=1.26.11", markers = "python_version >= \"3.6\""} + +[package.extras] +aiohttp = ["aiohttp (>=3.5)"] +arq = ["arq (>=0.23)"] +asyncpg = ["asyncpg (>=0.23)"] +beam = ["apache-beam (>=2.12)"] +bottle = ["bottle (>=0.12.13)"] +celery = ["celery (>=3)"] +celery-redbeat = ["celery-redbeat (>=2)"] +chalice = ["chalice (>=1.16.0)"] +clickhouse-driver = ["clickhouse-driver (>=0.2.0)"] +django = ["django (>=1.8)"] +falcon = ["falcon (>=1.4)"] +fastapi = ["fastapi (>=0.79.0)"] +flask = ["blinker (>=1.1)", "flask (>=0.11)", "markupsafe"] +grpcio = ["grpcio (>=1.21.1)"] +httpx = ["httpx (>=0.16.0)"] +huey = ["huey (>=2)"] +loguru = ["loguru (>=0.5)"] +openai = ["openai (>=1.0.0)", "tiktoken (>=0.3.0)"] +opentelemetry = ["opentelemetry-distro (>=0.35b0)"] +opentelemetry-experimental = ["opentelemetry-distro (>=0.40b0,<1.0)", "opentelemetry-instrumentation-aiohttp-client (>=0.40b0,<1.0)", "opentelemetry-instrumentation-django (>=0.40b0,<1.0)", "opentelemetry-instrumentation-fastapi (>=0.40b0,<1.0)", "opentelemetry-instrumentation-flask (>=0.40b0,<1.0)", "opentelemetry-instrumentation-requests (>=0.40b0,<1.0)", "opentelemetry-instrumentation-sqlite3 (>=0.40b0,<1.0)", "opentelemetry-instrumentation-urllib (>=0.40b0,<1.0)"] +pure-eval = ["asttokens", "executing", "pure-eval"] +pymongo = ["pymongo (>=3.1)"] +pyspark = ["pyspark (>=2.4.4)"] +quart = ["blinker (>=1.1)", "quart (>=0.16.1)"] +rq = ["rq (>=0.6)"] +sanic = ["sanic (>=0.8)"] +sqlalchemy = ["sqlalchemy (>=1.2)"] +starlette = ["starlette (>=0.19.1)"] +starlite = ["starlite (>=1.48)"] +tornado = ["tornado (>=5)"] + +[[package]] +name = "setuptools" +version = "69.2.0" +description = "Easily download, build, install, upgrade, and uninstall Python packages" +optional = false +python-versions = ">=3.8" +files = [ + {file = "setuptools-69.2.0-py3-none-any.whl", hash = "sha256:c21c49fb1042386df081cb5d86759792ab89efca84cf114889191cd09aacc80c"}, + {file = "setuptools-69.2.0.tar.gz", hash = "sha256:0ff4183f8f42cd8fa3acea16c45205521a4ef28f73c6391d8a25e92893134f2e"}, +] + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"] +testing = ["build[virtualenv]", "filelock (>=3.4.0)", "importlib-metadata", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "mypy (==1.9)", "packaging (>=23.2)", "pip (>=19.1)", "pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff (>=0.2.1)", "pytest-timeout", "pytest-xdist (>=3)", "tomli", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] +testing-integration = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.2)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] + +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +description = "Sniff out which async library your code is running under" +optional = false +python-versions = ">=3.7" +files = [ + {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, + {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, +] + +[[package]] +name = "spotipy" +version = "2.23.0" +description = "A light weight Python library for the Spotify Web API" +optional = false +python-versions = "*" +files = [ + {file = "spotipy-2.23.0-py2-none-any.whl", hash = "sha256:da850fbf62faaa05912132d2886c293a5fbbe8350d0821e7208a6a2fdd6a0079"}, + {file = "spotipy-2.23.0-py3-none-any.whl", hash = "sha256:6bf8b963c10d0a3e51037e4baf92e29732dee36b2a1f1b7dcc8cd5771e662a5b"}, + {file = "spotipy-2.23.0.tar.gz", hash = "sha256:0dfafe08239daae6c16faa68f60b5775d40c4110725e1a7c545ad4c7fb66d4e8"}, +] + +[package.dependencies] +redis = ">=3.5.3" +requests = ">=2.25.0" +six = ">=1.15.0" +urllib3 = ">=1.26.0" + +[package.extras] +doc = ["Sphinx (>=1.5.2)"] +test = ["mock (==2.0.0)"] + +[[package]] +name = "tenacity" +version = "8.2.3" +description = "Retry code until it succeeds" +optional = false +python-versions = ">=3.7" +files = [ + {file = "tenacity-8.2.3-py3-none-any.whl", hash = "sha256:ce510e327a630c9e1beaf17d42e6ffacc88185044ad85cf74c0a8887c6a0f88c"}, + {file = "tenacity-8.2.3.tar.gz", hash = "sha256:5398ef0d78e63f40007c1fb4c0bff96e1911394d2fa8d194f77619c05ff6cc8a"}, +] + +[package.extras] +doc = ["reno", "sphinx", "tornado (>=4.5)"] + +[[package]] +name = "thefuzz" +version = "0.22.1" +description = "Fuzzy string matching in python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "thefuzz-0.22.1-py3-none-any.whl", hash = "sha256:59729b33556850b90e1093c4cf9e618af6f2e4c985df193fdf3c5b5cf02ca481"}, + {file = "thefuzz-0.22.1.tar.gz", hash = "sha256:7138039a7ecf540da323792d8592ef9902b1d79eb78c147d4f20664de79f3680"}, +] + +[package.dependencies] +rapidfuzz = ">=3.0.0,<4.0.0" + +[[package]] +name = "typing-extensions" +version = "4.10.0" +description = "Backported and Experimental Type Hints for Python 3.8+" +optional = false +python-versions = ">=3.8" +files = [ + {file = "typing_extensions-4.10.0-py3-none-any.whl", hash = "sha256:69b1a937c3a517342112fb4c6df7e72fc39a38e7891a5730ed4985b5214b5475"}, + {file = "typing_extensions-4.10.0.tar.gz", hash = "sha256:b0abd7c89e8fb96f98db18d86106ff1d90ab692004eb746cf6eda2682f91b3cb"}, +] + +[[package]] +name = "urllib3" +version = "2.2.1" +description = "HTTP library with thread-safe connection pooling, file post, and more." +optional = false +python-versions = ">=3.8" +files = [ + {file = "urllib3-2.2.1-py3-none-any.whl", hash = "sha256:450b20ec296a467077128bff42b73080516e71b56ff59a60a02bef2232c4fa9d"}, + {file = "urllib3-2.2.1.tar.gz", hash = "sha256:d0570876c61ab9e520d776c38acbbb5b05a776d3f9ff98a5c8fd5162a444cf19"}, +] + +[package.extras] +brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] +h2 = ["h2 (>=4,<5)"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["zstandard (>=0.18.0)"] + +[[package]] +name = "validators" +version = "0.24.0" +description = "Python Data Validation for Humans™" +optional = false +python-versions = ">=3.8" +files = [ + {file = "validators-0.24.0-py3-none-any.whl", hash = "sha256:4a99eb368747e60900bae947418eb21e230ff4ff5e7b7944b9308c456d86da32"}, + {file = "validators-0.24.0.tar.gz", hash = "sha256:cd23defb36de42d14e7559cf0757f761bb46b10d9de2998e6ef805f769d859e3"}, +] + +[[package]] +name = "virtualenv" +version = "20.25.1" +description = "Virtual Python Environment builder" +optional = false +python-versions = ">=3.7" +files = [ + {file = "virtualenv-20.25.1-py3-none-any.whl", hash = "sha256:961c026ac520bac5f69acb8ea063e8a4f071bcc9457b9c1f28f6b085c511583a"}, + {file = "virtualenv-20.25.1.tar.gz", hash = "sha256:e08e13ecdca7a0bd53798f356d5831434afa5b07b93f0abdf0797b7a06ffe197"}, +] + +[package.dependencies] +distlib = ">=0.3.7,<1" +filelock = ">=3.12.2,<4" +platformdirs = ">=3.9.1,<5" + +[package.extras] +docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] +test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"] + +[[package]] +name = "yarl" +version = "1.9.4" +description = "Yet another URL library" +optional = false +python-versions = ">=3.7" +files = [ + {file = "yarl-1.9.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a8c1df72eb746f4136fe9a2e72b0c9dc1da1cbd23b5372f94b5820ff8ae30e0e"}, + {file = "yarl-1.9.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a3a6ed1d525bfb91b3fc9b690c5a21bb52de28c018530ad85093cc488bee2dd2"}, + {file = "yarl-1.9.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c38c9ddb6103ceae4e4498f9c08fac9b590c5c71b0370f98714768e22ac6fa66"}, + {file = "yarl-1.9.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d9e09c9d74f4566e905a0b8fa668c58109f7624db96a2171f21747abc7524234"}, + {file = "yarl-1.9.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b8477c1ee4bd47c57d49621a062121c3023609f7a13b8a46953eb6c9716ca392"}, + {file = "yarl-1.9.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5ff2c858f5f6a42c2a8e751100f237c5e869cbde669a724f2062d4c4ef93551"}, + {file = "yarl-1.9.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:357495293086c5b6d34ca9616a43d329317feab7917518bc97a08f9e55648455"}, + {file = "yarl-1.9.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:54525ae423d7b7a8ee81ba189f131054defdb122cde31ff17477951464c1691c"}, + {file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:801e9264d19643548651b9db361ce3287176671fb0117f96b5ac0ee1c3530d53"}, + {file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e516dc8baf7b380e6c1c26792610230f37147bb754d6426462ab115a02944385"}, + {file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:7d5aaac37d19b2904bb9dfe12cdb08c8443e7ba7d2852894ad448d4b8f442863"}, + {file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:54beabb809ffcacbd9d28ac57b0db46e42a6e341a030293fb3185c409e626b8b"}, + {file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:bac8d525a8dbc2a1507ec731d2867025d11ceadcb4dd421423a5d42c56818541"}, + {file = "yarl-1.9.4-cp310-cp310-win32.whl", hash = "sha256:7855426dfbddac81896b6e533ebefc0af2f132d4a47340cee6d22cac7190022d"}, + {file = "yarl-1.9.4-cp310-cp310-win_amd64.whl", hash = "sha256:848cd2a1df56ddbffeb375535fb62c9d1645dde33ca4d51341378b3f5954429b"}, + {file = "yarl-1.9.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:35a2b9396879ce32754bd457d31a51ff0a9d426fd9e0e3c33394bf4b9036b099"}, + {file = "yarl-1.9.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4c7d56b293cc071e82532f70adcbd8b61909eec973ae9d2d1f9b233f3d943f2c"}, + {file = "yarl-1.9.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d8a1c6c0be645c745a081c192e747c5de06e944a0d21245f4cf7c05e457c36e0"}, + {file = "yarl-1.9.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4b3c1ffe10069f655ea2d731808e76e0f452fc6c749bea04781daf18e6039525"}, + {file = "yarl-1.9.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:549d19c84c55d11687ddbd47eeb348a89df9cb30e1993f1b128f4685cd0ebbf8"}, + {file = "yarl-1.9.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a7409f968456111140c1c95301cadf071bd30a81cbd7ab829169fb9e3d72eae9"}, + {file = "yarl-1.9.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e23a6d84d9d1738dbc6e38167776107e63307dfc8ad108e580548d1f2c587f42"}, + {file = "yarl-1.9.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d8b889777de69897406c9fb0b76cdf2fd0f31267861ae7501d93003d55f54fbe"}, + {file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:03caa9507d3d3c83bca08650678e25364e1843b484f19986a527630ca376ecce"}, + {file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:4e9035df8d0880b2f1c7f5031f33f69e071dfe72ee9310cfc76f7b605958ceb9"}, + {file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:c0ec0ed476f77db9fb29bca17f0a8fcc7bc97ad4c6c1d8959c507decb22e8572"}, + {file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:ee04010f26d5102399bd17f8df8bc38dc7ccd7701dc77f4a68c5b8d733406958"}, + {file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:49a180c2e0743d5d6e0b4d1a9e5f633c62eca3f8a86ba5dd3c471060e352ca98"}, + {file = "yarl-1.9.4-cp311-cp311-win32.whl", hash = "sha256:81eb57278deb6098a5b62e88ad8281b2ba09f2f1147c4767522353eaa6260b31"}, + {file = "yarl-1.9.4-cp311-cp311-win_amd64.whl", hash = "sha256:d1d2532b340b692880261c15aee4dc94dd22ca5d61b9db9a8a361953d36410b1"}, + {file = "yarl-1.9.4-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0d2454f0aef65ea81037759be5ca9947539667eecebca092733b2eb43c965a81"}, + {file = "yarl-1.9.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:44d8ffbb9c06e5a7f529f38f53eda23e50d1ed33c6c869e01481d3fafa6b8142"}, + {file = "yarl-1.9.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:aaaea1e536f98754a6e5c56091baa1b6ce2f2700cc4a00b0d49eca8dea471074"}, + {file = "yarl-1.9.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3777ce5536d17989c91696db1d459574e9a9bd37660ea7ee4d3344579bb6f129"}, + {file = "yarl-1.9.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9fc5fc1eeb029757349ad26bbc5880557389a03fa6ada41703db5e068881e5f2"}, + {file = "yarl-1.9.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea65804b5dc88dacd4a40279af0cdadcfe74b3e5b4c897aa0d81cf86927fee78"}, + {file = "yarl-1.9.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa102d6d280a5455ad6a0f9e6d769989638718e938a6a0a2ff3f4a7ff8c62cc4"}, + {file = "yarl-1.9.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09efe4615ada057ba2d30df871d2f668af661e971dfeedf0c159927d48bbeff0"}, + {file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:008d3e808d03ef28542372d01057fd09168419cdc8f848efe2804f894ae03e51"}, + {file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:6f5cb257bc2ec58f437da2b37a8cd48f666db96d47b8a3115c29f316313654ff"}, + {file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:992f18e0ea248ee03b5a6e8b3b4738850ae7dbb172cc41c966462801cbf62cf7"}, + {file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:0e9d124c191d5b881060a9e5060627694c3bdd1fe24c5eecc8d5d7d0eb6faabc"}, + {file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3986b6f41ad22988e53d5778f91855dc0399b043fc8946d4f2e68af22ee9ff10"}, + {file = "yarl-1.9.4-cp312-cp312-win32.whl", hash = "sha256:4b21516d181cd77ebd06ce160ef8cc2a5e9ad35fb1c5930882baff5ac865eee7"}, + {file = "yarl-1.9.4-cp312-cp312-win_amd64.whl", hash = "sha256:a9bd00dc3bc395a662900f33f74feb3e757429e545d831eef5bb280252631984"}, + {file = "yarl-1.9.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:63b20738b5aac74e239622d2fe30df4fca4942a86e31bf47a81a0e94c14df94f"}, + {file = "yarl-1.9.4-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7d7f7de27b8944f1fee2c26a88b4dabc2409d2fea7a9ed3df79b67277644e17"}, + {file = "yarl-1.9.4-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c74018551e31269d56fab81a728f683667e7c28c04e807ba08f8c9e3bba32f14"}, + {file = "yarl-1.9.4-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ca06675212f94e7a610e85ca36948bb8fc023e458dd6c63ef71abfd482481aa5"}, + {file = "yarl-1.9.4-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5aef935237d60a51a62b86249839b51345f47564208c6ee615ed2a40878dccdd"}, + {file = "yarl-1.9.4-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2b134fd795e2322b7684155b7855cc99409d10b2e408056db2b93b51a52accc7"}, + {file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d25039a474c4c72a5ad4b52495056f843a7ff07b632c1b92ea9043a3d9950f6e"}, + {file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:f7d6b36dd2e029b6bcb8a13cf19664c7b8e19ab3a58e0fefbb5b8461447ed5ec"}, + {file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:957b4774373cf6f709359e5c8c4a0af9f6d7875db657adb0feaf8d6cb3c3964c"}, + {file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:d7eeb6d22331e2fd42fce928a81c697c9ee2d51400bd1a28803965883e13cead"}, + {file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:6a962e04b8f91f8c4e5917e518d17958e3bdee71fd1d8b88cdce74dd0ebbf434"}, + {file = "yarl-1.9.4-cp37-cp37m-win32.whl", hash = "sha256:f3bc6af6e2b8f92eced34ef6a96ffb248e863af20ef4fde9448cc8c9b858b749"}, + {file = "yarl-1.9.4-cp37-cp37m-win_amd64.whl", hash = "sha256:ad4d7a90a92e528aadf4965d685c17dacff3df282db1121136c382dc0b6014d2"}, + {file = "yarl-1.9.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ec61d826d80fc293ed46c9dd26995921e3a82146feacd952ef0757236fc137be"}, + {file = "yarl-1.9.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8be9e837ea9113676e5754b43b940b50cce76d9ed7d2461df1af39a8ee674d9f"}, + {file = "yarl-1.9.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:bef596fdaa8f26e3d66af846bbe77057237cb6e8efff8cd7cc8dff9a62278bbf"}, + {file = "yarl-1.9.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2d47552b6e52c3319fede1b60b3de120fe83bde9b7bddad11a69fb0af7db32f1"}, + {file = "yarl-1.9.4-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84fc30f71689d7fc9168b92788abc977dc8cefa806909565fc2951d02f6b7d57"}, + {file = "yarl-1.9.4-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4aa9741085f635934f3a2583e16fcf62ba835719a8b2b28fb2917bb0537c1dfa"}, + {file = "yarl-1.9.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:206a55215e6d05dbc6c98ce598a59e6fbd0c493e2de4ea6cc2f4934d5a18d130"}, + {file = "yarl-1.9.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07574b007ee20e5c375a8fe4a0789fad26db905f9813be0f9fef5a68080de559"}, + {file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:5a2e2433eb9344a163aced6a5f6c9222c0786e5a9e9cac2c89f0b28433f56e23"}, + {file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:6ad6d10ed9b67a382b45f29ea028f92d25bc0bc1daf6c5b801b90b5aa70fb9ec"}, + {file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:6fe79f998a4052d79e1c30eeb7d6c1c1056ad33300f682465e1b4e9b5a188b78"}, + {file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:a825ec844298c791fd28ed14ed1bffc56a98d15b8c58a20e0e08c1f5f2bea1be"}, + {file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8619d6915b3b0b34420cf9b2bb6d81ef59d984cb0fde7544e9ece32b4b3043c3"}, + {file = "yarl-1.9.4-cp38-cp38-win32.whl", hash = "sha256:686a0c2f85f83463272ddffd4deb5e591c98aac1897d65e92319f729c320eece"}, + {file = "yarl-1.9.4-cp38-cp38-win_amd64.whl", hash = "sha256:a00862fb23195b6b8322f7d781b0dc1d82cb3bcac346d1e38689370cc1cc398b"}, + {file = "yarl-1.9.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:604f31d97fa493083ea21bd9b92c419012531c4e17ea6da0f65cacdcf5d0bd27"}, + {file = "yarl-1.9.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8a854227cf581330ffa2c4824d96e52ee621dd571078a252c25e3a3b3d94a1b1"}, + {file = "yarl-1.9.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ba6f52cbc7809cd8d74604cce9c14868306ae4aa0282016b641c661f981a6e91"}, + {file = "yarl-1.9.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a6327976c7c2f4ee6816eff196e25385ccc02cb81427952414a64811037bbc8b"}, + {file = "yarl-1.9.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8397a3817d7dcdd14bb266283cd1d6fc7264a48c186b986f32e86d86d35fbac5"}, + {file = "yarl-1.9.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e0381b4ce23ff92f8170080c97678040fc5b08da85e9e292292aba67fdac6c34"}, + {file = "yarl-1.9.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:23d32a2594cb5d565d358a92e151315d1b2268bc10f4610d098f96b147370136"}, + {file = "yarl-1.9.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ddb2a5c08a4eaaba605340fdee8fc08e406c56617566d9643ad8bf6852778fc7"}, + {file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:26a1dc6285e03f3cc9e839a2da83bcbf31dcb0d004c72d0730e755b33466c30e"}, + {file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:18580f672e44ce1238b82f7fb87d727c4a131f3a9d33a5e0e82b793362bf18b4"}, + {file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:29e0f83f37610f173eb7e7b5562dd71467993495e568e708d99e9d1944f561ec"}, + {file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:1f23e4fe1e8794f74b6027d7cf19dc25f8b63af1483d91d595d4a07eca1fb26c"}, + {file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:db8e58b9d79200c76956cefd14d5c90af54416ff5353c5bfd7cbe58818e26ef0"}, + {file = "yarl-1.9.4-cp39-cp39-win32.whl", hash = "sha256:c7224cab95645c7ab53791022ae77a4509472613e839dab722a72abe5a684575"}, + {file = "yarl-1.9.4-cp39-cp39-win_amd64.whl", hash = "sha256:824d6c50492add5da9374875ce72db7a0733b29c2394890aef23d533106e2b15"}, + {file = "yarl-1.9.4-py3-none-any.whl", hash = "sha256:928cecb0ef9d5a7946eb6ff58417ad2fe9375762382f1bf5c55e61645f2c43ad"}, + {file = "yarl-1.9.4.tar.gz", hash = "sha256:566db86717cf8080b99b58b083b773a908ae40f06681e87e589a976faf8246bf"}, +] + +[package.dependencies] +idna = ">=2.0" +multidict = ">=4.0" + +[metadata] +lock-version = "2.0" +python-versions = "^3.12" +content-hash = "569c94d436f2db5b6412292efa2930368ad66067ac9307d1f9081bdb28630b08" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..7e3ce16 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,33 @@ +[tool.poetry] +name = "blanco-bot" +version = "1.0.0" +description = "Simple, no-frills Discord music bot" +authors = ["Jared Dantis "] +license = "MIT" +readme = "README.md" +package-mode = false + +[tool.poetry.dependencies] +aiohttp-jinja2 = "^1.6" +aiohttp-session = "^2.12.0" +cryptography = "^42.0.5" +mafic = "^2.10.0" +nextcord = "^2.6.0" +pylast = "^5.2.0" +python = "^3.12" +ratelimit = "^2.2.1" +redis = "^5.0.3" +sentry-sdk = "^1.44.0" +spotipy = "^2.23.0" +tenacity = "^8.2.3" +thefuzz = "^0.22.1" +validators = "^0.24.0" + +[tool.poetry.group.dev.dependencies] +mypy = "^1.9.0" +pre-commit = "^3.7.0" +ruff = "^0.3.4" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 4768fcb..0000000 --- a/requirements.txt +++ /dev/null @@ -1,43 +0,0 @@ -aiohttp==3.9.1 -aiohttp-jinja2==1.6 -aiohttp-session==2.12.0 -aiosignal==1.3.1 -anyio==4.2.0 -async-timeout==4.0.3 -attrs==23.2.0 -certifi==2023.11.17 -cffi==1.16.0 -charset-normalizer==3.3.2 -cryptography==41.0.7 -decorator==5.1.1 -Deprecated==1.2.14 -frozenlist==1.4.1 -h11==0.14.0 -httpcore==0.18.0 -httpx==0.25.0 -idna==3.6 -Jinja2==3.1.3 -mafic==2.10.0 -MarkupSafe==2.1.3 -multidict==6.0.4 -nextcord==2.6.0 -packaging==23.2 -pycparser==2.21 -pylast==5.2.0 -pyparsing==3.1.1 -PyYAML==6.0.1 -rapidfuzz==3.6.1 -ratelimit==2.2.1 -redis==5.0.1 -requests==2.31.0 -sentry-sdk==1.39.2 -six==1.16.0 -sniffio==1.3.0 -spotipy==2.23.0 -tenacity==8.2.3 -thefuzz==0.20.0 -typing_extensions==4.9.0 -urllib3==2.1.0 -validators==0.22.0 -wrapt==1.16.0 -yarl==1.9.4 From efc06b4e384e248ee6fa17383ad5d461ca2ed4c5 Mon Sep 17 00:00:00 2001 From: Jared Dantis Date: Sat, 30 Mar 2024 16:32:36 +0800 Subject: [PATCH 03/33] [skip ci] chore: Ruff lint and format --- .github/workflows/release.yml | 2 +- .pre-commit-config.yaml | 17 + .vscode/.gitignore | 2 +- .vscode/launch.json | 2 +- .vscode/settings.json | 2 +- Makefile | 4 + cogs/__init__.py | 18 +- cogs/debug.py | 230 +-- cogs/player/__init__.py | 1365 +++++++++-------- cogs/player/jockey.py | 1257 +++++++-------- cogs/player/jockey_helpers.py | 873 +++++------ cogs/player/lavalink_client.py | 298 ++-- cogs/player/queue.py | 725 ++++----- database/__init__.py | 722 ++++----- database/migrations/0000-create.py | 16 +- .../migrations/0001-lavalink-sessionid.py | 17 +- database/migrations/0002-statuschannel.py | 27 +- database/migrations/0003-oauth.py | 25 +- database/migrations/0004-loop-all.py | 27 +- database/migrations/__init__.py | 32 +- database/redis.py | 298 ++-- dataclass/config.py | 120 +- dataclass/custom_embed.py | 115 +- dataclass/lavalink_result.py | 21 +- dataclass/oauth.py | 31 +- dataclass/queue_item.py | 113 +- dataclass/spotify.py | 36 +- dev_server.py | 44 +- main.py | 109 +- pyproject.toml | 9 + server/__init__.py | 10 +- server/main.py | 72 +- server/routes.py | 47 +- server/static/css/.gitignore | 2 +- server/static/images/favicon/site.webmanifest | 2 +- server/templates/dashboard.html | 2 +- server/views/__init__.py | 16 - server/views/dashboard.py | 70 +- server/views/deleteaccount.py | 28 +- server/views/discordoauth.py | 155 +- server/views/homepage.py | 16 +- server/views/lastfmtoken.py | 120 +- server/views/linklastfm.py | 41 +- server/views/linkspotify.py | 82 +- server/views/login.py | 62 +- server/views/logout.py | 18 +- server/views/robotstxt.py | 18 +- server/views/spotifyoauth.py | 162 +- server/views/unlink.py | 56 +- utils/blanco.py | 976 ++++++------ utils/config.py | 240 +-- utils/constants.py | 73 +- utils/embeds.py | 52 +- utils/exceptions.py | 103 +- utils/fuzzy.py | 82 +- utils/logger.py | 109 +- utils/musicbrainz.py | 430 +++--- utils/paginator.py | 195 ++- utils/player_checks.py | 98 +- utils/scrobbler.py | 111 +- utils/spotify_client.py | 633 ++++---- utils/spotify_private.py | 295 ++-- utils/time.py | 52 +- utils/url.py | 269 ++-- views/now_playing.py | 369 ++--- views/paginator.py | 85 +- views/spotify_dropdown.py | 177 ++- 67 files changed, 5979 insertions(+), 5906 deletions(-) create mode 100644 .pre-commit-config.yaml delete mode 100644 server/views/__init__.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8873d2c..d0c1995 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -57,7 +57,7 @@ jobs: prerelease: false - name: Prune untagged images uses: actions/delete-package-versions@v4 - with: + with: package-name: 'blanco-bot' package-type: 'container' min-versions-to-keep: 3 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..d2e55ef --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,17 @@ +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v2.3.0 + hooks: + - id: double-quote-string-fixer + - id: end-of-file-fixer + - id: trailing-whitespace + - id: no-commit-to-branch + args: ['--branch', 'main'] + - id: mixed-line-ending + - id: check-merge-conflict +- repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.3.4 + hooks: + - id: ruff + args: [ --fix ] + - id: ruff-format diff --git a/.vscode/.gitignore b/.vscode/.gitignore index a5122bd..450a234 100644 --- a/.vscode/.gitignore +++ b/.vscode/.gitignore @@ -1 +1 @@ -launch.json \ No newline at end of file +launch.json diff --git a/.vscode/launch.json b/.vscode/launch.json index c1af0dd..fd83634 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -27,4 +27,4 @@ } } ] -} \ No newline at end of file +} diff --git a/.vscode/settings.json b/.vscode/settings.json index e8c0ee5..b1800db 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -12,4 +12,4 @@ "--disable=too-many-return-statements", "--disable=too-many-branches" ] -} \ No newline at end of file +} diff --git a/Makefile b/Makefile index 397163c..3cf7576 100644 --- a/Makefile +++ b/Makefile @@ -3,9 +3,13 @@ install: poetry env use 3.12 poetry install + poetry run pre-commit install dev-frontend: config.yml blanco.db poetry run python dev_server.py dev: config.yml blanco.db poetry run python main.py + +precommit: + poetry run pre-commit run --all-files diff --git a/cogs/__init__.py b/cogs/__init__.py index 301ebfe..b221547 100644 --- a/cogs/__init__.py +++ b/cogs/__init__.py @@ -4,18 +4,20 @@ """ from typing import TYPE_CHECKING + from .debug import DebugCog from .player import PlayerCog + from .bumps import BumpCog if TYPE_CHECKING: - from utils.blanco import BlancoBot + from utils.blanco import BlancoBot def setup(bot: 'BlancoBot'): - """ - Setup function for the cogs extension. - """ - # Add cogs - bot.add_cog(DebugCog(bot)) - bot.add_cog(PlayerCog(bot)) - bot.add_cog(BumpCog(bot)) + """ + Setup function for the cogs extension. + """ + # Add cogs + bot.add_cog(DebugCog(bot)) + bot.add_cog(PlayerCog(bot)) + bot.add_cog(BumpCog(bot)) diff --git a/cogs/debug.py b/cogs/debug.py index e1d439d..64c3940 100644 --- a/cogs/debug.py +++ b/cogs/debug.py @@ -4,8 +4,7 @@ from typing import TYPE_CHECKING -from nextcord import (Color, Interaction, PartialMessageable, SlashOption, - slash_command) +from nextcord import Color, Interaction, PartialMessageable, SlashOption, slash_command from nextcord.ext import application_checks from nextcord.ext.commands import Cog @@ -15,7 +14,7 @@ from utils.paginator import Paginator if TYPE_CHECKING: - from utils.blanco import BlancoBot + from utils.blanco import BlancoBot STATS_FORMAT = """ ```asciidoc @@ -29,117 +28,124 @@ ``` """ + class DebugCog(Cog): + """ + Cog for debugging commands. + """ + + def __init__(self, bot: 'BlancoBot'): + """ + Constructor for DebugCog. + """ + self._bot = bot + self._logger = create_logger(self.__class__.__name__) + self._logger.info('Loaded DebugCog') + + @slash_command(name='announce') + @application_checks.is_owner() + async def announce( + self, + itx: Interaction, + message: str = SlashOption(description='The message to announce.', required=True), + ): + """ + Posts an announcement to the system channel in all guilds. + If there is no system channel, attempt to send to the last channel + used by the bot for now playing embeds. + """ + await itx.response.defer() + + # Create announcement embed + embed = CustomEmbed( + color=Color.yellow(), + title=':warning: Announcement', + description=message, + footer='From the bot owner', + timestamp_now=True, + ).get() + + # Send announcement to all guilds + for guild in self._bot.guilds: + # Get system channel + system_channel = guild.system_channel + if system_channel is None: + # Attempt to get status channel + system_channel = self._bot.get_status_channel(guild.id) + + if system_channel is None or ( + not isinstance(system_channel, PartialMessageable) + and not system_channel.permissions_for(guild.me).send_messages + ): + self._logger.error('No suitable announcement channel saved for %s', guild.name) + else: + # Send message + await system_channel.send(embed=embed) + self._logger.info('Sent announcement to %s', guild.name) + + await itx.followup.send(embed=create_success_embed('Announced!'), ephemeral=True) + + @slash_command(name='reload') + @application_checks.is_owner() + async def reload(self, itx: Interaction): """ - Cog for debugging commands. + Reloads all cogs. """ - def __init__(self, bot: 'BlancoBot'): - """ - Constructor for DebugCog. - """ - self._bot = bot - self._logger = create_logger(self.__class__.__name__) - self._logger.info('Loaded DebugCog') - - @slash_command(name='announce') - @application_checks.is_owner() - async def announce( - self, - itx: Interaction, - message: str = SlashOption(description='The message to announce.', required=True) - ): - """ - Posts an announcement to the system channel in all guilds. - If there is no system channel, attempt to send to the last channel - used by the bot for now playing embeds. - """ - await itx.response.defer() - - # Create announcement embed - embed = CustomEmbed( - color=Color.yellow(), - title=':warning: Announcement', - description=message, - footer='From the bot owner', - timestamp_now=True - ).get() - - # Send announcement to all guilds - for guild in self._bot.guilds: - # Get system channel - system_channel = guild.system_channel - if system_channel is None: - # Attempt to get status channel - system_channel = self._bot.get_status_channel(guild.id) - - if system_channel is None or ( - not isinstance(system_channel, PartialMessageable) and - not system_channel.permissions_for(guild.me).send_messages - ): - self._logger.error('No suitable announcement channel saved for %s', guild.name) - else: - # Send message - await system_channel.send(embed=embed) - self._logger.info('Sent announcement to %s', guild.name) - - await itx.followup.send(embed=create_success_embed('Announced!'), ephemeral=True) - - @slash_command(name='reload') - @application_checks.is_owner() - async def reload(self, itx: Interaction): - """ - Reloads all cogs. - """ - # Reload cogs - self._bot.unload_extension('cogs') - self._bot.load_extension('cogs') - - # Resync commands - await self._bot.sync_all_application_commands() - - await itx.response.send_message( - embed=create_success_embed('Reloaded extensions!'), - ephemeral=True + # Reload cogs + self._bot.unload_extension('cogs') + self._bot.load_extension('cogs') + + # Resync commands + await self._bot.sync_all_application_commands() + + await itx.response.send_message( + embed=create_success_embed('Reloaded extensions!'), ephemeral=True + ) + + @slash_command(name='stats') + async def stats(self, itx: Interaction): + """ + Shows bot statistics. + """ + await itx.response.defer() + + pages = [] + nodes = self._bot.pool.nodes + for node in nodes: + stats = node.stats + + if stats is not None: + # Adapted from @ooliver1/mafic test bot + pages.append( + CustomEmbed( + color=Color.purple(), + title=f':bar_chart:|Stats for node `{node.label}`', + description='No statistics available' + if stats is None + else STATS_FORMAT.format( + uptime=stats.uptime, + used=stats.memory.used / 1024 / 1024, + free=stats.memory.free / 1024 / 1024, + allocated=stats.memory.allocated / 1024 / 1024, + reservable=stats.memory.reservable / 1024 / 1024, + system_load=stats.cpu.system_load * 100, + lavalink_load=stats.cpu.lavalink_load * 100, + player_count=stats.player_count, + playing_player_count=stats.playing_player_count, + ), + footer=f'{len(nodes)} total node(s)', + ).get() + ) + else: + pages.append( + CustomEmbed( + color=Color.red(), + title=f':bar_chart:|Stats for node `{node.label}`', + description='No statistics available', + footer=f'{len(nodes)} total node(s)', + ).get() ) - @slash_command(name='stats') - async def stats(self, itx: Interaction): - """ - Shows bot statistics. - """ - await itx.response.defer() - - pages = [] - nodes = self._bot.pool.nodes - for node in nodes: - stats = node.stats - - if stats is not None: - # Adapted from @ooliver1/mafic test bot - pages.append(CustomEmbed( - color=Color.purple(), - title=f':bar_chart:|Stats for node `{node.label}`', - description='No statistics available' if stats is None else STATS_FORMAT.format( - uptime=stats.uptime, - used=stats.memory.used / 1024 / 1024, - free=stats.memory.free / 1024 / 1024, - allocated=stats.memory.allocated / 1024 / 1024, - reservable=stats.memory.reservable / 1024 / 1024, - system_load=stats.cpu.system_load * 100, - lavalink_load=stats.cpu.lavalink_load * 100, - player_count=stats.player_count, - playing_player_count=stats.playing_player_count - ), - footer=f'{len(nodes)} total node(s)' - ).get()) - else: - pages.append(CustomEmbed( - color=Color.red(), - title=f':bar_chart:|Stats for node `{node.label}`', - description='No statistics available', - footer=f'{len(nodes)} total node(s)' - ).get()) - - # Run paginator - paginator = Paginator(itx) - return await paginator.run(pages) + # Run paginator + paginator = Paginator(itx) + return await paginator.run(pages) diff --git a/cogs/player/__init__.py b/cogs/player/__init__.py index 496aca3..dd1939a 100644 --- a/cogs/player/__init__.py +++ b/cogs/player/__init__.py @@ -6,8 +6,17 @@ from typing import TYPE_CHECKING, Any, Generator, List, Optional from mafic import PlayerNotConnected -from nextcord import (Color, Forbidden, Guild, HTTPException, Interaction, - Member, SlashOption, VoiceState, slash_command) +from nextcord import ( + Color, + Forbidden, + Guild, + HTTPException, + Interaction, + Member, + SlashOption, + VoiceState, + slash_command, +) from nextcord.abc import Messageable from nextcord.ext import application_checks from nextcord.ext.commands import Cog @@ -16,8 +25,13 @@ from dataclass.custom_embed import CustomEmbed from utils.constants import RELEASE, SPOTIFY_403_ERR_MSG from utils.embeds import create_error_embed, create_success_embed -from utils.exceptions import (EmptyQueueError, EndOfQueueError, JockeyError, - JockeyException, SpotifyNoResultsError) +from utils.exceptions import ( + EmptyQueueError, + EndOfQueueError, + JockeyError, + JockeyException, + SpotifyNoResultsError, +) from utils.logger import create_logger from utils.paginator import Paginator, list_chunks from utils.player_checks import check_mutual_voice @@ -26,667 +40,710 @@ from .jockey import Jockey if TYPE_CHECKING: - from dataclass.queue_item import QueueItem - from utils.blanco import BlancoBot + from dataclass.queue_item import QueueItem + from utils.blanco import BlancoBot class PlayerCog(Cog): + """ + Cog for creating, controlling, and destroying music players for guilds. + """ + + def __init__(self, bot: 'BlancoBot'): """ - Cog for creating, controlling, and destroying music players for guilds. - """ - def __init__(self, bot: 'BlancoBot'): - """ - Constructor for PlayerCog. - """ - self._bot = bot - self._logger = create_logger(self.__class__.__name__) - - # Initialize Lavalink client instance - if not bot.pool_initialized: - bot.loop.create_task(bot.init_pool()) - - self._logger.info('Loaded PlayerCog') - - @Cog.listener() - async def on_voice_state_update(self, member: Member, before: VoiceState, after: VoiceState): - """ - Called every time the voice state of a member changes. - In this cog, we use it to check if the bot is left alone in a voice channel, - or if the bot has been server-undeafened. - """ - # Get the player for this guild from cache - jockey: Jockey = member.guild.voice_client # type: ignore - if jockey is not None: - # Stop playing if we're left alone - if (hasattr(jockey.channel, 'members') and - len(jockey.channel.members) == 1 and # type: ignore - jockey.channel.members[0].id == member.guild.me.id and # type: ignore - after.channel is None): - return await self._disconnect(jockey=jockey, reason='You left me alone :(') - - # Did we get server undeafened? - if member.id == member.guild.me.id and before.deaf and not after.deaf: - await self._deafen( - member.guild.me, - was_deafened=True, - channel=jockey.status_channel - ) - - async def _get_jockey(self, itx: Interaction) -> Jockey: - """ - Gets the Jockey instance for the specified guild. - """ - jockey: Jockey = itx.guild.voice_client # type: ignore - if jockey is None: - if not itx.response.is_done(): - await itx.followup.send(embed=create_error_embed('Not connected to voice')) - raise RuntimeError('Attempted to access nonexistent jockey') - - return jockey - - async def _deafen( - self, - bot_user: Member, - was_deafened: bool = False, - channel: Optional[Messageable] = None - ): - """ - Attempt to deafen the bot user. - - :param bot_user: The bot user to deafen. Should be an instance of nextcord.Member. - :param was_deafened: Whether the bot user was previously deafened. - :param channel: The Messageable channel to send the error message to. - """ - # Check if we're already deafened - if not was_deafened and bot_user.voice is not None and bot_user.voice.deaf: - return - - if bot_user.guild_permissions.deafen_members: - try: - await bot_user.edit(deafen=True) - except Forbidden: - pass - - # Send message - if channel is not None and hasattr(channel, 'send'): - err = 'Please server deafen me.' - if was_deafened: - err = 'Please do not undeafen me.' - - try: - await channel.send(embed=create_error_embed( - message=f'{err} Deafening helps save server resources.' - )) - except (Forbidden, HTTPException): - self._logger.error('Unable to send deafen message in guild %d', bot_user.guild.id) - - async def _disconnect( - self, - jockey: Optional[Jockey] = None, - itx: Optional[Interaction] = None, - reason: Optional[str] = None - ): - # Destroy jockey instance - if jockey is None: - if itx is None: - raise ValueError('[player::_disconnect] Either jockey or itx must be specified') - jockey = await self._get_jockey(itx) - - try: - await jockey.stop() - except PlayerNotConnected: - self._logger.warning('Attempted to disconnect disconnected Jockey') - await jockey.disconnect() - - # Send disconnection message - embed = CustomEmbed( - title=':wave:|Disconnected from voice', - description=reason, - footer=f'Blanco release {RELEASE}' - ).get() - - # Try to send disconnection message - try: - if itx is not None: - await itx.followup.send(embed=embed) - else: - guild_id = jockey.guild.id - channel = self._bot.get_status_channel(guild_id) - if channel is not None: - await channel.send(embed=embed) - except (Forbidden, HTTPException): - self._logger.error('Unable to send disconnect message in guild %d', jockey.guild.id) - - # Dispatch disconnect event - self._bot.dispatch('jockey_disconnect', jockey) - - @slash_command(name='jump') - @application_checks.check(check_mutual_voice) - async def jump( - self, - itx: Interaction, - position: int = SlashOption(description='Position to jump to', required=True) - ): - """ - Jumps to the specified position in the queue. - """ - jockey = await self._get_jockey(itx) - - # First check if the value is within range - if position < 1 or position > len(jockey.queue): - await itx.response.send_message( - f'Specify a number from 1 to {str(len(jockey.queue))}.', - ephemeral=True - ) - return - - # Dispatch to jockey - await itx.response.defer() - try: - await jockey.skip(index=position - 1, auto=False) - except JockeyError as err: - await itx.followup.send(embed=create_error_embed(str(err))) - else: - await itx.followup.send(embed=create_success_embed(f'Jumped to track {str(position)}')) - - @slash_command(name='loop') - @application_checks.check(check_mutual_voice) - async def loop(self, itx: Interaction): - """ - Loops the current track. - """ - jockey = await self._get_jockey(itx) - if not jockey.queue_manager.is_looping_one: - jockey.queue_manager.is_looping_one = True - - # Update now playing message - await jockey.update_now_playing() - - return await itx.response.send_message(embed=create_success_embed('Looping current track')) - - @slash_command(name='loopall') - @application_checks.check(check_mutual_voice) - async def loopall(self, itx: Interaction): - """ - Loops the whole queue. - """ - jockey = await self._get_jockey(itx) - if not jockey.queue_manager.is_looping_all: - jockey.queue_manager.is_looping_all = True - - # Update now playing message - await jockey.update_now_playing() - - return await itx.response.send_message(embed=create_success_embed('Looping entire queue')) - - @slash_command(name='nowplaying') - @application_checks.check(check_mutual_voice) - async def now_playing(self, itx: Interaction): - """ - Displays the currently playing track. - """ - await itx.response.defer(ephemeral=True) - jockey = await self._get_jockey(itx) - embed = jockey.now_playing() - await itx.followup.send(embed=embed) + Constructor for PlayerCog. + """ + self._bot = bot + self._logger = create_logger(self.__class__.__name__) - @slash_command(name='pause') - @application_checks.check(check_mutual_voice) - async def pause(self, itx: Interaction, quiet: bool = False): - """ - Pauses the current track. - """ - if not quiet: - await itx.response.defer() - - # Dispatch to jockey - jockey = await self._get_jockey(itx) - await jockey.pause() - - if not quiet: - await itx.followup.send(embed=create_success_embed('Paused'), delete_after=5.0) - - @slash_command(name='play') - @application_checks.check(check_mutual_voice) - async def play( - self, - itx: Interaction, - query: str = SlashOption(description='Query string or URL', required=True) - ): - """ - Play a song from a search query or a URL. - If you want to unpause a paused player, use /unpause instead. - """ - if (not isinstance(itx.user, Member) or not itx.user.voice or - not itx.user.voice.channel or not isinstance(itx.guild, Guild)): - return await itx.response.send_message(embed=create_error_embed( - message='Connect to a server voice channel to use this command.' - ), ephemeral=True) - - # Set status channel - guild_id = itx.guild.id - channel = itx.channel - if not isinstance(channel, Messageable): - raise RuntimeError('[player::play] itx.channel is not Messageable') - self._bot.set_status_channel(guild_id, channel) - - # Check if Lavalink is ready - if not self._bot.pool_initialized or len(self._bot.pool.nodes) == 0: - return await itx.response.send_message(embed=create_error_embed( - message='No Lavalink nodes available. Try again later.' - )) - - # Connect to voice - await itx.response.defer() - voice_channel = itx.user.voice.channel - if itx.guild.voice_client is None: - try: - await voice_channel.connect(cls=Jockey) # type: ignore - await voice_channel.guild.change_voice_state( - channel=voice_channel, - self_deaf=True - ) - await self._deafen(itx.guild.me, channel=channel) - except AsyncioTimeoutError: - return await itx.followup.send(embed=create_error_embed( - message='Timed out while connecting to voice. Try again later.' - )) - - # Dispatch to jockey - jockey = await self._get_jockey(itx) - try: - track_name = await jockey.play_impl(query, itx.user.id) - except JockeyError as err: - # Disconnect if we're not playing anything - if not jockey.playing: - return await self._disconnect(itx=itx, reason=f'Error: `{err}`') - - return await itx.followup.send(embed=create_error_embed(str(err))) - except JockeyException as exc: - return await itx.followup.send(embed=create_error_embed(str(exc))) - - body = [f'{track_name}\n'] - - # Add Last.fm integration promo if enabled - assert self._bot.config is not None - if (self._bot.config.base_url is not None and - self._bot.config.lastfm_api_key is not None and - self._bot.config.lastfm_shared_secret is not None): - # Check if the user has connected their Last.fm account - if self._bot.database.get_lastfm_credentials(itx.user.id) is not None: - body.append(f':handshake: {itx.user.mention} is scrobbling to Last.fm!') - body.append( - f':sparkles: [Link Last.fm]({self._bot.config.base_url}) to scrobble as you listen' - ) - - # Update now playing message - await jockey.update_now_playing() - - embed = create_success_embed( - title='Added to queue', - body='\n'.join(body), + # Initialize Lavalink client instance + if not bot.pool_initialized: + bot.loop.create_task(bot.init_pool()) + + self._logger.info('Loaded PlayerCog') + + @Cog.listener() + async def on_voice_state_update( + self, member: Member, before: VoiceState, after: VoiceState + ): + """ + Called every time the voice state of a member changes. + In this cog, we use it to check if the bot is left alone in a voice channel, + or if the bot has been server-undeafened. + """ + # Get the player for this guild from cache + jockey: Jockey = member.guild.voice_client # type: ignore + if jockey is not None: + # Stop playing if we're left alone + if ( + hasattr(jockey.channel, 'members') + and len(jockey.channel.members) == 1 # type: ignore + and jockey.channel.members[0].id == member.guild.me.id # type: ignore + and after.channel is None + ): + return await self._disconnect(jockey=jockey, reason='You left me alone :(') + + # Did we get server undeafened? + if member.id == member.guild.me.id and before.deaf and not after.deaf: + await self._deafen( + member.guild.me, was_deafened=True, channel=jockey.status_channel + ) + + async def _get_jockey(self, itx: Interaction) -> Jockey: + """ + Gets the Jockey instance for the specified guild. + """ + jockey: Jockey = itx.guild.voice_client # type: ignore + if jockey is None: + if not itx.response.is_done(): + await itx.followup.send(embed=create_error_embed('Not connected to voice')) + raise RuntimeError('Attempted to access nonexistent jockey') + + return jockey + + async def _deafen( + self, + bot_user: Member, + was_deafened: bool = False, + channel: Optional[Messageable] = None, + ): + """ + Attempt to deafen the bot user. + + :param bot_user: The bot user to deafen. Should be an instance of nextcord.Member. + :param was_deafened: Whether the bot user was previously deafened. + :param channel: The Messageable channel to send the error message to. + """ + # Check if we're already deafened + if not was_deafened and bot_user.voice is not None and bot_user.voice.deaf: + return + + if bot_user.guild_permissions.deafen_members: + try: + await bot_user.edit(deafen=True) + except Forbidden: + pass + + # Send message + if channel is not None and hasattr(channel, 'send'): + err = 'Please server deafen me.' + if was_deafened: + err = 'Please do not undeafen me.' + + try: + await channel.send( + embed=create_error_embed( + message=f'{err} Deafening helps save server resources.' + ) ) - return await itx.followup.send(embed=embed.set_footer(text=f'Blanco release {RELEASE}')) - - @slash_command(name='playlists') - async def playlist(self, itx: Interaction): - """ - Pick a Spotify playlist from your library to play. - """ - if itx.user is None: - return - await itx.response.defer() - - # Get Spotify client - try: - spotify = self._bot.get_spotify_client(itx.user.id) - if spotify is None: - raise ValueError('You are not connected to Spotify.') - except ValueError as err: - return await itx.followup.send( - embed=create_error_embed(err.args[0]), - ephemeral=True - ) - - # Get the user's playlists - try: - playlists = spotify.get_user_playlists() - except HTTPError as err: - if err.response is not None and err.response.status_code == 403: - return await itx.followup.send(embed=create_error_embed( - message=SPOTIFY_403_ERR_MSG.format('get your playlists') - ), ephemeral=True) - raise - if len(playlists) == 0: - return await itx.followup.send(embed=create_error_embed( - message='You have no playlists.' - ), ephemeral=True) - - # Create dropdown - view = SpotifyDropdownView(self._bot, playlists, itx.user.id, 'playlist') - await itx.followup.send(embed=create_success_embed( - title='Pick a playlist', - body='Select a playlist from the dropdown below.' - ), view=view, delete_after=60.0) - - @slash_command(name='previous') - @application_checks.check(check_mutual_voice) - async def previous(self, itx: Interaction): - """ - Skip to the previous song. - """ - # Dispatch to jockey - await itx.response.defer() - jockey = await self._get_jockey(itx) - try: - await jockey.skip(forward=False, auto=False) - except EndOfQueueError as err: - embed = create_error_embed(f'Unable to rewind: {err.args[0]}') - await itx.followup.send(embed=embed) - - @slash_command(name='queue') - @application_checks.check(check_mutual_voice) - async def queue(self, itx: Interaction): - """ - Displays the current queue. - """ - if itx.guild is None: - raise RuntimeError('[player::queue] itx.guild is None') - await itx.response.defer() - - # Get jockey - jockey = await self._get_jockey(itx) - if len(jockey.queue) == 0: - await itx.followup.send(embed=create_error_embed('Queue is empty')) - return - - # Show loop status - embed_header = [f'{len(jockey.queue)} total'] - if jockey.queue_manager.is_looping_all: - embed_header.append(':repeat: Looping entire queue (`/unloopall` to disable)') - - # Show shuffle status - queue = jockey.queue_manager.shuffled_queue - current = jockey.queue_manager.current_shuffled_index - if jockey.queue_manager.is_shuffling: - embed_header.append( - ':twisted_rightwards_arrows: Shuffling queue (`/unshuffle` to disable)' - ) - - # Show queue in chunks of 10 per page - pages = [] - homepage = 0 - count = 1 - prefix_len = len(str(len(jockey.queue))) - for i, chunk in enumerate(list_chunks(queue)): - chunk_tracks = [] - - # Create page content - track: 'QueueItem' - for track in chunk: - title, artist = track.get_details() - - # Pad index with spaces if necessary - index = str(count) - while len(index) < prefix_len: - index = ' ' + index - - # Is this the current track? - line_prefix = ' ' - if count - 1 == current: - line_prefix = '> ' - homepage = i - - # Create item line - line_prefix = '> ' if count - 1 == current else ' ' - line = f'{line_prefix} {index} :: {title} - {artist}' - - # Truncate line if necessary - if len(line) > 50: - line = line[:47] + '...' - else: - line = f'{line:50.50}' - chunk_tracks.append(line) - count += 1 - - # Create page - tracks = '\n'.join(chunk_tracks) - embed_body = embed_header + [f'```asciidoc\n{tracks}```'] - embed = CustomEmbed( - title=f'Queue for {itx.guild.name}', - description='\n'.join(embed_body), - color=Color.lighter_gray() - ) - pages.append(embed.get()) - - # Run paginator - paginator = Paginator(itx) - return await paginator.run(pages, start=homepage) - - @slash_command(name='remove') - @application_checks.check(check_mutual_voice) - async def remove( - self, - itx: Interaction, - position: int = SlashOption( - description='Position to remove', - required=True + except (Forbidden, HTTPException): + self._logger.error( + 'Unable to send deafen message in guild %d', bot_user.guild.id ) + + async def _disconnect( + self, + jockey: Optional[Jockey] = None, + itx: Optional[Interaction] = None, + reason: Optional[str] = None, + ): + # Destroy jockey instance + if jockey is None: + if itx is None: + raise ValueError('[player::_disconnect] Either jockey or itx must be specified') + jockey = await self._get_jockey(itx) + + try: + await jockey.stop() + except PlayerNotConnected: + self._logger.warning('Attempted to disconnect disconnected Jockey') + await jockey.disconnect() + + # Send disconnection message + embed = CustomEmbed( + title=':wave:|Disconnected from voice', + description=reason, + footer=f'Blanco release {RELEASE}', + ).get() + + # Try to send disconnection message + try: + if itx is not None: + await itx.followup.send(embed=embed) + else: + guild_id = jockey.guild.id + channel = self._bot.get_status_channel(guild_id) + if channel is not None: + await channel.send(embed=embed) + except (Forbidden, HTTPException): + self._logger.error( + 'Unable to send disconnect message in guild %d', jockey.guild.id + ) + + # Dispatch disconnect event + self._bot.dispatch('jockey_disconnect', jockey) + + @slash_command(name='jump') + @application_checks.check(check_mutual_voice) + async def jump( + self, + itx: Interaction, + position: int = SlashOption(description='Position to jump to', required=True), + ): + """ + Jumps to the specified position in the queue. + """ + jockey = await self._get_jockey(itx) + + # First check if the value is within range + if position < 1 or position > len(jockey.queue): + await itx.response.send_message( + f'Specify a number from 1 to {str(len(jockey.queue))}.', ephemeral=True + ) + return + + # Dispatch to jockey + await itx.response.defer() + try: + await jockey.skip(index=position - 1, auto=False) + except JockeyError as err: + await itx.followup.send(embed=create_error_embed(str(err))) + else: + await itx.followup.send( + embed=create_success_embed(f'Jumped to track {str(position)}') + ) + + @slash_command(name='loop') + @application_checks.check(check_mutual_voice) + async def loop(self, itx: Interaction): + """ + Loops the current track. + """ + jockey = await self._get_jockey(itx) + if not jockey.queue_manager.is_looping_one: + jockey.queue_manager.is_looping_one = True + + # Update now playing message + await jockey.update_now_playing() + + return await itx.response.send_message( + embed=create_success_embed('Looping current track') + ) + + @slash_command(name='loopall') + @application_checks.check(check_mutual_voice) + async def loopall(self, itx: Interaction): + """ + Loops the whole queue. + """ + jockey = await self._get_jockey(itx) + if not jockey.queue_manager.is_looping_all: + jockey.queue_manager.is_looping_all = True + + # Update now playing message + await jockey.update_now_playing() + + return await itx.response.send_message( + embed=create_success_embed('Looping entire queue') + ) + + @slash_command(name='nowplaying') + @application_checks.check(check_mutual_voice) + async def now_playing(self, itx: Interaction): + """ + Displays the currently playing track. + """ + await itx.response.defer(ephemeral=True) + jockey = await self._get_jockey(itx) + embed = jockey.now_playing() + await itx.followup.send(embed=embed) + + @slash_command(name='pause') + @application_checks.check(check_mutual_voice) + async def pause(self, itx: Interaction, quiet: bool = False): + """ + Pauses the current track. + """ + if not quiet: + await itx.response.defer() + + # Dispatch to jockey + jockey = await self._get_jockey(itx) + await jockey.pause() + + if not quiet: + await itx.followup.send(embed=create_success_embed('Paused'), delete_after=5.0) + + @slash_command(name='play') + @application_checks.check(check_mutual_voice) + async def play( + self, + itx: Interaction, + query: str = SlashOption(description='Query string or URL', required=True), + ): + """ + Play a song from a search query or a URL. + If you want to unpause a paused player, use /unpause instead. + """ + if ( + not isinstance(itx.user, Member) + or not itx.user.voice + or not itx.user.voice.channel + or not isinstance(itx.guild, Guild) ): - """ - Remove a track from queue. - """ - jockey = await self._get_jockey(itx) - if position < 1 or position > jockey.queue_size: - return await itx.response.send_message(embed=create_error_embed( - message=f'Specify a number from 1 to {str(jockey.queue_size)}.' - ), ephemeral=True) - if position - 1 == jockey.queue_manager.current_index: - return await itx.response.send_message(embed=create_error_embed( - message='You cannot remove the currently playing track.' - ), ephemeral=True) - - # Dispatch to jockey - await itx.response.defer() - title, artist = await jockey.remove(index=position - 1) - await itx.followup.send(embed=create_success_embed( - title='Removed from queue', - body=f'**{title}**\n{artist}' - )) - - # Update now playing message - await jockey.update_now_playing() - - @slash_command(name='search') - async def search( - self, - itx: Interaction, - search_type: str = SlashOption( - description='Search type', - required=True, - choices=['track', 'playlist', 'album', 'artist'] + return await itx.response.send_message( + embed=create_error_embed( + message='Connect to a server voice channel to use this command.' ), - query: str = SlashOption(description='Query string', required=True) + ephemeral=True, + ) + + # Set status channel + guild_id = itx.guild.id + channel = itx.channel + if not isinstance(channel, Messageable): + raise RuntimeError('[player::play] itx.channel is not Messageable') + self._bot.set_status_channel(guild_id, channel) + + # Check if Lavalink is ready + if not self._bot.pool_initialized or len(self._bot.pool.nodes) == 0: + return await itx.response.send_message( + embed=create_error_embed( + message='No Lavalink nodes available. Try again later.' + ) + ) + + # Connect to voice + await itx.response.defer() + voice_channel = itx.user.voice.channel + if itx.guild.voice_client is None: + try: + await voice_channel.connect(cls=Jockey) # type: ignore + await voice_channel.guild.change_voice_state( + channel=voice_channel, self_deaf=True + ) + await self._deafen(itx.guild.me, channel=channel) + except AsyncioTimeoutError: + return await itx.followup.send( + embed=create_error_embed( + message='Timed out while connecting to voice. Try again later.' + ) + ) + + # Dispatch to jockey + jockey = await self._get_jockey(itx) + try: + track_name = await jockey.play_impl(query, itx.user.id) + except JockeyError as err: + # Disconnect if we're not playing anything + if not jockey.playing: + return await self._disconnect(itx=itx, reason=f'Error: `{err}`') + + return await itx.followup.send(embed=create_error_embed(str(err))) + except JockeyException as exc: + return await itx.followup.send(embed=create_error_embed(str(exc))) + + body = [f'{track_name}\n'] + + # Add Last.fm integration promo if enabled + assert self._bot.config is not None + if ( + self._bot.config.base_url is not None + and self._bot.config.lastfm_api_key is not None + and self._bot.config.lastfm_shared_secret is not None ): - """ - Search Spotify's catalog for tracks to play. - """ - if itx.user is None: - return - await itx.response.defer() - - # Search catalog - try: - results = self._bot.spotify.search(query, search_type) - except SpotifyNoResultsError: - return await itx.followup.send(embed=create_error_embed( - message=f'No results found for `{query}`.' - ), ephemeral=True) - - # Create dropdown - view = SpotifyDropdownView(self._bot, results, itx.user.id, search_type) - await itx.followup.send(embed=create_success_embed( - title=f'Results for `{query}`', - body='Select a result to play from the dropdown below.' - ), view=view, delete_after=60.0) - - @slash_command(name='shuffle') - @application_checks.check(check_mutual_voice) - async def shuffle(self, itx: Interaction, quiet: bool = False): - """ - Shuffle the current playlist. - If you want to unshuffle the current queue, use /unshuffle instead. - """ - if not quiet: - await itx.response.defer() - - # Dispatch to jockey - jockey = await self._get_jockey(itx) - try: - jockey.queue_manager.shuffle() - except EmptyQueueError as err: - if not quiet: - await itx.followup.send(embed=create_error_embed(str(err.args[0]))) + # Check if the user has connected their Last.fm account + if self._bot.database.get_lastfm_credentials(itx.user.id) is not None: + body.append(f':handshake: {itx.user.mention} is scrobbling to Last.fm!') + body.append( + f':sparkles: [Link Last.fm]({self._bot.config.base_url}) to scrobble as you listen' + ) + + # Update now playing message + await jockey.update_now_playing() + + embed = create_success_embed( + title='Added to queue', + body='\n'.join(body), + ) + return await itx.followup.send( + embed=embed.set_footer(text=f'Blanco release {RELEASE}') + ) + + @slash_command(name='playlists') + async def playlist(self, itx: Interaction): + """ + Pick a Spotify playlist from your library to play. + """ + if itx.user is None: + return None + await itx.response.defer() + + # Get Spotify client + try: + spotify = self._bot.get_spotify_client(itx.user.id) + if spotify is None: + raise ValueError('You are not connected to Spotify.') + except ValueError as err: + return await itx.followup.send( + embed=create_error_embed(err.args[0]), ephemeral=True + ) + + # Get the user's playlists + try: + playlists = spotify.get_user_playlists() + except HTTPError as err: + if err.response is not None and err.response.status_code == 403: + return await itx.followup.send( + embed=create_error_embed( + message=SPOTIFY_403_ERR_MSG.format('get your playlists') + ), + ephemeral=True, + ) + raise + if len(playlists) == 0: + return await itx.followup.send( + embed=create_error_embed(message='You have no playlists.'), + ephemeral=True, + ) + + # Create dropdown + view = SpotifyDropdownView(self._bot, playlists, itx.user.id, 'playlist') + await itx.followup.send( + embed=create_success_embed( + title='Pick a playlist', + body='Select a playlist from the dropdown below.', + ), + view=view, + delete_after=60.0, + ) + + @slash_command(name='previous') + @application_checks.check(check_mutual_voice) + async def previous(self, itx: Interaction): + """ + Skip to the previous song. + """ + # Dispatch to jockey + await itx.response.defer() + jockey = await self._get_jockey(itx) + try: + await jockey.skip(forward=False, auto=False) + except EndOfQueueError as err: + embed = create_error_embed(f'Unable to rewind: {err.args[0]}') + await itx.followup.send(embed=embed) + + @slash_command(name='queue') + @application_checks.check(check_mutual_voice) + async def queue(self, itx: Interaction): + """ + Displays the current queue. + """ + if itx.guild is None: + raise RuntimeError('[player::queue] itx.guild is None') + await itx.response.defer() + + # Get jockey + jockey = await self._get_jockey(itx) + if len(jockey.queue) == 0: + await itx.followup.send(embed=create_error_embed('Queue is empty')) + return None + + # Show loop status + embed_header = [f'{len(jockey.queue)} total'] + if jockey.queue_manager.is_looping_all: + embed_header.append(':repeat: Looping entire queue (`/unloopall` to disable)') + + # Show shuffle status + queue = jockey.queue_manager.shuffled_queue + current = jockey.queue_manager.current_shuffled_index + if jockey.queue_manager.is_shuffling: + embed_header.append( + ':twisted_rightwards_arrows: Shuffling queue (`/unshuffle` to disable)' + ) + + # Show queue in chunks of 10 per page + pages = [] + homepage = 0 + count = 1 + prefix_len = len(str(len(jockey.queue))) + for i, chunk in enumerate(list_chunks(queue)): + chunk_tracks = [] + + # Create page content + track: 'QueueItem' + for track in chunk: + title, artist = track.get_details() + + # Pad index with spaces if necessary + index = str(count) + while len(index) < prefix_len: + index = ' ' + index + + # Is this the current track? + line_prefix = ' ' + if count - 1 == current: + line_prefix = '> ' + homepage = i + + # Create item line + line_prefix = '> ' if count - 1 == current else ' ' + line = f'{line_prefix} {index} :: {title} - {artist}' + + # Truncate line if necessary + if len(line) > 50: + line = line[:47] + '...' else: - # Update now playing message - await jockey.update_now_playing() - - if not quiet: - await itx.followup.send( - embed=create_success_embed(f'{len(jockey.queue)} tracks shuffled') - ) - - @slash_command(name='skip') - @application_checks.check(check_mutual_voice) - async def skip(self, itx: Interaction): - """ - Skip the current song. - """ - # Dispatch to jockey - await itx.response.defer(ephemeral=True) - jockey = await self._get_jockey(itx) - try: - await jockey.skip(auto=False) - except EndOfQueueError as err: - embed = create_error_embed(f'Unable to skip: {err.args[0]}') - await itx.followup.send(embed=embed) - - @slash_command(name='stop') - @application_checks.check(check_mutual_voice) - async def stop(self, itx: Interaction): - """ - Stops the current song and disconnects from voice. - """ - if not isinstance(itx.user, Member): - raise RuntimeError('[player::stop] itx.user is not a Member') - await itx.response.defer() - await self._disconnect(itx=itx, reason=f'Stopped by <@{itx.user.id}>') - - @slash_command(name='unloop') - @application_checks.check(check_mutual_voice) - async def unloop(self, itx: Interaction): - """ - Stops looping the current track. - """ - # Dispatch to jockey - jockey = await self._get_jockey(itx) - if jockey.queue_manager.is_looping_one: - jockey.queue_manager.is_looping_one = False - - # Update now playing message - await jockey.update_now_playing() - - return await itx.response.send_message( - embed=create_success_embed('Not looping current track') + line = f'{line:50.50}' + chunk_tracks.append(line) + count += 1 + + # Create page + tracks = '\n'.join(chunk_tracks) + embed_body = embed_header + [f'```asciidoc\n{tracks}```'] + embed = CustomEmbed( + title=f'Queue for {itx.guild.name}', + description='\n'.join(embed_body), + color=Color.lighter_gray(), + ) + pages.append(embed.get()) + + # Run paginator + paginator = Paginator(itx) + return await paginator.run(pages, start=homepage) + + @slash_command(name='remove') + @application_checks.check(check_mutual_voice) + async def remove( + self, + itx: Interaction, + position: int = SlashOption(description='Position to remove', required=True), + ): + """ + Remove a track from queue. + """ + jockey = await self._get_jockey(itx) + if position < 1 or position > jockey.queue_size: + return await itx.response.send_message( + embed=create_error_embed( + message=f'Specify a number from 1 to {str(jockey.queue_size)}.' + ), + ephemeral=True, + ) + if position - 1 == jockey.queue_manager.current_index: + return await itx.response.send_message( + embed=create_error_embed( + message='You cannot remove the currently playing track.' + ), + ephemeral=True, + ) + + # Dispatch to jockey + await itx.response.defer() + title, artist = await jockey.remove(index=position - 1) + await itx.followup.send( + embed=create_success_embed( + title='Removed from queue', body=f'**{title}**\n{artist}' + ) + ) + + # Update now playing message + await jockey.update_now_playing() + + @slash_command(name='search') + async def search( + self, + itx: Interaction, + search_type: str = SlashOption( + description='Search type', + required=True, + choices=['track', 'playlist', 'album', 'artist'], + ), + query: str = SlashOption(description='Query string', required=True), + ): + """ + Search Spotify's catalog for tracks to play. + """ + if itx.user is None: + return None + await itx.response.defer() + + # Search catalog + try: + results = self._bot.spotify.search(query, search_type) + except SpotifyNoResultsError: + return await itx.followup.send( + embed=create_error_embed(message=f'No results found for `{query}`.'), + ephemeral=True, + ) + + # Create dropdown + view = SpotifyDropdownView(self._bot, results, itx.user.id, search_type) + await itx.followup.send( + embed=create_success_embed( + title=f'Results for `{query}`', + body='Select a result to play from the dropdown below.', + ), + view=view, + delete_after=60.0, + ) + + @slash_command(name='shuffle') + @application_checks.check(check_mutual_voice) + async def shuffle(self, itx: Interaction, quiet: bool = False): + """ + Shuffle the current playlist. + If you want to unshuffle the current queue, use /unshuffle instead. + """ + if not quiet: + await itx.response.defer() + + # Dispatch to jockey + jockey = await self._get_jockey(itx) + try: + jockey.queue_manager.shuffle() + except EmptyQueueError as err: + if not quiet: + await itx.followup.send(embed=create_error_embed(str(err.args[0]))) + else: + # Update now playing message + await jockey.update_now_playing() + + if not quiet: + await itx.followup.send( + embed=create_success_embed(f'{len(jockey.queue)} tracks shuffled') ) - @slash_command(name='unloopall') - @application_checks.check(check_mutual_voice) - async def unloopall(self, itx: Interaction): - """ - Stops looping the whole queue. - """ - # Dispatch to jockey - jockey = await self._get_jockey(itx) - if jockey.queue_manager.is_looping_all: - jockey.queue_manager.is_looping_all = False - - # Update now playing message - await jockey.update_now_playing() - - return await itx.response.send_message( - embed=create_success_embed('Not looping entire queue') - ) + @slash_command(name='skip') + @application_checks.check(check_mutual_voice) + async def skip(self, itx: Interaction): + """ + Skip the current song. + """ + # Dispatch to jockey + await itx.response.defer(ephemeral=True) + jockey = await self._get_jockey(itx) + try: + await jockey.skip(auto=False) + except EndOfQueueError as err: + embed = create_error_embed(f'Unable to skip: {err.args[0]}') + await itx.followup.send(embed=embed) + + @slash_command(name='stop') + @application_checks.check(check_mutual_voice) + async def stop(self, itx: Interaction): + """ + Stops the current song and disconnects from voice. + """ + if not isinstance(itx.user, Member): + raise RuntimeError('[player::stop] itx.user is not a Member') + await itx.response.defer() + await self._disconnect(itx=itx, reason=f'Stopped by <@{itx.user.id}>') + + @slash_command(name='unloop') + @application_checks.check(check_mutual_voice) + async def unloop(self, itx: Interaction): + """ + Stops looping the current track. + """ + # Dispatch to jockey + jockey = await self._get_jockey(itx) + if jockey.queue_manager.is_looping_one: + jockey.queue_manager.is_looping_one = False - @slash_command(name='unpause') - @application_checks.check(check_mutual_voice) - async def unpause(self, itx: Interaction, quiet: bool = False): - """ - Unpauses the current track. - """ - if not quiet: - await itx.response.defer() - - # Dispatch to jockey - jockey = await self._get_jockey(itx) - await jockey.resume() - - if not quiet: - await itx.followup.send(embed=create_success_embed('Unpaused'), delete_after=5.0) - - @slash_command(name='unshuffle') - @application_checks.check(check_mutual_voice) - async def unshuffle(self, itx: Interaction, quiet: bool = False): - """ - Unshuffle the current playlist. - """ - if not quiet: - await itx.response.defer() - - # Dispatch to jockey - jockey = await self._get_jockey(itx) - if jockey.queue_manager.is_shuffling: - jockey.queue_manager.unshuffle() - if not quiet: - return await itx.followup.send(embed=create_success_embed('Unshuffled')) - - # Update now playing message - await jockey.update_now_playing() - - if not quiet: - return await itx.followup.send( - embed=create_error_embed('Current queue is not shuffled') - ) - - @slash_command(name='volume') - @application_checks.check(check_mutual_voice) - async def volume( - self, - itx: Interaction, - volume: Optional[int] = SlashOption( - description='Volume level. Leave empty to print current volume.', - required=False, - min_value=0, - max_value=1000 - ) - ): - """ - Sets the volume level. - """ - jockey = await self._get_jockey(itx) - - # Is the volume argument empty? - if not volume: - # Print current volume - return await itx.response.send_message( - f'The volume is set to {jockey.volume}.', - ephemeral=True - ) - - # Dispatch to jockey - await itx.response.defer() - await jockey.set_volume(volume) - await itx.followup.send(embed=create_success_embed(f'Volume set to {volume}')) - - # Update now playing message - await jockey.update_now_playing() + # Update now playing message + await jockey.update_now_playing() + + return await itx.response.send_message( + embed=create_success_embed('Not looping current track') + ) + + @slash_command(name='unloopall') + @application_checks.check(check_mutual_voice) + async def unloopall(self, itx: Interaction): + """ + Stops looping the whole queue. + """ + # Dispatch to jockey + jockey = await self._get_jockey(itx) + if jockey.queue_manager.is_looping_all: + jockey.queue_manager.is_looping_all = False + + # Update now playing message + await jockey.update_now_playing() + + return await itx.response.send_message( + embed=create_success_embed('Not looping entire queue') + ) + + @slash_command(name='unpause') + @application_checks.check(check_mutual_voice) + async def unpause(self, itx: Interaction, quiet: bool = False): + """ + Unpauses the current track. + """ + if not quiet: + await itx.response.defer() + + # Dispatch to jockey + jockey = await self._get_jockey(itx) + await jockey.resume() + + if not quiet: + await itx.followup.send(embed=create_success_embed('Unpaused'), delete_after=5.0) + + @slash_command(name='unshuffle') + @application_checks.check(check_mutual_voice) + async def unshuffle(self, itx: Interaction, quiet: bool = False): + """ + Unshuffle the current playlist. + """ + if not quiet: + await itx.response.defer() + + # Dispatch to jockey + jockey = await self._get_jockey(itx) + if jockey.queue_manager.is_shuffling: + jockey.queue_manager.unshuffle() + if not quiet: + return await itx.followup.send(embed=create_success_embed('Unshuffled')) + + # Update now playing message + await jockey.update_now_playing() + + if not quiet: + return await itx.followup.send( + embed=create_error_embed('Current queue is not shuffled') + ) + + @slash_command(name='volume') + @application_checks.check(check_mutual_voice) + async def volume( + self, + itx: Interaction, + volume: Optional[int] = SlashOption( + description='Volume level. Leave empty to print current volume.', + required=False, + min_value=0, + max_value=1000, + ), + ): + """ + Sets the volume level. + """ + jockey = await self._get_jockey(itx) + + # Is the volume argument empty? + if not volume: + # Print current volume + return await itx.response.send_message( + f'The volume is set to {jockey.volume}.', ephemeral=True + ) + + # Dispatch to jockey + await itx.response.defer() + await jockey.set_volume(volume) + await itx.followup.send(embed=create_success_embed(f'Volume set to {volume}')) + + # Update now playing message + await jockey.update_now_playing() diff --git a/cogs/player/jockey.py b/cogs/player/jockey.py index f8aebfc..8a1ee00 100644 --- a/cogs/player/jockey.py +++ b/cogs/player/jockey.py @@ -7,677 +7,684 @@ from typing import TYPE_CHECKING, List, Optional, Tuple from mafic import Player, PlayerNotConnected -from nextcord import (Colour, Forbidden, HTTPException, Message, NotFound, - StageChannel, VoiceChannel) +from nextcord import ( + Colour, + Forbidden, + HTTPException, + Message, + NotFound, + StageChannel, + VoiceChannel, +) from dataclass.custom_embed import CustomEmbed from utils.constants import UNPAUSE_THRESHOLD from utils.embeds import create_error_embed -from utils.exceptions import (EndOfQueueError, JockeyError, JockeyException, - LavalinkSearchError, SpotifyNoResultsError, - BumpError, BumpNotReadyError, BumpNotEnabledError) +from utils.exceptions import ( + BumpError, + BumpNotEnabledError, + BumpNotReadyError, + EndOfQueueError, + JockeyError, + JockeyException, + LavalinkSearchError, + SpotifyNoResultsError, +) from utils.musicbrainz import annotate_track from utils.time import human_readable_time from views.now_playing import NowPlayingView -from .jockey_helpers import (find_lavalink_track, invalidate_lavalink_track, - parse_query) +from .jockey_helpers import find_lavalink_track, invalidate_lavalink_track, parse_query from .queue import QueueManager if TYPE_CHECKING: - from mafic import Track - from nextcord import Embed - from nextcord.abc import Connectable, Messageable + from mafic import Track + from nextcord import Embed + from nextcord.abc import Connectable, Messageable - from dataclass.queue_item import QueueItem - from utils.blanco import BlancoBot + from dataclass.queue_item import QueueItem + from utils.blanco import BlancoBot class Jockey(Player['BlancoBot']): + """ + Class that handles music playback for a single guild. + Contains all the methods for music playback, along with a + local instance of an in-memory database for fast queueing. + """ + + def __init__(self, client: 'BlancoBot', channel: 'Connectable'): + super().__init__(client, channel) + self._bot = client + + if not isinstance(channel, StageChannel) and not isinstance(channel, VoiceChannel): + raise TypeError(f'Channel must be a voice channel, not {type(channel)}') + + # Database + self._db = client.database + client.database.init_guild(channel.guild.id) + + # Pause timestamp + self._pause_ts: Optional[int] = None + + # Queue + self._queue_mgr = QueueManager(channel.guild.id, client.database) + + # Volume + self._volume = client.database.get_volume(channel.guild.id) + + # Logger + self._logger = client.jockey_logger + self._logger.info("Using node `%s' for %s", self.node.label, channel.guild.name) + + @property + def playing(self) -> bool: """ - Class that handles music playback for a single guild. - Contains all the methods for music playback, along with a - local instance of an in-memory database for fast queueing. + Returns whether the player is currently playing a track. """ + return self.current is not None - def __init__(self, client: 'BlancoBot', channel: 'Connectable'): - super().__init__(client, channel) - self._bot = client + @property + def queue(self) -> List['QueueItem']: + """ + Returns the player queue. + """ + return self._queue_mgr.queue - if not isinstance(channel, StageChannel) and not isinstance(channel, VoiceChannel): - raise TypeError(f'Channel must be a voice channel, not {type(channel)}') + @property + def queue_manager(self) -> QueueManager: + """ + Returns the queue manager for the player. + """ + return self._queue_mgr - # Database - self._db = client.database - client.database.init_guild(channel.guild.id) + @property + def queue_size(self) -> int: + """ + Returns the player queue size. + """ + return self._queue_mgr.size - # Pause timestamp - self._pause_ts: Optional[int] = None + @property + def status_channel(self) -> 'Messageable': + """ + Returns the status channel for the player. + """ + channel = self._bot.get_status_channel(self.guild.id) + if channel is None: + raise ValueError('Status channel has not been set') + return channel - # Queue - self._queue_mgr = QueueManager(channel.guild.id, client.database) + @property + def volume(self) -> int: + """ + Returns the player volume. + """ + return self._volume - # Volume - self._volume = client.database.get_volume(channel.guild.id) + @volume.setter + def volume(self, value: int): + """ + Sets the player volume and saves it to the database. + """ + self._volume = value + self._db.set_volume(self.guild.id, value) - # Logger - self._logger = client.jockey_logger - self._logger.info( - 'Using node `%s\' for %s', - self.node.label, - channel.guild.name + async def _edit_np_controls(self, show_controls: bool = True): + """ + Edits the now playing message to show or hide controls. + """ + view = None + if show_controls: + view = NowPlayingView(self._bot, self) + + np_msg = await self._get_now_playing() + if isinstance(np_msg, Message): + try: + await np_msg.edit(view=view) + except (HTTPException, Forbidden) as exc: + self._logger.warning( + 'Could not edit now playing message for %s: %s', + self.guild.name, + exc, ) - @property - def playing(self) -> bool: - """ - Returns whether the player is currently playing a track. - """ - return self.current is not None - - @property - def queue(self) -> List['QueueItem']: - """ - Returns the player queue. - """ - return self._queue_mgr.queue - - @property - def queue_manager(self) -> QueueManager: - """ - Returns the queue manager for the player. - """ - return self._queue_mgr - - @property - def queue_size(self) -> int: - """ - Returns the player queue size. - """ - return self._queue_mgr.size - - @property - def status_channel(self) -> 'Messageable': - """ - Returns the status channel for the player. - """ - channel = self._bot.get_status_channel(self.guild.id) - if channel is None: - raise ValueError('Status channel has not been set') - return channel - - @property - def volume(self) -> int: - """ - Returns the player volume. - """ - return self._volume - - @volume.setter - def volume(self, value: int): - """ - Sets the player volume and saves it to the database. - """ - self._volume = value - self._db.set_volume(self.guild.id, value) - - async def _edit_np_controls(self, show_controls: bool = True): - """ - Edits the now playing message to show or hide controls. - """ - view = None - if show_controls: - view = NowPlayingView(self._bot, self) - - np_msg = await self._get_now_playing() - if isinstance(np_msg, Message): - try: - await np_msg.edit(view=view) - except (HTTPException, Forbidden) as exc: - self._logger.warning( - 'Could not edit now playing message for %s: %s', - self.guild.name, - exc - ) - - async def _enqueue(self, index: int, auto: bool = True): - """ - Attempt to enqueue a track, for use with the skip() method. - - :param index: The index of the track to enqueue. - :param auto: Whether this is an automatic enqueue, i.e. not part of a user's command. - """ - try: - track = self._queue_mgr.queue[index] - await self._play(track) - except PlayerNotConnected: - if not auto: - await self.status_channel.send(embed=create_error_embed( - 'Attempted to skip while disconnected' - )) - raise JockeyError('Player is not connected') - except JockeyError as err: - self._logger.error('Failed to enqueue track: %s', err) - raise - - # Scrobble if possible - await self._scrobble(self._queue_mgr.current) - - # Update queue index - self._queue_mgr.current_index = index - - async def _get_now_playing(self) -> Optional[Message]: - np_msg_id = self._db.get_now_playing(self.guild.id) - if np_msg_id != -1: - try: - np_msg = await self.status_channel.fetch_message(np_msg_id) - return np_msg - except (Forbidden, HTTPException, NotFound) as exc: - self._logger.warning( - 'Failed to fetch now playing message for %s: %s', - self.guild.name, - exc - ) - - return None - - async def _play(self, item: 'QueueItem', position: Optional[int] = None): - if item.lavalink_track is None: - try: - assert self._bot.config is not None - deezer_enabled = self._bot.config.lavalink_nodes[self.node.label].deezer - item.lavalink_track = await find_lavalink_track( - self.node, - item, - deezer_enabled=deezer_enabled - ) - except LavalinkSearchError as err: - self._logger.critical('Failed to play `%s\'.', item.title) - raise JockeyError(err.args[0]) from err - - # Play track - has_retried = False - while True: - try: - await self.play( - item.lavalink_track, - volume=self.volume, - start_time=position, - replace=True, - pause=False - ) - except PlayerNotConnected as err: - # If we've already retried, give up - if has_retried: - raise JockeyError(err.args[0]) from err - - # Wait until we're connected - wait_time = 0 - self._logger.warning( - 'PlayerNotConnected raised while trying to play `%s\', retrying...', - item.title - ) - while not self.connected: - if wait_time >= 10: - raise JockeyError('Timeout while waiting for player to connect') from err - - # Print wait message only once - if wait_time == 0: - self._logger.debug('Waiting 10 sec for player to connect...') - await sleep(0.1) - wait_time += 0.1 - - # Remove cached Lavalink track and try again - invalidate_lavalink_track(item) - has_retried = True - else: - # Clear pause timestamp for new track - if position is None: - self._pause_ts = None - - break - - # Save start time for scrobbling - item.start_time = int(time()) - - async def _scrobble(self, item: 'QueueItem'): - """ - Scrobbles a track in a separate thread. - - :param item: The track to scrobble. - """ - get_event_loop().create_task(self._scrobble_impl(item)) - - async def _scrobble_impl(self, item: 'QueueItem'): - """ - Scrobbles a track for all users in the channel who have - linked their Last.fm accounts. - - Called by _scrobble() in a separate thread. - - :param item: The track to scrobble. - """ - if not isinstance(self.channel, VoiceChannel): - return - - # Check if scrobbling is enabled - assert self._bot.config is not None - if not self._bot.config.lastfm_enabled: - return + async def _enqueue(self, index: int, auto: bool = True): + """ + Attempt to enqueue a track, for use with the skip() method. - # Check if track can be scrobbled - time_now = int(time()) - try: - duration = item.duration - if item.lavalink_track is not None: - duration = item.lavalink_track.length - - if item.start_time is not None and duration is not None: - # Check if track is longer than 30 seconds - if duration < 30000: - raise ValueError('Track is too short') - - # Check if enough time has passed (1/2 duration or 4 min, whichever is less) - elapsed_ms = (time_now - item.start_time) * 1000 - if elapsed_ms < min(duration // 2, 240000): - raise ValueError('Not enough time has passed') - else: - # Default to current time for timestamp - item.start_time = time_now - except ValueError as err: - self._logger.warning('Failed to scrobble `%s\': %s', item.title, err.args[0]) - return - - # Lookup MusicBrainz ID if needed - if item.mbid is None: - annotate_track(item) - - # Don't scrobble with no MBID and ISRC, - # as the track probably isn't on Last.fm - if item.mbid is None and item.isrc is None: - self._logger.warning( - 'Not scrobbling `%s\': no MusicBrainz ID or ISRC', - item.title - ) - return - - # Scrobble for every user - for member in self.channel.members: - if not member.bot: - scrobbler = self._bot.get_scrobbler(member.id) - if scrobbler is not None: - scrobbler.scrobble(item) - - async def disconnect(self, *, force: bool = False): - """ - Removes the controls from Now Playing, then disconnects. - """ - # Get now playing message - np_msg = await self._get_now_playing() - if np_msg is not None: - try: - await np_msg.edit(view=None) - except (HTTPException, Forbidden): - self._logger.warning( - 'Failed to remove now playing message for %s', - self.guild.name - ) - - # Disconnect - await super().disconnect(force=force) - - def now_playing(self, current: Optional['Track'] = None) -> 'Embed': - """ - Returns information about the currently playing track. - - :return: An instance of nextcord.Embed - """ - if current is None: - if self.current is None: - raise EndOfQueueError('No track is currently playing') - current = self.current - - # Construct Spotify URL if it exists - track = self._queue_mgr.current - uri = current.uri - if track.spotify_id is not None: - uri = f'https://open.spotify.com/track/{track.spotify_id}' - - # Get track duration - duration_ms = track.duration - if track.lavalink_track is not None: - duration_ms = track.lavalink_track.length - - # Build track duration string - duration = '' - if duration_ms is not None: - duration = human_readable_time(duration_ms) - - # Display complete artists if available - artist = track.artist if track.author is None else track.author - if artist is None: - artist = 'Unknown artist' - - # Display type of track - is_stream = False - if track.lavalink_track is not None: - is_stream = track.lavalink_track.stream - - # Build footer - footer = f'Track {self._queue_mgr.current_shuffled_index + 1} of {self.queue_size}' - if self._queue_mgr.is_shuffling: - footer += ' 🔀' - if self._queue_mgr.is_looping_one: - footer += ' 🔂' - if self._queue_mgr.is_looping_all: - footer += ' 🔁' - footer += f' • Volume {self.volume}%' - - imperfect_msg = ':warning: Playing the [**closest match**]({})' - embed = CustomEmbed( - title='Now streaming' if is_stream else 'Now playing', - description=[ - f'[**{track.title}**]({uri})', - artist, - duration if not is_stream else '', - f'\nrequested by <@{track.requester}>', - imperfect_msg.format(current.uri) if track.is_imperfect else '' - ], - footer=footer, - color=Colour.teal(), - thumbnail_url=track.artwork + :param index: The index of the track to enqueue. + :param auto: Whether this is an automatic enqueue, i.e. not part of a user's command. + """ + try: + track = self._queue_mgr.queue[index] + await self._play(track) + except PlayerNotConnected: + if not auto: + await self.status_channel.send( + embed=create_error_embed('Attempted to skip while disconnected') ) - return embed.get() - - async def on_load_failed(self, failed_source: 'Track'): - """ - Called when a track fails to load. - Sends an error message to the status channel - and skips to the next track in queue. - - :param failed_track: The track that failed to load. Must be an instance of mafic.Track. - """ - # Get current track and its index - failed_track = self._queue_mgr.current - index = self._queue_mgr.current_shuffled_index + 1 - queue_size = self._queue_mgr.size - - # Send error embed - embed = CustomEmbed( - color=Colour.red(), - title=':warning:|Failed to load track', - description=[ - 'This could be due to a temporary issue with the source,', - 'a bot outage, or the track may be unavailable for playback.', - 'You can try playing the track again later.' - ], - fields=[ - ['Track', f'`{failed_track.title}`\n{failed_track.artist}'], - ['Position in queue', f'{index} of {queue_size}'], - ['Playback source', f'`{failed_source.title}`\n{failed_source.author}'], - ['Playback URL', f'[{failed_source.source}]({failed_source.uri})'], - ], - footer='Skipping to next track...', + raise JockeyError('Player is not connected') + except JockeyError as err: + self._logger.error('Failed to enqueue track: %s', err) + raise + + # Scrobble if possible + await self._scrobble(self._queue_mgr.current) + + # Update queue index + self._queue_mgr.current_index = index + + async def _get_now_playing(self) -> Optional[Message]: + np_msg_id = self._db.get_now_playing(self.guild.id) + if np_msg_id != -1: + try: + np_msg = await self.status_channel.fetch_message(np_msg_id) + return np_msg + except (Forbidden, HTTPException, NotFound) as exc: + self._logger.warning( + 'Failed to fetch now playing message for %s: %s', + self.guild.name, + exc, ) - await self.status_channel.send(embed=embed.get()) - # Skip to next track - await self.skip() + return None - async def pause(self, pause: bool = True): - """ - Pauses the player and stores the time at which playback was paused. + async def _play(self, item: 'QueueItem', position: Optional[int] = None): + if item.lavalink_track is None: + try: + assert self._bot.config is not None + deezer_enabled = self._bot.config.lavalink_nodes[self.node.label].deezer + item.lavalink_track = await find_lavalink_track( + self.node, item, deezer_enabled=deezer_enabled + ) + except LavalinkSearchError as err: + self._logger.critical("Failed to play `%s'.", item.title) + raise JockeyError(err.args[0]) from err + + # Play track + has_retried = False + while True: + try: + await self.play( + item.lavalink_track, + volume=self.volume, + start_time=position, + replace=True, + pause=False, + ) + except PlayerNotConnected as err: + # If we've already retried, give up + if has_retried: + raise JockeyError(err.args[0]) from err + + # Wait until we're connected + wait_time = 0 + self._logger.warning( + "PlayerNotConnected raised while trying to play `%s', retrying...", + item.title, + ) + while not self.connected: + if wait_time >= 10: + raise JockeyError('Timeout while waiting for player to connect') from err + + # Print wait message only once + if wait_time == 0: + self._logger.debug('Waiting 10 sec for player to connect...') + await sleep(0.1) + wait_time += 0.1 + + # Remove cached Lavalink track and try again + invalidate_lavalink_track(item) + has_retried = True + else: + # Clear pause timestamp for new track + if position is None: + self._pause_ts = None + + break + + # Save start time for scrobbling + item.start_time = int(time()) + + async def _scrobble(self, item: 'QueueItem'): + """ + Scrobbles a track in a separate thread. - The timestamp is necessary because Lavalink 4.0.0 (beta) does not - properly resume tracks when they are paused for an extended period, - causing the track to skip to the next one in the queue after a few - seconds of resumed playback. + :param item: The track to scrobble. + """ + get_event_loop().create_task(self._scrobble_impl(item)) - :param pause: Whether to pause or resume playback. - """ - await super().pause(pause=pause) + async def _scrobble_impl(self, item: 'QueueItem'): + """ + Scrobbles a track for all users in the channel who have + linked their Last.fm accounts. - # Store pause timestamp - self._pause_ts = int(time()) + Called by _scrobble() in a separate thread. - async def play_impl(self, query: str, requester: int) -> str: - """ - Adds an item to the player queue and begins playback if necessary. + :param item: The track to scrobble. + """ + if not isinstance(self.channel, VoiceChannel): + return + + # Check if scrobbling is enabled + assert self._bot.config is not None + if not self._bot.config.lastfm_enabled: + return + + # Check if track can be scrobbled + time_now = int(time()) + try: + duration = item.duration + if item.lavalink_track is not None: + duration = item.lavalink_track.length + + if item.start_time is not None and duration is not None: + # Check if track is longer than 30 seconds + if duration < 30000: + raise ValueError('Track is too short') + + # Check if enough time has passed (1/2 duration or 4 min, whichever is less) + elapsed_ms = (time_now - item.start_time) * 1000 + if elapsed_ms < min(duration // 2, 240000): + raise ValueError('Not enough time has passed') + else: + # Default to current time for timestamp + item.start_time = time_now + except ValueError as err: + self._logger.warning("Failed to scrobble `%s': %s", item.title, err.args[0]) + return + + # Lookup MusicBrainz ID if needed + if item.mbid is None: + annotate_track(item) + + # Don't scrobble with no MBID and ISRC, + # as the track probably isn't on Last.fm + if item.mbid is None and item.isrc is None: + self._logger.warning("Not scrobbling `%s': no MusicBrainz ID or ISRC", item.title) + return + + # Scrobble for every user + for member in self.channel.members: + if not member.bot: + scrobbler = self._bot.get_scrobbler(member.id) + if scrobbler is not None: + scrobbler.scrobble(item) + + async def disconnect(self, *, force: bool = False): + """ + Removes the controls from Now Playing, then disconnects. + """ + # Get now playing message + np_msg = await self._get_now_playing() + if np_msg is not None: + try: + await np_msg.edit(view=None) + except (HTTPException, Forbidden): + self._logger.warning( + 'Failed to remove now playing message for %s', self.guild.name + ) - :param query: The query to play. - :param requester: The ID of the user who requested the track. - :return: A string containing the name of the track that was added. - """ - # Get results for query - try: - new_tracks = await parse_query( - self.node, - self._bot.spotify, - query, - requester - ) - except JockeyException: - raise - except SpotifyNoResultsError as err: - raise JockeyError(err.args[0]) from err - except Exception as exc: - if self.playing: - raise JockeyException(str(exc)) from exc - raise JockeyError(str(exc)) from exc - - # Add new tracks to queue - old_size = self._queue_mgr.size - self._queue_mgr.extend(new_tracks) - - # Get info for first track - first = new_tracks[0] - first_name = f'**{first.title}**\n{first.artist}' if first.title is not None else query - - # Are we beginning a new queue or is the player idle? - if not self.playing: - # We are! Play the first new track. - old_index = self._queue_mgr.current_index - self._queue_mgr.current_index = old_size - - try: - await self._play(new_tracks[0]) - except (JockeyError, PlayerNotConnected) as err: - # Remove enqueued tracks - for _ in range(old_size, self._queue_mgr.size): - self._queue_mgr.remove(old_size) - - # Restore old index - self._queue_mgr.current_index = old_index - - raise JockeyError(f'Failed to play "{first.title}"') from err - - # Send embed - return first_name if len(new_tracks) == 1 else f'{len(new_tracks)} item(s)' - - async def remove(self, index: int) -> Tuple[str | None, str | None]: - """ - Removes a track from the queue. - """ - # Remove track from queue - removed_track = self._queue_mgr.remove(index) - - # Return removed track details - return removed_track.title, removed_track.artist - - async def resume(self): - """ - Resumes the player from a paused state. - - If the player was paused for an extended period, the current track - will be re-enqueued and played from the last position to work around - a bug in Lavalink 4.0.0 (beta). - """ - # Check if we were paused for too long or if reenqueuing is disabled - assert self._bot.config is not None - if not self._bot.config.reenqueue_paused or ( - self._pause_ts is None or int(time()) - self._pause_ts < UNPAUSE_THRESHOLD): - await super().resume() - return - - # We were paused for too long, re-enqueue the current track - # and play from a little bit before the last position - last_pos = max(self.position - 10, 0) - self._pause_ts = None - self._logger.debug('Unpaused beyond %d sec threshold, re-enqueueing', UNPAUSE_THRESHOLD) - await self._play(self._queue_mgr.current, last_pos) - - async def set_volume(self, volume: int, /): - """ - Sets the player volume. - """ - await super().set_volume(volume) - self.volume = volume - - async def skip(self, *, forward: bool = True, index: int = -1, auto: bool = True): - """ - Skips the current track and plays the next one in the queue. - - :param forward: Whether to skip forward or backward. - :param index: The index of the track to skip to. - :param auto: Whether this is an automatic skip, i.e. not part of a user's command. - This is True when the player skips to the next track automatically, - such as when the current track ends. - """ - # It takes a while for the player to skip, - # so let's remove the player controls while we wait - # to prevent the user from spamming them. - await self._edit_np_controls(show_controls=False) + # Disconnect + await super().disconnect(force=force) - try: - await self.play_bump() - return - except (JockeyException, SpotifyNoResultsError) as err: - self._logger.error('Error parsing bump into track: %s', err) - except BumpError as err: - self._logger.error('Error playing bump: %s', err) - except BumpNotEnabledError: - self._logger.debug('Bumps are not enabled in this guild.') - except BumpNotReadyError: - self._logger.debug('Not ready to play a bump yet.') - - # If index is specified, use that instead - if index != -1: - try: - await self._enqueue(index, auto=auto) - except JockeyError: - await self._edit_np_controls(show_controls=True) - await self.status_channel.send(embed=create_error_embed( - f'Unable to skip to index {index}' - )) - raise - - return - - # Is this autoskipping? - if auto: - # Check if we're looping the current track - if self._queue_mgr.is_looping_one: - # Re-enqueue the current track - try: - await self._enqueue(self._queue_mgr.current_index, auto=auto) - except JockeyError as err: - await self._edit_np_controls(show_controls=True) - await self.status_channel.send(embed=create_error_embed( - f'Unable to loop track: {err}' - )) - - return - - # Try to enqueue the next playable track - delta = 1 if forward else -1 - while True: - # Get next index - try: - next_i = self._queue_mgr.calc_next_index(delta=delta) - except EndOfQueueError: - # We've reached the end of the queue and looping is disabled - return - - # Get details of next track for logging - next_track = self._queue_mgr.queue[next_i] - next_title = next_track.title if next_track.title is not None else 'Unknown track' - next_artist = next_track.artist if next_track.artist is not None else 'Unknown artist' - - # Try to enqueue the next track - try: - await self._enqueue(next_i, auto=auto) - except JockeyError as err: - await self._edit_np_controls(show_controls=True) - delta += 1 if forward else -1 - - await self.status_channel.send(embed=CustomEmbed( - color=Colour.red(), - title=':warning:|Failed to skip to track', - description='It might be unavailable temporarily ' - 'or restricted to specific regions.\n', - fields=[ - ['Track', f'`{next_title}`\n{next_artist}'], - ['Position in queue', f'{next_i + 1} of {self.queue_size}'], - ['Error', f'```{err}```'] - ], - footer='Skipping to next track...' if auto else None - ).get()) - else: - break - - async def update_now_playing(self): - """ - Update the existing Now Playing view with current information. - """ - # Get now playing message - np_msg = await self._get_now_playing() - if np_msg is None: - return - - # Edit message - try: - await np_msg.edit(embed=self.now_playing()) - except (HTTPException, Forbidden) as exc: - # Ignore 404 - if not isinstance(exc, NotFound): - self._logger.warning( - 'Failed to edit now playing message for %s: %s', - self.guild.name, - exc - ) + def now_playing(self, current: Optional['Track'] = None) -> 'Embed': + """ + Returns information about the currently playing track. - async def play_bump(self): - """ - Check and attempt to play a bump if it's been long enough. - """ + :return: An instance of nextcord.Embed + """ + if current is None: + if self.current is None: + raise EndOfQueueError('No track is currently playing') + current = self.current + + # Construct Spotify URL if it exists + track = self._queue_mgr.current + uri = current.uri + if track.spotify_id is not None: + uri = f'https://open.spotify.com/track/{track.spotify_id}' + + # Get track duration + duration_ms = track.duration + if track.lavalink_track is not None: + duration_ms = track.lavalink_track.length + + # Build track duration string + duration = '' + if duration_ms is not None: + duration = human_readable_time(duration_ms) + + # Display complete artists if available + artist = track.artist if track.author is None else track.author + if artist is None: + artist = 'Unknown artist' + + # Display type of track + is_stream = False + if track.lavalink_track is not None: + is_stream = track.lavalink_track.stream + + # Build footer + footer = f'Track {self._queue_mgr.current_shuffled_index + 1} of {self.queue_size}' + if self._queue_mgr.is_shuffling: + footer += ' 🔀' + if self._queue_mgr.is_looping_one: + footer += ' 🔂' + if self._queue_mgr.is_looping_all: + footer += ' 🔁' + footer += f' • Volume {self.volume}%' + + imperfect_msg = ':warning: Playing the [**closest match**]({})' + embed = CustomEmbed( + title='Now streaming' if is_stream else 'Now playing', + description=[ + f'[**{track.title}**]({uri})', + artist, + duration if not is_stream else '', + f'\nrequested by <@{track.requester}>', + imperfect_msg.format(current.uri) if track.is_imperfect else '', + ], + footer=footer, + color=Colour.teal(), + thumbnail_url=track.artwork, + ) + return embed.get() + + async def on_load_failed(self, failed_source: 'Track'): + """ + Called when a track fails to load. + Sends an error message to the status channel + and skips to the next track in queue. - enabled = self._db.get_bumps_enabled(self.guild.id) - if not enabled: - raise BumpNotEnabledError + :param failed_track: The track that failed to load. Must be an instance of mafic.Track. + """ + # Get current track and its index + failed_track = self._queue_mgr.current + index = self._queue_mgr.current_shuffled_index + 1 + queue_size = self._queue_mgr.size + + # Send error embed + embed = CustomEmbed( + color=Colour.red(), + title=':warning:|Failed to load track', + description=[ + 'This could be due to a temporary issue with the source,', + 'a bot outage, or the track may be unavailable for playback.', + 'You can try playing the track again later.', + ], + fields=[ + ['Track', f'`{failed_track.title}`\n{failed_track.artist}'], + ['Position in queue', f'{index} of {queue_size}'], + ['Playback source', f'`{failed_source.title}`\n{failed_source.author}'], + ['Playback URL', f'[{failed_source.source}]({failed_source.uri})'], + ], + footer='Skipping to next track...', + ) + await self.status_channel.send(embed=embed.get()) + + # Skip to next track + await self.skip() + + async def pause(self, pause: bool = True): + """ + Pauses the player and stores the time at which playback was paused. - interval = self._db.get_bump_interval(self.guild.id) * 60 - last_bump = self._db.get_last_bump(self.guild.id) + The timestamp is necessary because Lavalink 4.0.0 (beta) does not + properly resume tracks when they are paused for an extended period, + causing the track to skip to the next one in the queue after a few + seconds of resumed playback. - if last_bump == 0: - self._db.set_last_bump(self.guild.id) - raise BumpNotReadyError + :param pause: Whether to pause or resume playback. + """ + await super().pause(pause=pause) - if int(time()) - last_bump < interval: - raise BumpNotReadyError + # Store pause timestamp + self._pause_ts = int(time()) - bump = self._db.get_random_bump(self.guild.id) - if bump is None: - raise BumpError('Guild has no bumps.') + async def play_impl(self, query: str, requester: int) -> str: + """ + Adds an item to the player queue and begins playback if necessary. - requester = self._bot.user.id if self._bot.user is not None else self.guild.me.id + :param query: The query to play. + :param requester: The ID of the user who requested the track. + :return: A string containing the name of the track that was added. + """ + # Get results for query + try: + new_tracks = await parse_query(self.node, self._bot.spotify, query, requester) + except JockeyException: + raise + except SpotifyNoResultsError as err: + raise JockeyError(err.args[0]) from err + except Exception as exc: + if self.playing: + raise JockeyException(str(exc)) from exc + raise JockeyError(str(exc)) from exc + + # Add new tracks to queue + old_size = self._queue_mgr.size + self._queue_mgr.extend(new_tracks) + + # Get info for first track + first = new_tracks[0] + first_name = ( + f'**{first.title}**\n{first.artist}' if first.title is not None else query + ) + + # Are we beginning a new queue or is the player idle? + if not self.playing: + # We are! Play the first new track. + old_index = self._queue_mgr.current_index + self._queue_mgr.current_index = old_size + + try: + await self._play(new_tracks[0]) + except (JockeyError, PlayerNotConnected) as err: + # Remove enqueued tracks + for _ in range(old_size, self._queue_mgr.size): + self._queue_mgr.remove(old_size) + + # Restore old index + self._queue_mgr.current_index = old_index + + raise JockeyError(f'Failed to play "{first.title}"') from err + + # Send embed + return first_name if len(new_tracks) == 1 else f'{len(new_tracks)} item(s)' + + async def remove(self, index: int) -> Tuple[str | None, str | None]: + """ + Removes a track from the queue. + """ + # Remove track from queue + removed_track = self._queue_mgr.remove(index) + + # Return removed track details + return removed_track.title, removed_track.artist + + async def resume(self): + """ + Resumes the player from a paused state. + + If the player was paused for an extended period, the current track + will be re-enqueued and played from the last position to work around + a bug in Lavalink 4.0.0 (beta). + """ + # Check if we were paused for too long or if reenqueuing is disabled + assert self._bot.config is not None + if not self._bot.config.reenqueue_paused or ( + self._pause_ts is None or int(time()) - self._pause_ts < UNPAUSE_THRESHOLD + ): + await super().resume() + return + + # We were paused for too long, re-enqueue the current track + # and play from a little bit before the last position + last_pos = max(self.position - 10, 0) + self._pause_ts = None + self._logger.debug( + 'Unpaused beyond %d sec threshold, re-enqueueing', UNPAUSE_THRESHOLD + ) + await self._play(self._queue_mgr.current, last_pos) + + async def set_volume(self, volume: int, /): + """ + Sets the player volume. + """ + await super().set_volume(volume) + self.volume = volume + + async def skip(self, *, forward: bool = True, index: int = -1, auto: bool = True): + """ + Skips the current track and plays the next one in the queue. + :param forward: Whether to skip forward or backward. + :param index: The index of the track to skip to. + :param auto: Whether this is an automatic skip, i.e. not part of a user's command. + This is True when the player skips to the next track automatically, + such as when the current track ends. + """ + # It takes a while for the player to skip, + # so let's remove the player controls while we wait + # to prevent the user from spamming them. + await self._edit_np_controls(show_controls=False) + + try: + await self.play_bump() + return + except (JockeyException, SpotifyNoResultsError) as err: + self._logger.error('Error parsing bump into track: %s', err) + except BumpError as err: + self._logger.error('Error playing bump: %s', err) + except BumpNotEnabledError: + self._logger.debug('Bumps are not enabled in this guild.') + except BumpNotReadyError: + self._logger.debug('Not ready to play a bump yet.') + + # If index is specified, use that instead + if index != -1: + try: + await self._enqueue(index, auto=auto) + except JockeyError: + await self._edit_np_controls(show_controls=True) + await self.status_channel.send( + embed=create_error_embed(f'Unable to skip to index {index}') + ) + raise + + return + + # Is this autoskipping? + if auto: + # Check if we're looping the current track + if self._queue_mgr.is_looping_one: + # Re-enqueue the current track try: - tracks = await parse_query(self.node, self._bot.spotify, bump.url, requester) - except (JockeyException, SpotifyNoResultsError): - raise + await self._enqueue(self._queue_mgr.current_index, auto=auto) + except JockeyError as err: + await self._edit_np_controls(show_controls=True) + await self.status_channel.send( + embed=create_error_embed(f'Unable to loop track: {err}') + ) + + return + + # Try to enqueue the next playable track + delta = 1 if forward else -1 + while True: + # Get next index + try: + next_i = self._queue_mgr.calc_next_index(delta=delta) + except EndOfQueueError: + # We've reached the end of the queue and looping is disabled + return + + # Get details of next track for logging + next_track = self._queue_mgr.queue[next_i] + next_title = next_track.title if next_track.title is not None else 'Unknown track' + next_artist = ( + next_track.artist if next_track.artist is not None else 'Unknown artist' + ) + + # Try to enqueue the next track + try: + await self._enqueue(next_i, auto=auto) + except JockeyError as err: + await self._edit_np_controls(show_controls=True) + delta += 1 if forward else -1 + + await self.status_channel.send( + embed=CustomEmbed( + color=Colour.red(), + title=':warning:|Failed to skip to track', + description='It might be unavailable temporarily ' + 'or restricted to specific regions.\n', + fields=[ + ['Track', f'`{next_title}`\n{next_artist}'], + ['Position in queue', f'{next_i + 1} of {self.queue_size}'], + ['Error', f'```{err}```'], + ], + footer='Skipping to next track...' if auto else None, + ).get() + ) + else: + break + + async def update_now_playing(self): + """ + Update the existing Now Playing view with current information. + """ + # Get now playing message + np_msg = await self._get_now_playing() + if np_msg is None: + return + + # Edit message + try: + await np_msg.edit(embed=self.now_playing()) + except (HTTPException, Forbidden) as exc: + # Ignore 404 + if not isinstance(exc, NotFound): + self._logger.warning( + 'Failed to edit now playing message for %s: %s', + self.guild.name, + exc, + ) + + async def play_bump(self): + """ + Check and attempt to play a bump if it's been long enough. + """ + + enabled = self._db.get_bumps_enabled(self.guild.id) + if not enabled: + raise BumpNotEnabledError - if len(tracks) == 0: - raise BumpError('Unable to parse bump URL into tracks.') + interval = self._db.get_bump_interval(self.guild.id) * 60 + last_bump = self._db.get_last_bump(self.guild.id) - await self._play(tracks[0]) + if last_bump == 0: self._db.set_last_bump(self.guild.id) + raise BumpNotReadyError + + if int(time()) - last_bump < interval: + raise BumpNotReadyError + + bump = self._db.get_random_bump(self.guild.id) + if bump is None: + raise BumpError('Guild has no bumps.') + + requester = self._bot.user.id if self._bot.user is not None else self.guild.me.id + + try: + tracks = await parse_query(self.node, self._bot.spotify, bump.url, requester) + except (JockeyException, SpotifyNoResultsError): + raise + + if len(tracks) == 0: + raise BumpError('Unable to parse bump URL into tracks.') + + await self._play(tracks[0]) + self._db.set_last_bump(self.guild.id) diff --git a/cogs/player/jockey_helpers.py b/cogs/player/jockey_helpers.py index 3bebf34..1656c06 100644 --- a/cogs/player/jockey_helpers.py +++ b/cogs/player/jockey_helpers.py @@ -10,25 +10,40 @@ from database.redis import REDIS from dataclass.queue_item import QueueItem from utils.constants import CONFIDENCE_THRESHOLD -from utils.exceptions import (JockeyException, LavalinkInvalidIdentifierError, - LavalinkSearchError, SpotifyNoResultsError) +from utils.exceptions import ( + JockeyException, + LavalinkInvalidIdentifierError, + LavalinkSearchError, + SpotifyNoResultsError, +) from utils.fuzzy import check_similarity_weighted from utils.logger import create_logger from utils.musicbrainz import annotate_track from utils.spotify_client import Spotify -from utils.url import (check_sc_url, check_spotify_url, check_url, - check_youtube_playlist_url, check_youtube_url, - check_ytmusic_playlist_url, check_ytmusic_url, - get_spinfo_from_url, get_ytid_from_url, - get_ytlistid_from_url) - -from .lavalink_client import (get_deezer_matches, get_deezer_track, - get_soundcloud_matches, get_youtube_matches) +from utils.url import ( + check_sc_url, + check_spotify_url, + check_url, + check_youtube_playlist_url, + check_youtube_url, + check_ytmusic_playlist_url, + check_ytmusic_url, + get_spinfo_from_url, + get_ytid_from_url, + get_ytlistid_from_url, +) + +from .lavalink_client import ( + get_deezer_matches, + get_deezer_track, + get_soundcloud_matches, + get_youtube_matches, +) if TYPE_CHECKING: - from mafic import Node, Track + from mafic import Node, Track - from dataclass.spotify import SpotifyTrack + from dataclass.spotify import SpotifyTrack LOGGER = create_logger('jockey_helpers') @@ -36,465 +51,425 @@ def rank_results( - query: str, - results: List[T], - result_type: SearchType + query: str, results: List[T], result_type: SearchType ) -> List[Tuple[T, int]]: - """ - Ranks search results based on similarity to a fuzzy query. - - :param query: The query to check against. - :param results: The results to rank. Can be mafic.Track, dataclass.SpotifyTrack, - or any object with a title and author string attribute. - :param result_type: The type of result. See ResultType. - :return: A list of tuples containing the result and its similarity to the query. - """ - # Rank results - similarities = [ - check_similarity_weighted( - query, - f'{result.title} {result.author}', # type: ignore - int(100 * (0.8 ** i)) - ) - for i, result in enumerate(results) - ] - ranked = sorted(zip(results, similarities), key=lambda x: x[1], reverse=True) - - # Print confidences for debugging - type_name = 'YouTube' - if result_type == SearchType.SPOTIFY_SEARCH: - type_name = 'Spotify' - elif result_type == SearchType.DEEZER_SEARCH: - type_name = 'Deezer' - LOGGER.debug('%s results and confidences for "%s":', type_name, query) - for result, confidence in ranked: - LOGGER.debug( - ' %3d %-20s %-25s', - confidence, - result.author[:20], # type: ignore - result.title[:25] # type: ignore - ) - - return ranked + """ + Ranks search results based on similarity to a fuzzy query. + + :param query: The query to check against. + :param results: The results to rank. Can be mafic.Track, dataclass.SpotifyTrack, + or any object with a title and author string attribute. + :param result_type: The type of result. See ResultType. + :return: A list of tuples containing the result and its similarity to the query. + """ + # Rank results + similarities = [ + check_similarity_weighted( + query, + f'{result.title} {result.author}', # type: ignore + int(100 * (0.8**i)), + ) + for i, result in enumerate(results) + ] + ranked = sorted(zip(results, similarities), key=lambda x: x[1], reverse=True) + + # Print confidences for debugging + type_name = 'YouTube' + if result_type == SearchType.SPOTIFY_SEARCH: + type_name = 'Spotify' + elif result_type == SearchType.DEEZER_SEARCH: + type_name = 'Deezer' + LOGGER.debug('%s results and confidences for "%s":', type_name, query) + for result, confidence in ranked: + LOGGER.debug( + ' %3d %-20s %-25s', + confidence, + result.author[:20], # type: ignore + result.title[:25], # type: ignore + ) + + return ranked + + +async def find_lavalink_track( # pylint: disable=too-many-statements + node: 'Node', + item: QueueItem, + /, + deezer_enabled: bool = False, + in_place: bool = False, + lookup_mbid: bool = False, +) -> 'Track': + """ + Finds a matching playable Lavalink track for a QueueItem. + + :param node: The Lavalink node to use for searching. Must be an instance of mafic.Node. + :param item: The QueueItem to find a track for. + :param deezer_enabled: Whether to use Deezer for searching. + :param in_place: Whether to modify the QueueItem in place. + :param lookup_mbid: Whether to look up the MBID for the track. + """ + results = [] + + # Check Redis if enabled + redis_key = None + redis_key_type = None + if REDIS is not None: + # Determine key type + if item.spotify_id is not None: + redis_key = item.spotify_id + redis_key_type = 'spotify_id' + elif item.isrc is not None: + redis_key = item.isrc + redis_key_type = 'isrc' + # Get cached Lavalink track + if redis_key is not None and redis_key_type is not None: + encoded = REDIS.get_lavalink_track(redis_key, key_type=redis_key_type) + if encoded is not None: + LOGGER.info('Found cached Lavalink track for Spotify ID %s', item.spotify_id) + if in_place: + item.lavalink_track = await node.decode_track(encoded) + + return await node.decode_track(encoded) + + # Annotate track with ISRC and/or MBID + if item.isrc is None or lookup_mbid: + annotate_track(item) + + # Use ISRC if present + if item.isrc is not None: + # Try to match ISRC on Deezer if enabled + if deezer_enabled: + try: + result = await get_deezer_track(node, item.isrc) + except LavalinkSearchError: + LOGGER.warning("No Deezer match for ISRC %s `%s'", item.isrc, item.title) + else: + results.append(result) + LOGGER.debug("Matched ISRC %s `%s' on Deezer", item.isrc, item.title) + + # Try to match ISRC on YouTube + if len(results) == 0: + try: + results = await get_youtube_matches( + node, f'"{item.isrc}"', desired_duration_ms=item.duration + ) + except LavalinkSearchError: + LOGGER.warning("No YouTube match for ISRC %s `%s'", item.isrc, item.title) + else: + LOGGER.debug("Matched ISRC %s `%s' on YouTube", item.isrc, item.title) + else: + LOGGER.warning( + "`%s' has no ISRC. Scrobbling might fail for this track.", item.title + ) + item.is_imperfect = True + + # Fallback to metadata search + if len(results) == 0: + query = f'{item.title} {item.artist}' -async def find_lavalink_track( # pylint: disable=too-many-statements - node: 'Node', - item: QueueItem, - /, - deezer_enabled: bool = False, - in_place: bool = False, - lookup_mbid: bool = False -) -> 'Track': - """ - Finds a matching playable Lavalink track for a QueueItem. - - :param node: The Lavalink node to use for searching. Must be an instance of mafic.Node. - :param item: The QueueItem to find a track for. - :param deezer_enabled: Whether to use Deezer for searching. - :param in_place: Whether to modify the QueueItem in place. - :param lookup_mbid: Whether to look up the MBID for the track. - """ - results = [] - - # Check Redis if enabled - redis_key = None - redis_key_type = None - if REDIS is not None: - # Determine key type - if item.spotify_id is not None: - redis_key = item.spotify_id - redis_key_type = 'spotify_id' - elif item.isrc is not None: - redis_key = item.isrc - redis_key_type = 'isrc' - - # Get cached Lavalink track - if redis_key is not None and redis_key_type is not None: - encoded = REDIS.get_lavalink_track(redis_key, key_type=redis_key_type) - if encoded is not None: - LOGGER.info( - 'Found cached Lavalink track for Spotify ID %s', - item.spotify_id - ) - if in_place: - item.lavalink_track = await node.decode_track(encoded) - - return await node.decode_track(encoded) - - # Annotate track with ISRC and/or MBID - if item.isrc is None or lookup_mbid: - annotate_track(item) - - # Use ISRC if present if item.isrc is not None: - # Try to match ISRC on Deezer if enabled - if deezer_enabled: - try: - result = await get_deezer_track(node, item.isrc) - except LavalinkSearchError: - LOGGER.warning( - 'No Deezer match for ISRC %s `%s\'', - item.isrc, - item.title - ) - else: - results.append(result) - LOGGER.debug( - 'Matched ISRC %s `%s\' on Deezer', - item.isrc, - item.title - ) - - # Try to match ISRC on YouTube - if len(results) == 0: - try: - results = await get_youtube_matches( - node, - f'"{item.isrc}"', - desired_duration_ms=item.duration - ) - except LavalinkSearchError: - LOGGER.warning( - 'No YouTube match for ISRC %s `%s\'', - item.isrc, - item.title - ) - else: - LOGGER.debug( - 'Matched ISRC %s `%s\' on YouTube', - item.isrc, - item.title - ) - else: - LOGGER.warning( - '`%s\' has no ISRC. Scrobbling might fail for this track.', - item.title + LOGGER.warning( + "No ISRC match for `%s'. Falling back to metadata search.", item.title + ) + + # Try to match on Deezer if enabled + if deezer_enabled: + try: + dz_results = await get_deezer_matches( + node, query, desired_duration_ms=item.duration, auto_filter=True ) - item.is_imperfect = True + except LavalinkSearchError: + LOGGER.warning("No Deezer results for `%s'", item.title) + else: + # Use top result if it's good enough + ranked = rank_results(query, dz_results, SearchType.DEEZER_SEARCH) + if ranked[0][1] >= CONFIDENCE_THRESHOLD: + LOGGER.warning( + "Using Deezer result `%s' (%s) for `%s'", + ranked[0][0].title, + ranked[0][0].lavalink_track.identifier, + item.title, + ) + results.append(ranked[0][0]) + else: + LOGGER.warning("No similar Deezer results for `%s'", item.title) - # Fallback to metadata search if len(results) == 0: - query = f'{item.title} {item.artist}' - - if item.isrc is not None: - LOGGER.warning( - 'No ISRC match for `%s\'. Falling back to metadata search.', - item.title - ) - - # Try to match on Deezer if enabled - if deezer_enabled: - try: - dz_results = await get_deezer_matches( - node, - query, - desired_duration_ms=item.duration, - auto_filter=True - ) - except LavalinkSearchError: - LOGGER.warning( - 'No Deezer results for `%s\'', - item.title - ) - else: - # Use top result if it's good enough - ranked = rank_results( - query, - dz_results, - SearchType.DEEZER_SEARCH - ) - if ranked[0][1] >= CONFIDENCE_THRESHOLD: - LOGGER.warning( - 'Using Deezer result `%s\' (%s) for `%s\'', - ranked[0][0].title, - ranked[0][0].lavalink_track.identifier, - item.title - ) - results.append(ranked[0][0]) - else: - LOGGER.warning( - 'No similar Deezer results for `%s\'', - item.title - ) - - if len(results) == 0: - try: - yt_results = await get_youtube_matches( - node, - query, - desired_duration_ms=item.duration - ) - except LavalinkSearchError as err: - LOGGER.error(err.message) - raise - - # Use top result - ranked = rank_results( - query, - yt_results, - SearchType.YOUTUBE - ) - LOGGER.warning( - 'Using YouTube result `%s\' (%s) for `%s\'', - ranked[0][0].title, - ranked[0][0].lavalink_track.identifier, - item.title - ) - results.append(ranked[0][0]) - - # Save Lavalink result - lavalink_track = results[0].lavalink_track - if in_place: - item.lavalink_track = lavalink_track - - # Save data to Redis if enabled - if REDIS is not None and redis_key_type is not None and redis_key is not None: - # Save Lavalink track - REDIS.set_lavalink_track( - redis_key, - lavalink_track.id, - key_type=redis_key_type + try: + yt_results = await get_youtube_matches( + node, query, desired_duration_ms=item.duration ) + except LavalinkSearchError as err: + LOGGER.error(err.message) + raise - return lavalink_track + # Use top result + ranked = rank_results(query, yt_results, SearchType.YOUTUBE) + LOGGER.warning( + "Using YouTube result `%s' (%s) for `%s'", + ranked[0][0].title, + ranked[0][0].lavalink_track.identifier, + item.title, + ) + results.append(ranked[0][0]) + # Save Lavalink result + lavalink_track = results[0].lavalink_track + if in_place: + item.lavalink_track = lavalink_track -def invalidate_lavalink_track(item: QueueItem): - """ - Removes a cached Lavalink track from Redis. + # Save data to Redis if enabled + if REDIS is not None and redis_key_type is not None and redis_key is not None: + # Save Lavalink track + REDIS.set_lavalink_track(redis_key, lavalink_track.id, key_type=redis_key_type) - :param item: The QueueItem to invalidate the track for. - """ - if REDIS is None: - return + return lavalink_track - # Determine key type - redis_key = None - redis_key_type = None - if item.spotify_id is not None: - redis_key = item.spotify_id - redis_key_type = 'spotify_id' - elif item.isrc is not None: - redis_key = item.isrc - redis_key_type = 'isrc' - # Invalidate cached Lavalink track - if redis_key is not None and redis_key_type is not None: - REDIS.invalidate_lavalink_track( - redis_key, - key_type=redis_key_type - ) - else: - LOGGER.warning( - 'Could not invalidate cached track for `%s\': no key', - item.title - ) +def invalidate_lavalink_track(item: QueueItem): + """ + Removes a cached Lavalink track from Redis. + + :param item: The QueueItem to invalidate the track for. + """ + if REDIS is None: + return + + # Determine key type + redis_key = None + redis_key_type = None + if item.spotify_id is not None: + redis_key = item.spotify_id + redis_key_type = 'spotify_id' + elif item.isrc is not None: + redis_key = item.isrc + redis_key_type = 'isrc' + + # Invalidate cached Lavalink track + if redis_key is not None and redis_key_type is not None: + REDIS.invalidate_lavalink_track(redis_key, key_type=redis_key_type) + else: + LOGGER.warning("Could not invalidate cached track for `%s': no key", item.title) async def parse_query( - node: 'Node', - spotify: Spotify, - query: str, - requester: int + node: 'Node', spotify: Spotify, query: str, requester: int ) -> List[QueueItem]: - """ - Parse a query and return a list of QueueItems. - - :param node: The Lavalink node to use for searching. Must be an instance of mafic.Node. - :param spotify: The Spotify client to use for searching. See utils/spotify_client.py. - :param query: The query to parse. Can be plain language or a URL. - :param requester: The ID of the user who requested the track. - """ - query_is_url = check_url(query) - if query_is_url: - if check_spotify_url(query): - # Query is a Spotify URL. - return await parse_spotify_query(spotify, query, requester) - if check_youtube_url(query) or check_ytmusic_url(query): - # Query is a YouTube URL. - return await parse_youtube_query(node, query, requester) - if check_youtube_playlist_url(query) or check_ytmusic_playlist_url(query): - # Query is a YouTube playlist URL. - return await parse_youtube_playlist(node, query, requester) - if check_sc_url(query): - # Query is a SoundCloud URL. - return await parse_sc_query(node, query, requester) - - # Direct URL playback is deprecated - raise JockeyException('Direct playback from unsupported URLs is deprecated') - - # Attempt to look for a matching track on Spotify - try: - results = spotify.search_track(query, limit=10) - except SpotifyNoResultsError: - pass - else: - # Return top result if it's good enough - ranked = rank_results(query, results, SearchType.SPOTIFY_SEARCH) - if ranked[0][1] >= CONFIDENCE_THRESHOLD: - track = ranked[0][0] - return [QueueItem( - requester=requester, - title=track.title, - artist=track.artist, - author=track.author, - album=track.album, - spotify_id=track.spotify_id, - duration=track.duration_ms, - artwork=track.artwork, - isrc=track.isrc - )] - - # Get matching tracks from YouTube - results = await get_youtube_matches(node, query, auto_filter=False) - - # Return top result - ranked = rank_results(query, results, SearchType.YOUTUBE) - result = ranked[0][0] - return [QueueItem( - title=result.title, - artist=result.author, - artwork=result.artwork_url, - duration=result.duration_ms, - requester=requester, - url=result.url, - lavalink_track=result.lavalink_track - )] + """ + Parse a query and return a list of QueueItems. + + :param node: The Lavalink node to use for searching. Must be an instance of mafic.Node. + :param spotify: The Spotify client to use for searching. See utils/spotify_client.py. + :param query: The query to parse. Can be plain language or a URL. + :param requester: The ID of the user who requested the track. + """ + query_is_url = check_url(query) + if query_is_url: + if check_spotify_url(query): + # Query is a Spotify URL. + return await parse_spotify_query(spotify, query, requester) + if check_youtube_url(query) or check_ytmusic_url(query): + # Query is a YouTube URL. + return await parse_youtube_query(node, query, requester) + if check_youtube_playlist_url(query) or check_ytmusic_playlist_url(query): + # Query is a YouTube playlist URL. + return await parse_youtube_playlist(node, query, requester) + if check_sc_url(query): + # Query is a SoundCloud URL. + return await parse_sc_query(node, query, requester) + + # Direct URL playback is deprecated + raise JockeyException('Direct playback from unsupported URLs is deprecated') + + # Attempt to look for a matching track on Spotify + try: + results = spotify.search_track(query, limit=10) + except SpotifyNoResultsError: + pass + else: + # Return top result if it's good enough + ranked = rank_results(query, results, SearchType.SPOTIFY_SEARCH) + if ranked[0][1] >= CONFIDENCE_THRESHOLD: + track = ranked[0][0] + return [ + QueueItem( + requester=requester, + title=track.title, + artist=track.artist, + author=track.author, + album=track.album, + spotify_id=track.spotify_id, + duration=track.duration_ms, + artwork=track.artwork, + isrc=track.isrc, + ) + ] + + # Get matching tracks from YouTube + results = await get_youtube_matches(node, query, auto_filter=False) + + # Return top result + ranked = rank_results(query, results, SearchType.YOUTUBE) + result = ranked[0][0] + return [ + QueueItem( + title=result.title, + artist=result.author, + artwork=result.artwork_url, + duration=result.duration_ms, + requester=requester, + url=result.url, + lavalink_track=result.lavalink_track, + ) + ] async def parse_sc_query(node: 'Node', query: str, requester: int) -> List[QueueItem]: - """ - Parse a SoundCloud query and return a list of QueueItems. - See parse_query() for more information. - """ - try: - # Get results with Lavalink - tracks = await get_soundcloud_matches(node, query) - except Exception as exc: - raise LavalinkInvalidIdentifierError( - f'Entity {query} is private, nonexistent, or has no stream URL' - ) from exc - - return [QueueItem( + """ + Parse a SoundCloud query and return a list of QueueItems. + See parse_query() for more information. + """ + try: + # Get results with Lavalink + tracks = await get_soundcloud_matches(node, query) + except Exception as exc: + raise LavalinkInvalidIdentifierError( + f'Entity {query} is private, nonexistent, or has no stream URL' + ) from exc + + return [ + QueueItem( + requester=requester, + title=track.title, + artist=track.author, + artwork=track.artwork_url, + duration=track.duration_ms, + url=track.url, + lavalink_track=track.lavalink_track, + ) + for track in tracks + ] + + +async def parse_spotify_query( + spotify: Spotify, query: str, requester: int +) -> List[QueueItem]: + """ + Parse a Spotify query and return a list of QueueItems. + See parse_query() for more information. + """ + # Get artwork for Spotify album/playlist + sp_type, sp_id = get_spinfo_from_url(query) + + new_tracks = [] + track_queue: List['SpotifyTrack'] + try: + if sp_type == 'track': + # Get track details from Spotify + track_queue = [spotify.get_track(sp_id)] + elif sp_type == 'artist': + # Get top tracks from Spotify + track_queue = spotify.get_artist_top_tracks(sp_id) + else: + # Get playlist or album tracks from Spotify + track_queue = spotify.get_tracks(sp_type, sp_id)[2] + except SpotifyException as exc: + if exc.http_status == 404: + # No tracks. + raise SpotifyNoResultsError( + f'The {sp_type} does not exist or is private.' + ) from exc + + raise SpotifyNoResultsError( + f'An error occurred while fetching the playlist: {exc.msg}' + ) from exc + + if len(track_queue) < 1: + if sp_type == 'track': + # No tracks. + raise SpotifyNoResultsError('Track does not exist or is private.') + raise SpotifyNoResultsError(f'{sp_type} does not have any public tracks.') + + # At least one track. + for track in track_queue: + new_tracks.append( + QueueItem( requester=requester, title=track.title, - artist=track.author, - artwork=track.artwork_url, + artist=track.artist, + author=track.author, + album=track.album, + spotify_id=track.spotify_id, duration=track.duration_ms, - url=track.url, - lavalink_track=track.lavalink_track - ) for track in tracks] - - -async def parse_spotify_query(spotify: Spotify, query: str, requester: int) -> List[QueueItem]: - """ - Parse a Spotify query and return a list of QueueItems. - See parse_query() for more information. - """ - # Get artwork for Spotify album/playlist - sp_type, sp_id = get_spinfo_from_url(query) - - new_tracks = [] - track_queue: List['SpotifyTrack'] - try: - if sp_type == 'track': - # Get track details from Spotify - track_queue = [spotify.get_track(sp_id)] - elif sp_type == 'artist': - # Get top tracks from Spotify - track_queue = spotify.get_artist_top_tracks(sp_id) - else: - # Get playlist or album tracks from Spotify - track_queue = spotify.get_tracks(sp_type, sp_id)[2] - except SpotifyException as exc: - if exc.http_status == 404: - # No tracks. - raise SpotifyNoResultsError( - f'The {sp_type} does not exist or is private.' - ) from exc - - raise SpotifyNoResultsError( - f'An error occurred while fetching the playlist: {exc.msg}' - ) from exc - - if len(track_queue) < 1: - if sp_type == 'track': - # No tracks. - raise SpotifyNoResultsError('Track does not exist or is private.') - raise SpotifyNoResultsError(f'{sp_type} does not have any public tracks.') - - # At least one track. - for track in track_queue: - new_tracks.append(QueueItem( - requester=requester, - title=track.title, - artist=track.artist, - author=track.author, - album=track.album, - spotify_id=track.spotify_id, - duration=track.duration_ms, - artwork=track.artwork, - isrc=track.isrc - )) - - return new_tracks - - -async def parse_youtube_playlist(node: 'Node', query: str, requester: int) -> List[QueueItem]: - """ - Parse a YouTube playlist query and return a list of QueueItems. - See parse_query() for more information. - """ - try: - # Get playlist tracks from YouTube - playlist_id = get_ytlistid_from_url(query) - tracks = await get_youtube_matches( - node, - f'https://youtube.com/playlist?list={playlist_id}' - ) - except Exception as exc: - # No tracks. - raise LavalinkInvalidIdentifierError( - query, - 'Playlist is empty, private, or nonexistent' - ) from exc - - return [QueueItem( + artwork=track.artwork, + isrc=track.isrc, + ) + ) + + return new_tracks + + +async def parse_youtube_playlist( + node: 'Node', query: str, requester: int +) -> List[QueueItem]: + """ + Parse a YouTube playlist query and return a list of QueueItems. + See parse_query() for more information. + """ + try: + # Get playlist tracks from YouTube + playlist_id = get_ytlistid_from_url(query) + tracks = await get_youtube_matches( + node, f'https://youtube.com/playlist?list={playlist_id}' + ) + except Exception as exc: + # No tracks. + raise LavalinkInvalidIdentifierError( + query, 'Playlist is empty, private, or nonexistent' + ) from exc + + return [ + QueueItem( + requester=requester, + title=track.title, + artist=track.author, + artwork=track.artwork_url, + duration=track.duration_ms, + url=track.url, + lavalink_track=track.lavalink_track, + ) + for track in tracks + ] + + +async def parse_youtube_query( + node: 'Node', query: str, requester: int +) -> List[QueueItem]: + """ + Parse a non-playlist YouTube query and return a list of QueueItems. + See parse_query() for more information. + """ + # Is it a video? + try: + video_id = get_ytid_from_url(query) + + # Get the video's details + video = await get_youtube_matches(node, video_id) + return [ + QueueItem( + title=video[0].title, + artist=video[0].author, + artwork=video[0].artwork_url, requester=requester, - title=track.title, - artist=track.author, - artwork=track.artwork_url, - duration=track.duration_ms, - url=track.url, - lavalink_track=track.lavalink_track - ) for track in tracks] - - -async def parse_youtube_query(node: 'Node', query: str, requester: int) -> List[QueueItem]: - """ - Parse a non-playlist YouTube query and return a list of QueueItems. - See parse_query() for more information. - """ - # Is it a video? - try: - video_id = get_ytid_from_url(query) - - # Get the video's details - video = await get_youtube_matches(node, video_id) - return [QueueItem( - title=video[0].title, - artist=video[0].author, - artwork=video[0].artwork_url, - requester=requester, - duration=video[0].duration_ms, - url=video[0].url, - lavalink_track=video[0].lavalink_track - )] - except LavalinkInvalidIdentifierError: - raise - except Exception as exc: - raise LavalinkInvalidIdentifierError( - query, - 'Only YouTube video and playlist URLs are supported.' - ) from exc + duration=video[0].duration_ms, + url=video[0].url, + lavalink_track=video[0].lavalink_track, + ) + ] + except LavalinkInvalidIdentifierError: + raise + except Exception as exc: + raise LavalinkInvalidIdentifierError( + query, 'Only YouTube video and playlist URLs are supported.' + ) from exc diff --git a/cogs/player/lavalink_client.py b/cogs/player/lavalink_client.py index 7891731..c763c9b 100644 --- a/cogs/player/lavalink_client.py +++ b/cogs/player/lavalink_client.py @@ -14,182 +14,180 @@ from utils.fuzzy import check_similarity if TYPE_CHECKING: - from mafic import Node, Track + from mafic import Node, Track def filter_results(query: str, search_results: List['Track']) -> List[LavalinkResult]: - """ - Filters search results by removing karaoke, live, instrumental etc versions. - """ - results = [] + """ + Filters search results by removing karaoke, live, instrumental etc versions. + """ + results = [] - for result in search_results: - if not result.length: - # Can't play a track with no duration - continue + for result in search_results: + if not result.length: + # Can't play a track with no duration + continue - # Skip karaoke, live, instrumental etc versions - # if the original query did not ask for it - valid = True - for word in BLACKLIST: - if word in result.title.lower() and word not in query.lower(): - valid = False - break + # Skip karaoke, live, instrumental etc versions + # if the original query did not ask for it + valid = True + for word in BLACKLIST: + if word in result.title.lower() and word not in query.lower(): + valid = False + break - if valid: - results.append(parse_result(result)) + if valid: + results.append(parse_result(result)) - return results + return results def parse_result(result: 'Track') -> LavalinkResult: - """ - Parses a Lavalink track result into a LavalinkResult object. - """ - parsed = LavalinkResult( - title=result.title, - author=result.author, - duration_ms=result.length, - artwork_url=result.artwork_url, - lavalink_track=result - ) - if result.uri is not None: - parsed.url = result.uri - - return parsed + """ + Parses a Lavalink track result into a LavalinkResult object. + """ + parsed = LavalinkResult( + title=result.title, + author=result.author, + duration_ms=result.length, + artwork_url=result.artwork_url, + lavalink_track=result, + ) + if result.uri is not None: + parsed.url = result.uri + + return parsed async def get_deezer_matches( - node: 'Node', - query: str, - desired_duration_ms: Optional[int] = None, - auto_filter: bool = False + node: 'Node', + query: str, + desired_duration_ms: Optional[int] = None, + auto_filter: bool = False, ) -> List[LavalinkResult]: - """ - Gets Deezer tracks from Lavalink, and returns a list of LavalinkResult objects. - - :param node: The Lavalink node to use. - :param query: The query to search for. - :param desired_duration_ms: The desired duration of the track, in milliseconds. - :param automatic: Whether to automatically filter results. - """ - return await search_lavalink( - node, - query, - search_type=SearchType.DEEZER_SEARCH.value, - desired_duration_ms=desired_duration_ms, - auto_filter=auto_filter - ) + """ + Gets Deezer tracks from Lavalink, and returns a list of LavalinkResult objects. + + :param node: The Lavalink node to use. + :param query: The query to search for. + :param desired_duration_ms: The desired duration of the track, in milliseconds. + :param automatic: Whether to automatically filter results. + """ + return await search_lavalink( + node, + query, + search_type=SearchType.DEEZER_SEARCH.value, + desired_duration_ms=desired_duration_ms, + auto_filter=auto_filter, + ) async def get_deezer_track(node: 'Node', isrc: str) -> LavalinkResult: - """ - Gets a single Deezer track from Lavalink, and returns a LavalinkResult object. - - :param node: The Lavalink node to use. - :param isrc: The ISRC to search for. - """ - results = await search_lavalink( - node, - isrc, - search_type=SearchType.DEEZER_ISRC.value, - auto_filter=False - ) - return results[0] + """ + Gets a single Deezer track from Lavalink, and returns a LavalinkResult object. + + :param node: The Lavalink node to use. + :param isrc: The ISRC to search for. + """ + results = await search_lavalink( + node, isrc, search_type=SearchType.DEEZER_ISRC.value, auto_filter=False + ) + return results[0] async def get_soundcloud_matches( - node: 'Node', - query: str, - desired_duration_ms: Optional[int] = None, - auto_filter: bool = False + node: 'Node', + query: str, + desired_duration_ms: Optional[int] = None, + auto_filter: bool = False, ) -> List[LavalinkResult]: - """ - Gets SoundCloud tracks from Lavalink, and returns a list of LavalinkResult objects. - - :param node: The Lavalink node to use. - :param query: The query to search for. - :param desired_duration_ms: The desired duration of the track, in milliseconds. - :param automatic: Whether to automatically filter results. - """ - return await search_lavalink( - node, - query, - search_type=SearchType.SOUNDCLOUD.value, - desired_duration_ms=desired_duration_ms, - auto_filter=auto_filter - ) + """ + Gets SoundCloud tracks from Lavalink, and returns a list of LavalinkResult objects. + + :param node: The Lavalink node to use. + :param query: The query to search for. + :param desired_duration_ms: The desired duration of the track, in milliseconds. + :param automatic: Whether to automatically filter results. + """ + return await search_lavalink( + node, + query, + search_type=SearchType.SOUNDCLOUD.value, + desired_duration_ms=desired_duration_ms, + auto_filter=auto_filter, + ) async def get_youtube_matches( - node: 'Node', - query: str, - desired_duration_ms: Optional[int] = None, - auto_filter: bool = False + node: 'Node', + query: str, + desired_duration_ms: Optional[int] = None, + auto_filter: bool = False, ) -> List[LavalinkResult]: - """ - Gets YouTube tracks from Lavalink, and returns a list of LavalinkResult objects. - - :param node: The Lavalink node to use. - :param query: The query to search for. - :param desired_duration_ms: The desired duration of the track, in milliseconds. - :param automatic: Whether to automatically filter results. - """ - return await search_lavalink( - node, - query, - search_type=SearchType.YOUTUBE.value, - desired_duration_ms=desired_duration_ms, - auto_filter=auto_filter - ) + """ + Gets YouTube tracks from Lavalink, and returns a list of LavalinkResult objects. + + :param node: The Lavalink node to use. + :param query: The query to search for. + :param desired_duration_ms: The desired duration of the track, in milliseconds. + :param automatic: Whether to automatically filter results. + """ + return await search_lavalink( + node, + query, + search_type=SearchType.YOUTUBE.value, + desired_duration_ms=desired_duration_ms, + auto_filter=auto_filter, + ) async def search_lavalink( - node: 'Node', - query: str, - search_type: str = SearchType.YOUTUBE.value, - desired_duration_ms: Optional[int] = None, - auto_filter: bool = False + node: 'Node', + query: str, + search_type: str = SearchType.YOUTUBE.value, + desired_duration_ms: Optional[int] = None, + auto_filter: bool = False, ) -> List[LavalinkResult]: - """ - Generic search function for Lavalink that returns a list of LavalinkResult objects. - - :param node: The Lavalink node to use. - :param query: The query to search for. - :param search_type: The search type to use. See mafic.SearchType. - :param desired_duration_ms: The desired duration of the track, in milliseconds. - :param automatic: Whether to automatically filter results. - """ - try: - search = await node.fetch_tracks(query, search_type=search_type) - except TrackLoadException as exc: - raise LavalinkSearchError( - query, - reason=f'Could not get tracks for `{query}\': {exc.cause}' - ) from exc - - if isinstance(search, Playlist) and len(search.tracks) == 0: - raise LavalinkSearchError(query, reason='Playlist is empty') - if (isinstance(search, list) and len(search) == 0) or search is None: - raise LavalinkSearchError(query, reason='No results found') - - search_results = search if isinstance(search, list) else search.tracks - if auto_filter: - results = filter_results(query, search_results) - else: - results = [parse_result(result) for result in search_results] - - # Are there valid results? - if len(results) == 0: - raise LavalinkSearchError(query, reason='No valid results found') - - # Sort by descending similarity - if desired_duration_ms is not None: - results.sort( - key=lambda x: (1 - check_similarity(query, x.title), - abs(x.duration_ms - desired_duration_ms)) - ) - else: - results.sort(key=lambda x: 1 - check_similarity(query, x.title)) - - return results + """ + Generic search function for Lavalink that returns a list of LavalinkResult objects. + + :param node: The Lavalink node to use. + :param query: The query to search for. + :param search_type: The search type to use. See mafic.SearchType. + :param desired_duration_ms: The desired duration of the track, in milliseconds. + :param automatic: Whether to automatically filter results. + """ + try: + search = await node.fetch_tracks(query, search_type=search_type) + except TrackLoadException as exc: + raise LavalinkSearchError( + query, reason=f"Could not get tracks for `{query}': {exc.cause}" + ) from exc + + if isinstance(search, Playlist) and len(search.tracks) == 0: + raise LavalinkSearchError(query, reason='Playlist is empty') + if (isinstance(search, list) and len(search) == 0) or search is None: + raise LavalinkSearchError(query, reason='No results found') + + search_results = search if isinstance(search, list) else search.tracks + if auto_filter: + results = filter_results(query, search_results) + else: + results = [parse_result(result) for result in search_results] + + # Are there valid results? + if len(results) == 0: + raise LavalinkSearchError(query, reason='No valid results found') + + # Sort by descending similarity + if desired_duration_ms is not None: + results.sort( + key=lambda x: ( + 1 - check_similarity(query, x.title), + abs(x.duration_ms - desired_duration_ms), + ) + ) + else: + results.sort(key=lambda x: 1 - check_similarity(query, x.title)) + + return results diff --git a/cogs/player/queue.py b/cogs/player/queue.py index 49594ac..9ef240e 100644 --- a/cogs/player/queue.py +++ b/cogs/player/queue.py @@ -10,369 +10,370 @@ from utils.logger import create_logger if TYPE_CHECKING: - from database import Database + from database import Database class QueueManager: + """ + Queue manager for Blanco's Jockey. + """ + + def __init__(self, guild_id: int, database: 'Database', /): + self._guild_id = guild_id + self._queue: List[QueueItem] = [] + self._shuf_i: List[int] = [] + + # Restore loop preferences from database + self._db = database + self._loop_one = database.get_loop(guild_id) + self._loop_all = database.get_loop_all(guild_id) + + # The current track index. + # Even if the queue is shuffled, this must ALWAYS + # correspond to an element in self._queue, not self._shuf_i. + self._i = -1 + + # Logger + self._logger = create_logger(self.__class__.__name__) + self._logger.info('Initialized queue manager for guild %d', guild_id) + + @property + def queue(self) -> List[QueueItem]: """ - Queue manager for Blanco's Jockey. - """ - def __init__(self, guild_id: int, database: 'Database', /): - self._guild_id = guild_id - self._queue: List[QueueItem] = [] - self._shuf_i: List[int] = [] - - # Restore loop preferences from database - self._db = database - self._loop_one = database.get_loop(guild_id) - self._loop_all = database.get_loop_all(guild_id) - - # The current track index. - # Even if the queue is shuffled, this must ALWAYS - # correspond to an element in self._queue, not self._shuf_i. - self._i = -1 - - # Logger - self._logger = create_logger(self.__class__.__name__) - self._logger.info('Initialized queue manager for guild %d', guild_id) - - @property - def queue(self) -> List[QueueItem]: - """ - Returns the queue. - """ - return self._queue - - @property - def shuffled_queue(self) -> List[QueueItem]: - """ - Returns the queue, shuffled. - """ - if not self.is_shuffling: - return self.queue - return [self.queue[i] for i in self._shuf_i] - - @property - def is_shuffling(self) -> bool: - """ - Returns whether the queue is shuffled. - """ - return len(self._shuf_i) > 0 - - @property - def is_looping_one(self) -> bool: - """ - Returns whether the queue is looping the current track. - """ - return self._loop_one - - @is_looping_one.setter - def is_looping_one(self, value: bool): - """ - Sets whether the queue is looping the current track. - """ - self._loop_one = value - self._db.set_loop(self._guild_id, value) - - @property - def is_looping_all(self) -> bool: - """ - Returns whether the queue is looping all tracks. - """ - return self._loop_all - - @is_looping_all.setter - def is_looping_all(self, value: bool): - """ - Sets whether the queue is looping all tracks. - """ - self._loop_all = value - self._db.set_loop_all(self._guild_id, value) - - @property - def size(self) -> int: - """ - Returns the size of the queue. - """ - return len(self.queue) - - @property - def current(self) -> QueueItem: - """ - Returns the current track in the queue. - - Raises: - EmptyQueueError: If the queue is empty. - """ - if self.size == 0: - raise EmptyQueueError - - return self.queue[self.current_index] - - @property - def current_index(self) -> int: - """ - Returns the current track index, NOT accounting for shuffling. - This is the index of the current track in self._queue. - """ - return self._i - - @property - def current_shuffled_index(self) -> int: - """ - Returns the current track index, accounting for shuffling. - This is the index of the current track in self._shuf_i. - """ - if not self.is_shuffling: - return self.current_index - return self._shuf_i.index(self.current_index) - - @current_index.setter - def current_index(self, i: int): - """ - Sets the current track index. - - Args: - i: The new current track index. Must be adjusted for shuffling, - i.e., i must correspond to an element in self._queue, - not self._shuf_i. - """ - self._i = i - - @property - def next_track(self) -> Tuple[int, QueueItem]: - """ - Returns a tuple containing the index of the next track in the queue - and the track itself. - - Raises: - EmptyQueueError: If the queue is empty. - EndOfQueueError: If the last track in the queue is reached. - """ - if self.size == 0: - raise EmptyQueueError - - try: - i = self.calc_next_index() - track = self.queue[i] - except EndOfQueueError as err: - raise EndOfQueueError('No next track in queue.') from err - - return i, track - - @property - def previous_track(self) -> Tuple[int, QueueItem]: - """ - Returns a tuple containing the index of the previous track in the queue - and the track itself. - - Raises: - EmptyQueueError: If the queue is empty. - EndOfQueueError: If the first track in the queue is reached. - """ - if self.size == 0: - raise EmptyQueueError - - try: - i = self.calc_next_index(delta=-1) - track = self.queue[i] - except EndOfQueueError as err: - raise EndOfQueueError('No previous track in queue.') from err - - return i, track - - def calc_next_index(self, *, delta: int = 1) -> int: - """ - Calculate the next track index, accounting for shuffling and - looping a single track. - - Args: - delta: How far ahead or back to seek the next index. - - Returns: - The next track index in self._queue. - - Raises: - EndOfQueueError: If one of the ends of the queue is reached, - and the queue is not looping all tracks. - """ - forward = delta > 0 - - # Return the current index if the queue is looping a single track. - next_i = self.current_index - if self.is_looping_one: - return next_i - - # If we're shuffling, we need to use self._shuf_i to calculate the next index. - # Otherwise, we can just use the current index. - if self.is_shuffling: - next_i = self._shuf_i.index(next_i) - - # Calculate the next index. - next_i += delta - if (next_i >= self.size and forward) or (next_i < 0 and not forward): - if self.is_looping_all: - next_i = 0 if forward else self.size - 1 - else: - raise EndOfQueueError - - # If we're shuffling, we need to convert the next index back to - # an index in self._queue. - if self.is_shuffling: - next_i = self._shuf_i[next_i] - return next_i - - def skip(self) -> QueueItem: - """ - Returns the next track in the queue and adjusts the current - track index. - - Raises: - EmptyQueueError: If the queue is empty. - EndOfQueueError: If the last track in the queue is reached. - """ - i, track = self.next_track - self._i = i - return track - - def rewind(self) -> QueueItem: - """ - Returns the previous track in the queue and adjusts the current - track index. - - Raises: - EmptyQueueError: If the queue is empty. - EndOfQueueError: If the first track in the queue is reached. - """ - i, track = self.previous_track - self._i = i - return track - - def shuffle(self): - """ - Shuffles the queue non-destructively by generating a random - permutation of indices. Each call to shuffle() will generate - a different permutation, with the current track always at - the beginning. - - Raises: - EmptyQueueError: If the queue is empty. - """ - if self.size == 0: - raise EmptyQueueError - - # Shuffle everything except the current track. - indices = [i for i in range(self.size) if i != self.current_index] - shuffle(indices) - - # Prepend the current track index to the shuffle index list. - self._shuf_i = [self.current_index] + indices - - def unshuffle(self): - """ - Unshuffles the queue by clearing the shuffle index list. - """ - self._shuf_i = [] - - def extend(self, items: List[QueueItem]): - """ - Appends multiple items to the end of the queue. - - Args: - items: The QueueItems to append. - """ - new_queue = self.size == 0 - - # Append the items to the queue. - self.queue.extend(items) - if self.is_shuffling: - self._shuf_i.extend(list(range(self.size - len(items), self.size))) - - # Update index - if new_queue: - self.current_index = 0 - - def insert(self, item: QueueItem, /, index: int): - """ - Inserts an item in the queue at a specified index. - - Args: - item: The QueueItem to insert. - index: The index at which to insert the item. - - Raises: - EmptyQueueError: If the queue is empty and we are trying - to insert past index zero. Use enqueue() instead. - IndexError: If the index is out of range. - """ - if self.size == 0 and index != 0: - raise EmptyQueueError - if not 0 <= index <= self.size: - raise IndexError(f'Index {index} out of range.') - - if self.is_shuffling: - # If we're shuffling, insert the item at the end of self._queue, - # then insert the new index at the specified index in self._shuf_i. - self.queue.append(item) - self._shuf_i.insert(index, self.size - 1) - else: - # Otherwise, just insert the item at the specified index in self._queue. - self.queue.insert(index, item) - - def move(self, source_i: int, dest_i: int, /): - """ - Moves a queue item from one index to another. - - Args: - source_i: The index of the item to move. - dest_i: The index to move the item to. - - Raises: - EmptyQueueError: If the queue is empty. - IndexError: If either index is out of range, or if the - source and destination indices are the same, or if - the source index is the current track index. - """ - if self.size == 0: - raise EmptyQueueError - if not 0 <= source_i < self.size: - raise IndexError(f'Source index {source_i} out of range.') - if not 0 <= dest_i < self.size: - raise IndexError(f'Destination index {dest_i} out of range.') - if source_i == dest_i: - raise IndexError('Source and destination indices are the same.') - if source_i == self.current_index: - raise IndexError('Cannot move the current track.') - - self.insert(self.remove(source_i), dest_i) - - def remove(self, index: int, /) -> QueueItem: - """ - Removes an element at the given index and returns the element. - - Raises: - EmptyQueueError: If the queue is empty. - IndexError: If the index is out of range. - """ - if self.size == 0: - raise EmptyQueueError - if not 0 <= index < self.size: - raise IndexError(f'Index {index} out of range.') - - # Adjust the index if we're shuffling. - adjusted_index = index - if self.is_shuffling: - # Remove the index from self._shuf_i. - adjusted_index = self._shuf_i.pop(index) - - # Adjust the indices in self._shuf_i. - for i, j in enumerate(self._shuf_i): - if j > index: - self._shuf_i[i] -= 1 - - # If we're removing the current track, adjust the current track index. - if adjusted_index == self.current_index: - self._i = self.calc_next_index() - - # Remove the element from self._queue. - return self.queue.pop(adjusted_index) + Returns the queue. + """ + return self._queue + + @property + def shuffled_queue(self) -> List[QueueItem]: + """ + Returns the queue, shuffled. + """ + if not self.is_shuffling: + return self.queue + return [self.queue[i] for i in self._shuf_i] + + @property + def is_shuffling(self) -> bool: + """ + Returns whether the queue is shuffled. + """ + return len(self._shuf_i) > 0 + + @property + def is_looping_one(self) -> bool: + """ + Returns whether the queue is looping the current track. + """ + return self._loop_one + + @is_looping_one.setter + def is_looping_one(self, value: bool): + """ + Sets whether the queue is looping the current track. + """ + self._loop_one = value + self._db.set_loop(self._guild_id, value) + + @property + def is_looping_all(self) -> bool: + """ + Returns whether the queue is looping all tracks. + """ + return self._loop_all + + @is_looping_all.setter + def is_looping_all(self, value: bool): + """ + Sets whether the queue is looping all tracks. + """ + self._loop_all = value + self._db.set_loop_all(self._guild_id, value) + + @property + def size(self) -> int: + """ + Returns the size of the queue. + """ + return len(self.queue) + + @property + def current(self) -> QueueItem: + """ + Returns the current track in the queue. + + Raises: + EmptyQueueError: If the queue is empty. + """ + if self.size == 0: + raise EmptyQueueError + + return self.queue[self.current_index] + + @property + def current_index(self) -> int: + """ + Returns the current track index, NOT accounting for shuffling. + This is the index of the current track in self._queue. + """ + return self._i + + @property + def current_shuffled_index(self) -> int: + """ + Returns the current track index, accounting for shuffling. + This is the index of the current track in self._shuf_i. + """ + if not self.is_shuffling: + return self.current_index + return self._shuf_i.index(self.current_index) + + @current_index.setter + def current_index(self, i: int): + """ + Sets the current track index. + + Args: + i: The new current track index. Must be adjusted for shuffling, + i.e., i must correspond to an element in self._queue, + not self._shuf_i. + """ + self._i = i + + @property + def next_track(self) -> Tuple[int, QueueItem]: + """ + Returns a tuple containing the index of the next track in the queue + and the track itself. + + Raises: + EmptyQueueError: If the queue is empty. + EndOfQueueError: If the last track in the queue is reached. + """ + if self.size == 0: + raise EmptyQueueError + + try: + i = self.calc_next_index() + track = self.queue[i] + except EndOfQueueError as err: + raise EndOfQueueError('No next track in queue.') from err + + return i, track + + @property + def previous_track(self) -> Tuple[int, QueueItem]: + """ + Returns a tuple containing the index of the previous track in the queue + and the track itself. + + Raises: + EmptyQueueError: If the queue is empty. + EndOfQueueError: If the first track in the queue is reached. + """ + if self.size == 0: + raise EmptyQueueError + + try: + i = self.calc_next_index(delta=-1) + track = self.queue[i] + except EndOfQueueError as err: + raise EndOfQueueError('No previous track in queue.') from err + + return i, track + + def calc_next_index(self, *, delta: int = 1) -> int: + """ + Calculate the next track index, accounting for shuffling and + looping a single track. + + Args: + delta: How far ahead or back to seek the next index. + + Returns: + The next track index in self._queue. + + Raises: + EndOfQueueError: If one of the ends of the queue is reached, + and the queue is not looping all tracks. + """ + forward = delta > 0 + + # Return the current index if the queue is looping a single track. + next_i = self.current_index + if self.is_looping_one: + return next_i + + # If we're shuffling, we need to use self._shuf_i to calculate the next index. + # Otherwise, we can just use the current index. + if self.is_shuffling: + next_i = self._shuf_i.index(next_i) + + # Calculate the next index. + next_i += delta + if (next_i >= self.size and forward) or (next_i < 0 and not forward): + if self.is_looping_all: + next_i = 0 if forward else self.size - 1 + else: + raise EndOfQueueError + + # If we're shuffling, we need to convert the next index back to + # an index in self._queue. + if self.is_shuffling: + next_i = self._shuf_i[next_i] + return next_i + + def skip(self) -> QueueItem: + """ + Returns the next track in the queue and adjusts the current + track index. + + Raises: + EmptyQueueError: If the queue is empty. + EndOfQueueError: If the last track in the queue is reached. + """ + i, track = self.next_track + self._i = i + return track + + def rewind(self) -> QueueItem: + """ + Returns the previous track in the queue and adjusts the current + track index. + + Raises: + EmptyQueueError: If the queue is empty. + EndOfQueueError: If the first track in the queue is reached. + """ + i, track = self.previous_track + self._i = i + return track + + def shuffle(self): + """ + Shuffles the queue non-destructively by generating a random + permutation of indices. Each call to shuffle() will generate + a different permutation, with the current track always at + the beginning. + + Raises: + EmptyQueueError: If the queue is empty. + """ + if self.size == 0: + raise EmptyQueueError + + # Shuffle everything except the current track. + indices = [i for i in range(self.size) if i != self.current_index] + shuffle(indices) + + # Prepend the current track index to the shuffle index list. + self._shuf_i = [self.current_index] + indices + + def unshuffle(self): + """ + Unshuffles the queue by clearing the shuffle index list. + """ + self._shuf_i = [] + + def extend(self, items: List[QueueItem]): + """ + Appends multiple items to the end of the queue. + + Args: + items: The QueueItems to append. + """ + new_queue = self.size == 0 + + # Append the items to the queue. + self.queue.extend(items) + if self.is_shuffling: + self._shuf_i.extend(list(range(self.size - len(items), self.size))) + + # Update index + if new_queue: + self.current_index = 0 + + def insert(self, item: QueueItem, /, index: int): + """ + Inserts an item in the queue at a specified index. + + Args: + item: The QueueItem to insert. + index: The index at which to insert the item. + + Raises: + EmptyQueueError: If the queue is empty and we are trying + to insert past index zero. Use enqueue() instead. + IndexError: If the index is out of range. + """ + if self.size == 0 and index != 0: + raise EmptyQueueError + if not 0 <= index <= self.size: + raise IndexError(f'Index {index} out of range.') + + if self.is_shuffling: + # If we're shuffling, insert the item at the end of self._queue, + # then insert the new index at the specified index in self._shuf_i. + self.queue.append(item) + self._shuf_i.insert(index, self.size - 1) + else: + # Otherwise, just insert the item at the specified index in self._queue. + self.queue.insert(index, item) + + def move(self, source_i: int, dest_i: int, /): + """ + Moves a queue item from one index to another. + + Args: + source_i: The index of the item to move. + dest_i: The index to move the item to. + + Raises: + EmptyQueueError: If the queue is empty. + IndexError: If either index is out of range, or if the + source and destination indices are the same, or if + the source index is the current track index. + """ + if self.size == 0: + raise EmptyQueueError + if not 0 <= source_i < self.size: + raise IndexError(f'Source index {source_i} out of range.') + if not 0 <= dest_i < self.size: + raise IndexError(f'Destination index {dest_i} out of range.') + if source_i == dest_i: + raise IndexError('Source and destination indices are the same.') + if source_i == self.current_index: + raise IndexError('Cannot move the current track.') + + self.insert(self.remove(source_i), dest_i) + + def remove(self, index: int, /) -> QueueItem: + """ + Removes an element at the given index and returns the element. + + Raises: + EmptyQueueError: If the queue is empty. + IndexError: If the index is out of range. + """ + if self.size == 0: + raise EmptyQueueError + if not 0 <= index < self.size: + raise IndexError(f'Index {index} out of range.') + + # Adjust the index if we're shuffling. + adjusted_index = index + if self.is_shuffling: + # Remove the index from self._shuf_i. + adjusted_index = self._shuf_i.pop(index) + + # Adjust the indices in self._shuf_i. + for i, j in enumerate(self._shuf_i): + if j > index: + self._shuf_i[i] -= 1 + + # If we're removing the current track, adjust the current track index. + if adjusted_index == self.current_index: + self._i = self.calc_next_index() + + # Remove the element from self._queue. + return self.queue.pop(adjusted_index) diff --git a/database/__init__.py b/database/__init__.py index 153de7c..61bd0d5 100644 --- a/database/__init__.py +++ b/database/__init__.py @@ -14,190 +14,194 @@ class Database: + """ + Class for handling connections to the bot's SQLite DB. + """ + + def __init__(self, db_filename: str): + self._con = sql.connect(db_filename, check_same_thread=False) + self._cur = self._con.cursor() + self._logger = create_logger(self.__class__.__name__) + + # Run migrations + self._logger.info('Connected to database %s, running migrations...', db_filename) + run_migrations(self._logger, self._con) + + def init_guild(self, guild_id: int): + """ + Initialize a guild in the database if it hasn't been yet. + """ + self._cur.execute( + f'INSERT OR IGNORE INTO player_settings (guild_id) VALUES ({guild_id})' + ) + self._con.commit() + + def get_volume(self, guild_id: int) -> int: + """ + Get the volume for a guild. + """ + self._cur.execute(f'SELECT volume FROM player_settings WHERE guild_id = {guild_id}') + return self._cur.fetchone()[0] + + def set_volume(self, guild_id: int, volume: int): + """ + Set the volume for a guild. + """ + self._cur.execute( + f'UPDATE player_settings SET volume = {volume} WHERE guild_id = {guild_id}' + ) + self._con.commit() + + def get_loop(self, guild_id: int) -> bool: + """ + Get the loop setting for a guild. + """ + self._cur.execute(f'SELECT loop FROM player_settings WHERE guild_id = {guild_id}') + return self._cur.fetchone()[0] == 1 + + def set_loop(self, guild_id: int, loop: bool): + """ + Set the loop setting for a guild. + """ + self._cur.execute( + f'UPDATE player_settings SET loop = {int(loop)} WHERE guild_id = {guild_id}' + ) + self._con.commit() + + def get_loop_all(self, guild_id: int) -> bool: + """ + Get the whole-queue loop setting for a guild. + """ + self._cur.execute( + f'SELECT loop_all FROM player_settings WHERE guild_id = {guild_id}' + ) + return self._cur.fetchone()[0] == 1 + + def set_loop_all(self, guild_id: int, loop: bool): + """ + Set the whole-queue loop setting for a guild. + """ + self._cur.execute( + f'UPDATE player_settings SET loop_all = {int(loop)} WHERE guild_id = {guild_id}' + ) + self._con.commit() + + def get_now_playing(self, guild_id: int) -> int: + """ + Get the last now playing message ID for a guild. + """ + self._cur.execute( + f'SELECT last_np_msg FROM player_settings WHERE guild_id = {guild_id}' + ) + return self._cur.fetchone()[0] + + def set_now_playing(self, guild_id: int, msg_id: int): + """ + Set the last now playing message ID for a guild. + """ + self._cur.execute( + f'UPDATE player_settings SET last_np_msg = {msg_id} WHERE guild_id = {guild_id}' + ) + self._con.commit() + + def get_status_channel(self, guild_id: int) -> int: + """ + Get the status channel for a guild. + """ + self._cur.execute( + f'SELECT status_channel FROM player_settings WHERE guild_id = {guild_id}' + ) + return self._cur.fetchone()[0] + + def set_status_channel(self, guild_id: int, channel_id: int): + """ + Set the status channel for a guild. + """ + self._cur.execute( + f'UPDATE player_settings SET status_channel = {channel_id} WHERE guild_id = {guild_id}' + ) + self._con.commit() + + def set_last_bump(self, guild_id: int): + """ + Set the last bump for a guild. + """ + seconds = int(time.time()) + self._cur.execute( + f'UPDATE player_settings SET last_bump = {seconds} WHERE guild_id = {guild_id}' + ) + self._con.commit() + + def get_last_bump(self, guild_id: int) -> int: + """ + Get the last bump for a guild. + """ + self._cur.execute(f'SELECT last_bump FROM player_settings WHERE guild_id = {guild_id}') + return self._cur.fetchone()[0] + + def set_bumps_enabled(self, guild_id: int, enabled: bool): + """ + Set whether bumps are enabled for a guild. + """ + self._cur.execute( + f'UPDATE player_settings SET bumps_enabled = {int(enabled)} WHERE guild_id = {guild_id}' + ) + self._con.commit() + + def get_bumps_enabled(self, guild_id: int) -> bool: + """ + Get whether bumps are enabled for a guild. + """ + self._cur.execute( + f'SELECT bumps_enabled FROM player_settings WHERE guild_id = {guild_id}' + ) + return self._cur.fetchone()[0] == 1 + + def set_bump_interval(self, guild_id: int, interval: int): """ - Class for handling connections to the bot's SQLite DB. - """ - - def __init__(self, db_filename: str): - self._con = sql.connect(db_filename, check_same_thread=False) - self._cur = self._con.cursor() - self._logger = create_logger(self.__class__.__name__) - - # Run migrations - self._logger.info('Connected to database %s, running migrations...', db_filename) - run_migrations(self._logger, self._con) - - def init_guild(self, guild_id: int): - """ - Initialize a guild in the database if it hasn't been yet. - """ - self._cur.execute(f'INSERT OR IGNORE INTO player_settings (guild_id) VALUES ({guild_id})') - self._con.commit() - - def get_volume(self, guild_id: int) -> int: - """ - Get the volume for a guild. - """ - self._cur.execute(f'SELECT volume FROM player_settings WHERE guild_id = {guild_id}') - return self._cur.fetchone()[0] - - def set_volume(self, guild_id: int, volume: int): - """ - Set the volume for a guild. - """ - self._cur.execute( - f'UPDATE player_settings SET volume = {volume} WHERE guild_id = {guild_id}' - ) - self._con.commit() - - def get_loop(self, guild_id: int) -> bool: - """ - Get the loop setting for a guild. - """ - self._cur.execute(f'SELECT loop FROM player_settings WHERE guild_id = {guild_id}') - return self._cur.fetchone()[0] == 1 - - def set_loop(self, guild_id: int, loop: bool): - """ - Set the loop setting for a guild. - """ - self._cur.execute( - f'UPDATE player_settings SET loop = {int(loop)} WHERE guild_id = {guild_id}' - ) - self._con.commit() - - def get_loop_all(self, guild_id: int) -> bool: - """ - Get the whole-queue loop setting for a guild. - """ - self._cur.execute(f'SELECT loop_all FROM player_settings WHERE guild_id = {guild_id}') - return self._cur.fetchone()[0] == 1 - - def set_loop_all(self, guild_id: int, loop: bool): - """ - Set the whole-queue loop setting for a guild. - """ - self._cur.execute( - f'UPDATE player_settings SET loop_all = {int(loop)} WHERE guild_id = {guild_id}' - ) - self._con.commit() - - def get_now_playing(self, guild_id: int) -> int: - """ - Get the last now playing message ID for a guild. - """ - self._cur.execute( - f'SELECT last_np_msg FROM player_settings WHERE guild_id = {guild_id}' - ) - return self._cur.fetchone()[0] - - def set_now_playing(self, guild_id: int, msg_id: int): - """ - Set the last now playing message ID for a guild. - """ - self._cur.execute( - f'UPDATE player_settings SET last_np_msg = {msg_id} WHERE guild_id = {guild_id}' - ) - self._con.commit() - - def get_status_channel(self, guild_id: int) -> int: - """ - Get the status channel for a guild. - """ - self._cur.execute( - f'SELECT status_channel FROM player_settings WHERE guild_id = {guild_id}' - ) - return self._cur.fetchone()[0] - - def set_status_channel(self, guild_id: int, channel_id: int): - """ - Set the status channel for a guild. - """ - self._cur.execute( - f'UPDATE player_settings SET status_channel = {channel_id} WHERE guild_id = {guild_id}' - ) - self._con.commit() - - def set_last_bump(self, guild_id: int): - """ - Set the last bump for a guild. - """ - seconds = int(time.time()) - self._cur.execute( - f'UPDATE player_settings SET last_bump = {seconds} WHERE guild_id = {guild_id}' - ) - self._con.commit() - - def get_last_bump(self, guild_id: int) -> int: - """ - Get the last bump for a guild. - """ - self._cur.execute(f'SELECT last_bump FROM player_settings WHERE guild_id = {guild_id}') - return self._cur.fetchone()[0] - - def set_bumps_enabled(self, guild_id: int, enabled: bool): - """ - Set whether bumps are enabled for a guild. - """ - self._cur.execute( - f'UPDATE player_settings SET bumps_enabled = {int(enabled)} WHERE guild_id = {guild_id}' - ) - self._con.commit() - - def get_bumps_enabled(self, guild_id: int) -> bool: - """ - Get whether bumps are enabled for a guild. - """ - self._cur.execute( - f'SELECT bumps_enabled FROM player_settings WHERE guild_id = {guild_id}' - ) - return self._cur.fetchone()[0] == 1 - - def set_bump_interval(self, guild_id: int, interval: int): - """ - Set the bump interval for a guild. - """ - self._cur.execute( - f'UPDATE player_settings SET bump_interval = {interval} WHERE guild_id = {guild_id}' - ) - self._con.commit() - - def get_bump_interval(self, guild_id: int) -> int: - """ - Get the bump interval for a guild. - """ - self._cur.execute( - f'SELECT bump_interval FROM player_settings WHERE guild_id = {guild_id}' - ) - return self._cur.fetchone()[0] - - def get_session_id(self, node_id: str) -> str: - """ - Get the session ID for a Lavalink node. - """ - self._cur.execute(f'SELECT session_id FROM lavalink WHERE node_id = "{node_id}"') - return self._cur.fetchone()[0] - - def set_session_id(self, node_id: str, session_id: str): - """ - Set the session ID for a Lavalink node. - """ - self._cur.execute( - f'''INSERT OR REPLACE INTO lavalink ( + Set the bump interval for a guild. + """ + self._cur.execute( + f'UPDATE player_settings SET bump_interval = {interval} WHERE guild_id = {guild_id}' + ) + self._con.commit() + + def get_bump_interval(self, guild_id: int) -> int: + """ + Get the bump interval for a guild. + """ + self._cur.execute( + f'SELECT bump_interval FROM player_settings WHERE guild_id = {guild_id}' + ) + return self._cur.fetchone()[0] + + def get_session_id(self, node_id: str) -> str: + """ + Get the session ID for a Lavalink node. + """ + self._cur.execute(f'SELECT session_id FROM lavalink WHERE node_id = "{node_id}"') + return self._cur.fetchone()[0] + + def set_session_id(self, node_id: str, session_id: str): + """ + Set the session ID for a Lavalink node. + """ + self._cur.execute( + f"""INSERT OR REPLACE INTO lavalink ( node_id, session_id - ) VALUES ("{node_id}", "{session_id}")''' - ) - self._con.commit() - - def set_oauth(self, provider: str, credentials: OAuth): - """ - Save OAuth2 data for a user. - - :param provider: The provider to save the data for. Can be either 'discord' or 'spotify'. - :param credentials: The OAuth2 credentials to save. - """ - self._cur.execute(f''' + ) VALUES ("{node_id}", "{session_id}")""" + ) + self._con.commit() + + def set_oauth(self, provider: str, credentials: OAuth): + """ + Save OAuth2 data for a user. + + :param provider: The provider to save the data for. Can be either 'discord' or 'spotify'. + :param credentials: The OAuth2 credentials to save. + """ + self._cur.execute(f""" INSERT OR REPLACE INTO {provider}_oauth ( user_id, username, @@ -211,33 +215,33 @@ def set_oauth(self, provider: str, credentials: OAuth): "{credentials.refresh_token}", {credentials.expires_at} ) - ''') - self._con.commit() - - def get_oauth(self, provider: str, user_id: int) -> Optional[OAuth]: - """ - Get OAuth2 data for a user from the database. - - :param provider: The provider to get credentials for. Can be either 'discord' or 'spotify'. - :param user_id: The user ID to get credentials for - """ - self._cur.execute(f'SELECT * FROM {provider}_oauth WHERE user_id = {user_id}') - row = self._cur.fetchone() - if row is None: - return None - return OAuth( - user_id=row[0], - username=row[1], - access_token=row[2], - refresh_token=row[3], - expires_at=row[4] - ) - - def set_lastfm_credentials(self, credentials: LastfmAuth): - """ - Save Last.fm credentials for a user. - """ - self._cur.execute(f''' + """) + self._con.commit() + + def get_oauth(self, provider: str, user_id: int) -> Optional[OAuth]: + """ + Get OAuth2 data for a user from the database. + + :param provider: The provider to get credentials for. Can be either 'discord' or 'spotify'. + :param user_id: The user ID to get credentials for + """ + self._cur.execute(f'SELECT * FROM {provider}_oauth WHERE user_id = {user_id}') + row = self._cur.fetchone() + if row is None: + return None + return OAuth( + user_id=row[0], + username=row[1], + access_token=row[2], + refresh_token=row[3], + expires_at=row[4], + ) + + def set_lastfm_credentials(self, credentials: LastfmAuth): + """ + Save Last.fm credentials for a user. + """ + self._cur.execute(f""" INSERT OR REPLACE INTO lastfm_oauth ( user_id, username, @@ -247,155 +251,153 @@ def set_lastfm_credentials(self, credentials: LastfmAuth): "{credentials.username}", "{credentials.session_key}" ) - ''') - self._con.commit() - - def get_lastfm_credentials(self, user_id: int) -> Optional[LastfmAuth]: - """ - Get Last.fm credentials for a user. - """ - self._cur.execute( - f'SELECT * FROM lastfm_oauth WHERE user_id = {user_id}' - ) - row = self._cur.fetchone() - if row is None: - return None - return LastfmAuth(*row) - - def delete_oauth(self, provider: str, user_id: int): - """ - Delete OAuth2 data for a user from the database. - """ - self._cur.execute(f'DELETE FROM {provider}_oauth WHERE user_id = {user_id}') - self._con.commit() - - def set_spotify_scopes(self, user_id: int, scopes: List[str]): - """ - Set the Spotify scopes for a user. - """ - self._cur.execute(f''' - UPDATE spotify_oauth SET scopes = "{",".join(scopes)}" WHERE user_id = {user_id} - ''') - self._con.commit() - - def get_spotify_scopes(self, user_id: int) -> List[str]: - """ - Get the Spotify scopes for a user. - """ - self._cur.execute(f'SELECT scopes FROM spotify_oauth WHERE user_id = {user_id}') - return self._cur.fetchone()[0].split(',') - - def add_bump(self, guild_id: int, url: str, title: str, author: str): - """ - Set a bump for a guild. - """ - self._cur.execute(f'SELECT MAX(idx) FROM bumps WHERE guild_id = {guild_id}') - idx = self._cur.fetchone()[0] - if idx is None: - idx = 0 - idx += 1 - self._cur.execute(f''' - INSERT INTO bumps ( - guild_id, - idx, - url, - title, - author - ) VALUES ( - {guild_id}, - {idx}, - "{url}", - "{title}", - "{author}" - ) - ''' - ) - self._con.commit() - - def get_bumps(self, guild_id: int) -> Optional[List[Bump]]: - """ - Get every bump for a guild. - """ - self._cur.execute(f'''SELECT idx, guild_id, url, title, author - FROM bumps WHERE guild_id = {guild_id}''') - rows = self._cur.fetchall() - if len(rows) == 0: - return None - - return [ - Bump( - idx=row[0], - guild_id=row[1], - url=row[2], - title=row[3], - author=row[4] - ) - for row in rows - ] - - def get_bump(self, guild_id: int, idx: int) -> Optional[Bump]: - """ - Get a guild bump by its index. - """ - self._cur.execute( - f'''SELECT idx, guild_id, url, title, author FROM bumps - WHERE guild_id = {guild_id} AND idx = {idx} - ''' - ) - row = self._cur.fetchone() - if row is None: - return None - return Bump( - idx=row[0], - guild_id=row[1], - url=row[2], - title=row[3], - author=row[4] - ) - - def get_bump_by_url(self, guild_id: int, url: str) -> Optional[Bump]: - """ - Get a guild bump by its URL. - """ - self._cur.execute( - f'''SELECT idx, guild_id, url, title, author FROM bumps - WHERE guild_id = {guild_id} AND url = "{url}" - ''' - ) - row = self._cur.fetchone() - if row is None: - return None - return Bump( - idx=row[0], - guild_id=row[1], - url=row[2], - title=row[3], - author=row[4] - ) - - def get_random_bump(self, guild_id: int) -> Optional[Bump]: - """ - Get a random guild bump. - """ - self._cur.execute( - f'''SELECT idx, guild_id, url, title, author FROM bumps WHERE - guild_id = {guild_id} ORDER BY RANDOM() LIMIT 1 - ''' - ) - row = self._cur.fetchone() - if row is None: - return None - return Bump( - idx=row[0], - guild_id=row[1], - url=row[2], - title=row[3], - author=row[4] - ) - - def delete_bump(self, guild_id: int, idx: int): - """ - Delete a guild bump by its index. - """ - self._cur.execute(f'DELETE FROM bumps WHERE guild_id = {guild_id} AND idx = {idx}') - self._con.commit() + """) + self._con.commit() + + def get_lastfm_credentials(self, user_id: int) -> Optional[LastfmAuth]: + """ + Get Last.fm credentials for a user. + """ + self._cur.execute(f'SELECT * FROM lastfm_oauth WHERE user_id = {user_id}') + row = self._cur.fetchone() + if row is None: + return None + return LastfmAuth(*row) + + def delete_oauth(self, provider: str, user_id: int): + """ + Delete OAuth2 data for a user from the database. + """ + self._cur.execute(f'DELETE FROM {provider}_oauth WHERE user_id = {user_id}') + self._con.commit() + + def set_spotify_scopes(self, user_id: int, scopes: List[str]): + """ + Set the Spotify scopes for a user. + """ + self._cur.execute(f""" + UPDATE spotify_oauth SET scopes = "{','.join(scopes)}" WHERE user_id = {user_id} + """) + self._con.commit() + + def get_spotify_scopes(self, user_id: int) -> List[str]: + """ + Get the Spotify scopes for a user. + """ + self._cur.execute(f'SELECT scopes FROM spotify_oauth WHERE user_id = {user_id}') + return self._cur.fetchone()[0].split(',') + + def add_bump(self, guild_id: int, url: str, title: str, author: str): + """ + Set a bump for a guild. + """ + self._cur.execute(f'SELECT MAX(idx) FROM bumps WHERE guild_id = {guild_id}') + idx = self._cur.fetchone()[0] + if idx is None: + idx = 0 + idx += 1 + self._cur.execute(f''' + INSERT INTO bumps ( + guild_id, + idx, + url, + title, + author + ) VALUES ( + {guild_id}, + {idx}, + "{url}", + "{title}", + "{author}" + ) + ''' + ) + self._con.commit() + + def get_bumps(self, guild_id: int) -> Optional[List[Bump]]: + """ + Get every bump for a guild. + """ + self._cur.execute(f'''SELECT idx, guild_id, url, title, author + FROM bumps WHERE guild_id = {guild_id}''') + rows = self._cur.fetchall() + if len(rows) == 0: + return None + + return [ + Bump( + idx=row[0], + guild_id=row[1], + url=row[2], + title=row[3], + author=row[4] + ) + for row in rows + ] + + def get_bump(self, guild_id: int, idx: int) -> Optional[Bump]: + """ + Get a guild bump by its index. + """ + self._cur.execute( + f'''SELECT idx, guild_id, url, title, author FROM bumps + WHERE guild_id = {guild_id} AND idx = {idx} + ''' + ) + row = self._cur.fetchone() + if row is None: + return None + return Bump( + idx=row[0], + guild_id=row[1], + url=row[2], + title=row[3], + author=row[4] + ) + + def get_bump_by_url(self, guild_id: int, url: str) -> Optional[Bump]: + """ + Get a guild bump by its URL. + """ + self._cur.execute( + f'''SELECT idx, guild_id, url, title, author FROM bumps + WHERE guild_id = {guild_id} AND url = "{url}" + ''' + ) + row = self._cur.fetchone() + if row is None: + return None + return Bump( + idx=row[0], + guild_id=row[1], + url=row[2], + title=row[3], + author=row[4] + ) + + def get_random_bump(self, guild_id: int) -> Optional[Bump]: + """ + Get a random guild bump. + """ + self._cur.execute( + f'''SELECT idx, guild_id, url, title, author FROM bumps WHERE + guild_id = {guild_id} ORDER BY RANDOM() LIMIT 1 + ''' + ) + row = self._cur.fetchone() + if row is None: + return None + return Bump( + idx=row[0], + guild_id=row[1], + url=row[2], + title=row[3], + author=row[4] + ) + + def delete_bump(self, guild_id: int, idx: int): + """ + Delete a guild bump by its index. + """ + self._cur.execute(f'DELETE FROM bumps WHERE guild_id = {guild_id} AND idx = {idx}') + self._con.commit() diff --git a/database/migrations/0000-create.py b/database/migrations/0000-create.py index 21d3a8e..0d5bb86 100644 --- a/database/migrations/0000-create.py +++ b/database/migrations/0000-create.py @@ -6,20 +6,20 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from sqlite3 import Connection + from sqlite3 import Connection def run(con: 'Connection'): - """ - Run the migration. - """ - cur = con.cursor() - cur.execute(''' + """ + Run the migration. + """ + cur = con.cursor() + cur.execute(""" CREATE TABLE IF NOT EXISTS player_settings ( guild_id INTEGER PRIMARY KEY NOT NULL, volume INTEGER NOT NULL DEFAULT 100, loop INTEGER NOT NULL DEFAULT 0, last_np_msg INTEGER NOT NULL DEFAULT -1 ) - ''') - con.commit() + """) + con.commit() diff --git a/database/migrations/0001-lavalink-sessionid.py b/database/migrations/0001-lavalink-sessionid.py index 4e24b36..3c7bab3 100644 --- a/database/migrations/0001-lavalink-sessionid.py +++ b/database/migrations/0001-lavalink-sessionid.py @@ -7,17 +7,18 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from sqlite3 import Connection + from sqlite3 import Connection + def run(con: 'Connection'): - """ - Run the migration. - """ - cur = con.cursor() - cur.execute(''' + """ + Run the migration. + """ + cur = con.cursor() + cur.execute(""" CREATE TABLE IF NOT EXISTS lavalink ( node_id TEXT PRIMARY KEY NOT NULL, session_id TEXT NOT NULL ) - ''') - con.commit() + """) + con.commit() diff --git a/database/migrations/0002-statuschannel.py b/database/migrations/0002-statuschannel.py index 5f84642..e1f180a 100644 --- a/database/migrations/0002-statuschannel.py +++ b/database/migrations/0002-statuschannel.py @@ -7,21 +7,22 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from sqlite3 import Connection + from sqlite3 import Connection + def run(con: 'Connection'): - """ - Run the migration. - """ - cur = con.cursor() + """ + Run the migration. + """ + cur = con.cursor() - # There's no built-in way to check if a column exists in SQLite, - # so we just try to add it and ignore the error if it already exists. - try: - cur.execute(''' + # There's no built-in way to check if a column exists in SQLite, + # so we just try to add it and ignore the error if it already exists. + try: + cur.execute(""" ALTER TABLE player_settings ADD COLUMN status_channel INTEGER NOT NULL DEFAULT -1 - ''') - except OperationalError: - pass + """) + except OperationalError: + pass - con.commit() + con.commit() diff --git a/database/migrations/0003-oauth.py b/database/migrations/0003-oauth.py index 985dbb1..1849cd3 100644 --- a/database/migrations/0003-oauth.py +++ b/database/migrations/0003-oauth.py @@ -6,14 +6,15 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from sqlite3 import Connection + from sqlite3 import Connection + def run(con: 'Connection'): - """ - Run the migration. - """ - cur = con.cursor() - cur.execute(''' + """ + Run the migration. + """ + cur = con.cursor() + cur.execute(""" CREATE TABLE IF NOT EXISTS discord_oauth ( user_id INTEGER PRIMARY KEY NOT NULL, username TEXT NOT NULL, @@ -21,8 +22,8 @@ def run(con: 'Connection'): refresh_token TEXT NOT NULL, expires_at INTEGER NOT NULL ) - ''') - cur.execute(''' + """) + cur.execute(""" CREATE TABLE IF NOT EXISTS spotify_oauth ( user_id INTEGER PRIMARY KEY NOT NULL, username TEXT NOT NULL, @@ -31,12 +32,12 @@ def run(con: 'Connection'): expires_at INTEGER NOT NULL, scopes TEXT NOT NULL DEFAULT '' ) - ''') - cur.execute(''' + """) + cur.execute(""" CREATE TABLE IF NOT EXISTS lastfm_oauth ( user_id INTEGER PRIMARY KEY NOT NULL, username TEXT NOT NULL, session_key TEXT NOT NULL ) - ''') - con.commit() + """) + con.commit() diff --git a/database/migrations/0004-loop-all.py b/database/migrations/0004-loop-all.py index adb5c91..6a7d4de 100644 --- a/database/migrations/0004-loop-all.py +++ b/database/migrations/0004-loop-all.py @@ -7,21 +7,22 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from sqlite3 import Connection + from sqlite3 import Connection + def run(con: 'Connection'): - """ - Run the migration. - """ - cur = con.cursor() + """ + Run the migration. + """ + cur = con.cursor() - # There's no built-in way to check if a column exists in SQLite, - # so we just try to add it and ignore the error if it already exists. - try: - cur.execute(''' + # There's no built-in way to check if a column exists in SQLite, + # so we just try to add it and ignore the error if it already exists. + try: + cur.execute(""" ALTER TABLE player_settings ADD COLUMN loop_all INTEGER NOT NULL DEFAULT 0 - ''') - except OperationalError: - pass + """) + except OperationalError: + pass - con.commit() + con.commit() diff --git a/database/migrations/__init__.py b/database/migrations/__init__.py index e910133..849938d 100644 --- a/database/migrations/__init__.py +++ b/database/migrations/__init__.py @@ -10,24 +10,24 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from logging import Logger - from sqlite3 import Connection + from logging import Logger + from sqlite3 import Connection def run_migrations(logger: 'Logger', con: 'Connection'): - """ - Run all migrations on Blanco's database. + """ + Run all migrations on Blanco's database. - :param con: The Connection instance to the SQLite database. - """ - for file in sorted(listdir(path.dirname(__file__))): - if file != path.basename(__file__) and file.endswith('.py'): - logger.debug('Running migration: %s', file) - migration = import_module(f'database.migrations.{file[:-3]}') + :param con: The Connection instance to the SQLite database. + """ + for file in sorted(listdir(path.dirname(__file__))): + if file != path.basename(__file__) and file.endswith('.py'): + logger.debug('Running migration: %s', file) + migration = import_module(f'database.migrations.{file[:-3]}') - try: - migration.run(con) - except OperationalError as err: - logger.error('Error running migration %s: %s', file, err) - logger.critical('Aborting migrations.') - raise RuntimeError('Error running migrations.') from err + try: + migration.run(con) + except OperationalError as err: + logger.error('Error running migration %s: %s', file, err) + logger.critical('Aborting migrations.') + raise RuntimeError('Error running migrations.') from err diff --git a/database/redis.py b/database/redis.py index a8b2fd0..45c909d 100644 --- a/database/redis.py +++ b/database/redis.py @@ -12,153 +12,161 @@ class RedisClient: + """ + Redis client that takes care of caching MusicBrainz and Spotify lookups. + """ + + def __init__(self, host: str, port: int, password: Optional[str] = None): + self._client = redis.StrictRedis( + host=host, + port=port, + password=password, + encoding='utf-8', + decode_responses=True, + ) + + # Logger + self._logger = create_logger(self.__class__.__name__) + self._logger.debug('Attempting to connect to Redis server...') + + # Test connection + try: + self._client.ping() + except redis.ConnectionError as err: + self._logger.critical( + 'Could not connect to Redis server. Check your configuration.' + ) + raise RuntimeError('Could not connect to Redis server.') from err + + self._logger.info( + 'Connected to Redis server. Enable debug logging to see cache hits.' + ) + + def set_lavalink_track(self, key: str, value: str, *, key_type: str): """ - Redis client that takes care of caching MusicBrainz and Spotify lookups. - """ - def __init__(self, host: str, port: int, password: Optional[str] = None): - self._client = redis.StrictRedis( - host=host, - port=port, - password=password, - encoding='utf-8', - decode_responses=True - ) - - # Logger - self._logger = create_logger(self.__class__.__name__) - self._logger.debug('Attempting to connect to Redis server...') - - # Test connection - try: - self._client.ping() - except redis.ConnectionError as err: - self._logger.critical('Could not connect to Redis server. Check your configuration.') - raise RuntimeError('Could not connect to Redis server.') from err - - self._logger.info('Connected to Redis server. Enable debug logging to see cache hits.') - - def set_lavalink_track(self, key: str, value: str, *, key_type: str): - """ - Save an encoded Lavalink track. - - :param key: The key to save the track under. - :param value: The encoded track. - :param key_type: The type of key to save the track under, e.g. 'isrc' or 'spotify_id'. - """ - self._logger.debug('Caching Lavalink track for %s:%s', key_type, key) - self._client.set(f'lavalink:{key_type}:{key}', value) - - def get_lavalink_track(self, key: str, *, key_type: str) -> Optional[str]: - """ - Get an encoded Lavalink track. - - :param key: The key to get the track from. - :param key_type: The type of key to get the track from, e.g. 'isrc' or 'spotify_id'. - """ - if not self._client.exists(f'lavalink:{key_type}:{key}'): - return None - - self._logger.debug('Got cached Lavalink track for %s:%s', key_type, key) - return self._client.get(f'lavalink:{key_type}:{key}') # type: ignore - - def invalidate_lavalink_track(self, key: str, *, key_type: str): - """ - Removes a cached Lavalink track. - - :param key: The key to remove the track for. - :param key_type: The type of key to remove the track for, e.g. 'isrc' or 'spotify_id'. - """ - self._logger.debug('Invalidating Lavalink track for %s:%s', key_type, key) - if self._client.exists(f'lavalink:{key_type}:{key}'): - self._client.delete(f'lavalink:{key_type}:{key}') - - def set_spotify_track(self, spotify_id: str, track: 'SpotifyTrack'): - """ - Save a Spotify track. - """ - self._logger.debug('Caching info for Spotify track %s', spotify_id) - self._client.hmset(f'spotify:{spotify_id}', { - 'title': track.title, - 'artist': track.artist, - 'author': track.author, - 'duration_ms': track.duration_ms, - 'artwork': track.artwork if track.artwork is not None else '', - 'album': track.album if track.album is not None else '', - 'isrc': track.isrc if track.isrc is not None else '', - }) - - # Remove standalone ISRC cache - if self._client.exists(f'isrc:{spotify_id}'): - self._client.delete(f'isrc:{spotify_id}') - - def get_spotify_track(self, spotify_id: str) -> Optional['SpotifyTrack']: - """ - Get a Spotify track. - """ - track = self._client.hgetall(f'spotify:{spotify_id}') - - if not track: - return None - - self._logger.debug('Got cached info for Spotify track %s', spotify_id) - return SpotifyTrack( - title=track['title'], # type: ignore - artist=track['artist'], # type: ignore - author=track['author'], # type: ignore - duration_ms=int(track['duration_ms']), # type: ignore - artwork=track['artwork'] if track['artwork'] else None, # type: ignore - album=track['album'] if track['album'] else None, # type: ignore - isrc=track['isrc'] if track['isrc'] else None, # type: ignore - spotify_id=spotify_id - ) - - def set_mbid(self, spotify_id: str, mbid: str): - """ - Save a MusicBrainz ID for a Spotify track. - """ - self._logger.debug('Caching MusicBrainz ID for Spotify track %s', spotify_id) - self._client.set(f'mbid:{spotify_id}', mbid) - - def get_mbid(self, spotify_id: str) -> Optional[str]: - """ - Get a MusicBrainz ID for a Spotify track. - """ - if not self._client.exists(f'mbid:{spotify_id}'): - return None - - self._logger.debug('Got cached MusicBrainz ID for Spotify track %s', spotify_id) - return self._client.get(f'mbid:{spotify_id}') # type: ignore - - def set_isrc(self, spotify_id: str, isrc: str): - """ - Save an ISRC for a Spotify track. - """ - # Check if there is a Spotify track with this ID - if self._client.exists(f'spotify:{spotify_id}'): - # Update ISRC in Spotify track - self._logger.debug('Updating cached ISRC for Spotify track %s', spotify_id) - self._client.hset(f'spotify:{spotify_id}', 'isrc', isrc) - - self._logger.debug('Caching ISRC for Spotify track %s', spotify_id) - self._client.set(f'isrc:{spotify_id}', isrc) - - def get_isrc(self, spotify_id: str) -> Optional[str]: - """ - Get an ISRC for a Spotify track. - """ - # Check if there is a Spotify track with this ID - if self._client.exists(f'spotify:{spotify_id}'): - # Return ISRC from Spotify track - self._logger.debug('Got cached ISRC for Spotify track %s', spotify_id) - return self._client.hget(f'spotify:{spotify_id}', 'isrc') # type: ignore - - if not self._client.exists(f'isrc:{spotify_id}'): - return None - - self._logger.debug('Got cached ISRC for Spotify track %s', spotify_id) - return self._client.get(f'isrc:{spotify_id}') # type: ignore + Save an encoded Lavalink track. + + :param key: The key to save the track under. + :param value: The encoded track. + :param key_type: The type of key to save the track under, e.g. 'isrc' or 'spotify_id'. + """ + self._logger.debug('Caching Lavalink track for %s:%s', key_type, key) + self._client.set(f'lavalink:{key_type}:{key}', value) + + def get_lavalink_track(self, key: str, *, key_type: str) -> Optional[str]: + """ + Get an encoded Lavalink track. + + :param key: The key to get the track from. + :param key_type: The type of key to get the track from, e.g. 'isrc' or 'spotify_id'. + """ + if not self._client.exists(f'lavalink:{key_type}:{key}'): + return None + + self._logger.debug('Got cached Lavalink track for %s:%s', key_type, key) + return self._client.get(f'lavalink:{key_type}:{key}') # type: ignore + + def invalidate_lavalink_track(self, key: str, *, key_type: str): + """ + Removes a cached Lavalink track. + + :param key: The key to remove the track for. + :param key_type: The type of key to remove the track for, e.g. 'isrc' or 'spotify_id'. + """ + self._logger.debug('Invalidating Lavalink track for %s:%s', key_type, key) + if self._client.exists(f'lavalink:{key_type}:{key}'): + self._client.delete(f'lavalink:{key_type}:{key}') + + def set_spotify_track(self, spotify_id: str, track: 'SpotifyTrack'): + """ + Save a Spotify track. + """ + self._logger.debug('Caching info for Spotify track %s', spotify_id) + self._client.hmset( + f'spotify:{spotify_id}', + { + 'title': track.title, + 'artist': track.artist, + 'author': track.author, + 'duration_ms': track.duration_ms, + 'artwork': track.artwork if track.artwork is not None else '', + 'album': track.album if track.album is not None else '', + 'isrc': track.isrc if track.isrc is not None else '', + }, + ) + + # Remove standalone ISRC cache + if self._client.exists(f'isrc:{spotify_id}'): + self._client.delete(f'isrc:{spotify_id}') + + def get_spotify_track(self, spotify_id: str) -> Optional['SpotifyTrack']: + """ + Get a Spotify track. + """ + track = self._client.hgetall(f'spotify:{spotify_id}') + + if not track: + return None + + self._logger.debug('Got cached info for Spotify track %s', spotify_id) + return SpotifyTrack( + title=track['title'], # type: ignore + artist=track['artist'], # type: ignore + author=track['author'], # type: ignore + duration_ms=int(track['duration_ms']), # type: ignore + artwork=track['artwork'] if track['artwork'] else None, # type: ignore + album=track['album'] if track['album'] else None, # type: ignore + isrc=track['isrc'] if track['isrc'] else None, # type: ignore + spotify_id=spotify_id, + ) + + def set_mbid(self, spotify_id: str, mbid: str): + """ + Save a MusicBrainz ID for a Spotify track. + """ + self._logger.debug('Caching MusicBrainz ID for Spotify track %s', spotify_id) + self._client.set(f'mbid:{spotify_id}', mbid) + + def get_mbid(self, spotify_id: str) -> Optional[str]: + """ + Get a MusicBrainz ID for a Spotify track. + """ + if not self._client.exists(f'mbid:{spotify_id}'): + return None + + self._logger.debug('Got cached MusicBrainz ID for Spotify track %s', spotify_id) + return self._client.get(f'mbid:{spotify_id}') # type: ignore + + def set_isrc(self, spotify_id: str, isrc: str): + """ + Save an ISRC for a Spotify track. + """ + # Check if there is a Spotify track with this ID + if self._client.exists(f'spotify:{spotify_id}'): + # Update ISRC in Spotify track + self._logger.debug('Updating cached ISRC for Spotify track %s', spotify_id) + self._client.hset(f'spotify:{spotify_id}', 'isrc', isrc) + + self._logger.debug('Caching ISRC for Spotify track %s', spotify_id) + self._client.set(f'isrc:{spotify_id}', isrc) + + def get_isrc(self, spotify_id: str) -> Optional[str]: + """ + Get an ISRC for a Spotify track. + """ + # Check if there is a Spotify track with this ID + if self._client.exists(f'spotify:{spotify_id}'): + # Return ISRC from Spotify track + self._logger.debug('Got cached ISRC for Spotify track %s', spotify_id) + return self._client.hget(f'spotify:{spotify_id}', 'isrc') # type: ignore + + if not self._client.exists(f'isrc:{spotify_id}'): + return None + + self._logger.debug('Got cached ISRC for Spotify track %s', spotify_id) + return self._client.get(f'isrc:{spotify_id}') # type: ignore REDIS = None if REDIS_HOST is not None and REDIS_PORT != -1: - REDIS = RedisClient(REDIS_HOST, REDIS_PORT, REDIS_PASSWORD) + REDIS = RedisClient(REDIS_HOST, REDIS_PORT, REDIS_PASSWORD) diff --git a/dataclass/config.py b/dataclass/config.py index abcc97b..91ab2a2 100644 --- a/dataclass/config.py +++ b/dataclass/config.py @@ -8,73 +8,75 @@ @dataclass class LavalinkNode: - """ - Dataclass for storing Lavalink node information. - """ - id: str # pylint: disable=invalid-name - password: str - host: str - port: int - regions: List[str] - secure: bool = False - deezer: bool = False + """ + Dataclass for storing Lavalink node information. + """ - # Type checking - def __post_init__(self): - # Check if host, password, and label are strings - if not isinstance(self.host, str): - raise TypeError('server must be a string') - if not isinstance(self.password, str): - raise TypeError('password must be a string') - if not isinstance(self.id, str): - raise TypeError('id must be a string') + id: str # pylint: disable=invalid-name + password: str + host: str + port: int + regions: List[str] + secure: bool = False + deezer: bool = False - # Check if port is an int - if not isinstance(self.port, int): - raise TypeError('port must be an int') + # Type checking + def __post_init__(self): + # Check if host, password, and label are strings + if not isinstance(self.host, str): + raise TypeError('server must be a string') + if not isinstance(self.password, str): + raise TypeError('password must be a string') + if not isinstance(self.id, str): + raise TypeError('id must be a string') - # Check if ssl is a bool - if not isinstance(self.secure, bool): - raise TypeError('ssl must be a bool') + # Check if port is an int + if not isinstance(self.port, int): + raise TypeError('port must be an int') - # Check if deezer is a bool - if not isinstance(self.deezer, bool): - raise TypeError('deezer must be a bool') + # Check if ssl is a bool + if not isinstance(self.secure, bool): + raise TypeError('ssl must be a bool') - # Check if regions is a list - if not isinstance(self.regions, list): - raise TypeError('regions must be a list') + # Check if deezer is a bool + if not isinstance(self.deezer, bool): + raise TypeError('deezer must be a bool') + + # Check if regions is a list + if not isinstance(self.regions, list): + raise TypeError('regions must be a list') @dataclass class Config: - """ - Dataclass for storing Blanco's configuration. - """ - # Required - db_file: str - discord_token: str - spotify_client_id: str - spotify_client_secret: str - lavalink_nodes: Dict[str, LavalinkNode] - enable_server: bool + """ + Dataclass for storing Blanco's configuration. + """ + + # Required + db_file: str + discord_token: str + spotify_client_id: str + spotify_client_secret: str + lavalink_nodes: Dict[str, LavalinkNode] + enable_server: bool - # Optional - server_port: int = 8080 - base_url: Optional[str] = None - discord_oauth_id: Optional[str] = None - discord_oauth_secret: Optional[str] = None - lastfm_api_key: Optional[str] = None - lastfm_shared_secret: Optional[str] = None - match_ahead: bool = False - debug_enabled: bool = False - debug_guild_ids: Optional[List[int]] = None - reenqueue_paused: bool = False + # Optional + server_port: int = 8080 + base_url: Optional[str] = None + discord_oauth_id: Optional[str] = None + discord_oauth_secret: Optional[str] = None + lastfm_api_key: Optional[str] = None + lastfm_shared_secret: Optional[str] = None + match_ahead: bool = False + debug_enabled: bool = False + debug_guild_ids: Optional[List[int]] = None + reenqueue_paused: bool = False - # Convenience - @property - def lastfm_enabled(self) -> bool: - """ - Returns whether Last.fm is enabled. - """ - return self.lastfm_api_key is not None and self.lastfm_shared_secret is not None + # Convenience + @property + def lastfm_enabled(self) -> bool: + """ + Returns whether Last.fm is enabled. + """ + return self.lastfm_api_key is not None and self.lastfm_shared_secret is not None diff --git a/dataclass/custom_embed.py b/dataclass/custom_embed.py index c95536b..4db8658 100644 --- a/dataclass/custom_embed.py +++ b/dataclass/custom_embed.py @@ -12,66 +12,69 @@ @dataclass class CustomEmbed: - """ - Dataclass for an instance of nextcord.Embed with convenience fields - for the timestamp, multiline description, etc. - """ - # All optional - title: Optional[str] = None - color: Colour = Colour.og_blurple() - description: Optional[Union[str, List[str]]] = None - fields: List[List[str]] = field(default_factory=list) - inline_fields: bool = False - thumbnail_url: Optional[str] = None - image_url: Optional[str] = None + """ + Dataclass for an instance of nextcord.Embed with convenience fields + for the timestamp, multiline description, etc. + """ + + # All optional + title: Optional[str] = None + color: Colour = Colour.og_blurple() + description: Optional[Union[str, List[str]]] = None + fields: List[List[str]] = field(default_factory=list) + inline_fields: bool = False + thumbnail_url: Optional[str] = None + image_url: Optional[str] = None - # Header and footer - header: Optional[str] = None - header_url: Optional[str] = None - header_icon_url: Optional[str] = None - footer: Optional[str] = None - footer_icon_url: Optional[str] = None - timestamp_now: bool = False + # Header and footer + header: Optional[str] = None + header_url: Optional[str] = None + header_icon_url: Optional[str] = None + footer: Optional[str] = None + footer_icon_url: Optional[str] = None + timestamp_now: bool = False - # Create embed - def __post_init__(self): - # Can't specify header/footer icons without header/footer names - if self.header is None and self.header_icon_url is not None: - raise ValueError("Can't specify header icon without header text.") - if self.footer is None and self.footer_icon_url is not None: - raise ValueError("Can't specify footer icon without footer text.") + # Create embed + def __post_init__(self): + # Can't specify header/footer icons without header/footer names + if self.header is None and self.header_icon_url is not None: + raise ValueError("Can't specify header icon without header text.") + if self.footer is None and self.footer_icon_url is not None: + raise ValueError("Can't specify footer icon without footer text.") - # Create embed object - description = self.description - if isinstance(self.description, list): - description = '\n'.join(list(filter(None, self.description))) - embed = Embed(title=self.title, description=description, color=self.color) + # Create embed object + description = self.description + if isinstance(self.description, list): + description = '\n'.join(list(filter(None, self.description))) + embed = Embed(title=self.title, description=description, color=self.color) - # Set embed parts - if self.header is not None: - embed.set_author(name=self.header) - if self.thumbnail_url is not None and self.thumbnail_url != '': - embed.set_thumbnail(url=self.thumbnail_url) - if self.image_url is not None: - embed.set_image(url=self.image_url) - if self.header is not None: - embed.set_author(name=self.header, url=self.header_url, icon_url=self.header_icon_url) - if self.footer is not None: - embed.set_footer(text=self.footer, icon_url=self.footer_icon_url) - if len(self.fields) > 0: - for f in self.fields: # pylint: disable=invalid-name - embed.add_field(name=f[0], value=f[1], inline=self.inline_fields) + # Set embed parts + if self.header is not None: + embed.set_author(name=self.header) + if self.thumbnail_url is not None and self.thumbnail_url != '': + embed.set_thumbnail(url=self.thumbnail_url) + if self.image_url is not None: + embed.set_image(url=self.image_url) + if self.header is not None: + embed.set_author( + name=self.header, url=self.header_url, icon_url=self.header_icon_url + ) + if self.footer is not None: + embed.set_footer(text=self.footer, icon_url=self.footer_icon_url) + if len(self.fields) > 0: + for f in self.fields: # pylint: disable=invalid-name + embed.add_field(name=f[0], value=f[1], inline=self.inline_fields) - # Save embed - self.embed = embed + # Save embed + self.embed = embed - # Get embed object - def get(self) -> Embed: - """ - Get the resulting nextcord.Embed object. - """ - # Add timestamp to embed - if self.timestamp_now: - self.embed.timestamp = datetime.now() + # Get embed object + def get(self) -> Embed: + """ + Get the resulting nextcord.Embed object. + """ + # Add timestamp to embed + if self.timestamp_now: + self.embed.timestamp = datetime.now() - return self.embed + return self.embed diff --git a/dataclass/lavalink_result.py b/dataclass/lavalink_result.py index 00cab0f..d5932e3 100644 --- a/dataclass/lavalink_result.py +++ b/dataclass/lavalink_result.py @@ -6,17 +6,18 @@ from typing import TYPE_CHECKING, Optional if TYPE_CHECKING: - from mafic import Track + from mafic import Track @dataclass class LavalinkResult: - """ - Dataclass for storing Lavalink search results. - """ - title: str - author: str - duration_ms: int - lavalink_track: 'Track' - artwork_url: Optional[str] = None - url: Optional[str] = None + """ + Dataclass for storing Lavalink search results. + """ + + title: str + author: str + duration_ms: int + lavalink_track: 'Track' + artwork_url: Optional[str] = None + url: Optional[str] = None diff --git a/dataclass/oauth.py b/dataclass/oauth.py index db61570..109fbbe 100644 --- a/dataclass/oauth.py +++ b/dataclass/oauth.py @@ -1,26 +1,29 @@ """ Dataclasses for storing authentication data for Discord, Last.fm, Spotify, etc. """ + from dataclasses import dataclass @dataclass class OAuth: - """ - Dataclass for storing authentication data for Discord, Spotify, etc. - """ - user_id: int - username: str - access_token: str - refresh_token: str - expires_at: int + """ + Dataclass for storing authentication data for Discord, Spotify, etc. + """ + + user_id: int + username: str + access_token: str + refresh_token: str + expires_at: int @dataclass class LastfmAuth: - """ - Dataclass for storing authentication data for Last.fm. - """ - user_id: int - username: str - session_key: str + """ + Dataclass for storing authentication data for Last.fm. + """ + + user_id: int + username: str + session_key: str diff --git a/dataclass/queue_item.py b/dataclass/queue_item.py index a01fe2b..f5efa67 100644 --- a/dataclass/queue_item.py +++ b/dataclass/queue_item.py @@ -8,65 +8,66 @@ from typing import TYPE_CHECKING, Optional, Tuple if TYPE_CHECKING: - from mafic import Track + from mafic import Track @dataclass class QueueItem: + """ + Dataclass for storing a track in the player queue. + """ + + # Who requested the track (required) + requester: int + + # The Spotify ID for the track, if any + spotify_id: Optional[str] = None + + # The MusicBrainz ID for the track, if any + mbid: Optional[str] = None + + # International Standard Recording Code (ISRC) + isrc: Optional[str] = None + + # Direct track URL + url: Optional[str] = None + + # Album artwork + artwork: Optional[str] = None + + # Track details + title: Optional[str] = None + artist: Optional[str] = None # First artist + author: Optional[str] = None # All artists, separated by ', ' + album: Optional[str] = None + duration: Optional[int] = 0 # milliseconds + lavalink_track: Optional['Track'] = None + + # Imperfect match - True when ISRC is present but no match found on YouTube + is_imperfect: Optional[bool] = False + + # If annotate_track() was called on this track + is_annotated: Optional[bool] = False + + # When the track started playing + start_time: Optional[int] = None + + # Get title and artist + def get_details(self) -> Tuple[str, str]: """ - Dataclass for storing a track in the player queue. + Get a string of the form `title - artist` for the track. """ - # Who requested the track (required) - requester: int - - # The Spotify ID for the track, if any - spotify_id: Optional[str] = None - - # The MusicBrainz ID for the track, if any - mbid: Optional[str] = None - - # International Standard Recording Code (ISRC) - isrc: Optional[str] = None - - # Direct track URL - url: Optional[str] = None - - # Album artwork - artwork: Optional[str] = None - - # Track details - title: Optional[str] = None - artist: Optional[str] = None # First artist - author: Optional[str] = None # All artists, separated by ', ' - album: Optional[str] = None - duration: Optional[int] = 0 # milliseconds - lavalink_track: Optional['Track'] = None - - # Imperfect match - True when ISRC is present but no match found on YouTube - is_imperfect: Optional[bool] = False - - # If annotate_track() was called on this track - is_annotated: Optional[bool] = False - - # When the track started playing - start_time: Optional[int] = None - - # Get title and artist - def get_details(self) -> Tuple[str, str]: - """ - Get a string of the form `title - artist` for the track. - """ - if self.title is not None: - title = self.title - if self.artist is not None: - artist = self.artist - else: - artist = 'Unknown artist' - elif self.url is not None: - title = self.url - artist = '(direct link)' - else: - title = 'Unknown title' - artist = 'Unknown query' - - return title, artist + if self.title is not None: + title = self.title + if self.artist is not None: + artist = self.artist + else: + artist = 'Unknown artist' + elif self.url is not None: + title = self.url + artist = '(direct link)' + else: + title = 'Unknown title' + artist = 'Unknown query' + + return title, artist diff --git a/dataclass/spotify.py b/dataclass/spotify.py index 911114e..e0ced08 100644 --- a/dataclass/spotify.py +++ b/dataclass/spotify.py @@ -8,24 +8,26 @@ @dataclass class SpotifyResult: - """ - Dataclass for storing a Spotify catalogue search result. - """ - name: str - description: str - spotify_id: str + """ + Dataclass for storing a Spotify catalogue search result. + """ + + name: str + description: str + spotify_id: str @dataclass class SpotifyTrack: - """ - Dataclass for storing a Spotify track entity. - """ - title: str - artist: str # First artist - author: str # All artists, separated by ', ' - spotify_id: str - duration_ms: int - artwork: Optional[str] = None - album: Optional[str] = None - isrc: Optional[str] = None + """ + Dataclass for storing a Spotify track entity. + """ + + title: str + artist: str # First artist + author: str # All artists, separated by ', ' + spotify_id: str + duration_ms: int + artwork: Optional[str] = None + album: Optional[str] = None + isrc: Optional[str] = None diff --git a/dev_server.py b/dev_server.py index bb78e85..1066f50 100644 --- a/dev_server.py +++ b/dev_server.py @@ -15,28 +15,30 @@ def run_tailwind(): - """ - Run the TailwindCSS compiler. - """ - run( - ' '.join([ - 'tailwindcss', - '-i', - './server/static/css/base.css', - '-o', - './server/static/css/main.css', - '--watch' - ]), - check=False, - shell=True - ) + """ + Run the TailwindCSS compiler. + """ + run( + ' '.join( + [ + 'tailwindcss', + '-i', + './server/static/css/base.css', + '-o', + './server/static/css/main.css', + '--watch', + ] + ), + check=False, + shell=True, + ) if __name__ == '__main__': - thread = threading.Thread(target=run_tailwind) - thread.start() + thread = threading.Thread(target=run_tailwind) + thread.start() - db = Database(config.db_file) - loop = asyncio.new_event_loop() - loop.create_task(run_app(db, config)) - loop.run_forever() + db = Database(config.db_file) + loop = asyncio.new_event_loop() + loop.create_task(run_app(db, config)) + loop.run_forever() diff --git a/main.py b/main.py index 60e8b6f..0386b96 100644 --- a/main.py +++ b/main.py @@ -5,66 +5,73 @@ from nextcord import Intents from utils.blanco import BlancoBot -from utils.config import (REDIS_HOST, REDIS_PASSWORD, REDIS_PORT, SENTRY_DSN, - SENTRY_ENV, config) +from utils.config import ( + REDIS_HOST, + REDIS_PASSWORD, + REDIS_PORT, + SENTRY_DSN, + SENTRY_ENV, + config, +) from utils.constants import RELEASE from utils.logger import create_logger - if __name__ == '__main__': - logger = create_logger('main') + logger = create_logger('main') - # Print parsed config - if config.debug_enabled: - logger.debug('Parsed configuration:') - logger.debug(' Database file: %s', config.db_file) - logger.debug(' Discord token: %s...', config.discord_token[:3]) - logger.debug(' Spotify client ID: %s...', config.spotify_client_id[:3]) - logger.debug(' Spotify client secret: %s...', config.spotify_client_secret[:3]) - logger.debug(' Match ahead: %s', 'enabled' if config.match_ahead else 'disabled') + # Print parsed config + if config.debug_enabled: + logger.debug('Parsed configuration:') + logger.debug(' Database file: %s', config.db_file) + logger.debug(' Discord token: %s...', config.discord_token[:3]) + logger.debug(' Spotify client ID: %s...', config.spotify_client_id[:3]) + logger.debug(' Spotify client secret: %s...', config.spotify_client_secret[:3]) + logger.debug(' Match ahead: %s', 'enabled' if config.match_ahead else 'disabled') - if SENTRY_DSN is not None and SENTRY_ENV is not None: - logger.debug(' Sentry DSN: %s...', SENTRY_DSN[:10]) - logger.debug(' Sentry environment: %s', SENTRY_ENV) - else: - logger.debug(' Sentry integration disabled') + if SENTRY_DSN is not None and SENTRY_ENV is not None: + logger.debug(' Sentry DSN: %s...', SENTRY_DSN[:10]) + logger.debug(' Sentry environment: %s', SENTRY_ENV) + else: + logger.debug(' Sentry integration disabled') - if REDIS_HOST is not None and REDIS_PORT != -1: - logger.debug(' Redis host: %s', REDIS_HOST) - logger.debug(' Redis port: %d', REDIS_PORT) - if REDIS_PASSWORD is not None: - logger.debug(' Redis password: %s...', REDIS_PASSWORD[:3]) - else: - logger.debug(' Redis integration disabled') + if REDIS_HOST is not None and REDIS_PORT != -1: + logger.debug(' Redis host: %s', REDIS_HOST) + logger.debug(' Redis port: %d', REDIS_PORT) + if REDIS_PASSWORD is not None: + logger.debug(' Redis password: %s...', REDIS_PASSWORD[:3]) + else: + logger.debug(' Redis integration disabled') - if config.lastfm_enabled: - assert config.lastfm_api_key is not None and config.lastfm_shared_secret is not None - logger.debug(' Last.fm API key: %s...', config.lastfm_api_key[:3]) - logger.debug(' Last.fm shared secret: %s...', config.lastfm_shared_secret[:3]) - else: - logger.debug(' Last.fm integration disabled') + if config.lastfm_enabled: + assert ( + config.lastfm_api_key is not None and config.lastfm_shared_secret is not None + ) + logger.debug(' Last.fm API key: %s...', config.lastfm_api_key[:3]) + logger.debug(' Last.fm shared secret: %s...', config.lastfm_shared_secret[:3]) + else: + logger.debug(' Last.fm integration disabled') - logger.debug(' Webserver: %s', 'enabled' if config.enable_server else 'disabled') - if config.enable_server: - assert config.discord_oauth_secret is not None - logger.debug(' - Listening on port %d', config.server_port) - logger.debug(' - Base URL: %s', config.base_url) - logger.debug(' - OAuth ID: %s...', str(config.discord_oauth_id)[:3]) - logger.debug(' - OAuth secret: %s...', config.discord_oauth_secret[:3]) + logger.debug(' Webserver: %s', 'enabled' if config.enable_server else 'disabled') + if config.enable_server: + assert config.discord_oauth_secret is not None + logger.debug(' - Listening on port %d', config.server_port) + logger.debug(' - Base URL: %s', config.base_url) + logger.debug(' - OAuth ID: %s...', str(config.discord_oauth_id)[:3]) + logger.debug(' - OAuth secret: %s...', config.discord_oauth_secret[:3]) - logger.debug(' Lavalink nodes:') - for node in config.lavalink_nodes.values(): - logger.debug(' - %s (%s:%d)', node.id, node.host, node.port) - logger.debug(' Secure: %s', 'yes' if node.secure else 'no') - logger.debug(' Supports Deezer: %s', 'yes' if node.deezer else 'no') - logger.debug(' Regions: %s', ', '.join(node.regions)) + logger.debug(' Lavalink nodes:') + for node in config.lavalink_nodes.values(): + logger.debug(' - %s (%s:%d)', node.id, node.host, node.port) + logger.debug(' Secure: %s', 'yes' if node.secure else 'no') + logger.debug(' Supports Deezer: %s', 'yes' if node.deezer else 'no') + logger.debug(' Regions: %s', ', '.join(node.regions)) - # Create bot instance - intents = Intents.default() - intents.members = True - client = BlancoBot(intents=intents, default_guild_ids=config.debug_guild_ids) - client.init_config(config) + # Create bot instance + intents = Intents.default() + intents.members = True + client = BlancoBot(intents=intents, default_guild_ids=config.debug_guild_ids) + client.init_config(config) - # Run client - logger.info('Blanco release %s booting up...', RELEASE) - client.run(config.discord_token) + # Run client + logger.info('Blanco release %s booting up...', RELEASE) + client.run(config.discord_token) diff --git a/pyproject.toml b/pyproject.toml index 7e3ce16..db24019 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,15 @@ mypy = "^1.9.0" pre-commit = "^3.7.0" ruff = "^0.3.4" +[tool.ruff] +indent-width = 2 + +[tool.ruff.lint] +select = ["E4", "E7", "E9", "F", "I", "PL"] + +[tool.ruff.format] +quote-style = "single" + [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" diff --git a/server/__init__.py b/server/__init__.py index 8c87fc3..191fca2 100644 --- a/server/__init__.py +++ b/server/__init__.py @@ -8,8 +8,8 @@ def setup(bot: 'BlancoBot'): - """ - Run the web server as an async task. - """ - assert bot.config is not None - bot.loop.create_task(run_app(bot.database, bot.config)) + """ + Run the web server as an async task. + """ + assert bot.config is not None + bot.loop.create_task(run_app(bot.database, bot.config)) diff --git a/server/main.py b/server/main.py index cdabf26..dda0272 100644 --- a/server/main.py +++ b/server/main.py @@ -18,45 +18,47 @@ from .routes import setup_routes if TYPE_CHECKING: - from database import Database - from dataclass.config import Config + from database import Database + from dataclass.config import Config class AccessLogger(AbstractAccessLogger): - """ - Custom access logger that logs the response status code, request method, - path, and time taken to process the request. - """ + """ + Custom access logger that logs the response status code, request method, + path, and time taken to process the request. + """ - def log(self, request, response, time): - log_fmt = 'Server: %s %s %s (took %.2f ms)' - self.logger.info(log_fmt, response.status, request.method, request.path, time*1000) + def log(self, request, response, time): + log_fmt = 'Server: %s %s %s (took %.2f ms)' + self.logger.info( + log_fmt, response.status, request.method, request.path, time * 1000 + ) async def run_app(database: 'Database', config: 'Config'): - """ - Run the web server. - """ - # Create logger - logger = create_logger('server') - - # Create app - app = web.Application() - app['db'] = database - app['config'] = config - - # Setup sessions - fernet_key = Fernet.generate_key() - setup_sessions(app, EncryptedCookieStorage(urlsafe_b64decode(fernet_key))) - - # Setup templates and routes - aiohttp_jinja2.setup(app, loader=jinja2.FileSystemLoader('server/templates')) - setup_routes(app) - - # Run app - runner = web.AppRunner(app, access_log=logger, access_log_class=AccessLogger) - await runner.setup() - site = web.TCPSite(runner, port=config.server_port) - await site.start() - - logger.info('Web server listening on %s', config.base_url) + """ + Run the web server. + """ + # Create logger + logger = create_logger('server') + + # Create app + app = web.Application() + app['db'] = database + app['config'] = config + + # Setup sessions + fernet_key = Fernet.generate_key() + setup_sessions(app, EncryptedCookieStorage(urlsafe_b64decode(fernet_key))) + + # Setup templates and routes + aiohttp_jinja2.setup(app, loader=jinja2.FileSystemLoader('server/templates')) + setup_routes(app) + + # Run app + runner = web.AppRunner(app, access_log=logger, access_log_class=AccessLogger) + await runner.setup() + site = web.TCPSite(runner, port=config.server_port) + await site.start() + + logger.info('Web server listening on %s', config.base_url) diff --git a/server/routes.py b/server/routes.py index a8a3caf..b4ec11b 100644 --- a/server/routes.py +++ b/server/routes.py @@ -4,26 +4,37 @@ from typing import TYPE_CHECKING -from .views import * # pylint: disable=wildcard-import +from views.dashboard import dashboard +from views.deleteaccount import delete_account +from views.discordoauth import discordoauth +from views.homepage import homepage +from views.lastfmtoken import lastfm_token +from views.linklastfm import link_lastfm +from views.linkspotify import link_spotify +from views.login import login +from views.logout import logout +from views.robotstxt import robotstxt +from views.spotifyoauth import spotifyoauth +from views.unlink import unlink if TYPE_CHECKING: - from aiohttp.web import Application + from aiohttp.web import Application def setup_routes(app: 'Application'): - """ - Add all available routes to the application. - """ - app.router.add_get('/', homepage) - app.router.add_get('/dashboard', dashboard) - app.router.add_get('/deleteaccount', delete_account) - app.router.add_get('/discordoauth', discordoauth) - app.router.add_get('/lastfmtoken', lastfm_token) - app.router.add_get('/linklastfm', link_lastfm) - app.router.add_get('/linkspotify', link_spotify) - app.router.add_get('/login', login) - app.router.add_get('/logout', logout) - app.router.add_get('/robots.txt', robotstxt) - app.router.add_get('/spotifyoauth', spotifyoauth) - app.router.add_get('/unlink', unlink) - app.router.add_static('/static/', path='server/static', name='static') + """ + Add all available routes to the application. + """ + app.router.add_get('/', homepage) + app.router.add_get('/dashboard', dashboard) + app.router.add_get('/deleteaccount', delete_account) + app.router.add_get('/discordoauth', discordoauth) + app.router.add_get('/lastfmtoken', lastfm_token) + app.router.add_get('/linklastfm', link_lastfm) + app.router.add_get('/linkspotify', link_spotify) + app.router.add_get('/login', login) + app.router.add_get('/logout', logout) + app.router.add_get('/robots.txt', robotstxt) + app.router.add_get('/spotifyoauth', spotifyoauth) + app.router.add_get('/unlink', unlink) + app.router.add_static('/static/', path='server/static', name='static') diff --git a/server/static/css/.gitignore b/server/static/css/.gitignore index 5f93228..96f805b 100644 --- a/server/static/css/.gitignore +++ b/server/static/css/.gitignore @@ -1 +1 @@ -main.css \ No newline at end of file +main.css diff --git a/server/static/images/favicon/site.webmanifest b/server/static/images/favicon/site.webmanifest index 5ad2c38..a8c5765 100755 --- a/server/static/images/favicon/site.webmanifest +++ b/server/static/images/favicon/site.webmanifest @@ -1 +1 @@ -{"name":"Blanco","short_name":"Blanco","icons":[{"src":"/static/images/favicon/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/static/images/favicon/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#7a80ba","background_color":"#f4f1de","display":"standalone"} \ No newline at end of file +{"name":"Blanco","short_name":"Blanco","icons":[{"src":"/static/images/favicon/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/static/images/favicon/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#7a80ba","background_color":"#f4f1de","display":"standalone"} diff --git a/server/templates/dashboard.html b/server/templates/dashboard.html index 8a27dd6..2322f30 100644 --- a/server/templates/dashboard.html +++ b/server/templates/dashboard.html @@ -169,4 +169,4 @@

Delete account

-{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/server/views/__init__.py b/server/views/__init__.py deleted file mode 100644 index ba40fe3..0000000 --- a/server/views/__init__.py +++ /dev/null @@ -1,16 +0,0 @@ -""" -This module imports all the views for the server. -""" - -from .dashboard import dashboard -from .deleteaccount import delete_account -from .discordoauth import discordoauth -from .homepage import homepage -from .lastfmtoken import lastfm_token -from .linklastfm import link_lastfm -from .linkspotify import link_spotify -from .login import login -from .logout import logout -from .robotstxt import robotstxt -from .spotifyoauth import spotifyoauth -from .unlink import unlink diff --git a/server/views/dashboard.py b/server/views/dashboard.py index 55e5192..78fa129 100644 --- a/server/views/dashboard.py +++ b/server/views/dashboard.py @@ -9,42 +9,42 @@ from aiohttp_session import get_session if TYPE_CHECKING: - from dataclass.oauth import LastfmAuth, OAuth + from dataclass.oauth import LastfmAuth, OAuth @aiohttp_jinja2.template('dashboard.html') async def dashboard(request: web.Request): - """ - Render the dashboard. - """ - # Get session - session = await get_session(request) - if 'user_id' not in session: - return web.HTTPFound('/login') - - # Get user info - database = request.app['db'] - user: OAuth = database.get_oauth('discord', session['user_id']) - if user is None: - return web.HTTPFound('/login') - - # Get Spotify info - spotify_username = None - spotify: OAuth = database.get_oauth('spotify', session['user_id']) - if spotify is not None: - spotify_username = spotify.username - - # Get Last.fm info - lastfm_username = None - lastfm: LastfmAuth = database.get_lastfm_credentials(session['user_id']) - if lastfm is not None: - lastfm_username = lastfm.username - - # Render template - return { - 'username': user.username, - 'spotify_logged_in': spotify is not None, - 'spotify_username': spotify_username, - 'lastfm_logged_in': lastfm is not None, - 'lastfm_username': lastfm_username - } + """ + Render the dashboard. + """ + # Get session + session = await get_session(request) + if 'user_id' not in session: + return web.HTTPFound('/login') + + # Get user info + database = request.app['db'] + user: OAuth = database.get_oauth('discord', session['user_id']) + if user is None: + return web.HTTPFound('/login') + + # Get Spotify info + spotify_username = None + spotify: OAuth = database.get_oauth('spotify', session['user_id']) + if spotify is not None: + spotify_username = spotify.username + + # Get Last.fm info + lastfm_username = None + lastfm: LastfmAuth = database.get_lastfm_credentials(session['user_id']) + if lastfm is not None: + lastfm_username = lastfm.username + + # Render template + return { + 'username': user.username, + 'spotify_logged_in': spotify is not None, + 'spotify_username': spotify_username, + 'lastfm_logged_in': lastfm is not None, + 'lastfm_username': lastfm_username, + } diff --git a/server/views/deleteaccount.py b/server/views/deleteaccount.py index 25b409f..d11f7dc 100644 --- a/server/views/deleteaccount.py +++ b/server/views/deleteaccount.py @@ -7,19 +7,19 @@ async def delete_account(request: web.Request): - """ - Delete user data from all tables and redirect to logout. - """ - # Get session - session = await get_session(request) - if 'user_id' not in session: - return web.HTTPFound('/login') + """ + Delete user data from all tables and redirect to logout. + """ + # Get session + session = await get_session(request) + if 'user_id' not in session: + return web.HTTPFound('/login') - # Delete user data from all tables - database = request.app['db'] - database.delete_oauth('discord', session['user_id']) - database.delete_oauth('spotify', session['user_id']) - database.delete_oauth('lastfm', session['user_id']) + # Delete user data from all tables + database = request.app['db'] + database.delete_oauth('discord', session['user_id']) + database.delete_oauth('spotify', session['user_id']) + database.delete_oauth('lastfm', session['user_id']) - # Redirect to logout - return web.HTTPFound('/logout') + # Redirect to logout + return web.HTTPFound('/logout') diff --git a/server/views/discordoauth.py b/server/views/discordoauth.py index bc7a082..32ad294 100644 --- a/server/views/discordoauth.py +++ b/server/views/discordoauth.py @@ -14,88 +14,91 @@ async def discordoauth(request: web.Request): - """ - Exchange the code for an access token and store it in the database. - """ - # Get session - session = await get_session(request) + """ + Exchange the code for an access token and store it in the database. + """ + # Get session + session = await get_session(request) - # Get state - if 'state' not in session: - return web.HTTPBadRequest(text='Missing state, try logging in again.') - state = session['state'] + # Get state + if 'state' not in session: + return web.HTTPBadRequest(text='Missing state, try logging in again.') + state = session['state'] - # Get OAuth ID, secret, and base URL - oauth_id = request.app['config'].discord_oauth_id - oauth_secret = request.app['config'].discord_oauth_secret - base_url = request.app['config'].base_url + # Get OAuth ID, secret, and base URL + oauth_id = request.app['config'].discord_oauth_id + oauth_secret = request.app['config'].discord_oauth_secret + base_url = request.app['config'].base_url - # Get code - try: - code = request.query['code'] - state = request.query['state'] - except KeyError as err: - return web.HTTPBadRequest(text=f'Missing parameter: {err.args[0]}') + # Get code + try: + code = request.query['code'] + state = request.query['state'] + except KeyError as err: + return web.HTTPBadRequest(text=f'Missing parameter: {err.args[0]}') - # Check state - if state != session['state']: - return web.HTTPBadRequest(text='Invalid state, try logging in again.') + # Check state + if state != session['state']: + return web.HTTPBadRequest(text='Invalid state, try logging in again.') - # Get access token - response = requests.post( - str(DISCORD_API_BASE_URL / 'oauth2/token'), - data={ - 'client_id': oauth_id, - 'client_secret': oauth_secret, - 'grant_type': 'authorization_code', - 'code': code, - 'redirect_uri': f'{base_url}/discordoauth' - }, - headers={ - 'Content-Type': 'application/x-www-form-urlencoded', - 'User-Agent': USER_AGENT - }, - timeout=5 - ) - try: - response.raise_for_status() - except HTTPError as err: - return web.HTTPBadRequest(text=f'Error getting access token: {err}') - except Timeout: - return web.HTTPBadRequest(text='Timed out while requesting access token') + # Get access token + response = requests.post( + str(DISCORD_API_BASE_URL / 'oauth2/token'), + data={ + 'client_id': oauth_id, + 'client_secret': oauth_secret, + 'grant_type': 'authorization_code', + 'code': code, + 'redirect_uri': f'{base_url}/discordoauth', + }, + headers={ + 'Content-Type': 'application/x-www-form-urlencoded', + 'User-Agent': USER_AGENT, + }, + timeout=5, + ) + try: + response.raise_for_status() + except HTTPError as err: + return web.HTTPBadRequest(text=f'Error getting access token: {err}') + except Timeout: + return web.HTTPBadRequest(text='Timed out while requesting access token') - # Get user info - parsed = response.json() - user_info = requests.get( - str(DISCORD_API_BASE_URL / 'users/@me'), - headers={ - 'Authorization': f'Bearer {parsed["access_token"]}', - 'User-Agent': USER_AGENT - }, - timeout=5 - ) - try: - user_info.raise_for_status() - except HTTPError as err: - return web.HTTPBadRequest(text=f'Error getting user info: {err}') - except Timeout: - return web.HTTPBadRequest(text='Timed out while requesting user info') + # Get user info + parsed = response.json() + user_info = requests.get( + str(DISCORD_API_BASE_URL / 'users/@me'), + headers={ + 'Authorization': f'Bearer {parsed['access_token']}', + 'User-Agent': USER_AGENT, + }, + timeout=5, + ) + try: + user_info.raise_for_status() + except HTTPError as err: + return web.HTTPBadRequest(text=f'Error getting user info: {err}') + except Timeout: + return web.HTTPBadRequest(text='Timed out while requesting user info') - # Calculate expiry timestamp - user_parsed = user_info.json() - expires_at = int(time()) + parsed['expires_in'] + # Calculate expiry timestamp + user_parsed = user_info.json() + expires_at = int(time()) + parsed['expires_in'] - # Store user info in DB - database = request.app['db'] - database.set_oauth('discord', OAuth( - user_id=user_parsed['id'], - username=user_parsed['username'], - access_token=parsed['access_token'], - refresh_token=parsed['refresh_token'], - expires_at=expires_at - )) + # Store user info in DB + database = request.app['db'] + database.set_oauth( + 'discord', + OAuth( + user_id=user_parsed['id'], + username=user_parsed['username'], + access_token=parsed['access_token'], + refresh_token=parsed['refresh_token'], + expires_at=expires_at, + ), + ) - # Redirect to dashboard - del session['state'] - session['user_id'] = user_parsed['id'] - return web.HTTPFound('/dashboard') + # Redirect to dashboard + del session['state'] + session['user_id'] = user_parsed['id'] + return web.HTTPFound('/dashboard') diff --git a/server/views/homepage.py b/server/views/homepage.py index 99e7a20..0ba0a63 100644 --- a/server/views/homepage.py +++ b/server/views/homepage.py @@ -9,12 +9,12 @@ @aiohttp_jinja2.template('homepage.html') async def homepage(request: web.Request): - """ - Render the homepage, or redirect to the dashboard if the user is logged in. - """ - # Get session - session = await get_session(request) - if 'user_id' in session: - return web.HTTPFound('/dashboard') + """ + Render the homepage, or redirect to the dashboard if the user is logged in. + """ + # Get session + session = await get_session(request) + if 'user_id' in session: + return web.HTTPFound('/dashboard') - return {} + return {} diff --git a/server/views/lastfmtoken.py b/server/views/lastfmtoken.py index c7b38f1..2de1b4e 100644 --- a/server/views/lastfmtoken.py +++ b/server/views/lastfmtoken.py @@ -14,78 +14,66 @@ async def lastfm_token(request: web.Request): - """ - Exchange the token for a session key and store it in the database. - """ - # Get session - session = await get_session(request) + """ + Exchange the token for a session key and store it in the database. + """ + # Get session + session = await get_session(request) - # Get state and Discord user ID - if 'user_id' not in session: - return web.HTTPBadRequest(text='You are not logged into Blanco with Discord.') - user_id = session['user_id'] + # Get state and Discord user ID + if 'user_id' not in session: + return web.HTTPBadRequest(text='You are not logged into Blanco with Discord.') + user_id = session['user_id'] - # Get API key and secret - api_key = request.app['config'].lastfm_api_key - secret = request.app['config'].lastfm_shared_secret + # Get API key and secret + api_key = request.app['config'].lastfm_api_key + secret = request.app['config'].lastfm_shared_secret - # Get token - try: - token = request.query['token'] - except KeyError: - return web.HTTPBadRequest(text='Missing token, try logging in again.') + # Get token + try: + token = request.query['token'] + except KeyError: + return web.HTTPBadRequest(text='Missing token, try logging in again.') - # Create signature - signature = ''.join([ - 'api_key', - api_key, - 'method', - 'auth.getSession', - 'token', - token, - secret - ]) - hashed = md5(signature.encode('utf-8')).hexdigest() + # Create signature + signature = ''.join( + ['api_key', api_key, 'method', 'auth.getSession', 'token', token, secret] + ) + hashed = md5(signature.encode('utf-8')).hexdigest() - # Get session key - url = LASTFM_API_BASE_URL.with_query({ - 'method': 'auth.getSession', - 'api_key': api_key, - 'token': token, - 'api_sig': hashed, - 'format': 'json' - }) + # Get session key + url = LASTFM_API_BASE_URL.with_query( + { + 'method': 'auth.getSession', + 'api_key': api_key, + 'token': token, + 'api_sig': hashed, + 'format': 'json', + } + ) - # Get response - response = requests.get( - str(url), - headers={ - 'User-Agent': USER_AGENT - }, - timeout=5 - ) - try: - response.raise_for_status() - except HTTPError as err: - return web.HTTPBadRequest(text=f'Error logging into Last.fm: {err}') - except Timeout: - return web.HTTPBadRequest(text='Timed out while requesting session key') + # Get response + response = requests.get(str(url), headers={'User-Agent': USER_AGENT}, timeout=5) + try: + response.raise_for_status() + except HTTPError as err: + return web.HTTPBadRequest(text=f'Error logging into Last.fm: {err}') + except Timeout: + return web.HTTPBadRequest(text='Timed out while requesting session key') - # Get JSON - json = response.json() - try: - session_key = json['session']['key'] - username = json['session']['name'] - except KeyError as err: - return web.HTTPBadRequest(text=f'Error logging into Last.fm: missing {err.args[0]}') + # Get JSON + json = response.json() + try: + session_key = json['session']['key'] + username = json['session']['name'] + except KeyError as err: + return web.HTTPBadRequest(text=f'Error logging into Last.fm: missing {err.args[0]}') - # Store user info in DB - database = request.app['db'] - database.set_lastfm_credentials(LastfmAuth( - user_id=user_id, - username=username, - session_key=session_key - )) + # Store user info in DB + database = request.app['db'] + database.set_lastfm_credentials( + LastfmAuth(user_id=user_id, username=username, session_key=session_key) + ) - # Redirect to dashboard - return web.HTTPFound('/dashboard') + # Redirect to dashboard + return web.HTTPFound('/dashboard') diff --git a/server/views/linklastfm.py b/server/views/linklastfm.py index 5850b6f..cdc41b4 100644 --- a/server/views/linklastfm.py +++ b/server/views/linklastfm.py @@ -8,28 +8,25 @@ async def link_lastfm(request: web.Request): - """ - Redirect to Last.fm auth flow. - """ - # Create session - session = await get_session(request) + """ + Redirect to Last.fm auth flow. + """ + # Create session + session = await get_session(request) - # Check if user is logged in - if 'user_id' not in session: - return web.HTTPFound('/login') + # Check if user is logged in + if 'user_id' not in session: + return web.HTTPFound('/login') - # Get API key and base URL - api_key = request.app['config'].lastfm_api_key - base_url = request.app['config'].base_url + # Get API key and base URL + api_key = request.app['config'].lastfm_api_key + base_url = request.app['config'].base_url - # Redirect to Last.fm - url = URL.build( - scheme='https', - host='www.last.fm', - path='/api/auth', - query={ - 'api_key': api_key, - 'cb': f'{base_url}/lastfmtoken' - } - ) - return web.HTTPFound(url) + # Redirect to Last.fm + url = URL.build( + scheme='https', + host='www.last.fm', + path='/api/auth', + query={'api_key': api_key, 'cb': f'{base_url}/lastfmtoken'}, + ) + return web.HTTPFound(url) diff --git a/server/views/linkspotify.py b/server/views/linkspotify.py index 26d3aa2..50eb910 100644 --- a/server/views/linkspotify.py +++ b/server/views/linkspotify.py @@ -11,44 +11,44 @@ async def link_spotify(request: web.Request): - """ - Construct a Spotify OAuth2 authorization URL and redirect to it. - """ - # Create session - session = await get_session(request) - - # Check if user is logged in - if 'user_id' not in session: - return web.HTTPFound('/login') - - # Get OAuth ID and base URL - oauth_id = request.app['config'].spotify_client_id - base_url = request.app['config'].base_url - - # Generate and store state - state = ''.join(choice(ascii_letters + digits) for _ in range(16)) - session['state'] = state - - # Build URL - scopes = [ - 'user-read-private', # Get username - 'user-read-email', # Also for username, weirdly - 'user-library-modify', # Add/remove Liked Songs - 'user-top-read', # Get top tracks, for recommendations/radio - 'playlist-read-private' # Get owned playlists - ] - url = URL.build( - scheme='https', - host='accounts.spotify.com', - path='/authorize', - query={ - 'client_id': oauth_id, - 'response_type': 'code', - 'scope': ' '.join(scopes), - 'redirect_uri': f'{base_url}/spotifyoauth', - 'state': state - } - ) - - # Redirect to Discord - return web.HTTPFound(url) + """ + Construct a Spotify OAuth2 authorization URL and redirect to it. + """ + # Create session + session = await get_session(request) + + # Check if user is logged in + if 'user_id' not in session: + return web.HTTPFound('/login') + + # Get OAuth ID and base URL + oauth_id = request.app['config'].spotify_client_id + base_url = request.app['config'].base_url + + # Generate and store state + state = ''.join(choice(ascii_letters + digits) for _ in range(16)) + session['state'] = state + + # Build URL + scopes = [ + 'user-read-private', # Get username + 'user-read-email', # Also for username, weirdly + 'user-library-modify', # Add/remove Liked Songs + 'user-top-read', # Get top tracks, for recommendations/radio + 'playlist-read-private', # Get owned playlists + ] + url = URL.build( + scheme='https', + host='accounts.spotify.com', + path='/authorize', + query={ + 'client_id': oauth_id, + 'response_type': 'code', + 'scope': ' '.join(scopes), + 'redirect_uri': f'{base_url}/spotifyoauth', + 'state': state, + }, + ) + + # Redirect to Discord + return web.HTTPFound(url) diff --git a/server/views/login.py b/server/views/login.py index e3dfc81..80dfbbe 100644 --- a/server/views/login.py +++ b/server/views/login.py @@ -11,34 +11,34 @@ async def login(request: web.Request): - """ - Construct a Discord OAuth2 authorization URL and redirect to it. - """ - # Create session - session = await get_session(request) - - # Get OAuth ID and base URL - oauth_id = request.app['config'].discord_oauth_id - base_url = request.app['config'].base_url - - # Generate and store state - state = ''.join(choice(ascii_letters + digits) for _ in range(16)) - session['state'] = state - - # Build URL - url = URL.build( - scheme='https', - host='discord.com', - path='/api/oauth2/authorize', - query={ - 'client_id': oauth_id, - 'response_type': 'code', - 'scope': 'identify guilds email', - 'redirect_uri': f'{base_url}/discordoauth', - 'state': state, - 'prompt': 'none' - } - ) - - # Redirect to Discord - return web.HTTPFound(url) + """ + Construct a Discord OAuth2 authorization URL and redirect to it. + """ + # Create session + session = await get_session(request) + + # Get OAuth ID and base URL + oauth_id = request.app['config'].discord_oauth_id + base_url = request.app['config'].base_url + + # Generate and store state + state = ''.join(choice(ascii_letters + digits) for _ in range(16)) + session['state'] = state + + # Build URL + url = URL.build( + scheme='https', + host='discord.com', + path='/api/oauth2/authorize', + query={ + 'client_id': oauth_id, + 'response_type': 'code', + 'scope': 'identify guilds email', + 'redirect_uri': f'{base_url}/discordoauth', + 'state': state, + 'prompt': 'none', + }, + ) + + # Redirect to Discord + return web.HTTPFound(url) diff --git a/server/views/logout.py b/server/views/logout.py index a36f9d2..d7399f6 100644 --- a/server/views/logout.py +++ b/server/views/logout.py @@ -7,14 +7,14 @@ async def logout(request: web.Request): - """ - Clear the session and redirect to home. - """ - # Get session - session = await get_session(request) + """ + Clear the session and redirect to home. + """ + # Get session + session = await get_session(request) - # Clear session - session.clear() + # Clear session + session.clear() - # Redirect to home - return web.HTTPFound('/') + # Redirect to home + return web.HTTPFound('/') diff --git a/server/views/robotstxt.py b/server/views/robotstxt.py index 73f60ec..7e7f932 100644 --- a/server/views/robotstxt.py +++ b/server/views/robotstxt.py @@ -6,11 +6,15 @@ async def robotstxt(_: web.Request): - """ - Return robots.txt - """ - return web.Response(text='\n'.join([ + """ + Return robots.txt + """ + return web.Response( + text='\n'.join( + [ 'User-agent: *', - 'Allow: /$', # Allow homepage - 'Disallow: /' # Disallow everything else - ])) + 'Allow: /$', # Allow homepage + 'Disallow: /', # Disallow everything else + ] + ) + ) diff --git a/server/views/spotifyoauth.py b/server/views/spotifyoauth.py index 75a3980..83bed1f 100644 --- a/server/views/spotifyoauth.py +++ b/server/views/spotifyoauth.py @@ -11,95 +11,97 @@ from requests.exceptions import HTTPError, Timeout from dataclass.oauth import OAuth -from utils.constants import (SPOTIFY_ACCOUNTS_BASE_URL, SPOTIFY_API_BASE_URL, - USER_AGENT) +from utils.constants import SPOTIFY_ACCOUNTS_BASE_URL, SPOTIFY_API_BASE_URL, USER_AGENT async def spotifyoauth(request: web.Request): - """ - Exchange the code for an access token and store it in the database. - """ - # Get session - session = await get_session(request) + """ + Exchange the code for an access token and store it in the database. + """ + # Get session + session = await get_session(request) - # Get state and Discord user ID - if 'state' not in session: - return web.HTTPBadRequest(text='Missing state, try logging in again.') - if 'user_id' not in session: - return web.HTTPBadRequest(text='You are not logged into Blanco with Discord.') - state = session['state'] - user_id = session['user_id'] + # Get state and Discord user ID + if 'state' not in session: + return web.HTTPBadRequest(text='Missing state, try logging in again.') + if 'user_id' not in session: + return web.HTTPBadRequest(text='You are not logged into Blanco with Discord.') + state = session['state'] + user_id = session['user_id'] - # Get OAuth ID, secret, and base URL - oauth_id = request.app['config'].spotify_client_id - oauth_secret = request.app['config'].spotify_client_secret - base_url = request.app['config'].base_url + # Get OAuth ID, secret, and base URL + oauth_id = request.app['config'].spotify_client_id + oauth_secret = request.app['config'].spotify_client_secret + base_url = request.app['config'].base_url - # Get code - try: - code = request.query['code'] - state = request.query['state'] - except KeyError as err: - return web.HTTPBadRequest(text=f'Missing parameter: {err.args[0]}') + # Get code + try: + code = request.query['code'] + state = request.query['state'] + except KeyError as err: + return web.HTTPBadRequest(text=f'Missing parameter: {err.args[0]}') - # Check state - if state != session['state']: - return web.HTTPBadRequest(text='Invalid state, try logging in again.') + # Check state + if state != session['state']: + return web.HTTPBadRequest(text='Invalid state, try logging in again.') - # Get access token - response = requests.post( - str(SPOTIFY_ACCOUNTS_BASE_URL / 'token'), - data={ - 'grant_type': 'authorization_code', - 'code': code, - 'redirect_uri': f'{base_url}/spotifyoauth' - }, - headers={ - 'Authorization': f'Basic {b64encode(f"{oauth_id}:{oauth_secret}".encode()).decode()}', - 'Content-Type': 'application/x-www-form-urlencoded', - 'User-Agent': USER_AGENT - }, - timeout=5 - ) - try: - response.raise_for_status() - except HTTPError as err: - return web.HTTPBadRequest(text=f'Error getting Spotify access token: {err}') - except Timeout: - return web.HTTPBadRequest(text='Timed out while requesting Spotify access token') + # Get access token + response = requests.post( + str(SPOTIFY_ACCOUNTS_BASE_URL / 'token'), + data={ + 'grant_type': 'authorization_code', + 'code': code, + 'redirect_uri': f'{base_url}/spotifyoauth', + }, + headers={ + 'Authorization': f'Basic {b64encode(f"{oauth_id}:{oauth_secret}".encode()).decode()}', + 'Content-Type': 'application/x-www-form-urlencoded', + 'User-Agent': USER_AGENT, + }, + timeout=5, + ) + try: + response.raise_for_status() + except HTTPError as err: + return web.HTTPBadRequest(text=f'Error getting Spotify access token: {err}') + except Timeout: + return web.HTTPBadRequest(text='Timed out while requesting Spotify access token') - # Get user info - parsed = response.json() - user_info = requests.get( - str(SPOTIFY_API_BASE_URL / 'me'), - headers={ - 'Authorization': f'Bearer {parsed["access_token"]}', - 'User-Agent': USER_AGENT - }, - timeout=5 - ) - try: - user_info.raise_for_status() - except HTTPError as err: - return web.HTTPBadRequest(text=f'Error getting Spotify user info: {err}') - except Timeout: - return web.HTTPBadRequest(text='Timed out while requesting Spotify user info') + # Get user info + parsed = response.json() + user_info = requests.get( + str(SPOTIFY_API_BASE_URL / 'me'), + headers={ + 'Authorization': f'Bearer {parsed['access_token']}', + 'User-Agent': USER_AGENT, + }, + timeout=5, + ) + try: + user_info.raise_for_status() + except HTTPError as err: + return web.HTTPBadRequest(text=f'Error getting Spotify user info: {err}') + except Timeout: + return web.HTTPBadRequest(text='Timed out while requesting Spotify user info') - # Calculate expiry timestamp - user_parsed = user_info.json() - expires_at = int(time()) + parsed['expires_in'] + # Calculate expiry timestamp + user_parsed = user_info.json() + expires_at = int(time()) + parsed['expires_in'] - # Store user info in DB - database = request.app['db'] - database.set_oauth('spotify', OAuth( - user_id=user_id, - username=user_parsed['id'], - access_token=parsed['access_token'], - refresh_token=parsed['refresh_token'], - expires_at=expires_at - )) - database.set_spotify_scopes(user_id, parsed['scope'].split(' ')) + # Store user info in DB + database = request.app['db'] + database.set_oauth( + 'spotify', + OAuth( + user_id=user_id, + username=user_parsed['id'], + access_token=parsed['access_token'], + refresh_token=parsed['refresh_token'], + expires_at=expires_at, + ), + ) + database.set_spotify_scopes(user_id, parsed['scope'].split(' ')) - # Redirect to dashboard - del session['state'] - return web.HTTPFound('/dashboard') + # Redirect to dashboard + del session['state'] + return web.HTTPFound('/dashboard') diff --git a/server/views/unlink.py b/server/views/unlink.py index 42ec90a..0190f17 100644 --- a/server/views/unlink.py +++ b/server/views/unlink.py @@ -7,31 +7,31 @@ async def unlink(request: web.Request): - """ - Delete authentication data for the user from the specified service. - """ - # Get session - session = await get_session(request) - if 'user_id' not in session: - return web.HTTPFound('/login') - user_id = session['user_id'] - - # Get user info - database = request.app['db'] - user = database.get_oauth('discord', user_id) - if user is None: - return web.HTTPFound('/login') - - # Which service to unlink? - try: - service = request.query['service'] - except KeyError as err: - return web.HTTPBadRequest(text=f'Missing parameter: {err.args[0]}') - - if service not in ('lastfm', 'spotify'): - raise web.HTTPBadRequest(text=f'Unknown service: {service}') - - database.delete_oauth(service, user_id) - - # Redirect to dashboard - return web.HTTPFound('/dashboard') + """ + Delete authentication data for the user from the specified service. + """ + # Get session + session = await get_session(request) + if 'user_id' not in session: + return web.HTTPFound('/login') + user_id = session['user_id'] + + # Get user info + database = request.app['db'] + user = database.get_oauth('discord', user_id) + if user is None: + return web.HTTPFound('/login') + + # Which service to unlink? + try: + service = request.query['service'] + except KeyError as err: + return web.HTTPBadRequest(text=f'Missing parameter: {err.args[0]}') + + if service not in ('lastfm', 'spotify'): + raise web.HTTPBadRequest(text=f'Unknown service: {service}') + + database.delete_oauth(service, user_id) + + # Redirect to dashboard + return web.HTTPFound('/dashboard') diff --git a/utils/blanco.py b/utils/blanco.py index 9023e88..06ad11d 100644 --- a/utils/blanco.py +++ b/utils/blanco.py @@ -8,9 +8,20 @@ from aiohttp.client_exceptions import ClientConnectorError from mafic import EndReason, NodePool, VoiceRegion -from nextcord import (Activity, ActivityType, Forbidden, HTTPException, - Interaction, NotFound, PartialMessageable, StageChannel, - TextChannel, Thread, VoiceChannel, MessageFlags) +from nextcord import ( + Activity, + ActivityType, + Forbidden, + HTTPException, + Interaction, + MessageFlags, + NotFound, + PartialMessageable, + StageChannel, + TextChannel, + Thread, + VoiceChannel, +) from nextcord.ext.commands import Bot, ExtensionNotLoaded from cogs.player.jockey_helpers import find_lavalink_track @@ -25,518 +36,489 @@ from .spotify_private import PrivateSpotify if TYPE_CHECKING: - from asyncio import Task - from logging import Logger + from asyncio import Task + from logging import Logger - from mafic import Node, TrackEndEvent, TrackStartEvent + from mafic import Node, TrackEndEvent, TrackStartEvent - from cogs.player.jockey import Jockey - from dataclass.config import Config + from cogs.player.jockey import Jockey + from dataclass.config import Config -StatusChannel = Union[PartialMessageable, VoiceChannel, TextChannel, StageChannel, Thread] +StatusChannel = Union[ + PartialMessageable, VoiceChannel, TextChannel, StageChannel, Thread +] # Match-ahead wrapper for finding a Lavalink track with exception handling async def match_ahead(logger: 'Logger', *args, **kwargs): + """ + Wrapper for find_lavalink_track with exception handling. + """ + try: + return await find_lavalink_track(*args, **kwargs) + except LavalinkSearchError: + logger.warning('Failed to match track ahead') + + # No need to do anything special, the user will see the causes + # when Blanco tries to play the track for real + return None + + +class BlancoBot(Bot): + """ + Custom bot class for Blanco. + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._config: Optional['Config'] = None + self._db: Optional[Database] = None + + # Status channels + self._status_channels: Dict[int, 'StatusChannel'] = {} + + # Spotify client + self._spotify_client: Optional[Spotify] = None + + # Lavalink + self._pool = NodePool(self) + self._pool_initialized = False + + # Loggers + self._logger = create_logger(self.__class__.__name__) + self._jockey_logger = create_logger('jockey') + + # Scrobblers and private Spotify clients per user + self._scrobblers: Dict[int, 'Scrobbler'] = {} + self._scrobbler_logger = create_logger('scrobbler') + self._spotify_clients: Dict[int, PrivateSpotify] = {} + + # Annotator tasks + self._tasks: Dict[int, List['Task']] = {} + + @property + def config(self) -> Optional['Config']: """ - Wrapper for find_lavalink_track with exception handling. + Gets the bot's config. """ - try: - return await find_lavalink_track(*args, **kwargs) - except LavalinkSearchError: - logger.warning('Failed to match track ahead') + return self._config + + @property + def debug(self) -> bool: + """ + Gets whether debug mode is enabled. + """ + if self._config is None or self._config.debug_guild_ids is None: + return False + return self._config.debug_enabled and len(self._config.debug_guild_ids) > 0 - # No need to do anything special, the user will see the causes - # when Blanco tries to play the track for real - return None + @property + def database(self) -> Database: + """ + Gets the bot's database. + """ + if self._db is None: + raise RuntimeError('Database has not been initialized') + return self._db + @property + def jockey_logger(self) -> 'Logger': + """ + Gets the bot's reusable logger for all Jockey instances. + """ + return self._jockey_logger -class BlancoBot(Bot): + @property + def pool(self) -> NodePool: """ - Custom bot class for Blanco. - """ - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self._config: Optional['Config'] = None - self._db: Optional[Database] = None - - # Status channels - self._status_channels: Dict[int, 'StatusChannel'] = {} - - # Spotify client - self._spotify_client: Optional[Spotify] = None - - # Lavalink - self._pool = NodePool(self) - self._pool_initialized = False - - # Loggers - self._logger = create_logger(self.__class__.__name__) - self._jockey_logger = create_logger('jockey') - - # Scrobblers and private Spotify clients per user - self._scrobblers: Dict[int, 'Scrobbler'] = {} - self._scrobbler_logger = create_logger('scrobbler') - self._spotify_clients: Dict[int, PrivateSpotify] = {} - - # Annotator tasks - self._tasks: Dict[int, List['Task']] = {} - - @property - def config(self) -> Optional['Config']: - """ - Gets the bot's config. - """ - return self._config - - @property - def debug(self) -> bool: - """ - Gets whether debug mode is enabled. - """ - if self._config is None or self._config.debug_guild_ids is None: - return False - return self._config.debug_enabled and len(self._config.debug_guild_ids) > 0 - - @property - def database(self) -> Database: - """ - Gets the bot's database. - """ - if self._db is None: - raise RuntimeError('Database has not been initialized') - return self._db - - @property - def jockey_logger(self) -> 'Logger': - """ - Gets the bot's reusable logger for all Jockey instances. - """ - return self._jockey_logger - - @property - def pool(self) -> NodePool: - """ - Gets the bot's Lavalink node pool. - """ - return self._pool - - @property - def pool_initialized(self) -> bool: - """ - Gets whether the Lavalink node pool has been initialized. - """ - return self._pool_initialized - - @property - def spotify(self) -> Spotify: - """ - Gets the bot's Spotify client. See utils/spotify_client.py. - """ - if self._spotify_client is None: - raise RuntimeError('Spotify client has not been initialized') - return self._spotify_client - - ################### - # Event listeners # - ################### - - async def on_ready(self): - """ - Called when the bot is ready. - """ - if self._config is None: - raise RuntimeError('Received on_ready event before config was initialized') - - self._logger.info('Logged in as %s', self.user) - - # Try to unload cogs first if the bot was restarted - try: - self.unload_extension('cogs') - except ExtensionNotLoaded: - pass - self.load_extension('cogs') - - # Load server extension if server is enabled - if self._config.enable_server: - # Try to unload server first if the bot was restarted - try: - self.unload_extension('server') - except ExtensionNotLoaded: - pass - self._logger.info('Starting web server...') - self.load_extension('server') - else: - if self._config.base_url is not None: - self._logger.warning( - 'Server is disabled, but base URL is set to %s', - self._config.base_url - ) - - if self.debug: - self._logger.warning('Debug mode enabled') - await self.change_presence( - activity=Activity(name='/play (debug)', type=ActivityType.listening) - ) - - # Sync commands with debug guilds - if self._config is not None and self._config.debug_guild_ids is not None: - for guild in self._config.debug_guild_ids: - self._logger.info('Syncing commands for debug guild %d', guild) - await self.sync_application_commands(guild_id=guild) - self._logger.info( - 'Synced commands for %d guild(s)!', - len(self._config.debug_guild_ids) - ) - else: - await self.change_presence( - activity=Activity(name='/play', type=ActivityType.listening) - ) - - # Sync commands - self._logger.info('Syncing global commands...') - await self.sync_application_commands() - self._logger.info('Synced commands!') - - async def on_application_command_error(self, itx: Interaction, error: Exception): - """ - Called when an error occurs while processing an interaction. - """ - embed = create_error_embed(str(error)) - - # Check if we can reply to this interaction - try: - if itx.response.is_done(): - if isinstance(itx.channel, PartialMessageable): - await itx.channel.send(embed=embed) - else: - await itx.response.send_message(embed=embed) - except NotFound: - self._logger.warning('Error 404 while sending error msg for interaction %d', itx.id) - - async def on_jockey_disconnect(self, jockey: 'Jockey'): - """ - Called when a player disconnects from voice. - """ - self._logger.debug('Jockey disconnected from voice in %s', jockey.guild.name) - - # Clear tasks for this guild - if jockey.guild.id in self._tasks: - for task in self._tasks[jockey.guild.id]: - task.cancel() - del self._tasks[jockey.guild.id] - - async def on_node_ready(self, node: 'Node'): - """ - Called when a Lavalink node is connected and ready. - """ - self._logger.info('Connected to Lavalink node `%s\'', node.label) - - # Store session ID in database - if node.session_id is not None: - try: - old_id = self.database.get_session_id(node.label) - except (OperationalError, TypeError): - old_id = None - - if old_id is not None and old_id != node.session_id: - self._logger.debug( - 'Replacing old session ID `%s\' for node `%s\'', - old_id, - node.label - ) - self.database.set_session_id(node.label, node.session_id) - - async def on_track_start(self, event: 'TrackStartEvent[Jockey]'): - """ - Called when a track starts playing. - """ - guild = event.player.guild + Gets the bot's Lavalink node pool. + """ + return self._pool + + @property + def pool_initialized(self) -> bool: + """ + Gets whether the Lavalink node pool has been initialized. + """ + return self._pool_initialized + + @property + def spotify(self) -> Spotify: + """ + Gets the bot's Spotify client. See utils/spotify_client.py. + """ + if self._spotify_client is None: + raise RuntimeError('Spotify client has not been initialized') + return self._spotify_client + + ################### + # Event listeners # + ################### + + async def on_ready(self): + """ + Called when the bot is ready. + """ + if self._config is None: + raise RuntimeError('Received on_ready event before config was initialized') + + self._logger.info('Logged in as %s', self.user) + + # Try to unload cogs first if the bot was restarted + try: + self.unload_extension('cogs') + except ExtensionNotLoaded: + pass + self.load_extension('cogs') + + # Load server extension if server is enabled + if self._config.enable_server: + # Try to unload server first if the bot was restarted + try: + self.unload_extension('server') + except ExtensionNotLoaded: + pass + self._logger.info('Starting web server...') + self.load_extension('server') + elif self._config.base_url is not None: + self._logger.warning( + 'Server is disabled, but base URL is set to %s', + self._config.base_url, + ) + + if self.debug: + self._logger.warning('Debug mode enabled') + await self.change_presence( + activity=Activity(name='/play (debug)', type=ActivityType.listening) + ) + + # Sync commands with debug guilds + if self._config is not None and self._config.debug_guild_ids is not None: + for guild in self._config.debug_guild_ids: + self._logger.info('Syncing commands for debug guild %d', guild) + await self.sync_application_commands(guild_id=guild) self._logger.info( - 'Started playing `%s\' in %s', - event.track.title, - guild.name + 'Synced commands for %d guild(s)!', + len(self._config.debug_guild_ids), ) + else: + await self.change_presence( + activity=Activity(name='/play', type=ActivityType.listening) + ) + + # Sync commands + self._logger.info('Syncing global commands...') + await self.sync_application_commands() + self._logger.info('Synced commands!') + + async def on_application_command_error(self, itx: Interaction, error: Exception): + """ + Called when an error occurs while processing an interaction. + """ + embed = create_error_embed(str(error)) - # Send now playing embed - try: - await self.send_now_playing(event) - except EndOfQueueError: - self._logger.warning( - 'Got track_start event for idle player in %s', - guild.name - ) - return - - # Get queue manager and node - q_mgr = event.player.queue_manager - node = event.player.node - - # Check if Deezer is enabled for this node - assert self._config is not None - deezer_enabled = self._config.lavalink_nodes[node.label].deezer - - # Prefetch the next track in the background - if self._config.match_ahead: - try: - _, next_track = q_mgr.next_track - except EndOfQueueError: - return - if next_track.lavalink_track is not None: - return - - self._logger.debug( - 'Matching next track `%s\' in the background', - next_track.title - ) - task = get_event_loop().create_task( - match_ahead( - self._logger, - node, - next_track, - deezer_enabled=deezer_enabled, - in_place=True, - lookup_mbid=self._config.lastfm_enabled - ) - ) - - # Store task so it can be cancelled if the player disconnects - if guild.id not in self._tasks: - self._tasks[guild.id] = [] - task.add_done_callback(lambda _: self._tasks[guild.id].remove(task)) - self._tasks[guild.id].append(task) - - async def on_track_end(self, event: 'TrackEndEvent[Jockey]'): - """ - Called when a track ends. - """ - if event.reason == EndReason.REPLACED: - self._logger.warning( - 'Skipped `%s\' in %s', - event.track.title, - event.player.guild.name - ) - elif event.reason == EndReason.FINISHED: - # Play next track in queue - self._logger.info( - 'Finished playing `%s\' in %s', - event.track.title, - event.player.guild.name - ) - await event.player.skip() - elif event.reason == EndReason.STOPPED: - self._logger.info( - 'Stopped player in %s', - event.player.guild.name - ) - elif event.reason == EndReason.LOAD_FAILED: - self._logger.critical( - 'Failed to load `%s\' in %s', - event.track.title, - event.player.guild.name - ) - - # Call load failed hook - await event.player.on_load_failed(event.track) - else: - self._logger.error( - 'Unhandled %s in %s for `%s\'', - event.reason, - event.player.guild.name, - event.track.title - ) - - ##################### - # Utility functions # - ##################### - - def get_scrobbler(self, user_id: int) -> Optional['Scrobbler']: - """ - Gets a Last.fm scrobbler instance for the specified user. - """ - assert self._config is not None and self._db is not None - - # Check if user is authenticated with Last.fm - creds = self._db.get_lastfm_credentials(user_id) - if creds is None: - if user_id in self._scrobblers: - # User must have unlinked their account, so delete the cached scrobbler - del self._scrobblers[user_id] - - return None - - # Check if a scrobbler already exists - if user_id not in self._scrobblers: - # Create scrobbler - self._scrobblers[user_id] = Scrobbler(self._config, creds, self._scrobbler_logger) - - return self._scrobblers[user_id] - - def get_spotify_client(self, user_id: int) -> Optional[PrivateSpotify]: - """ - Gets a Spotify client instance for the specified user. - """ - assert self._config is not None and self._db is not None - - # Try to get credentials - creds = self._db.get_oauth('spotify', user_id) - if creds is None: - # Check if there is a cached client for this user - if user_id in self._spotify_clients: - # User must have unlinked their account, so delete the cached client - del self._spotify_clients[user_id] - - raise ValueError(f'Please link your Spotify account [here.]({self._config.base_url})') - - # Check if a client already exists - if user_id not in self._spotify_clients: - self._spotify_clients[user_id] = PrivateSpotify( - config=self._config, - database=self._db, - credentials=creds - ) - self._logger.debug('Created Spotify client for user %d', user_id) - - return self._spotify_clients[user_id] - - def set_status_channel(self, guild_id: int, channel: 'StatusChannel'): - """ - Sets the status channel for the specified guild, which is used to send - now playing messages and announcements. - """ - # If channel is None, remove the status channel - if channel is None: - del self._status_channels[guild_id] + # Check if we can reply to this interaction + try: + if itx.response.is_done(): + if isinstance(itx.channel, PartialMessageable): + await itx.channel.send(embed=embed) + else: + await itx.response.send_message(embed=embed) + except NotFound: + self._logger.warning( + 'Error 404 while sending error msg for interaction %d', itx.id + ) + + async def on_jockey_disconnect(self, jockey: 'Jockey'): + """ + Called when a player disconnects from voice. + """ + self._logger.debug('Jockey disconnected from voice in %s', jockey.guild.name) + # Clear tasks for this guild + if jockey.guild.id in self._tasks: + for task in self._tasks[jockey.guild.id]: + task.cancel() + del self._tasks[jockey.guild.id] + + async def on_node_ready(self, node: 'Node'): + """ + Called when a Lavalink node is connected and ready. + """ + self._logger.info("Connected to Lavalink node `%s'", node.label) + + # Store session ID in database + if node.session_id is not None: + try: + old_id = self.database.get_session_id(node.label) + except (OperationalError, TypeError): + old_id = None + + if old_id is not None and old_id != node.session_id: + self._logger.debug( + "Replacing old session ID `%s' for node `%s'", old_id, node.label + ) + self.database.set_session_id(node.label, node.session_id) + + async def on_track_start(self, event: 'TrackStartEvent[Jockey]'): + """ + Called when a track starts playing. + """ + guild = event.player.guild + self._logger.info("Started playing `%s' in %s", event.track.title, guild.name) + + # Send now playing embed + try: + await self.send_now_playing(event) + except EndOfQueueError: + self._logger.warning('Got track_start event for idle player in %s', guild.name) + return + + # Get queue manager and node + q_mgr = event.player.queue_manager + node = event.player.node + + # Check if Deezer is enabled for this node + assert self._config is not None + deezer_enabled = self._config.lavalink_nodes[node.label].deezer + + # Prefetch the next track in the background + if self._config.match_ahead: + try: + _, next_track = q_mgr.next_track + except EndOfQueueError: + return + if next_track.lavalink_track is not None: + return + + self._logger.debug("Matching next track `%s' in the background", next_track.title) + task = get_event_loop().create_task( + match_ahead( + self._logger, + node, + next_track, + deezer_enabled=deezer_enabled, + in_place=True, + lookup_mbid=self._config.lastfm_enabled, + ) + ) + + # Store task so it can be cancelled if the player disconnects + if guild.id not in self._tasks: + self._tasks[guild.id] = [] + task.add_done_callback(lambda _: self._tasks[guild.id].remove(task)) + self._tasks[guild.id].append(task) + + async def on_track_end(self, event: 'TrackEndEvent[Jockey]'): + """ + Called when a track ends. + """ + if event.reason == EndReason.REPLACED: + self._logger.warning( + "Skipped `%s' in %s", event.track.title, event.player.guild.name + ) + elif event.reason == EndReason.FINISHED: + # Play next track in queue + self._logger.info( + "Finished playing `%s' in %s", + event.track.title, + event.player.guild.name, + ) + await event.player.skip() + elif event.reason == EndReason.STOPPED: + self._logger.info('Stopped player in %s', event.player.guild.name) + elif event.reason == EndReason.LOAD_FAILED: + self._logger.critical( + "Failed to load `%s' in %s", event.track.title, event.player.guild.name + ) + + # Call load failed hook + await event.player.on_load_failed(event.track) + else: + self._logger.error( + "Unhandled %s in %s for `%s'", + event.reason, + event.player.guild.name, + event.track.title, + ) + + ##################### + # Utility functions # + ##################### + + def get_scrobbler(self, user_id: int) -> Optional['Scrobbler']: + """ + Gets a Last.fm scrobbler instance for the specified user. + """ + assert self._config is not None and self._db is not None + + # Check if user is authenticated with Last.fm + creds = self._db.get_lastfm_credentials(user_id) + if creds is None: + if user_id in self._scrobblers: + # User must have unlinked their account, so delete the cached scrobbler + del self._scrobblers[user_id] + + return None + + # Check if a scrobbler already exists + if user_id not in self._scrobblers: + # Create scrobbler + self._scrobblers[user_id] = Scrobbler(self._config, creds, self._scrobbler_logger) + + return self._scrobblers[user_id] + + def get_spotify_client(self, user_id: int) -> Optional[PrivateSpotify]: + """ + Gets a Spotify client instance for the specified user. + """ + assert self._config is not None and self._db is not None + + # Try to get credentials + creds = self._db.get_oauth('spotify', user_id) + if creds is None: + # Check if there is a cached client for this user + if user_id in self._spotify_clients: + # User must have unlinked their account, so delete the cached client + del self._spotify_clients[user_id] + + raise ValueError( + f'Please link your Spotify account [here.]({self._config.base_url})' + ) + + # Check if a client already exists + if user_id not in self._spotify_clients: + self._spotify_clients[user_id] = PrivateSpotify( + config=self._config, database=self._db, credentials=creds + ) + self._logger.debug('Created Spotify client for user %d', user_id) + + return self._spotify_clients[user_id] + + def set_status_channel(self, guild_id: int, channel: 'StatusChannel'): + """ + Sets the status channel for the specified guild, which is used to send + now playing messages and announcements. + """ + # If channel is None, remove the status channel + if channel is None: + del self._status_channels[guild_id] + + self._status_channels[guild_id] = channel + self.database.set_status_channel(guild_id, -1 if channel is None else channel.id) + + def get_status_channel(self, guild_id: int) -> Optional['StatusChannel']: + """ + Gets the status channel for the specified guild. + """ + # Check if status channel is cached + if guild_id in self._status_channels: + return self._status_channels[guild_id] + + # Get status channel ID from database + channel_id = -1 + try: + channel_id = self.database.get_status_channel(guild_id) + except OperationalError: + self._logger.warning( + 'Failed to get status channel ID for guild %d from database', guild_id + ) + + # Get status channel from ID + if channel_id != -1: + channel = self.get_channel(channel_id) + if channel is None: + self._logger.error('Failed to get status channel for guild %d', guild_id) + elif not isinstance(channel, StatusChannel): + self._logger.error('Status channel for guild %d is not Messageable', guild_id) + else: self._status_channels[guild_id] = channel - self.database.set_status_channel(guild_id, -1 if channel is None else channel.id) - - def get_status_channel(self, guild_id: int) -> Optional['StatusChannel']: - """ - Gets the status channel for the specified guild. - """ - # Check if status channel is cached - if guild_id in self._status_channels: - return self._status_channels[guild_id] - - # Get status channel ID from database - channel_id = -1 - try: - channel_id = self.database.get_status_channel(guild_id) - except OperationalError: - self._logger.warning( - 'Failed to get status channel ID for guild %d from database', - guild_id - ) - - # Get status channel from ID - if channel_id != -1: - channel = self.get_channel(channel_id) - if channel is None: - self._logger.error( - 'Failed to get status channel for guild %d', - guild_id - ) - elif not isinstance(channel, StatusChannel): - self._logger.error( - 'Status channel for guild %d is not Messageable', - guild_id - ) - else: - self._status_channels[guild_id] = channel - return channel - - return None - - def init_config(self, config: 'Config'): - """ - Initialize the bot with a config. - """ - self._config = config - self._db = Database(config.db_file) - self._spotify_client = Spotify( - client_id=config.spotify_client_id, - client_secret=config.spotify_client_secret + return channel + + return None + + def init_config(self, config: 'Config'): + """ + Initialize the bot with a config. + """ + self._config = config + self._db = Database(config.db_file) + self._spotify_client = Spotify( + client_id=config.spotify_client_id, + client_secret=config.spotify_client_secret, + ) + + async def init_pool(self): + """ + Initialize the Lavalink node pool. + """ + if self._config is None: + raise RuntimeError('Cannot initialize Lavalink without a config') + nodes = self._config.lavalink_nodes + + # Add local node + for node in nodes.values(): + # Try to match regions against enum + regions = [] + for region in node.regions: + regions.append(VoiceRegion(region)) + + # Get session ID from database + try: + session_id = self.database.get_session_id(node.id) + except (OperationalError, TypeError): + session_id = None + self._logger.debug("No session ID for node `%s'", node.id) + else: + self._logger.debug("Using session ID `%s' for node `%s'", session_id, node.id) + + try: + await self._pool.create_node( + host=node.host, + port=node.port, + password=node.password, + regions=regions, + resuming_session_id=session_id, + label=node.id, + secure=node.secure, ) + except ClientConnectorError: + self._logger.error("Lavalink node `%s' refused connection", node.id) + + # Check if we have any nodes + if len(self._pool.nodes) == 0: + self._logger.critical('No Lavalink nodes available') + + self._pool_initialized = True - async def init_pool(self): - """ - Initialize the Lavalink node pool. - """ - if self._config is None: - raise RuntimeError('Cannot initialize Lavalink without a config') - nodes = self._config.lavalink_nodes - - # Add local node - for node in nodes.values(): - # Try to match regions against enum - regions = [] - for region in node.regions: - regions.append(VoiceRegion(region)) - - # Get session ID from database - try: - session_id = self.database.get_session_id(node.id) - except (OperationalError, TypeError): - session_id = None - self._logger.debug('No session ID for node `%s\'', node.id) - else: - self._logger.debug( - 'Using session ID `%s\' for node `%s\'', - session_id, - node.id - ) - - try: - await self._pool.create_node( - host=node.host, - port=node.port, - password=node.password, - regions=regions, - resuming_session_id=session_id, - label=node.id, - secure=node.secure - ) - except ClientConnectorError: - self._logger.error( - 'Lavalink node `%s\' refused connection', - node.id - ) - - # Check if we have any nodes - if len(self._pool.nodes) == 0: - self._logger.critical('No Lavalink nodes available') - - self._pool_initialized = True - - async def send_now_playing(self, event: 'TrackStartEvent[Jockey]'): - """ - Send a now playing message for the specified track start event. - """ - guild_id = event.player.guild.id - channel = self.get_status_channel(guild_id) - if channel is None: - raise ValueError(f'Status channel has not been set for guild {guild_id}') - - # Delete last now playing message, if it exists - last_msg_id = self.database.get_now_playing(guild_id) - if last_msg_id != -1: - try: - last_msg = await channel.fetch_message(last_msg_id) - await last_msg.delete() - except (Forbidden, HTTPException, NotFound): - pass - - # Send now playing embed - current_track = event.player.queue_manager.current - embed = event.player.now_playing(event.track) - view = NowPlayingView(self, event.player, current_track.spotify_id) - - # Send message silently - flags = MessageFlags() - flags.suppress_notifications = True # pylint: disable=assigning-non-slot - msg = await channel.send(embed=embed, view=view, flags=flags) - - # Save now playing message ID - self.database.set_now_playing(guild_id, msg.id) + async def send_now_playing(self, event: 'TrackStartEvent[Jockey]'): + """ + Send a now playing message for the specified track start event. + """ + guild_id = event.player.guild.id + channel = self.get_status_channel(guild_id) + if channel is None: + raise ValueError(f'Status channel has not been set for guild {guild_id}') + + # Delete last now playing message, if it exists + last_msg_id = self.database.get_now_playing(guild_id) + if last_msg_id != -1: + try: + last_msg = await channel.fetch_message(last_msg_id) + await last_msg.delete() + except (Forbidden, HTTPException, NotFound): + pass + + # Send now playing embed + current_track = event.player.queue_manager.current + embed = event.player.now_playing(event.track) + view = NowPlayingView(self, event.player, current_track.spotify_id) + + # Send message silently + flags = MessageFlags() + flags.suppress_notifications = True # pylint: disable=assigning-non-slot + msg = await channel.send(embed=embed, view=view, flags=flags) + + # Save now playing message ID + self.database.set_now_playing(guild_id, msg.id) diff --git a/utils/config.py b/utils/config.py index 87cdc4b..c74dda3 100644 --- a/utils/config.py +++ b/utils/config.py @@ -14,7 +14,6 @@ from dataclass.config import Config, LavalinkNode - DATABASE_FILE = None DISCORD_TOKEN = None SPOTIFY_CLIENT_ID = None @@ -39,61 +38,61 @@ # Parse config file if it exists if isfile('config.yml'): - with open('config.yml', encoding='UTF-8') as f: - try: - config_file = safe_load(f) - except Exception as e: - raise ValueError(f'Error parsing config.yml: {e}') from e - - # Get config values - try: - # Read config from config.yml - DATABASE_FILE = config_file['bot']['database'] - DISCORD_TOKEN = config_file['bot']['discord_token'] - SPOTIFY_CLIENT_ID = config_file['spotify']['client_id'] - SPOTIFY_CLIENT_SECRET = config_file['spotify']['client_secret'] - - # Parse Lavalink nodes from config.yml - for node in config_file['lavalink']: - lavalink_node = LavalinkNode( - id=node['id'], - password=node['password'], - host=node['server'], - port=node['port'], - regions=node['regions'], - secure=node.get('secure', False) - ) - - # Add optional config values - if 'deezer' in node: - lavalink_node.deezer = node['deezer'] - - LAVALINK_NODES[node['id']] = lavalink_node - - # Add optional config values - MATCH_AHEAD = config_file['bot'].get('match_ahead', False) - REENQUEUE_PAUSED = config_file['bot'].get('reenqueue_paused', False) - if 'server' in config_file: - ENABLE_SERVER = config_file['server']['enabled'] - SERVER_PORT = config_file['server'].get('port', 8080) - SERVER_BASE_URL = config_file['server'].get('base_url', None) - DISCORD_OAUTH_ID = config_file['server'].get('oauth_id', None) - DISCORD_OAUTH_SECRET = config_file['server'].get('oauth_secret', None) - if 'lastfm' in config_file: - LASTFM_API_KEY = config_file['lastfm']['api_key'] - LASTFM_SHARED_SECRET = config_file['lastfm']['shared_secret'] - if 'debug' in config_file['bot']: - DEBUG_ENABLED = config_file['bot']['debug']['enabled'] - DEBUG_GUILDS = config_file['bot']['debug']['guild_ids'] - if 'sentry' in config_file: - SENTRY_DSN = config_file['sentry']['dsn'] - SENTRY_ENV = config_file['sentry']['environment'] - if 'redis' in config_file: - REDIS_HOST = config_file['redis']['host'] - REDIS_PORT = config_file['redis']['port'] - REDIS_PASSWORD = config_file['redis']['password'] - except KeyError as e: - raise RuntimeError(f'Config missing from config.yml: {e.args[0]}') from e + with open('config.yml', encoding='UTF-8') as f: + try: + config_file = safe_load(f) + except Exception as e: + raise ValueError(f'Error parsing config.yml: {e}') from e + + # Get config values + try: + # Read config from config.yml + DATABASE_FILE = config_file['bot']['database'] + DISCORD_TOKEN = config_file['bot']['discord_token'] + SPOTIFY_CLIENT_ID = config_file['spotify']['client_id'] + SPOTIFY_CLIENT_SECRET = config_file['spotify']['client_secret'] + + # Parse Lavalink nodes from config.yml + for node in config_file['lavalink']: + lavalink_node = LavalinkNode( + id=node['id'], + password=node['password'], + host=node['server'], + port=node['port'], + regions=node['regions'], + secure=node.get('secure', False), + ) + + # Add optional config values + if 'deezer' in node: + lavalink_node.deezer = node['deezer'] + + LAVALINK_NODES[node['id']] = lavalink_node + + # Add optional config values + MATCH_AHEAD = config_file['bot'].get('match_ahead', False) + REENQUEUE_PAUSED = config_file['bot'].get('reenqueue_paused', False) + if 'server' in config_file: + ENABLE_SERVER = config_file['server']['enabled'] + SERVER_PORT = config_file['server'].get('port', 8080) + SERVER_BASE_URL = config_file['server'].get('base_url', None) + DISCORD_OAUTH_ID = config_file['server'].get('oauth_id', None) + DISCORD_OAUTH_SECRET = config_file['server'].get('oauth_secret', None) + if 'lastfm' in config_file: + LASTFM_API_KEY = config_file['lastfm']['api_key'] + LASTFM_SHARED_SECRET = config_file['lastfm']['shared_secret'] + if 'debug' in config_file['bot']: + DEBUG_ENABLED = config_file['bot']['debug']['enabled'] + DEBUG_GUILDS = config_file['bot']['debug']['guild_ids'] + if 'sentry' in config_file: + SENTRY_DSN = config_file['sentry']['dsn'] + SENTRY_ENV = config_file['sentry']['environment'] + if 'redis' in config_file: + REDIS_HOST = config_file['redis']['host'] + REDIS_PORT = config_file['redis']['port'] + REDIS_PASSWORD = config_file['redis']['password'] + except KeyError as e: + raise RuntimeError(f'Config missing from config.yml: {e.args[0]}') from e # Override config from environment variables @@ -109,85 +108,88 @@ REDIS_PORT = int(environ.get('BLANCO_REDIS_PORT', REDIS_PORT)) REDIS_PASSWORD = environ.get('BLANCO_REDIS_PASSWORD', REDIS_PASSWORD) if 'BLANCO_REENQUEUE_PAUSED' in environ: - REENQUEUE_PAUSED = environ['BLANCO_REENQUEUE_PAUSED'].lower() == 'true' + REENQUEUE_PAUSED = environ['BLANCO_REENQUEUE_PAUSED'].lower() == 'true' if 'BLANCO_MATCH_AHEAD' in environ: - MATCH_AHEAD = environ['BLANCO_MATCH_AHEAD'].lower() == 'true' + MATCH_AHEAD = environ['BLANCO_MATCH_AHEAD'].lower() == 'true' if 'BLANCO_DEBUG' in environ: - DEBUG_ENABLED = environ['BLANCO_DEBUG'].lower() == 'true' - DEBUG_GUILDS = [int(id) for id in environ['BLANCO_DEBUG_GUILDS'].split(',')] + DEBUG_ENABLED = environ['BLANCO_DEBUG'].lower() == 'true' + DEBUG_GUILDS = [int(id) for id in environ['BLANCO_DEBUG_GUILDS'].split(',')] if 'BLANCO_ENABLE_SERVER' in environ: - ENABLE_SERVER = environ['BLANCO_ENABLE_SERVER'].lower() == 'true' - SERVER_PORT = int(environ.get('BLANCO_SERVER_PORT', SERVER_PORT)) - SERVER_BASE_URL = environ.get('BLANCO_BASE_URL', SERVER_BASE_URL) - DISCORD_OAUTH_ID = environ.get('BLANCO_OAUTH_ID', DISCORD_OAUTH_ID) - DISCORD_OAUTH_SECRET = environ.get('BLANCO_OAUTH_SECRET', DISCORD_OAUTH_SECRET) + ENABLE_SERVER = environ['BLANCO_ENABLE_SERVER'].lower() == 'true' + SERVER_PORT = int(environ.get('BLANCO_SERVER_PORT', SERVER_PORT)) + SERVER_BASE_URL = environ.get('BLANCO_BASE_URL', SERVER_BASE_URL) + DISCORD_OAUTH_ID = environ.get('BLANCO_OAUTH_ID', DISCORD_OAUTH_ID) + DISCORD_OAUTH_SECRET = environ.get('BLANCO_OAUTH_SECRET', DISCORD_OAUTH_SECRET) # Parse Lavalink nodes from environment variables i = 1 while True: - try: - credentials, host = environ[f'BLANCO_NODE_{i}'].split('@') - node_id, password = credentials.split(':') - server, port = host.split(':') - regions = environ[f'BLANCO_NODE_{i}_REGIONS'].split(',') - secure = environ.get(f'BLANCO_NODE_{i}_SECURE', 'false').lower() == 'true' - deezer = environ.get(f'BLANCO_NODE_{i}_DEEZER', 'false').lower() == 'true' - except KeyError as e: - missing_key = e.args[0] - if missing_key == f'BLANCO_NODE_{i}': - if len(LAVALINK_NODES) == 0: - raise ValueError('No Lavalink nodes specified') from e - break - - if missing_key == f'BLANCO_NODE_{i}_REGIONS': - raise ValueError(f'No regions specified for Lavalink node {i}') from e - - break - else: - # Add node to list - LAVALINK_NODES[node_id] = LavalinkNode( - id=node_id, - password=password, - host=server, - port=int(port), - regions=regions, - secure=secure, - deezer=deezer - ) - - i += 1 + try: + credentials, host = environ[f'BLANCO_NODE_{i}'].split('@') + node_id, password = credentials.split(':') + server, port = host.split(':') + regions = environ[f'BLANCO_NODE_{i}_REGIONS'].split(',') + secure = environ.get(f'BLANCO_NODE_{i}_SECURE', 'false').lower() == 'true' + deezer = environ.get(f'BLANCO_NODE_{i}_DEEZER', 'false').lower() == 'true' + except KeyError as e: + missing_key = e.args[0] + if missing_key == f'BLANCO_NODE_{i}': + if len(LAVALINK_NODES) == 0: + raise ValueError('No Lavalink nodes specified') from e + break + + if missing_key == f'BLANCO_NODE_{i}_REGIONS': + raise ValueError(f'No regions specified for Lavalink node {i}') from e + + break + else: + # Add node to list + LAVALINK_NODES[node_id] = LavalinkNode( + id=node_id, + password=password, + host=server, + port=int(port), + regions=regions, + secure=secure, + deezer=deezer, + ) + + i += 1 # Final checks if DATABASE_FILE is None: - raise ValueError('No database file specified') + raise ValueError('No database file specified') if DISCORD_TOKEN is None: - raise ValueError('No Discord token specified') + raise ValueError('No Discord token specified') if SPOTIFY_CLIENT_ID is None: - raise ValueError('No Spotify client ID specified') + raise ValueError('No Spotify client ID specified') if SPOTIFY_CLIENT_SECRET is None: - raise ValueError('No Spotify client secret specified') -if ENABLE_SERVER and (DISCORD_OAUTH_ID is None or - DISCORD_OAUTH_SECRET is None or SERVER_BASE_URL is None): - raise ValueError('Discord OAuth ID, secret, and base URL must be specified to enable server') + raise ValueError('No Spotify client secret specified') +if ENABLE_SERVER and ( + DISCORD_OAUTH_ID is None or DISCORD_OAUTH_SECRET is None or SERVER_BASE_URL is None +): + raise ValueError( + 'Discord OAuth ID, secret, and base URL must be specified to enable server' + ) # Create config object config = Config( - db_file=DATABASE_FILE, - discord_token=DISCORD_TOKEN, - spotify_client_id=SPOTIFY_CLIENT_ID, - spotify_client_secret=SPOTIFY_CLIENT_SECRET, - lavalink_nodes=LAVALINK_NODES, - debug_enabled=DEBUG_ENABLED, - debug_guild_ids=DEBUG_GUILDS, - enable_server=ENABLE_SERVER, - match_ahead=MATCH_AHEAD, - server_port=SERVER_PORT, - base_url=SERVER_BASE_URL, - discord_oauth_id=DISCORD_OAUTH_ID, - discord_oauth_secret=DISCORD_OAUTH_SECRET, - lastfm_api_key=LASTFM_API_KEY, - lastfm_shared_secret=LASTFM_SHARED_SECRET, - reenqueue_paused=REENQUEUE_PAUSED + db_file=DATABASE_FILE, + discord_token=DISCORD_TOKEN, + spotify_client_id=SPOTIFY_CLIENT_ID, + spotify_client_secret=SPOTIFY_CLIENT_SECRET, + lavalink_nodes=LAVALINK_NODES, + debug_enabled=DEBUG_ENABLED, + debug_guild_ids=DEBUG_GUILDS, + enable_server=ENABLE_SERVER, + match_ahead=MATCH_AHEAD, + server_port=SERVER_PORT, + base_url=SERVER_BASE_URL, + discord_oauth_id=DISCORD_OAUTH_ID, + discord_oauth_secret=DISCORD_OAUTH_SECRET, + lastfm_api_key=LASTFM_API_KEY, + lastfm_shared_secret=LASTFM_SHARED_SECRET, + reenqueue_paused=REENQUEUE_PAUSED, ) diff --git a/utils/constants.py b/utils/constants.py index 7bb386e..aef3ee0 100644 --- a/utils/constants.py +++ b/utils/constants.py @@ -4,60 +4,45 @@ from yarl import URL -RELEASE = '0.0.0-unknown' # This is replaced by the release tag during CI/CD +RELEASE = '0.0.0-unknown' # This is replaced by the release tag during CI/CD USER_AGENT = f'blanco-bot/{RELEASE} ( https://blanco.dantis.me )' -DISCORD_API_BASE_URL = URL.build( - scheme='https', - host='discord.com', - path='/api/v10' -) +DISCORD_API_BASE_URL = URL.build(scheme='https', host='discord.com', path='/api/v10') LASTFM_API_BASE_URL = URL.build( - scheme='https', - host='ws.audioscrobbler.com', - path='/2.0' + scheme='https', host='ws.audioscrobbler.com', path='/2.0' ) MUSICBRAINZ_API_BASE_URL = URL.build( - scheme='https', - host='musicbrainz.org', - path='/ws/2' + scheme='https', host='musicbrainz.org', path='/ws/2' ) SPOTIFY_ACCOUNTS_BASE_URL = URL.build( - scheme='https', - host='accounts.spotify.com', - path='/api' + scheme='https', host='accounts.spotify.com', path='/api' ) -SPOTIFY_API_BASE_URL = URL.build( - scheme='https', - host='api.spotify.com', - path='/v1' -) +SPOTIFY_API_BASE_URL = URL.build(scheme='https', host='api.spotify.com', path='/v1') # Results with these words in the title will be filtered out # unless the user's query also contains these words. BLACKLIST = ( - '3d' - '8d', - 'cover', - 'instrumental', - 'karaoke', - 'live', - 'loop', - 'mashup', - 'minus one', - 'performance', - 'piano', - 'remix', - 'rendition', - 'reverb', - 'slowed', - 'sped', - 'speed' + '3d' '8d', + 'cover', + 'instrumental', + 'karaoke', + 'live', + 'loop', + 'mashup', + 'minus one', + 'performance', + 'piano', + 'remix', + 'rendition', + 'reverb', + 'slowed', + 'sped', + 'speed', ) # A top search result below this threshold will not be considered for playback @@ -68,17 +53,19 @@ # A MusicBrainz recording whose duration exceeds this threshold will not be # considered for scrobbling and Blanco will either only scrobble if the track # has an ISRC or not at all. -DURATION_THRESHOLD = 10 * 1000 # 10 seconds +DURATION_THRESHOLD = 10 * 1000 # 10 seconds # Unpausing the player after this many seconds will cause Blanco # to re-enqueue the track and restart playback at the last known position # to work around a bug in the Lavalink unpausing logic. -UNPAUSE_THRESHOLD = 60 # 1 minute +UNPAUSE_THRESHOLD = 60 # 1 minute -SPOTIFY_403_ERR_MSG = ''.join([ +SPOTIFY_403_ERR_MSG = ''.join( + [ '**Error 403** encountered while trying to {}.\n', 'This is likely because this instance of Blanco uses Spotify API credentials ', 'that are in **development mode.** ', - 'See [this page](https://github.com/jareddantis-bots/blanco-bot/wiki/Prerequisites#a-note-on-development-mode) ', # pylint: disable=line-too-long - 'for more information.' -]) + 'See [this page](https://github.com/jareddantis-bots/blanco-bot/wiki/Prerequisites#a-note-on-development-mode) ', # pylint: disable=line-too-long + 'for more information.', + ] +) diff --git a/utils/embeds.py b/utils/embeds.py index 90b128c..6744f57 100644 --- a/utils/embeds.py +++ b/utils/embeds.py @@ -10,31 +10,27 @@ def create_error_embed(message: str) -> Embed: - """ - Create an error embed. - """ - embed = CustomEmbed( - color=Colour.red(), - title=':x:|Error', - description=message - ) - return embed.get() - - -def create_success_embed(title: Optional[str] = None, body: Optional[str] = None) -> Embed: - """ - Create a success embed. - """ - if body is None: - if title is None: - raise ValueError('Either title or body must be specified') - - body = title - title = 'Success' - - embed = CustomEmbed( - color=Colour.green(), - title=f':white_check_mark:|{title}', - description=body - ) - return embed.get() + """ + Create an error embed. + """ + embed = CustomEmbed(color=Colour.red(), title=':x:|Error', description=message) + return embed.get() + + +def create_success_embed( + title: Optional[str] = None, body: Optional[str] = None +) -> Embed: + """ + Create a success embed. + """ + if body is None: + if title is None: + raise ValueError('Either title or body must be specified') + + body = title + title = 'Success' + + embed = CustomEmbed( + color=Colour.green(), title=f':white_check_mark:|{title}', description=body + ) + return embed.get() diff --git a/utils/exceptions.py b/utils/exceptions.py index 819745e..1652c78 100644 --- a/utils/exceptions.py +++ b/utils/exceptions.py @@ -2,80 +2,87 @@ Custom exceptions for Blanco """ + class EmptyQueueError(Exception): - """ - Raised when the queue is empty. - """ - def __init__(self): - self.message = 'The queue is empty.' - super().__init__(self.message) + """ + Raised when the queue is empty. + """ + + def __init__(self): + self.message = 'The queue is empty.' + super().__init__(self.message) + class EndOfQueueError(Exception): - """ - Raised when the end of the queue is reached. - """ + """ + Raised when the end of the queue is reached. + """ class JockeyError(Exception): - """ - Raised when an error warrants disconnection from the voice channel. - """ + """ + Raised when an error warrants disconnection from the voice channel. + """ class JockeyException(Exception): - """ - Raised when an error does not warrant disconnection from the voice channel. - """ + """ + Raised when an error does not warrant disconnection from the voice channel. + """ + class LavalinkInvalidIdentifierError(Exception): - """ - Raised when an invalid identifier is passed to Lavalink. - """ - def __init__(self, url, reason=None): - self.message = f'Error encountered while processing "{url}": `{reason}`' - super().__init__(self.message) + """ + Raised when an invalid identifier is passed to Lavalink. + """ + + def __init__(self, url, reason=None): + self.message = f'Error encountered while processing "{url}": `{reason}`' + super().__init__(self.message) class LavalinkSearchError(Exception): - """ - Raised when Lavalink fails to search for a query. - """ - def __init__(self, query, reason=None): - self.message = f'Could not search for "{query}" on YouTube. Reason: {reason}' - super().__init__(self.message) + """ + Raised when Lavalink fails to search for a query. + """ + + def __init__(self, query, reason=None): + self.message = f'Could not search for "{query}" on YouTube. Reason: {reason}' + super().__init__(self.message) class SpotifyInvalidURLError(Exception): - """ - Raised when an invalid Spotify link or URI is passed. - """ - def __init__(self, url): - self.message = f'Invalid Spotify link or URI: {url}' - super().__init__(self.message) + """ + Raised when an invalid Spotify link or URI is passed. + """ + + def __init__(self, url): + self.message = f'Invalid Spotify link or URI: {url}' + super().__init__(self.message) class SpotifyNoResultsError(Exception): - """ - Raised when no results are found for a Spotify query. - """ + """ + Raised when no results are found for a Spotify query. + """ class VoiceCommandError(Exception): - """ - Raised when a command that requires a voice channel is invoked outside of one. - """ + """ + Raised when a command that requires a voice channel is invoked outside of one. + """ class BumpError(Exception): - """ - Raised when encountering an error while playing a bump. - """ + """ + Raised when encountering an error while playing a bump. + """ class BumpNotReadyError(Exception): - """ - Raised when it hasn't been long enough between bumps. - """ + """ + Raised when it hasn't been long enough between bumps. + """ class BumpNotEnabledError(Exception): - """ - Raised when bumps are not enabled in a guild. - """ \ No newline at end of file + """ + Raised when bumps are not enabled in a guild. + """ \ No newline at end of file diff --git a/utils/fuzzy.py b/utils/fuzzy.py index 59a7edf..c54b48f 100644 --- a/utils/fuzzy.py +++ b/utils/fuzzy.py @@ -8,50 +8,50 @@ def check_similarity(actual: str, candidate: str) -> float: - """ - Checks the similarity between two strings. Meant for comparing - song titles and artists with search results. + """ + Checks the similarity between two strings. Meant for comparing + song titles and artists with search results. - :param actual: The actual string. - :param candidate: The candidate string, i.e. from a search result. - :return: A float from 0 to 1, where 1 is a perfect match. - """ - actual_words = set(actual.lower().split(' ')) - candidate_words = set(candidate.lower().split(' ')) - intersection = actual_words.intersection(candidate_words) - difference = actual_words.difference(candidate_words) + :param actual: The actual string. + :param candidate: The candidate string, i.e. from a search result. + :return: A float from 0 to 1, where 1 is a perfect match. + """ + actual_words = set(actual.lower().split(' ')) + candidate_words = set(candidate.lower().split(' ')) + intersection = actual_words.intersection(candidate_words) + difference = actual_words.difference(candidate_words) - # Get words not in intersection - for word in difference: - # Look for close matches - close_matches = get_close_matches(word, candidate_words, cutoff=0.9) - if len(close_matches) > 0: - intersection.add(close_matches[0]) + # Get words not in intersection + for word in difference: + # Look for close matches + close_matches = get_close_matches(word, candidate_words, cutoff=0.9) + if len(close_matches) > 0: + intersection.add(close_matches[0]) - return len(intersection) / len(actual_words) + return len(intersection) / len(actual_words) def check_similarity_weighted(actual: str, candidate: str, candidate_rank: int) -> int: - """ - Checks the similarity between two strings using a weighted average - of a given similarity score and the results of multiple fuzzy string - matching algorithms. Meant for refining search results that are - already ranked. - - :param actual: The actual string. - :param candidate: The candidate string, i.e. from a search result. - :param candidate_rank: The rank of the candidate, from 0 to 100. - :return: An integer from 0 to 100, where 100 is the closest match. - """ - naive = check_similarity(actual, candidate) * 100 - tsr = fuzz.token_set_ratio(actual, candidate) - tsor = fuzz.token_sort_ratio(actual, candidate) - ptsr = fuzz.partial_token_sort_ratio(actual, candidate) - - return int( - (naive * 0.7) + - (tsr * 0.12) + - (candidate_rank * 0.08) + - (tsor * 0.06) + - (ptsr * 0.04) - ) + """ + Checks the similarity between two strings using a weighted average + of a given similarity score and the results of multiple fuzzy string + matching algorithms. Meant for refining search results that are + already ranked. + + :param actual: The actual string. + :param candidate: The candidate string, i.e. from a search result. + :param candidate_rank: The rank of the candidate, from 0 to 100. + :return: An integer from 0 to 100, where 100 is the closest match. + """ + naive = check_similarity(actual, candidate) * 100 + tsr = fuzz.token_set_ratio(actual, candidate) + tsor = fuzz.token_sort_ratio(actual, candidate) + ptsr = fuzz.partial_token_sort_ratio(actual, candidate) + + return int( + (naive * 0.7) + + (tsr * 0.12) + + (candidate_rank * 0.08) + + (tsor * 0.06) + + (ptsr * 0.04) + ) diff --git a/utils/logger.py b/utils/logger.py index da70771..3edc6ec 100644 --- a/utils/logger.py +++ b/utils/logger.py @@ -12,7 +12,7 @@ from .constants import RELEASE # Log line format -LOG_FMT_STR = '{0}%(asctime)s.%(msecs)03d {1}[%(levelname)s]{2} %(message)s (%(filename)s:%(lineno)d)' # pylint: disable=line-too-long +LOG_FMT_STR = '{0}%(asctime)s.%(msecs)03d {1}[%(levelname)s]{2} %(message)s (%(filename)s:%(lineno)d)' # pylint: disable=line-too-long # ANSI terminal colors (for logging) ANSI_BLUE = '\x1b[36;20m' @@ -23,69 +23,68 @@ ANSI_YELLOW = '\x1b[33;20m' ANSI_RESET = '\x1b[0m' LOG_FMT_COLOR = { - logging.DEBUG: LOG_FMT_STR.format(ANSI_GREY, ANSI_GREEN, ANSI_RESET), - logging.INFO: LOG_FMT_STR.format(ANSI_GREY, ANSI_BLUE, ANSI_RESET), - logging.WARNING: LOG_FMT_STR.format(ANSI_GREY, ANSI_YELLOW, ANSI_RESET), - logging.ERROR: LOG_FMT_STR.format(ANSI_GREY, ANSI_RED, ANSI_RESET), - logging.CRITICAL: LOG_FMT_STR.format(ANSI_RED_BOLD, ANSI_RED_BOLD, ANSI_RESET), + logging.DEBUG: LOG_FMT_STR.format(ANSI_GREY, ANSI_GREEN, ANSI_RESET), + logging.INFO: LOG_FMT_STR.format(ANSI_GREY, ANSI_BLUE, ANSI_RESET), + logging.WARNING: LOG_FMT_STR.format(ANSI_GREY, ANSI_YELLOW, ANSI_RESET), + logging.ERROR: LOG_FMT_STR.format(ANSI_GREY, ANSI_RED, ANSI_RESET), + logging.CRITICAL: LOG_FMT_STR.format(ANSI_RED_BOLD, ANSI_RED_BOLD, ANSI_RESET), } # Initialize sentry if SENTRY_DSN is not None and SENTRY_ENV is not None: - sentry_sdk.init( - dsn=SENTRY_DSN, - environment=SENTRY_ENV, - release=RELEASE, - traces_sample_rate=1.0 - ) + sentry_sdk.init( + dsn=SENTRY_DSN, environment=SENTRY_ENV, release=RELEASE, traces_sample_rate=1.0 + ) + class ColorFormatter(logging.Formatter): - """ - Custom logging formatter that supports ANSI color codes. + """ + Custom logging formatter that supports ANSI color codes. + + Adapted from https://stackoverflow.com/a/384125 + """ - Adapted from https://stackoverflow.com/a/384125 - """ - def __init__(self, fmt: Optional[str] = None, datefmt: Optional[str] = None): - logging.Formatter.__init__(self, fmt, datefmt) + def __init__(self, fmt: Optional[str] = None, datefmt: Optional[str] = None): + logging.Formatter.__init__(self, fmt, datefmt) - def format(self, record: logging.LogRecord): - log_fmt = LOG_FMT_COLOR.get(record.levelno) - formatter = logging.Formatter(fmt=log_fmt, datefmt='%Y-%m-%d %H:%M:%S') - return formatter.format(record) + def format(self, record: logging.LogRecord): + log_fmt = LOG_FMT_COLOR.get(record.levelno) + formatter = logging.Formatter(fmt=log_fmt, datefmt='%Y-%m-%d %H:%M:%S') + return formatter.format(record) def create_logger(name: str) -> logging.Logger: - """ - Creates a logger with the given name and returns it. - - :param name: Name of the logger - :return: Logger object - """ - logger = logging.getLogger(name) - if logger.hasHandlers(): - logger.handlers.clear() - - # Set level - level = logging.DEBUG if DEBUG_ENABLED else logging.INFO - logger.setLevel(level) - - # Set level names - logging.addLevelName(logging.DEBUG, 'DBUG') - logging.addLevelName(logging.INFO, 'INFO') - logging.addLevelName(logging.WARNING, 'WARN') - logging.addLevelName(logging.ERROR, 'ERR!') - logging.addLevelName(logging.CRITICAL, 'CRIT') - - # Add color formatter - color_handler = logging.StreamHandler() - color_handler.setFormatter(ColorFormatter()) - logger.addHandler(color_handler) - - # Add Sentry handler - if SENTRY_DSN is not None and SENTRY_ENV is not None: - sentry_handler = EventHandler() - sentry_handler.setLevel(logging.ERROR) - logger.addHandler(sentry_handler) - - return logger + """ + Creates a logger with the given name and returns it. + + :param name: Name of the logger + :return: Logger object + """ + logger = logging.getLogger(name) + if logger.hasHandlers(): + logger.handlers.clear() + + # Set level + level = logging.DEBUG if DEBUG_ENABLED else logging.INFO + logger.setLevel(level) + + # Set level names + logging.addLevelName(logging.DEBUG, 'DBUG') + logging.addLevelName(logging.INFO, 'INFO') + logging.addLevelName(logging.WARNING, 'WARN') + logging.addLevelName(logging.ERROR, 'ERR!') + logging.addLevelName(logging.CRITICAL, 'CRIT') + + # Add color formatter + color_handler = logging.StreamHandler() + color_handler.setFormatter(ColorFormatter()) + logger.addHandler(color_handler) + + # Add Sentry handler + if SENTRY_DSN is not None and SENTRY_ENV is not None: + sentry_handler = EventHandler() + sentry_handler.setLevel(logging.ERROR) + logger.addHandler(sentry_handler) + + return logger diff --git a/utils/musicbrainz.py b/utils/musicbrainz.py index a16131e..966334e 100644 --- a/utils/musicbrainz.py +++ b/utils/musicbrainz.py @@ -14,7 +14,7 @@ from .logger import create_logger if TYPE_CHECKING: - from dataclass.queue_item import QueueItem + from dataclass.queue_item import QueueItem LOGGER = create_logger('musicbrainz') @@ -23,246 +23,216 @@ @limits(calls=25, period=1) @sleep_and_retry def annotate_track( - track: 'QueueItem', - *, - in_place: bool = True + track: 'QueueItem', *, in_place: bool = True ) -> Optional[Tuple[str | None, str | None]]: - """ - Annotates a track with MusicBrainz ID and ISRC if they are not already present. - - Can be called up to 25 times per second, and will sleep and retry if this limit - is exceeded. This is because MusicBrainz has a rate limit of 50 requests per second, - but we need to make at most two requests per track (one to search for the track by ISRC, - and one to search for it by title and artist if the ISRC search fails). - - :param track: The track to annotate. Must be an instance of - dataclass.queue_item.QueueItem. - :param in_place: Whether to modify the track in place. If False, a tuple containing - the MusicBrainz ID and ISRC will be returned instead. - """ - # Check if track has already been annotated - if track.is_annotated: - return track.mbid, track.isrc - - # Check if information is already cached - mbid = track.mbid - isrc = track.isrc - mbid_cached = False - isrc_cached = False - if REDIS is not None: - # Check for cached MusicBrainz ID - if mbid is None and track.spotify_id is not None: - mbid = REDIS.get_mbid(track.spotify_id) - if mbid is not None: - mbid_cached = True - - # Check for cached ISRC - if isrc is None and track.spotify_id is not None: - isrc = REDIS.get_isrc(track.spotify_id) - if isrc is not None: - isrc_cached = True - - # Lookup MusicBrainz ID and ISRC if not cached - if mbid is None: - if isrc is not None: - LOGGER.info( - 'Looking up MusicBrainz ID for `%s\'', - track.title - ) - try: - mbid = mb_lookup_isrc(track) - except HTTPError as err: - if err.response is not None and err.response.status_code == 404: - mbid, isrc = mb_lookup(track) - else: - raise + """ + Annotates a track with MusicBrainz ID and ISRC if they are not already present. + + Can be called up to 25 times per second, and will sleep and retry if this limit + is exceeded. This is because MusicBrainz has a rate limit of 50 requests per second, + but we need to make at most two requests per track (one to search for the track by ISRC, + and one to search for it by title and artist if the ISRC search fails). + + :param track: The track to annotate. Must be an instance of + dataclass.queue_item.QueueItem. + :param in_place: Whether to modify the track in place. If False, a tuple containing + the MusicBrainz ID and ISRC will be returned instead. + """ + # Check if track has already been annotated + if track.is_annotated: + return track.mbid, track.isrc + + # Check if information is already cached + mbid = track.mbid + isrc = track.isrc + mbid_cached = False + isrc_cached = False + if REDIS is not None: + # Check for cached MusicBrainz ID + if mbid is None and track.spotify_id is not None: + mbid = REDIS.get_mbid(track.spotify_id) + if mbid is not None: + mbid_cached = True + + # Check for cached ISRC + if isrc is None and track.spotify_id is not None: + isrc = REDIS.get_isrc(track.spotify_id) + if isrc is not None: + isrc_cached = True + + # Lookup MusicBrainz ID and ISRC if not cached + if mbid is None: + if isrc is not None: + LOGGER.info("Looking up MusicBrainz ID for `%s'", track.title) + try: + mbid = mb_lookup_isrc(track) + except HTTPError as err: + if err.response is not None and err.response.status_code == 404: + mbid, isrc = mb_lookup(track) else: - LOGGER.info( - 'Looking up MusicBrainz ID and ISRC for `%s\'', - track.title - ) - mbid, isrc = mb_lookup(track) - - # Log MusicBrainz ID if found - if track.mbid is None and mbid is not None: - if in_place: - track.mbid = mbid - if REDIS is not None and track.spotify_id is not None: - REDIS.set_mbid(track.spotify_id, mbid) - - LOGGER.info( - 'Found %sMusicBrainz ID `%s\' for `%s\'', - 'cached ' if mbid_cached else '', - track.mbid, - track.title - ) - - # Log ISRC if found - if track.isrc is None and isrc is not None: - if in_place: - track.isrc = isrc - if REDIS is not None and track.spotify_id is not None: - REDIS.set_isrc(track.spotify_id, isrc) - - LOGGER.info( - 'Found %sISRC `%s\' for `%s\'', - 'cached ' if isrc_cached else '', - isrc, - track.title - ) + raise + else: + LOGGER.info("Looking up MusicBrainz ID and ISRC for `%s'", track.title) + mbid, isrc = mb_lookup(track) + # Log MusicBrainz ID if found + if track.mbid is None and mbid is not None: if in_place: - # Signal that the track has been annotated - track.is_annotated = True + track.mbid = mbid + if REDIS is not None and track.spotify_id is not None: + REDIS.set_mbid(track.spotify_id, mbid) + + LOGGER.info( + "Found %sMusicBrainz ID `%s' for `%s'", + 'cached ' if mbid_cached else '', + track.mbid, + track.title, + ) + + # Log ISRC if found + if track.isrc is None and isrc is not None: + if in_place: + track.isrc = isrc + if REDIS is not None and track.spotify_id is not None: + REDIS.set_isrc(track.spotify_id, isrc) + + LOGGER.info( + "Found %sISRC `%s' for `%s'", + 'cached ' if isrc_cached else '', + isrc, + track.title, + ) + + if in_place: + # Signal that the track has been annotated + track.is_annotated = True + + return mbid, isrc - return mbid, isrc def mb_lookup(track: 'QueueItem') -> Tuple[str | None, str | None]: - """ - Looks up a track on MusicBrainz and returns a tuple containing - a matching MusicBrainz ID and ISRC, if available. - """ - # Build MusicBrainz query - assert track.title is not None and track.artist is not None - query = f'recording:{track.title} && artist:{track.artist}' - if track.album is not None: - query += f' && release:{track.album}' - - # Perform search - response = get( - str(MUSICBRAINZ_API_BASE_URL / 'recording'), - headers={ - 'User-Agent': USER_AGENT, - 'Accept': 'application/json' - }, - params={ - 'query': query, - 'limit': 10, - 'inc': 'isrcs', - 'fmt': 'json' - }, - timeout=5.0 + """ + Looks up a track on MusicBrainz and returns a tuple containing + a matching MusicBrainz ID and ISRC, if available. + """ + # Build MusicBrainz query + assert track.title is not None and track.artist is not None + query = f'recording:{track.title} && artist:{track.artist}' + if track.album is not None: + query += f' && release:{track.album}' + + # Perform search + response = get( + str(MUSICBRAINZ_API_BASE_URL / 'recording'), + headers={'User-Agent': USER_AGENT, 'Accept': 'application/json'}, + params={'query': query, 'limit': 10, 'inc': 'isrcs', 'fmt': 'json'}, + timeout=5.0, + ) + try: + response.raise_for_status() + except HTTPError as err: + LOGGER.error( + "Error %d looking up track `%s' on MusicBrainz.\n%s", + err.response.status_code if err.response is not None else -1, + track.title, + err, ) - try: - response.raise_for_status() - except HTTPError as err: - LOGGER.error( - 'Error %d looking up track `%s\' on MusicBrainz.\n%s', - err.response.status_code if err.response is not None else -1, - track.title, - err - ) - raise - except Timeout: - LOGGER.warning( - 'Timed out while looking up track `%s\' on MusicBrainz', - track.title - ) - return None, None - - # Parse response - parsed = response.json() - if len(parsed['recordings']) == 0: - LOGGER.error( - 'No results found for track `%s\' on MusicBrainz', - track.title - ) - return None, None - - # Filter by duration difference - results = [ - result - for result in parsed['recordings'] - if 'length' in result and abs(track.duration - result['length']) < DURATION_THRESHOLD + raise + except Timeout: + LOGGER.warning("Timed out while looking up track `%s' on MusicBrainz", track.title) + return None, None + + # Parse response + parsed = response.json() + if len(parsed['recordings']) == 0: + LOGGER.error("No results found for track `%s' on MusicBrainz", track.title) + return None, None + + # Filter by duration difference + results = [ + result + for result in parsed['recordings'] + if 'length' in result + and abs(track.duration - result['length']) < DURATION_THRESHOLD + ] + if len(results) == 0: + LOGGER.error("No results found for track `%s' on MusicBrainz", track.title) + return None, None + + # Sort remaining results by similarity and ISRC presence + query = f'{track.title} {track.artist}' + best_match = results[0] + if len(results) > 1: + similarities = [ + check_similarity_weighted( + query, + f'{result['title']} {result['artist-credit'][0]['name']}', + result['score'], + ) + for result in results ] - if len(results) == 0: - LOGGER.error( - 'No results found for track `%s\' on MusicBrainz', - track.title - ) - return None, None - - # Sort remaining results by similarity and ISRC presence - query = f'{track.title} {track.artist}' - best_match = results[0] - if len(results) > 1: - similarities = [ - check_similarity_weighted( - query, - f'{result["title"]} {result["artist-credit"][0]["name"]}', - result['score'] - ) for result in results - ] - isrc_presence = [ - 'isrcs' in result and len(result['isrcs']) > 0 - for result in results - ] - ranked = sorted( - zip(results, similarities, isrc_presence), - key=lambda x: (x[1], x[2]), - reverse=True - ) - best_match = ranked[0][0] - - # Print confidences for debugging - LOGGER.debug('MusicBrainz results and confidences for "%s":', query) - for result, confidence, has_isrc in ranked: - LOGGER.debug( - ' %3d %-20s %-20s isrc=%s', - confidence, - result['artist-credit'][0]['name'][:20], - result['title'][:20], - has_isrc - ) - - # Extract ID and ISRC - mbid = best_match['id'] - isrc = None - if 'isrcs' in best_match and len(best_match['isrcs']) > 0: - isrc = best_match['isrcs'][0] - - return mbid, isrc + isrc_presence = [ + 'isrcs' in result and len(result['isrcs']) > 0 for result in results + ] + ranked = sorted( + zip(results, similarities, isrc_presence), + key=lambda x: (x[1], x[2]), + reverse=True, + ) + best_match = ranked[0][0] + + # Print confidences for debugging + LOGGER.debug('MusicBrainz results and confidences for "%s":', query) + for result, confidence, has_isrc in ranked: + LOGGER.debug( + ' %3d %-20s %-20s isrc=%s', + confidence, + result['artist-credit'][0]['name'][:20], + result['title'][:20], + has_isrc, + ) + + # Extract ID and ISRC + mbid = best_match['id'] + isrc = None + if 'isrcs' in best_match and len(best_match['isrcs']) > 0: + isrc = best_match['isrcs'][0] + + return mbid, isrc def mb_lookup_isrc(track: 'QueueItem') -> Optional[str]: - """ - Looks up a track by its ISRC on MusicBrainz and returns a MusicBrainz ID. - """ - assert track.isrc is not None - response = get( - str(MUSICBRAINZ_API_BASE_URL / 'isrc' / track.isrc.upper()), - headers={ - 'User-Agent': USER_AGENT, - 'Accept': 'application/json' - }, - params={'fmt': 'json'}, - timeout=5.0 + """ + Looks up a track by its ISRC on MusicBrainz and returns a MusicBrainz ID. + """ + assert track.isrc is not None + response = get( + str(MUSICBRAINZ_API_BASE_URL / 'isrc' / track.isrc.upper()), + headers={'User-Agent': USER_AGENT, 'Accept': 'application/json'}, + params={'fmt': 'json'}, + timeout=5.0, + ) + + try: + response.raise_for_status() + except HTTPError: + LOGGER.error("ISRC %s (`%s') is not on MusicBrainz", track.isrc, track.title) + raise + except Timeout: + LOGGER.warning( + "Timed out while looking up track `%s' (%s) on MusicBrainz", + track.title, + track.isrc, + ) + return None + + parsed = response.json() + if len(parsed['recordings']) == 0: + LOGGER.error( + "No results found for track `%s' (%s) on MusicBrainz", + track.title, + track.isrc, ) + return None - try: - response.raise_for_status() - except HTTPError: - LOGGER.error( - 'ISRC %s (`%s\') is not on MusicBrainz', - track.isrc, - track.title - ) - raise - except Timeout: - LOGGER.warning( - 'Timed out while looking up track `%s\' (%s) on MusicBrainz', - track.title, - track.isrc - ) - return None - - parsed = response.json() - if len(parsed['recordings']) == 0: - LOGGER.error( - 'No results found for track `%s\' (%s) on MusicBrainz', - track.title, - track.isrc - ) - return None - - return parsed['recordings'][0]['id'] + return parsed['recordings'][0]['id'] diff --git a/utils/paginator.py b/utils/paginator.py index bf5235b..765e391 100644 --- a/utils/paginator.py +++ b/utils/paginator.py @@ -14,7 +14,7 @@ from views.paginator import PaginatorView if TYPE_CHECKING: - from nextcord import Message + from nextcord import Message def list_chunks(data: List[Any]) -> Generator[List[Any], Any, Any]: @@ -26,102 +26,101 @@ def list_chunks(data: List[Any]) -> Generator[List[Any], Any, Any]: class Paginator: + """ + Paginator class for sending embeds with controls to change pages. + """ + + def __init__(self, itx: Interaction): + self.current = 0 + self.embeds = [] + self.home = 0 + self.itx = itx + self.msg: Optional['Message'] = None + self.original_timeout = 0 + self.timeout = 0 + + async def run( + self, + embeds: List[Embed], + start: int = 0, + timeout: int = 0, + callback: Optional[Callable[[int], None]] = None, + ): """ - Paginator class for sending embeds with controls to change pages. + Sends the given embeds and adds controls to change pages if there's more than one. """ - def __init__(self, itx: Interaction): - self.current = 0 - self.embeds = [] - self.home = 0 - self.itx = itx - self.msg: Optional['Message'] = None - self.original_timeout = 0 - self.timeout = 0 - - async def run( - self, - embeds: List[Embed], - start: int = 0, - timeout: int = 0, - callback: Optional[Callable[[int], None]] = None - ): - """ - Sends the given embeds and adds controls to change pages if there's more than one. - """ - # If there's only one page, just send it as is - if len(embeds) == 1: - msg = await self.itx.followup.send(embed=embeds[0], wait=True) - if callback is not None: - callback(msg.id) - return - - timeout = timeout if timeout > 0 else 60 - self.original_timeout = timeout - self.timeout = timeout - - # Add footer and timestamp to every embed - for i, embed in enumerate(embeds): - embed.timestamp = self.itx.created_at - embed.set_footer(text=f'Page {i + 1} of {len(embeds)}') - - # Send initial embed and call callback with message ID - self.home = start - self.current = start - self.embeds = embeds - msg = await self.itx.followup.send( - embed=self.embeds[start], - view=PaginatorView(self), - wait=True - ) - self.msg = await msg.channel.fetch_message(msg.id) - if callback is not None: - callback(msg.id) - - # Remove controls if inactive for more than timeout amount - while True: - await sleep(1) - self.timeout -= 1 - if self.timeout <= 0: - return await self.msg.edit(view=None) - - async def _switch_page(self, new_page: int) -> Optional['Message']: - self.current = new_page - if self.msg is not None: - try: - msg = await self.msg.edit(embed=self.embeds[self.current]) - except (Forbidden, HTTPException): - return None - - self.timeout = self.original_timeout - return msg - - async def first_page(self): - """ - Switches to the first page. - """ - await self._switch_page(0) - - async def previous_page(self): - """ - Switches to the previous page. - """ - await self._switch_page(self.current - 1) - - async def home_page(self): - """ - Switches to the home page, which is the first page by default, - but can be changed with the `start` parameter in `Paginator.run()`. - """ - await self._switch_page(self.home) - - async def next_page(self): - """ - Switches to the next page. - """ - await self._switch_page(self.current + 1) - - async def last_page(self): - """ - Switches to the last page. - """ - await self._switch_page(len(self.embeds) - 1) + # If there's only one page, just send it as is + if len(embeds) == 1: + msg = await self.itx.followup.send(embed=embeds[0], wait=True) + if callback is not None: + callback(msg.id) + return None + + timeout = timeout if timeout > 0 else 60 + self.original_timeout = timeout + self.timeout = timeout + + # Add footer and timestamp to every embed + for i, embed in enumerate(embeds): + embed.timestamp = self.itx.created_at + embed.set_footer(text=f'Page {i + 1} of {len(embeds)}') + + # Send initial embed and call callback with message ID + self.home = start + self.current = start + self.embeds = embeds + msg = await self.itx.followup.send( + embed=self.embeds[start], view=PaginatorView(self), wait=True + ) + self.msg = await msg.channel.fetch_message(msg.id) + if callback is not None: + callback(msg.id) + + # Remove controls if inactive for more than timeout amount + while True: + await sleep(1) + self.timeout -= 1 + if self.timeout <= 0: + return await self.msg.edit(view=None) + + async def _switch_page(self, new_page: int) -> Optional['Message']: + self.current = new_page + if self.msg is not None: + try: + msg = await self.msg.edit(embed=self.embeds[self.current]) + except (Forbidden, HTTPException): + return None + + self.timeout = self.original_timeout + return msg + + async def first_page(self): + """ + Switches to the first page. + """ + await self._switch_page(0) + + async def previous_page(self): + """ + Switches to the previous page. + """ + await self._switch_page(self.current - 1) + + async def home_page(self): + """ + Switches to the home page, which is the first page by default, + but can be changed with the `start` parameter in `Paginator.run()`. + """ + await self._switch_page(self.home) + + async def next_page(self): + """ + Switches to the next page. + """ + await self._switch_page(self.current + 1) + + async def last_page(self): + """ + Switches to the last page. + """ + await self._switch_page(len(self.embeds) - 1) diff --git a/utils/player_checks.py b/utils/player_checks.py index 02f191c..e1bb48f 100644 --- a/utils/player_checks.py +++ b/utils/player_checks.py @@ -3,6 +3,7 @@ the player is instantiated, and are used to check if the bot can connect and play music in a channel. """ + from typing import TYPE_CHECKING from nextcord import Interaction, Member @@ -10,53 +11,56 @@ from utils.exceptions import VoiceCommandError if TYPE_CHECKING: - from cogs.player.jockey import Jockey + from cogs.player.jockey import Jockey def check_mutual_voice(itx: Interaction, slash: bool = True) -> bool: - """ - This check ensures that the bot and command author are in the same voice channel. - - :param itx: The interaction object. - :param slash: Whether this check is being called as part of a slash command. See now_playing.py. - """ - - # Check that the user is in a voice channel in the first place. - if itx.guild is not None and isinstance(itx.user, Member): - if not itx.user.voice or not itx.user.voice.channel: - raise VoiceCommandError('Join a voice channel first.') - else: - # Not allowed in DMs - raise VoiceCommandError('You can only use this command in a server.') - - if itx.application_command is None and not slash: - raise VoiceCommandError('Abnormal invocation of command. Please try again.') - - player: 'Jockey' = itx.guild.voice_client # type: ignore - if player is None and slash: - assert itx.application_command is not None - if itx.application_command.name == 'play': - # The /play command causes the bot to connect to voice, - # so we don't have to worry about the rest of the checks here. - return True - raise VoiceCommandError('Please `/play` something first before using this command.') - - voice_channel = itx.user.voice.channel - if not player.is_connected(): - # Bot needs to already be in voice channel to pause, unpause, skip etc. - if itx.application_command is not None and itx.application_command.name != 'play': - raise VoiceCommandError('I\'m not connected to voice.') - - # Bot needs to have permissions to connect to voice. - permissions = voice_channel.permissions_for(itx.guild.me) - if not permissions.connect or not permissions.speak: - raise VoiceCommandError('I need the `CONNECT` and `SPEAK` permissions to play music.') - - # Bot needs to connect to a channel that isn't full. - if voice_channel.user_limit and voice_channel.user_limit <= len(voice_channel.members): - raise VoiceCommandError('Your voice channel is full.') - else: - if int(player.channel.id) != voice_channel.id: # type: ignore - raise VoiceCommandError('You need to be in my voice channel.') - - return True + """ + This check ensures that the bot and command author are in the same voice channel. + + :param itx: The interaction object. + :param slash: Whether this check is being called as part of a slash command. See now_playing.py. + """ + + # Check that the user is in a voice channel in the first place. + if itx.guild is not None and isinstance(itx.user, Member): + if not itx.user.voice or not itx.user.voice.channel: + raise VoiceCommandError('Join a voice channel first.') + else: + # Not allowed in DMs + raise VoiceCommandError('You can only use this command in a server.') + + if itx.application_command is None and not slash: + raise VoiceCommandError('Abnormal invocation of command. Please try again.') + + player: 'Jockey' = itx.guild.voice_client # type: ignore + if player is None and slash: + assert itx.application_command is not None + if itx.application_command.name == 'play': + # The /play command causes the bot to connect to voice, + # so we don't have to worry about the rest of the checks here. + return True + raise VoiceCommandError('Please `/play` something first before using this command.') + + voice_channel = itx.user.voice.channel + if not player.is_connected(): + # Bot needs to already be in voice channel to pause, unpause, skip etc. + if itx.application_command is not None and itx.application_command.name != 'play': + raise VoiceCommandError("I'm not connected to voice.") + + # Bot needs to have permissions to connect to voice. + permissions = voice_channel.permissions_for(itx.guild.me) + if not permissions.connect or not permissions.speak: + raise VoiceCommandError( + 'I need the `CONNECT` and `SPEAK` permissions to play music.' + ) + + # Bot needs to connect to a channel that isn't full. + if voice_channel.user_limit and voice_channel.user_limit <= len( + voice_channel.members + ): + raise VoiceCommandError('Your voice channel is full.') + elif int(player.channel.id) != voice_channel.id: # type: ignore + raise VoiceCommandError('You need to be in my voice channel.') + + return True diff --git a/utils/scrobbler.py b/utils/scrobbler.py index eefd344..cc39343 100644 --- a/utils/scrobbler.py +++ b/utils/scrobbler.py @@ -9,74 +9,67 @@ import pylast if TYPE_CHECKING: - from logging import Logger + from logging import Logger - from dataclass.config import Config - from dataclass.oauth import LastfmAuth - from dataclass.queue_item import QueueItem + from dataclass.config import Config + from dataclass.oauth import LastfmAuth + from dataclass.queue_item import QueueItem class Scrobbler: - """ - Scrobbler class for scrobbling songs to Last.fm. - Meant for single use, i.e., one instance per user per listening session. - """ - def __init__(self, config: 'Config', creds: 'LastfmAuth', logger: 'Logger'): - if config.lastfm_api_key is None or config.lastfm_shared_secret is None: - raise ValueError('Last.fm API key and/or shared secret not set.') - self._user_id = creds.user_id + """ + Scrobbler class for scrobbling songs to Last.fm. + Meant for single use, i.e., one instance per user per listening session. + """ + + def __init__(self, config: 'Config', creds: 'LastfmAuth', logger: 'Logger'): + if config.lastfm_api_key is None or config.lastfm_shared_secret is None: + raise ValueError('Last.fm API key and/or shared secret not set.') + self._user_id = creds.user_id - # Create Network object - self._net = pylast.LastFMNetwork( - api_key=config.lastfm_api_key, - api_secret=config.lastfm_shared_secret - ) + # Create Network object + self._net = pylast.LastFMNetwork( + api_key=config.lastfm_api_key, api_secret=config.lastfm_shared_secret + ) - # Set session key - self._net.session_key = creds.session_key + # Set session key + self._net.session_key = creds.session_key - # Logger - self._logger = logger - self._logger.debug('Created scrobbler for user %d', creds.user_id) + # Logger + self._logger = logger + self._logger.debug('Created scrobbler for user %d', creds.user_id) - def scrobble(self, track: 'QueueItem'): - """ - Scrobbles a QueueItem from the music player. - """ - timestamp = track.start_time - if timestamp is None: - timestamp = int(mktime(datetime.now().timetuple())) + def scrobble(self, track: 'QueueItem'): + """ + Scrobbles a QueueItem from the music player. + """ + timestamp = track.start_time + if timestamp is None: + timestamp = int(mktime(datetime.now().timetuple())) - duration = None - if track.duration is not None: - duration = track.duration // 1000 + duration = None + if track.duration is not None: + duration = track.duration // 1000 - # Warn if MBID is not set - if track.mbid is None: - self._logger.warning( - 'MBID not set for track `%s\'; scrobble might not be accurate.', - track.title - ) + # Warn if MBID is not set + if track.mbid is None: + self._logger.warning( + "MBID not set for track `%s'; scrobble might not be accurate.", + track.title, + ) - try: - self._net.scrobble( - artist=track.artist, - title=track.title, - timestamp=timestamp, - duration=duration, - mbid=track.mbid - ) - except pylast.PyLastError as err: - self._logger.error( - 'Error scrobbling `%s\' for user %d: %s', - track.title, - self._user_id, - err - ) - raise + try: + self._net.scrobble( + artist=track.artist, + title=track.title, + timestamp=timestamp, + duration=duration, + mbid=track.mbid, + ) + except pylast.PyLastError as err: + self._logger.error( + "Error scrobbling `%s' for user %d: %s", track.title, self._user_id, err + ) + raise - self._logger.debug( - 'Scrobbled `%s\' for user %d', - track.title, - self._user_id - ) + self._logger.debug("Scrobbled `%s' for user %d", track.title, self._user_id) diff --git a/utils/spotify_client.py b/utils/spotify_client.py index 5b2dc0f..7b71e06 100644 --- a/utils/spotify_client.py +++ b/utils/spotify_client.py @@ -6,8 +6,14 @@ import spotipy from requests.exceptions import ConnectionError as RequestsConnectionError -from tenacity import (RetryCallState, retry, retry_if_exception_type, - stop_after_attempt, wait_fixed, wait_random) +from tenacity import ( + RetryCallState, + retry, + retry_if_exception_type, + stop_after_attempt, + wait_fixed, + wait_random, +) from database.redis import REDIS from dataclass.spotify import SpotifyResult, SpotifyTrack @@ -22,323 +28,338 @@ def log_call(retry_state: RetryCallState) -> None: - """ - Logs an API call - """ - RETRY_LOGGER.debug( - 'Calling Spotify API: %s(%s, %s)', - getattr(retry_state.fn, '__name__', repr(retry_state.fn)), - retry_state.args, - retry_state.kwargs - ) + """ + Logs an API call + """ + RETRY_LOGGER.debug( + 'Calling Spotify API: %s(%s, %s)', + getattr(retry_state.fn, '__name__', repr(retry_state.fn)), + retry_state.args, + retry_state.kwargs, + ) def log_failure(retry_state: RetryCallState) -> None: - """ - Logs a retry attempt. - """ - func_name = getattr(retry_state.fn, '__name__', repr(retry_state.fn)) - - # Log outcome - if retry_state.outcome is not None: - RETRY_LOGGER.debug('%s() failed: %s', func_name, retry_state.outcome) - RETRY_LOGGER.debug(' Exception: %s', retry_state.outcome.exception()) - RETRY_LOGGER.debug(' Args: %s', retry_state.args) - RETRY_LOGGER.debug(' Kwargs: %s', retry_state.kwargs) - - RETRY_LOGGER.warning( - 'Retrying %s(), attempt %s', - func_name, - retry_state.attempt_number - ) + """ + Logs a retry attempt. + """ + func_name = getattr(retry_state.fn, '__name__', repr(retry_state.fn)) + + # Log outcome + if retry_state.outcome is not None: + RETRY_LOGGER.debug('%s() failed: %s', func_name, retry_state.outcome) + RETRY_LOGGER.debug(' Exception: %s', retry_state.outcome.exception()) + RETRY_LOGGER.debug(' Args: %s', retry_state.args) + RETRY_LOGGER.debug(' Kwargs: %s', retry_state.kwargs) + + RETRY_LOGGER.warning( + 'Retrying %s(), attempt %s', func_name, retry_state.attempt_number + ) def extract_track_info( - track_obj: Dict[str, Any], - artwork: Optional[str] = None, - album_name: Optional[str] = None + track_obj: Dict[str, Any], + artwork: Optional[str] = None, + album_name: Optional[str] = None, ) -> SpotifyTrack: + """ + Extracts track information from the Spotify API and returns a SpotifyTrack object. + """ + if 'track' in track_obj.keys(): + # Nested track (playlist track object) + track_obj = track_obj['track'] + + # Extract ISRC if present + isrc = None + if 'external_ids' in track_obj.keys(): + if 'isrc' in track_obj['external_ids'].keys(): + isrc = track_obj['external_ids']['isrc'].upper().replace('-', '') + + # Extract album artwork if present + if 'album' in track_obj.keys(): + album_name = track_obj['album']['name'] + if 'images' in track_obj['album'].keys(): + if len(track_obj['album']['images']) > 0: + artwork = track_obj['album']['images'][0]['url'] + + return SpotifyTrack( + title=track_obj['name'], + artist=track_obj['artists'][0]['name'], + author=', '.join([x['name'] for x in track_obj['artists']]), + album=album_name, + spotify_id=track_obj['id'], + duration_ms=int(track_obj['duration_ms']), + artwork=artwork, + isrc=isrc, + ) + + +class Spotify: + """ + Wrapper for the spotipy Spotify client which supports pagination by default. + """ + + def __init__(self, client_id: str, client_secret: str): + self._client = spotipy.Spotify( + auth_manager=spotipy.oauth2.SpotifyClientCredentials( + client_id=client_id, client_secret=client_secret + ) + ) + + @property + def client(self): + """ + Returns the internal spotipy client. + """ + return self._client + + def __get_art(self, art: List[Dict[str, str]], default='') -> str: + """ + Returns the first image URL from a list of artwork images, + or a specified default if the list is empty. + """ + if len(art) == 0: + return default + return art[0]['url'] + + @retry( + retry=retry_if_exception_type(RequestsConnectionError), + stop=stop_after_attempt(3), + wait=wait_fixed(1) + wait_random(0, 2), + before=log_call, + before_sleep=log_failure, + ) + def get_artist_top_tracks(self, artist_id: str) -> List[SpotifyTrack]: + """ + Returns a list of SpotifyTrack objects for a given artist's + top 10 tracks. + """ + response = self._client.artist_top_tracks(artist_id) + if response is None: + raise SpotifyInvalidURLError(f'spotify:artist:{artist_id}') + + return [extract_track_info(track) for track in response['tracks']] + + @retry( + retry=retry_if_exception_type(RequestsConnectionError), + stop=stop_after_attempt(3), + wait=wait_fixed(1) + wait_random(0, 2), + before=log_call, + before_sleep=log_failure, + ) + def get_track_art(self, track_id: str) -> str: + """ + Returns the track artwork for a given track ID. """ - Extracts track information from the Spotify API and returns a SpotifyTrack object. + result = self._client.track(track_id) + if result is None: + raise SpotifyInvalidURLError(f'spotify:track:{track_id}') + return self.__get_art(result['album']['images']) + + @retry( + retry=retry_if_exception_type(RequestsConnectionError), + stop=stop_after_attempt(3), + wait=wait_fixed(1) + wait_random(0, 2), + before=log_call, + before_sleep=log_failure, + ) + def get_track(self, track_id: str) -> SpotifyTrack: """ - if 'track' in track_obj.keys(): - # Nested track (playlist track object) - track_obj = track_obj['track'] - - # Extract ISRC if present - isrc = None - if 'external_ids' in track_obj.keys(): - if 'isrc' in track_obj['external_ids'].keys(): - isrc = track_obj['external_ids']['isrc'].upper().replace('-', '') - - # Extract album artwork if present - if 'album' in track_obj.keys(): - album_name = track_obj['album']['name'] - if 'images' in track_obj['album'].keys(): - if len(track_obj['album']['images']) > 0: - artwork = track_obj['album']['images'][0]['url'] - - return SpotifyTrack( - title=track_obj['name'], - artist=track_obj['artists'][0]['name'], - author=', '.join([x['name'] for x in track_obj['artists']]), - album=album_name, - spotify_id=track_obj['id'], - duration_ms=int(track_obj['duration_ms']), - artwork=artwork, - isrc=isrc + Returns a SpotifyTrack object for a given track ID. + """ + # Check cache + if REDIS is not None: + cached_track = REDIS.get_spotify_track(track_id) + if cached_track is not None: + return cached_track + + result = self._client.track(track_id) + if result is None: + raise SpotifyInvalidURLError(f'spotify:track:{track_id}') + + # Save to cache + if REDIS is not None: + REDIS.set_spotify_track(track_id, extract_track_info(result)) + + return extract_track_info(result) + + @retry( + retry=retry_if_exception_type(RequestsConnectionError), + stop=stop_after_attempt(3), + wait=wait_fixed(1) + wait_random(0, 2), + before=log_call, + before_sleep=log_failure, + ) + def get_tracks( + self, list_type: str, list_id: str + ) -> Tuple[str, str, List[SpotifyTrack]]: + """ + Returns a list of SpotifyTrack objects for a given album or playlist ID. + May take a long time to complete if the list is large. + """ + offset = 0 + tracks = [] + + # Get list name and author + list_artwork = None + if list_type == 'album': + album_info = self._client.album(list_id) + if album_info is None: + raise SpotifyInvalidURLError(f'spotify:{list_type}:{list_id}') + + list_artwork = album_info['images'][0]['url'] + list_name = album_info['name'] + list_author = album_info['artists'][0]['name'] + elif list_type == 'playlist': + playlist_info = self._client.playlist(list_id, fields='name,owner.display_name') + if playlist_info is None: + raise SpotifyInvalidURLError(f'spotify:{list_type}:{list_id}') + + list_name = playlist_info['name'] + list_author = playlist_info['owner']['display_name'] + else: + raise SpotifyInvalidURLError(f'spotify:{list_type}:{list_id}') + + # Get tracks + while True: + if list_type == 'album': + response = self._client.album_tracks(list_id, offset=offset) + else: + fields = ','.join( + [ + 'items.track.name', + 'items.track.artists', + 'items.track.album', + 'items.track.id', + 'items.track.duration_ms', + 'items.track.external_ids.isrc', + ] + ) + response = self._client.playlist_items( + list_id, offset=offset, fields=fields, additional_types=['track'] + ) + + if response is None: + raise SpotifyInvalidURLError(f'spotify:{list_type}:{list_id}') + if len(response['items']) == 0: + break + + tracks.extend(response['items']) + offset = offset + len(response['items']) + + if list_type == 'playlist': + return ( + list_name, + list_author, + [extract_track_info(x) for x in tracks if x['track'] is not None], + ) + return ( + list_name, + list_author, + [extract_track_info(x, list_artwork, album_name=list_name) for x in tracks], ) + @retry( + retry=retry_if_exception_type(RequestsConnectionError), + stop=stop_after_attempt(3), + wait=wait_fixed(1) + wait_random(0, 2), + before=log_call, + before_sleep=log_failure, + ) + def search_track(self, query, limit: int = 1) -> List[SpotifyTrack]: + """ + Searches Spotify for a given query and returns a list of SpotifyTrack objects. -class Spotify: + :param query: The name of a track to search for. + :param limit: The maximum number of results to return. """ - Wrapper for the spotipy Spotify client which supports pagination by default. + response = self._client.search(query, limit=20, type='track') + if response is None or len(response['tracks']['items']) == 0: + raise SpotifyNoResultsError + + # Filter out tracks with blacklisted words not in the original query + results = [] + for result in response['tracks']['items']: + for word in BLACKLIST: + if word in result['name'].lower() and word not in query.lower(): + break + else: + results.append(extract_track_info(result)) + + return results[:limit] + + @retry( + retry=retry_if_exception_type(RequestsConnectionError), + stop=stop_after_attempt(3), + wait=wait_fixed(1) + wait_random(0, 2), + before=log_call, + before_sleep=log_failure, + ) + def search(self, query: str, search_type: str) -> List[SpotifyResult]: + """ + Searches Spotify for a given artist, album, or playlist, + and returns a list of SpotifyResult objects. + + If you want to search for tracks specifically, use search_track(), + as that will yield a list of SpotifyTrack objects instead of SpotifyResults. + + :param query: The artist/album/playlist to search for. + :param search_type: The type of entity to search for. + Must be one of 'artist', 'album', 'playlist', or 'track'. """ - def __init__(self, client_id: str, client_secret: str): - self._client = spotipy.Spotify( - auth_manager=spotipy.oauth2.SpotifyClientCredentials( - client_id=client_id, - client_secret=client_secret - ) + if search_type not in ('artist', 'album', 'playlist', 'track'): + raise ValueError(f'Invalid search type: {search_type}') + + response = self._client.search(query, limit=10, type=search_type) + if response is None or len(response[f'{search_type}s']['items']) == 0: + raise SpotifyNoResultsError + + # Parse results + items = response[f'{search_type}s']['items'] + if search_type == 'artist': + # Sort artists by followers + items = sorted(items, key=lambda x: x['followers']['total'], reverse=True) + results = [ + SpotifyResult( + name=entity['name'], + description=f'{entity['followers']['total']} followers', + spotify_id=entity['id'], + ) + for entity in items + ] + elif search_type == 'album': + # Include artist name, track count, and release date in album results + results = [ + SpotifyResult( + name=entity['name'], + description=f'{entity['artists'][0]['name']} ' + f'({entity['total_tracks']} tracks, ' + f'released {entity['release_date']})', + spotify_id=entity['id'], + ) + for entity in items + ] + elif search_type == 'playlist': + # Include author name and track count in playlist results + results = [ + SpotifyResult( + name=entity['name'], + description=f'{entity['owner']['display_name']} ' + f'({entity['tracks']['total']} tracks)', + spotify_id=entity['id'], ) + for entity in items + ] + else: + # Include artist name and release date in track results + results = [ + SpotifyResult( + name=f'{entity['name']} ' f'({human_readable_time(entity['duration_ms'])})', + description=f'{entity['artists'][0]['name']} - ' + f'{entity['album']['name']} ', + spotify_id=entity['id'], + ) + for entity in items + ] - @property - def client(self): - """ - Returns the internal spotipy client. - """ - return self._client - - def __get_art(self, art: List[Dict[str, str]], default='') -> str: - """ - Returns the first image URL from a list of artwork images, - or a specified default if the list is empty. - """ - if len(art) == 0: - return default - return art[0]['url'] - - @retry( - retry=retry_if_exception_type(RequestsConnectionError), - stop=stop_after_attempt(3), - wait=wait_fixed(1) + wait_random(0, 2), - before=log_call, - before_sleep=log_failure - ) - def get_artist_top_tracks(self, artist_id: str) -> List[SpotifyTrack]: - """ - Returns a list of SpotifyTrack objects for a given artist's - top 10 tracks. - """ - response = self._client.artist_top_tracks(artist_id) - if response is None: - raise SpotifyInvalidURLError(f'spotify:artist:{artist_id}') - - return [extract_track_info(track) for track in response['tracks']] - - @retry( - retry=retry_if_exception_type(RequestsConnectionError), - stop=stop_after_attempt(3), - wait=wait_fixed(1) + wait_random(0, 2), - before=log_call, - before_sleep=log_failure - ) - def get_track_art(self, track_id: str) -> str: - """ - Returns the track artwork for a given track ID. - """ - result = self._client.track(track_id) - if result is None: - raise SpotifyInvalidURLError(f'spotify:track:{track_id}') - return self.__get_art(result['album']['images']) - - @retry( - retry=retry_if_exception_type(RequestsConnectionError), - stop=stop_after_attempt(3), - wait=wait_fixed(1) + wait_random(0, 2), - before=log_call, - before_sleep=log_failure - ) - def get_track(self, track_id: str) -> SpotifyTrack: - """ - Returns a SpotifyTrack object for a given track ID. - """ - # Check cache - if REDIS is not None: - cached_track = REDIS.get_spotify_track(track_id) - if cached_track is not None: - return cached_track - - result = self._client.track(track_id) - if result is None: - raise SpotifyInvalidURLError(f'spotify:track:{track_id}') - - # Save to cache - if REDIS is not None: - REDIS.set_spotify_track(track_id, extract_track_info(result)) - - return extract_track_info(result) - - @retry( - retry=retry_if_exception_type(RequestsConnectionError), - stop=stop_after_attempt(3), - wait=wait_fixed(1) + wait_random(0, 2), - before=log_call, - before_sleep=log_failure - ) - def get_tracks(self, list_type: str, list_id: str) -> Tuple[str, str, List[SpotifyTrack]]: - """ - Returns a list of SpotifyTrack objects for a given album or playlist ID. - May take a long time to complete if the list is large. - """ - offset = 0 - tracks = [] - - # Get list name and author - list_artwork = None - if list_type == 'album': - album_info = self._client.album(list_id) - if album_info is None: - raise SpotifyInvalidURLError(f'spotify:{list_type}:{list_id}') - - list_artwork = album_info['images'][0]['url'] - list_name = album_info['name'] - list_author = album_info['artists'][0]['name'] - elif list_type == 'playlist': - playlist_info = self._client.playlist(list_id, fields='name,owner.display_name') - if playlist_info is None: - raise SpotifyInvalidURLError(f'spotify:{list_type}:{list_id}') - - list_name = playlist_info['name'] - list_author = playlist_info['owner']['display_name'] - else: - raise SpotifyInvalidURLError(f'spotify:{list_type}:{list_id}') - - # Get tracks - while True: - if list_type == 'album': - response = self._client.album_tracks(list_id, offset=offset) - else: - fields = ','.join([ - 'items.track.name', - 'items.track.artists', - 'items.track.album', - 'items.track.id', - 'items.track.duration_ms', - 'items.track.external_ids.isrc' - ]) - response = self._client.playlist_items(list_id, offset=offset, - fields=fields, - additional_types=['track']) - - if response is None: - raise SpotifyInvalidURLError(f'spotify:{list_type}:{list_id}') - if len(response['items']) == 0: - break - - tracks.extend(response['items']) - offset = offset + len(response['items']) - - if list_type == 'playlist': - return list_name, list_author, [ - extract_track_info(x) - for x in tracks if x['track'] is not None - ] - return list_name, list_author, [ - extract_track_info(x, list_artwork, album_name=list_name) - for x in tracks - ] - - @retry( - retry=retry_if_exception_type(RequestsConnectionError), - stop=stop_after_attempt(3), - wait=wait_fixed(1) + wait_random(0, 2), - before=log_call, - before_sleep=log_failure - ) - def search_track(self, query, limit: int = 1) -> List[SpotifyTrack]: - """ - Searches Spotify for a given query and returns a list of SpotifyTrack objects. - - :param query: The name of a track to search for. - :param limit: The maximum number of results to return. - """ - response = self._client.search(query, limit=20, type='track') - if response is None or len(response['tracks']['items']) == 0: - raise SpotifyNoResultsError - - # Filter out tracks with blacklisted words not in the original query - results = [] - for result in response['tracks']['items']: - for word in BLACKLIST: - if word in result['name'].lower() and word not in query.lower(): - break - else: - results.append(extract_track_info(result)) - - return results[:limit] - - @retry( - retry=retry_if_exception_type(RequestsConnectionError), - stop=stop_after_attempt(3), - wait=wait_fixed(1) + wait_random(0, 2), - before=log_call, - before_sleep=log_failure - ) - def search(self, query: str, search_type: str) -> List[SpotifyResult]: - """ - Searches Spotify for a given artist, album, or playlist, - and returns a list of SpotifyResult objects. - - If you want to search for tracks specifically, use search_track(), - as that will yield a list of SpotifyTrack objects instead of SpotifyResults. - - :param query: The artist/album/playlist to search for. - :param search_type: The type of entity to search for. - Must be one of 'artist', 'album', 'playlist', or 'track'. - """ - if search_type not in ('artist', 'album', 'playlist', 'track'): - raise ValueError(f'Invalid search type: {search_type}') - - response = self._client.search(query, limit=10, type=search_type) - if response is None or len(response[f'{search_type}s']['items']) == 0: - raise SpotifyNoResultsError - - # Parse results - items = response[f'{search_type}s']['items'] - if search_type == 'artist': - # Sort artists by followers - items = sorted(items, key=lambda x: x['followers']['total'], reverse=True) - results = [SpotifyResult( - name=entity['name'], - description=f'{entity["followers"]["total"]} followers', - spotify_id=entity['id'] - ) for entity in items] - elif search_type == 'album': - # Include artist name, track count, and release date in album results - results = [SpotifyResult( - name=entity['name'], - description=f'{entity["artists"][0]["name"]} ' - f'({entity["total_tracks"]} tracks, ' - f'released {entity["release_date"]})', - spotify_id=entity['id'] - ) for entity in items] - elif search_type == 'playlist': - # Include author name and track count in playlist results - results = [SpotifyResult( - name=entity['name'], - description=f'{entity["owner"]["display_name"]} ' - f'({entity["tracks"]["total"]} tracks)', - spotify_id=entity['id'] - ) for entity in items] - else: - # Include artist name and release date in track results - results = [SpotifyResult( - name=f'{entity["name"]} ' - f'({human_readable_time(entity["duration_ms"])})', - description=f'{entity["artists"][0]["name"]} - ' - f'{entity["album"]["name"]} ', - spotify_id=entity['id'] - ) for entity in items] - - return results + return results diff --git a/utils/spotify_private.py b/utils/spotify_private.py index fd44963..136596a 100644 --- a/utils/spotify_private.py +++ b/utils/spotify_private.py @@ -15,161 +15,156 @@ from dataclass.oauth import OAuth from dataclass.spotify import SpotifyResult -from .constants import (SPOTIFY_ACCOUNTS_BASE_URL, SPOTIFY_API_BASE_URL, - USER_AGENT) +from .constants import SPOTIFY_ACCOUNTS_BASE_URL, SPOTIFY_API_BASE_URL, USER_AGENT from .logger import create_logger if TYPE_CHECKING: - from database import Database - from dataclass.config import Config + from database import Database + from dataclass.config import Config class PrivateSpotify: + """ + Custom Spotify client designed to work with predefined credentials + obtained using the Authorization Code Flow. Used for instances where + the user has already authorized the application and wants to access + their data through Blanco. + """ + + def __init__(self, config: 'Config', database: 'Database', credentials: 'OAuth'): + self._client_id = config.spotify_client_id + self._client_secret = config.spotify_client_secret + self._credentials = credentials + self._db = database + self._logger = create_logger(self.__class__.__name__) + + def _refresh_token(self): """ - Custom Spotify client designed to work with predefined credentials - obtained using the Authorization Code Flow. Used for instances where - the user has already authorized the application and wants to access - their data through Blanco. + Refresh the access token for a user. """ - def __init__(self, config: 'Config', database: 'Database', credentials: 'OAuth'): - self._client_id = config.spotify_client_id - self._client_secret = config.spotify_client_secret - self._credentials = credentials - self._db = database - self._logger = create_logger(self.__class__.__name__) - - def _refresh_token(self): - """ - Refresh the access token for a user. - """ - auth_token = b64encode(f"{self._client_id}:{self._client_secret}".encode()).decode() - response = requests.post( - str(SPOTIFY_ACCOUNTS_BASE_URL / 'token'), - headers={ - 'Authorization': f'Basic {auth_token}', - }, - data={ - 'grant_type': 'refresh_token', - 'refresh_token': self._credentials.refresh_token - }, - timeout=10 - ) - - try: - response.raise_for_status() - except HTTPError as err: - self._logger.error( - 'Error refreshing Spotify access token for user %d: %s', - self._credentials.user_id, - err - ) - raise - except Timeout: - self._logger.error( - 'Timed out while refreshing Spotify access token for user %d', - self._credentials.user_id - ) - - # Delete the user's credentials from the database - self._db.delete_oauth('spotify', self._credentials.user_id) - raise - - # Update the credentials - parsed = response.json() - new_credentials = OAuth( - user_id=self._credentials.user_id, - username=self._credentials.username, - access_token=parsed['access_token'], - refresh_token=self._credentials.refresh_token, - expires_at=int(time() + parsed['expires_in']) - ) - self._db.set_oauth('spotify', new_credentials) - self._db.set_spotify_scopes(self._credentials.user_id, parsed['scope'].split(' ')) - self._credentials = new_credentials - - def _ensure_auth(self): - """ - Makes sure that the credentials are up to date. - """ - if self._credentials.expires_at < time() + 60: - # Refresh token - self._logger.debug( - 'Refreshing Spotify token for user %d', - self._credentials.user_id - ) - self._refresh_token() - - def get_user_playlists(self) -> List[SpotifyResult]: - """ - Gets a list of 25 of the user's playlists. - """ - self._ensure_auth() - response = requests.get( - str(SPOTIFY_API_BASE_URL / 'me' / 'playlists'), - headers={ - 'Authorization': f'Bearer {self._credentials.access_token}', - 'User-Agent': USER_AGENT - }, - params={ - 'limit': 25 - }, - timeout=10 - ) - - try: - response.raise_for_status() - except HTTPError as err: - self._logger.error( - 'Error %d getting Spotify playlists for user %d.\n%s', - err.response.status_code if err.response is not None else -1, - self._credentials.user_id, - err - ) - raise - except Timeout: - self._logger.error( - 'Timed out while getting Spotify playlists for user %d', - self._credentials.user_id - ) - return [] - - parsed = response.json() - return [SpotifyResult( - name=playlist['name'], - description=f'{playlist["tracks"]["total"]} tracks', - spotify_id=playlist['id'] - ) for playlist in parsed['items']] - - def save_track(self, spotify_id: str): - """ - Adds a track to the user's Liked Songs. - """ - self._ensure_auth() - response = requests.put( - str(SPOTIFY_API_BASE_URL / 'me' / 'tracks'), - headers={ - 'Authorization': f'Bearer {self._credentials.access_token}', - 'User-Agent': USER_AGENT - }, - params={ - 'ids': spotify_id - }, - timeout=10 - ) - - try: - response.raise_for_status() - except HTTPError as err: - self._logger.error( - 'Error %d while trying to Like track %s.\n%s', - err.response.status_code if err.response is not None else -1, - spotify_id, - err - ) - raise - except Timeout: - self._logger.error( - 'Timed out while liking track %s', - spotify_id - ) - raise + auth_token = b64encode(f'{self._client_id}:{self._client_secret}'.encode()).decode() + response = requests.post( + str(SPOTIFY_ACCOUNTS_BASE_URL / 'token'), + headers={ + 'Authorization': f'Basic {auth_token}', + }, + data={ + 'grant_type': 'refresh_token', + 'refresh_token': self._credentials.refresh_token, + }, + timeout=10, + ) + + try: + response.raise_for_status() + except HTTPError as err: + self._logger.error( + 'Error refreshing Spotify access token for user %d: %s', + self._credentials.user_id, + err, + ) + raise + except Timeout: + self._logger.error( + 'Timed out while refreshing Spotify access token for user %d', + self._credentials.user_id, + ) + + # Delete the user's credentials from the database + self._db.delete_oauth('spotify', self._credentials.user_id) + raise + + # Update the credentials + parsed = response.json() + new_credentials = OAuth( + user_id=self._credentials.user_id, + username=self._credentials.username, + access_token=parsed['access_token'], + refresh_token=self._credentials.refresh_token, + expires_at=int(time() + parsed['expires_in']), + ) + self._db.set_oauth('spotify', new_credentials) + self._db.set_spotify_scopes(self._credentials.user_id, parsed['scope'].split(' ')) + self._credentials = new_credentials + + def _ensure_auth(self): + """ + Makes sure that the credentials are up to date. + """ + if self._credentials.expires_at < time() + 60: + # Refresh token + self._logger.debug( + 'Refreshing Spotify token for user %d', self._credentials.user_id + ) + self._refresh_token() + + def get_user_playlists(self) -> List[SpotifyResult]: + """ + Gets a list of 25 of the user's playlists. + """ + self._ensure_auth() + response = requests.get( + str(SPOTIFY_API_BASE_URL / 'me' / 'playlists'), + headers={ + 'Authorization': f'Bearer {self._credentials.access_token}', + 'User-Agent': USER_AGENT, + }, + params={'limit': 25}, + timeout=10, + ) + + try: + response.raise_for_status() + except HTTPError as err: + self._logger.error( + 'Error %d getting Spotify playlists for user %d.\n%s', + err.response.status_code if err.response is not None else -1, + self._credentials.user_id, + err, + ) + raise + except Timeout: + self._logger.error( + 'Timed out while getting Spotify playlists for user %d', + self._credentials.user_id, + ) + return [] + + parsed = response.json() + return [ + SpotifyResult( + name=playlist['name'], + description=f'{playlist['tracks']['total']} tracks', + spotify_id=playlist['id'], + ) + for playlist in parsed['items'] + ] + + def save_track(self, spotify_id: str): + """ + Adds a track to the user's Liked Songs. + """ + self._ensure_auth() + response = requests.put( + str(SPOTIFY_API_BASE_URL / 'me' / 'tracks'), + headers={ + 'Authorization': f'Bearer {self._credentials.access_token}', + 'User-Agent': USER_AGENT, + }, + params={'ids': spotify_id}, + timeout=10, + ) + + try: + response.raise_for_status() + except HTTPError as err: + self._logger.error( + 'Error %d while trying to Like track %s.\n%s', + err.response.status_code if err.response is not None else -1, + spotify_id, + err, + ) + raise + except Timeout: + self._logger.error('Timed out while liking track %s', spotify_id) + raise diff --git a/utils/time.py b/utils/time.py index 2af1ea7..dca14b3 100644 --- a/utils/time.py +++ b/utils/time.py @@ -7,36 +7,36 @@ def get_time_components(msec: Union[int, float]) -> Tuple[int, int, int]: - """ - Decompose milliseconds into a tuple of hours, minutes, and seconds. - """ - minute, sec = divmod(msec / 1000, 60) - hour, minute = divmod(minute, 60) - return floor(hour), floor(minute), floor(sec) + """ + Decompose milliseconds into a tuple of hours, minutes, and seconds. + """ + minute, sec = divmod(msec / 1000, 60) + hour, minute = divmod(minute, 60) + return floor(hour), floor(minute), floor(sec) def human_readable_time(msec: Union[int, float]) -> str: - """ - Turn milliseconds into a human readable time string. - """ - hour, minute, sec = get_time_components(msec) - string = '' - if hour > 0: - string += f'{hour} hr' - if minute > 0: - string += f' {minute} min' - if sec > 0: - string += f' {sec} sec' + """ + Turn milliseconds into a human readable time string. + """ + hour, minute, sec = get_time_components(msec) + string = '' + if hour > 0: + string += f'{hour} hr' + if minute > 0: + string += f' {minute} min' + if sec > 0: + string += f' {sec} sec' - return string.strip() + return string.strip() def machine_readable_time(colon_delimited_time: str) -> int: - """ - Parse colon delimited time (e.g. "1:30:00") into milliseconds. - """ - time_segments = colon_delimited_time.split(':') - sec = int(time_segments[-1]) - minute = int(time_segments[-2]) - hour = int(time_segments[0]) if len(time_segments) == 3 else 0 - return hour * 3600000 + minute * 60000 + sec * 1000 + """ + Parse colon delimited time (e.g. "1:30:00") into milliseconds. + """ + time_segments = colon_delimited_time.split(':') + sec = int(time_segments[-1]) + minute = int(time_segments[-2]) + hour = int(time_segments[0]) if len(time_segments) == 3 else 0 + return hour * 3600000 + minute * 60000 + sec * 1000 diff --git a/utils/url.py b/utils/url.py index 1109f67..3a85e09 100644 --- a/utils/url.py +++ b/utils/url.py @@ -11,171 +11,178 @@ def check_contains_ytlistid(url: str) -> bool: - """ - Checks if the URL is a YouTube URL with a 'list' query parameter. - """ - if not check_youtube_url(url): - return False + """ + Checks if the URL is a YouTube URL with a 'list' query parameter. + """ + if not check_youtube_url(url): + return False - parsed_url = urlparse(url) - query = parse_qs(parsed_url.query) - return 'list' in query and len(query['list']) > 0 + parsed_url = urlparse(url) + query = parse_qs(parsed_url.query) + return 'list' in query and len(query['list']) > 0 def check_url(url: str) -> bool: - """ - Checks if the URL is a valid URL. - """ - return validators.domain(url) or validators.url(url) # type: ignore + """ + Checks if the URL is a valid URL. + """ + return validators.domain(url) or validators.url(url) # type: ignore def check_sc_url(url: str) -> bool: - """ - Checks if the URL is a valid SoundCloud URL. - """ - url_regex = r"(^http(s)?://)?(soundcloud\.com|snd\.sc)/(.*)$" - return re.match(url_regex, url) is not None + """ + Checks if the URL is a valid SoundCloud URL. + """ + url_regex = r'(^http(s)?://)?(soundcloud\.com|snd\.sc)/(.*)$' + return re.match(url_regex, url) is not None def check_spotify_url(url: str) -> bool: - """ - Checks if the URL is a valid Spotify URL. - """ - url_regex = r"(https?://open\.)*spotify(\.com)*[/:]+(track|artist|album|playlist)[/:]+[A-Za-z0-9]+" # pylint: disable=line-too-long - return re.match(url_regex, url) is not None + """ + Checks if the URL is a valid Spotify URL. + """ + url_regex = r'(https?://open\.)*spotify(\.com)*[/:]+(track|artist|album|playlist)[/:]+[A-Za-z0-9]+' # pylint: disable=line-too-long + return re.match(url_regex, url) is not None def check_twitch_url(url: str) -> bool: - """ - Checks if the URL is a valid Twitch URL. - """ - url_regex = r"(^http(s)?://)?((www|en-es|en-gb|secure|beta|ro|www-origin|en-ca|fr-ca|lt|zh-tw|he|id|ca|mk|lv|ma|tl|hi|ar|bg|vi|th)\.)?twitch.tv/(?!directory|p|user/legal|admin|login|signup|jobs)(?P\w+)" # pylint: disable=line-too-long - return re.match(url_regex, url) is not None + """ + Checks if the URL is a valid Twitch URL. + """ + url_regex = r'(^http(s)?://)?((www|en-es|en-gb|secure|beta|ro|www-origin|en-ca|fr-ca|lt|zh-tw|he|id|ca|mk|lv|ma|tl|hi|ar|bg|vi|th)\.)?twitch.tv/(?!directory|p|user/legal|admin|login|signup|jobs)(?P\w+)' # pylint: disable=line-too-long + return re.match(url_regex, url) is not None def check_youtube_url(url: str) -> bool: - """ - Checks if the URL is a valid YouTube URL. - """ - url_regex = r"(?:https?://)?(?:youtu\.be/|(?:www\.|m\.)?youtube\.com/(?:watch|v|embed)(?:\.php)?(?:\?.*v=|/))([a-zA-Z0-9_-]+)" # pylint: disable=line-too-long - return re.match(url_regex, url) is not None + """ + Checks if the URL is a valid YouTube URL. + """ + url_regex = r'(?:https?://)?(?:youtu\.be/|(?:www\.|m\.)?youtube\.com/(?:watch|v|embed)(?:\.php)?(?:\?.*v=|/))([a-zA-Z0-9_-]+)' # pylint: disable=line-too-long + return re.match(url_regex, url) is not None def check_youtube_playlist_url(url: str) -> bool: - """ - Checks if the URL is a valid YouTube playlist URL. - """ - url_regex = r"(?:https?://)?(?:www\.)?youtube\.com/playlist\?list=([a-zA-Z0-9_-]+)" - return re.match(url_regex, url) is not None + """ + Checks if the URL is a valid YouTube playlist URL. + """ + url_regex = r'(?:https?://)?(?:www\.)?youtube\.com/playlist\?list=([a-zA-Z0-9_-]+)' + return re.match(url_regex, url) is not None def check_ytmusic_url(url: str) -> bool: - """ - Checks if the URL is a valid YouTube Music URL. - """ - url_regex = r"(?:https?://)?music\.youtube\.com/(?:watch|v|embed)(?:\.php)?(?:\?.*v=|/)([a-zA-Z0-9_-]+)" # pylint: disable=line-too-long - return re.match(url_regex, url) is not None + """ + Checks if the URL is a valid YouTube Music URL. + """ + url_regex = r'(?:https?://)?music\.youtube\.com/(?:watch|v|embed)(?:\.php)?(?:\?.*v=|/)([a-zA-Z0-9_-]+)' # pylint: disable=line-too-long + return re.match(url_regex, url) is not None def check_ytmusic_playlist_url(url: str) -> bool: - """ - Checks if the URL is a valid YouTube Music playlist URL. - """ - url_regex = r"(?:https?://)?music\.youtube\.com/playlist\?list=([a-zA-Z0-9_-]+)" - return re.match(url_regex, url) is not None + """ + Checks if the URL is a valid YouTube Music playlist URL. + """ + url_regex = r'(?:https?://)?music\.youtube\.com/playlist\?list=([a-zA-Z0-9_-]+)' + return re.match(url_regex, url) is not None def get_sctype_from_url(url: str) -> bool: - """ - Determine SoundCloud entity type from URL. - - Returns - ------- - True if URL is a SoundCloud track, False if URL is a SoundCloud playlist. - """ - if url.startswith(('soundcloud', 'www')): - url = 'http://' + url - - query = urlparse(url) - path = [x for x in query.path.split('/') if x] - if len(path) == 1: - raise LavalinkInvalidIdentifierError( - url, - reason='SoundCloud URL does not point to a track or set.' - ) - if len(path) == 2 and path[1] != 'sets': - return True - if path[1] == 'sets': - return False - raise LavalinkInvalidIdentifierError(url, reason='Unrecognized SoundCloud URL.') + """ + Determine SoundCloud entity type from URL. + + Returns + ------- + True if URL is a SoundCloud track, False if URL is a SoundCloud playlist. + """ + if url.startswith(('soundcloud', 'www')): + url = 'http://' + url + + query = urlparse(url) + path = [x for x in query.path.split('/') if x] + if len(path) == 1: + raise LavalinkInvalidIdentifierError( + url, reason='SoundCloud URL does not point to a track or set.' + ) + if len(path) == 2 and path[1] != 'sets': + return True + if path[1] == 'sets': + return False + raise LavalinkInvalidIdentifierError(url, reason='Unrecognized SoundCloud URL.') def get_spinfo_from_url(url: str) -> tuple[str, str]: - """ - Gets the Spotify type and ID from a Spotify URL. - Must be a URL that Blanco can play, i.e. a track, album, or playlist. - - :returns: A tuple containing the type and ID of the Spotify entity. - """ - if not check_spotify_url(url): - raise SpotifyInvalidURLError(url) - - parsed_path = [] - if re.match(r"^https?://open\.spotify\.com", url): - # We are dealing with a link - parsed_url = urlparse(url) - parsed_path = parsed_url.path.split("/")[1:] - elif re.match(r"^spotify:[a-z]", url): - # We are dealing with a Spotify URI - parsed_path = url.split(":")[1:] - if (len(parsed_path) < 2 or - parsed_path[0] not in ('track', 'album', 'playlist', 'artist')): - raise SpotifyInvalidURLError(url) - - return parsed_path[0], parsed_path[1] + """ + Gets the Spotify type and ID from a Spotify URL. + Must be a URL that Blanco can play, i.e. a track, album, or playlist. + + :returns: A tuple containing the type and ID of the Spotify entity. + """ + if not check_spotify_url(url): + raise SpotifyInvalidURLError(url) + + parsed_path = [] + if re.match(r'^https?://open\.spotify\.com', url): + # We are dealing with a link + parsed_url = urlparse(url) + parsed_path = parsed_url.path.split('/')[1:] + elif re.match(r'^spotify:[a-z]', url): + # We are dealing with a Spotify URI + parsed_path = url.split(':')[1:] + if len(parsed_path) < 2 or parsed_path[0] not in ( + 'track', + 'album', + 'playlist', + 'artist', + ): + raise SpotifyInvalidURLError(url) + + return parsed_path[0], parsed_path[1] def get_ytid_from_url(url: str, id_type: str = 'v') -> str: - """ - Gets the YouTube ID from a YouTube URL. - """ - # https://gist.github.com/kmonsoor/2a1afba4ee127cce50a0 - if url.startswith(('youtu', 'www')): - url = 'http://' + url - - query = urlparse(url) - if query.hostname is None: - raise LavalinkInvalidIdentifierError(url, reason='Not a valid YouTube URL') - - if 'youtube' in query.hostname: - if re.match(r"^/watch", query.path): - if len(query.query): - return parse_qs(query.query)[id_type][0] - return query.path.split("/")[2] - if query.path.startswith(('/embed/', '/v/')): - return query.path.split('/')[2] - elif 'youtu.be' in query.hostname: - return query.path[1:] - - raise LavalinkInvalidIdentifierError(url, reason='Could not get video ID from YouTube URL') + """ + Gets the YouTube ID from a YouTube URL. + """ + # https://gist.github.com/kmonsoor/2a1afba4ee127cce50a0 + if url.startswith(('youtu', 'www')): + url = 'http://' + url + + query = urlparse(url) + if query.hostname is None: + raise LavalinkInvalidIdentifierError(url, reason='Not a valid YouTube URL') + + if 'youtube' in query.hostname: + if re.match(r'^/watch', query.path): + if len(query.query): + return parse_qs(query.query)[id_type][0] + return query.path.split('/')[2] + if query.path.startswith(('/embed/', '/v/')): + return query.path.split('/')[2] + elif 'youtu.be' in query.hostname: + return query.path[1:] + + raise LavalinkInvalidIdentifierError( + url, reason='Could not get video ID from YouTube URL' + ) def get_ytlistid_from_url(url: str, force_extract: bool = False) -> str: - """ - Gets the YouTube playlist ID from a YouTube URL. - """ - if url.startswith(('youtu', 'www')): - url = 'http://' + url - - query = urlparse(url) - if query.hostname is None: - raise LavalinkInvalidIdentifierError(url, reason='Not a valid YouTube URL') - - if 'youtube' in query.hostname: - if re.match(r"^/playlist", query.path) or force_extract: - if len(query.query): - return parse_qs(query.query)['list'][0] - else: - raise ValueError('Not a YouTube playlist URL') - - raise LavalinkInvalidIdentifierError(url, reason='Could not get playlist ID from YouTube URL') + """ + Gets the YouTube playlist ID from a YouTube URL. + """ + if url.startswith(('youtu', 'www')): + url = 'http://' + url + + query = urlparse(url) + if query.hostname is None: + raise LavalinkInvalidIdentifierError(url, reason='Not a valid YouTube URL') + + if 'youtube' in query.hostname: + if re.match(r'^/playlist', query.path) or force_extract: + if len(query.query): + return parse_qs(query.query)['list'][0] + else: + raise ValueError('Not a YouTube playlist URL') + + raise LavalinkInvalidIdentifierError( + url, reason='Could not get playlist ID from YouTube URL' + ) diff --git a/views/now_playing.py b/views/now_playing.py index 3d480cb..65bd079 100644 --- a/views/now_playing.py +++ b/views/now_playing.py @@ -14,197 +14,206 @@ from utils.player_checks import check_mutual_voice if TYPE_CHECKING: - from nextcord import Interaction + from nextcord import Interaction - from cogs.player import PlayerCog - from cogs.player.jockey import Jockey - from utils.blanco import BlancoBot + from cogs.player import PlayerCog + from cogs.player.jockey import Jockey + from utils.blanco import BlancoBot class ShuffleButton(Button): + """ + Shuffle button for the Now Playing view. + """ + + def __init__(self, init_state: bool = False): """ - Shuffle button for the Now Playing view. - """ - def __init__(self, init_state: bool = False): - """ - Initialize the shuffle button. + Initialize the shuffle button. - :param init_state: Initial state of the shuffle button. - True if the queue is shuffled, False otherwise. - """ - super().__init__( - style=ButtonStyle.grey, - label='Unshuffle' if init_state else 'Shuffle' - ) + :param init_state: Initial state of the shuffle button. + True if the queue is shuffled, False otherwise. + """ + super().__init__( + style=ButtonStyle.grey, label='Unshuffle' if init_state else 'Shuffle' + ) - async def callback(self, interaction: 'Interaction'): - """ - Toggle shuffle on the current queue. - """ - assert self.view is not None - view: NowPlayingView = self.view + async def callback(self, interaction: 'Interaction'): + """ + Toggle shuffle on the current queue. + """ + assert self.view is not None + view: NowPlayingView = self.view - if await view.check_mutual_voice(interaction): - status = view.player.queue_manager.is_shuffling - self.label = 'Shuffle' if status else 'Unshuffle' - await interaction.response.edit_message(view=view) + if await view.check_mutual_voice(interaction): + status = view.player.queue_manager.is_shuffling + self.label = 'Shuffle' if status else 'Unshuffle' + await interaction.response.edit_message(view=view) - # Shuffle or unshuffle - if status: - return await view.cog.unshuffle(interaction, quiet=True) - return await view.cog.shuffle(interaction, quiet=True) + # Shuffle or unshuffle + if status: + return await view.cog.unshuffle(interaction, quiet=True) + return await view.cog.shuffle(interaction, quiet=True) class NowPlayingView(View): + """ + View for the Now Playing message, which contains buttons for interacting + with the player. + """ + + def __init__( + self, bot: 'BlancoBot', player: 'Jockey', spotify_id: Optional[str] = None + ): + super().__init__(timeout=None) + self._bot = bot + self._cog: 'PlayerCog' = bot.get_cog('PlayerCog') # type: ignore + if self._cog is None: + raise ValueError('PlayerCog not found') + + self._spotify_id = spotify_id + self._player = player + + # Add shuffle button + self.add_item(ShuffleButton(player.queue_manager.is_shuffling)) + + @property + def cog(self) -> 'PlayerCog': + """ + Return the PlayerCog that this View was created by. + """ + return self._cog + + @property + def player(self) -> 'Jockey': + """ + Return the player that this View is bound to. + """ + return self._player + + async def check_mutual_voice(self, interaction: 'Interaction') -> bool: + """ + Check if the user is in the same voice channel as the bot. + """ + try: + _ = check_mutual_voice(interaction) + except VoiceCommandError as err: + await interaction.response.send_message(err.args[0], ephemeral=True) + return False + + return True + + @button(label='📋', style=ButtonStyle.green) + async def queue(self, _: 'Button', interaction: 'Interaction'): """ - View for the Now Playing message, which contains buttons for interacting - with the player. - """ - def __init__(self, bot: 'BlancoBot', player: 'Jockey', spotify_id: Optional[str] = None): - super().__init__(timeout=None) - self._bot = bot - self._cog: 'PlayerCog' = bot.get_cog('PlayerCog') # type: ignore - if self._cog is None: - raise ValueError('PlayerCog not found') - - self._spotify_id = spotify_id - self._player = player - - # Add shuffle button - self.add_item(ShuffleButton(player.queue_manager.is_shuffling)) - - @property - def cog(self) -> 'PlayerCog': - """ - Return the PlayerCog that this View was created by. - """ - return self._cog - - @property - def player(self) -> 'Jockey': - """ - Return the player that this View is bound to. - """ - return self._player - - async def check_mutual_voice(self, interaction: 'Interaction') -> bool: - """ - Check if the user is in the same voice channel as the bot. - """ - try: - _ = check_mutual_voice(interaction) - except VoiceCommandError as err: - await interaction.response.send_message(err.args[0], ephemeral=True) - return False - - return True - - @button(label='📋', style=ButtonStyle.green) - async def queue(self, _: 'Button', interaction: 'Interaction'): - """ - Display the current queue. - """ - if await self.check_mutual_voice(interaction): - return await self._cog.queue(interaction) - - @button(label='⏮️', style=ButtonStyle.grey) - async def skip_backward(self, _: 'Button', interaction: 'Interaction'): - """ - Skip to the previous track. - """ - if await self.check_mutual_voice(interaction): - return await self._cog.previous(interaction) - - @button(label='⏸️', style=ButtonStyle.blurple) - async def toggle_pause(self, btn: 'Button', interaction: 'Interaction'): - """ - Toggle pause on the current track. - """ - if await self.check_mutual_voice(interaction): - if self._player.paused: - btn.label = '⏸️' - await interaction.response.edit_message(view=self) - return await self._cog.unpause(interaction, quiet=True) - - btn.label = '▶️' - await interaction.response.edit_message(view=self) - return await self._cog.pause(interaction, quiet=True) - - @button(label='⏭️', style=ButtonStyle.grey) - async def skip_forward(self, _: 'Button', interaction: 'Interaction'): - """ - Skip to the next track. - """ - if await self.check_mutual_voice(interaction): - return await self._cog.skip(interaction) - - @button(label='⏹️', style=ButtonStyle.red) - async def stop_player(self, _: 'Button', interaction: 'Interaction'): - """ - Stop the player. - """ - if await self.check_mutual_voice(interaction): - return await self._cog.stop(interaction) - - @button(label='Like on Spotify', style=ButtonStyle.grey) - async def like(self, _: 'Button', interaction: 'Interaction'): - """ - Like the current track on Spotify. - """ - if not interaction.user: - return - - await interaction.response.defer(ephemeral=True) - if self._spotify_id is None: - return await interaction.followup.send( - embed=create_error_embed('This track does not have a Spotify ID.'), - ephemeral=True - ) - - # Get Spotify client - try: - spotify = self._bot.get_spotify_client(interaction.user.id) - if spotify is None: - raise ValueError('Spotify client not initialized') - except ValueError as err: - return await interaction.followup.send(err.args[0]) - - # Save track - try: - spotify.save_track(self._spotify_id) - except HTTPError as err: - if err.response is not None: - if err.response.status_code == 403: - message = SPOTIFY_403_ERR_MSG.format('Like this track') - else: - message = ''.join([ - f'**Error {err.response.status_code}** while trying to Like this track.', - 'Please try again later.\n', - f'```\n{err}```' - ]) - else: - message = ''.join([ - 'Error while trying to Like this track.', - 'Please try again later.\n', - f'```\n{err}```' - ]) - - return await interaction.followup.send( - embed=create_error_embed(message), - ephemeral=True - ) - except Timeout as err: - return await interaction.followup.send( - embed=create_error_embed('\n'.join([ - 'Timed out while trying to Like this track.', - 'Please try again later.\n', - f'```{err}```' - ])), - ephemeral=True - ) - - # Send response - return await interaction.followup.send( - embed=create_success_embed('Added to your Liked Songs.'), - ephemeral=True + Display the current queue. + """ + if await self.check_mutual_voice(interaction): + return await self._cog.queue(interaction) + + @button(label='⏮️', style=ButtonStyle.grey) + async def skip_backward(self, _: 'Button', interaction: 'Interaction'): + """ + Skip to the previous track. + """ + if await self.check_mutual_voice(interaction): + return await self._cog.previous(interaction) + + @button(label='⏸️', style=ButtonStyle.blurple) + async def toggle_pause(self, btn: 'Button', interaction: 'Interaction'): + """ + Toggle pause on the current track. + """ + if await self.check_mutual_voice(interaction): + if self._player.paused: + btn.label = '⏸️' + await interaction.response.edit_message(view=self) + return await self._cog.unpause(interaction, quiet=True) + + btn.label = '▶️' + await interaction.response.edit_message(view=self) + return await self._cog.pause(interaction, quiet=True) + + @button(label='⏭️', style=ButtonStyle.grey) + async def skip_forward(self, _: 'Button', interaction: 'Interaction'): + """ + Skip to the next track. + """ + if await self.check_mutual_voice(interaction): + return await self._cog.skip(interaction) + + @button(label='⏹️', style=ButtonStyle.red) + async def stop_player(self, _: 'Button', interaction: 'Interaction'): + """ + Stop the player. + """ + if await self.check_mutual_voice(interaction): + return await self._cog.stop(interaction) + + @button(label='Like on Spotify', style=ButtonStyle.grey) + async def like(self, _: 'Button', interaction: 'Interaction'): + """ + Like the current track on Spotify. + """ + if not interaction.user: + return None + + await interaction.response.defer(ephemeral=True) + if self._spotify_id is None: + return await interaction.followup.send( + embed=create_error_embed('This track does not have a Spotify ID.'), + ephemeral=True, + ) + + # Get Spotify client + try: + spotify = self._bot.get_spotify_client(interaction.user.id) + if spotify is None: + raise ValueError('Spotify client not initialized') + except ValueError as err: + return await interaction.followup.send(err.args[0]) + + # Save track + try: + spotify.save_track(self._spotify_id) + except HTTPError as err: + if err.response is not None: + if err.response.status_code == 403: + message = SPOTIFY_403_ERR_MSG.format('Like this track') + else: + message = ''.join( + [ + f'**Error {err.response.status_code}** while trying to Like this track.', + 'Please try again later.\n', + f'```\n{err}```', + ] + ) + else: + message = ''.join( + [ + 'Error while trying to Like this track.', + 'Please try again later.\n', + f'```\n{err}```', + ] ) + + return await interaction.followup.send( + embed=create_error_embed(message), ephemeral=True + ) + except Timeout as err: + return await interaction.followup.send( + embed=create_error_embed( + '\n'.join( + [ + 'Timed out while trying to Like this track.', + 'Please try again later.\n', + f'```{err}```', + ] + ) + ), + ephemeral=True, + ) + + # Send response + return await interaction.followup.send( + embed=create_success_embed('Added to your Liked Songs.'), ephemeral=True + ) diff --git a/views/paginator.py b/views/paginator.py index 119ac91..8e2cb86 100644 --- a/views/paginator.py +++ b/views/paginator.py @@ -8,49 +8,50 @@ from nextcord.ui import View, button if TYPE_CHECKING: - from nextcord import Interaction - from nextcord.ui import Button + from nextcord import Interaction + from nextcord.ui import Button class PaginatorView(View): + """ + Controls for the Paginator. See utils/paginator.py for more information. + """ + + def __init__(self, paginator, timeout: int = 60): + super().__init__(timeout=None) + self.paginator = paginator + + @button(label='⏮️', style=ButtonStyle.grey) + async def first_page(self, _b: 'Button', _i: 'Interaction'): + """ + Go to the first page. + """ + return await self.paginator.first_page() + + @button(label='⏪', style=ButtonStyle.grey) + async def previous_page(self, _b: 'Button', _i: 'Interaction'): + """ + Go to the previous page. + """ + return await self.paginator.previous_page() + + @button(label='🏠', style=ButtonStyle.grey) + async def home_page(self, _b: 'Button', _i: 'Interaction'): + """ + Go to the home page. + """ + return await self.paginator.home_page() + + @button(label='⏩', style=ButtonStyle.grey) + async def next_page(self, _b: 'Button', _i: 'Interaction'): + """ + Go to the next page. + """ + return await self.paginator.next_page() + + @button(label='⏭️', style=ButtonStyle.grey) + async def last_page(self, _b: 'Button', _i: 'Interaction'): + """ + Go to the last page. """ - Controls for the Paginator. See utils/paginator.py for more information. - """ - def __init__(self, paginator, timeout: int = 60): - super().__init__(timeout=None) - self.paginator = paginator - - @button(label='⏮️', style=ButtonStyle.grey) - async def first_page(self, _b: 'Button', _i: 'Interaction'): - """ - Go to the first page. - """ - return await self.paginator.first_page() - - @button(label='⏪', style=ButtonStyle.grey) - async def previous_page(self, _b: 'Button', _i: 'Interaction'): - """ - Go to the previous page. - """ - return await self.paginator.previous_page() - - @button(label='🏠', style=ButtonStyle.grey) - async def home_page(self, _b: 'Button', _i: 'Interaction'): - """ - Go to the home page. - """ - return await self.paginator.home_page() - - @button(label='⏩', style=ButtonStyle.grey) - async def next_page(self, _b: 'Button', _i: 'Interaction'): - """ - Go to the next page. - """ - return await self.paginator.next_page() - - @button(label='⏭️', style=ButtonStyle.grey) - async def last_page(self, _b: 'Button', _i: 'Interaction'): - """ - Go to the last page. - """ - return await self.paginator.last_page() + return await self.paginator.last_page() diff --git a/views/spotify_dropdown.py b/views/spotify_dropdown.py index a98c8b8..3645ccd 100644 --- a/views/spotify_dropdown.py +++ b/views/spotify_dropdown.py @@ -11,102 +11,101 @@ from dataclass.custom_embed import CustomEmbed if TYPE_CHECKING: - from nextcord import Interaction + from nextcord import Interaction - from cogs.player import PlayerCog - from dataclass.spotify import SpotifyResult - from utils.blanco import BlancoBot + from cogs.player import PlayerCog + from dataclass.spotify import SpotifyResult + from utils.blanco import BlancoBot class SpotifyDropdown(Select): + """ + Dropdown menu for selecting a Spotify entity. + """ + + def __init__( + self, + bot: 'BlancoBot', + choices: List['SpotifyResult'], + user_id: int, + entity_type: str, + ): + self._cog: 'PlayerCog' = bot.get_cog('PlayerCog') # type: ignore + self._user_id = user_id + self._choices = {x.spotify_id: x.name for x in choices} + self._type = entity_type + + # Create options + options = [] + for choice in choices: + # Truncate names to 100 characters + choice_name = choice.name + if len(choice_name) > 100: + choice_name = choice_name[:97] + '...' + elif len(choice_name) == 0: + # Some playlists have empty names, for example: + # https://open.spotify.com/playlist/6HlbMZPay5jlI7KWA0Mwyu + choice_name = '(no name)' + + # Truncate descriptions to 100 characters + choice_desc = choice.description + if len(choice_desc) > 100: + choice_desc = choice_desc[:97] + '...' + + options.append( + SelectOption( + label=choice_name, description=choice_desc, value=choice.spotify_id + ) + ) + + super().__init__( + placeholder=f'Choose {entity_type}...', + options=options, + min_values=1, + max_values=1, + ) + + async def callback(self, interaction: 'Interaction'): """ - Dropdown menu for selecting a Spotify entity. + Callback for the dropdown menu. Calls the `/play` command with the + selected entity. """ - def __init__( - self, - bot: 'BlancoBot', - choices: List['SpotifyResult'], - user_id: int, - entity_type: str - ): - self._cog: 'PlayerCog' = bot.get_cog('PlayerCog') # type: ignore - self._user_id = user_id - self._choices = { x.spotify_id: x.name for x in choices } - self._type = entity_type - - # Create options - options = [] - for choice in choices: - # Truncate names to 100 characters - choice_name = choice.name - if len(choice_name) > 100: - choice_name = choice_name[:97] + '...' - elif len(choice_name) == 0: - # Some playlists have empty names, for example: - # https://open.spotify.com/playlist/6HlbMZPay5jlI7KWA0Mwyu - choice_name = '(no name)' - - # Truncate descriptions to 100 characters - choice_desc = choice.description - if len(choice_desc) > 100: - choice_desc = choice_desc[:97] + '...' - - options.append(SelectOption( - label=choice_name, - description=choice_desc, - value=choice.spotify_id - )) - - super().__init__( - placeholder=f'Choose {entity_type}...', - options=options, - min_values=1, - max_values=1 - ) + # Ignore if the user isn't the one who invoked the command + if not interaction.user or interaction.user.id != self._user_id: + return - async def callback(self, interaction: 'Interaction'): - """ - Callback for the dropdown menu. Calls the `/play` command with the - selected entity. - """ - # Ignore if the user isn't the one who invoked the command - if not interaction.user or interaction.user.id != self._user_id: - return - - # Edit message - entity_id = self.values[0] - entity_url = f'https://open.spotify.com/{self._type}/{entity_id}' - if interaction.message: - embed = CustomEmbed( - color=Colour.yellow(), - title=':hourglass:|Loading...', - description=f'Selected {self._type} [{self._choices[entity_id]}]({entity_url}).' - ) - await interaction.message.edit( - embed=embed.get(), - view=None - ) - - # Call the `/play` command with the entity URL - await self._cog.play(interaction, query=entity_url) - - # Delete message - if interaction.message: - await interaction.message.delete() + # Edit message + entity_id = self.values[0] + entity_url = f'https://open.spotify.com/{self._type}/{entity_id}' + if interaction.message: + embed = CustomEmbed( + color=Colour.yellow(), + title=':hourglass:|Loading...', + description=f'Selected {self._type} [{self._choices[entity_id]}]({entity_url}).', + ) + await interaction.message.edit(embed=embed.get(), view=None) + + # Call the `/play` command with the entity URL + await self._cog.play(interaction, query=entity_url) + + # Delete message + if interaction.message: + await interaction.message.delete() class SpotifyDropdownView(View): - """ - View for the `/playlists` command, which contains a dropdown menu for selecting - a Spotify entity. - """ - def __init__( - self, - bot: 'BlancoBot', - playlists: List['SpotifyResult'], - user_id: int, - entity_type: str - ): - super().__init__(timeout=None) - - self.add_item(SpotifyDropdown(bot, playlists, user_id, entity_type)) + """ + View for the `/playlists` command, which contains a dropdown menu for selecting + a Spotify entity. + """ + + def __init__( + self, + bot: 'BlancoBot', + playlists: List['SpotifyResult'], + user_id: int, + entity_type: str, + ): + super().__init__(timeout=None) + + self.add_item(SpotifyDropdown(bot, playlists, user_id, entity_type)) From 7269a424d98f76e59e75994fa5eb6d2c8b246271 Mon Sep 17 00:00:00 2001 From: Jared Dantis Date: Sat, 30 Mar 2024 17:14:59 +0800 Subject: [PATCH 04/33] server: Fix imports --- server/routes.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/server/routes.py b/server/routes.py index b4ec11b..bd3a1c9 100644 --- a/server/routes.py +++ b/server/routes.py @@ -4,18 +4,18 @@ from typing import TYPE_CHECKING -from views.dashboard import dashboard -from views.deleteaccount import delete_account -from views.discordoauth import discordoauth -from views.homepage import homepage -from views.lastfmtoken import lastfm_token -from views.linklastfm import link_lastfm -from views.linkspotify import link_spotify -from views.login import login -from views.logout import logout -from views.robotstxt import robotstxt -from views.spotifyoauth import spotifyoauth -from views.unlink import unlink +from .views.dashboard import dashboard +from .views.deleteaccount import delete_account +from .views.discordoauth import discordoauth +from .views.homepage import homepage +from .views.lastfmtoken import lastfm_token +from .views.linklastfm import link_lastfm +from .views.linkspotify import link_spotify +from .views.login import login +from .views.logout import logout +from .views.robotstxt import robotstxt +from .views.spotifyoauth import spotifyoauth +from .views.unlink import unlink if TYPE_CHECKING: from aiohttp.web import Application From 9e54fea5caddbf5aaa38d6c5b187140bf898a04c Mon Sep 17 00:00:00 2001 From: Jared Dantis Date: Sat, 30 Mar 2024 17:16:44 +0800 Subject: [PATCH 05/33] Jockey: Split off scrobble handler --- cogs/player/__init__.py | 40 +++++++---------- cogs/player/jockey.py | 95 ++++++++++++---------------------------- cogs/player/scrobbler.py | 89 +++++++++++++++++++++++++++++++++++++ utils/exceptions.py | 46 +++++++++++++++---- 4 files changed, 169 insertions(+), 101 deletions(-) create mode 100644 cogs/player/scrobbler.py diff --git a/cogs/player/__init__.py b/cogs/player/__init__.py index dd1939a..eeafaa9 100644 --- a/cogs/player/__init__.py +++ b/cogs/player/__init__.py @@ -20,12 +20,13 @@ from nextcord.abc import Messageable from nextcord.ext import application_checks from nextcord.ext.commands import Cog -from requests import HTTPError +from requests import HTTPError, codes from dataclass.custom_embed import CustomEmbed from utils.constants import RELEASE, SPOTIFY_403_ERR_MSG from utils.embeds import create_error_embed, create_success_embed from utils.exceptions import ( + BlancoException, EmptyQueueError, EndOfQueueError, JockeyError, @@ -44,6 +45,9 @@ from utils.blanco import BlancoBot +QUEUE_LINE_LENGTH = 50 + + class PlayerCog(Cog): """ Cog for creating, controlling, and destroying music players for guilds. @@ -291,10 +295,8 @@ async def play( or not itx.user.voice.channel or not isinstance(itx.guild, Guild) ): - return await itx.response.send_message( - embed=create_error_embed( - message='Connect to a server voice channel to use this command.' - ), + raise BlancoException( + 'Connect to a server voice channel to use this command.', ephemeral=True, ) @@ -302,16 +304,12 @@ async def play( guild_id = itx.guild.id channel = itx.channel if not isinstance(channel, Messageable): - raise RuntimeError('[player::play] itx.channel is not Messageable') + raise BlancoException('[player::play] itx.channel is not Messageable') self._bot.set_status_channel(guild_id, channel) # Check if Lavalink is ready if not self._bot.pool_initialized or len(self._bot.pool.nodes) == 0: - return await itx.response.send_message( - embed=create_error_embed( - message='No Lavalink nodes available. Try again later.' - ) - ) + raise BlancoException('No Lavalink nodes available. Try again later.') # Connect to voice await itx.response.defer() @@ -324,11 +322,7 @@ async def play( ) await self._deafen(itx.guild.me, channel=channel) except AsyncioTimeoutError: - return await itx.followup.send( - embed=create_error_embed( - message='Timed out while connecting to voice. Try again later.' - ) - ) + raise BlancoException('Timed out while connecting to voice. Try again later.') # Dispatch to jockey jockey = await self._get_jockey(itx) @@ -337,11 +331,11 @@ async def play( except JockeyError as err: # Disconnect if we're not playing anything if not jockey.playing: - return await self._disconnect(itx=itx, reason=f'Error: `{err}`') + await self._disconnect(itx=itx, reason=f'Error: `{err}`') - return await itx.followup.send(embed=create_error_embed(str(err))) + raise BlancoException(err) from err except JockeyException as exc: - return await itx.followup.send(embed=create_error_embed(str(exc))) + raise BlancoException(exc) from exc body = [f'{track_name}\n'] @@ -366,9 +360,7 @@ async def play( title='Added to queue', body='\n'.join(body), ) - return await itx.followup.send( - embed=embed.set_footer(text=f'Blanco release {RELEASE}') - ) + await itx.followup.send(embed=embed.set_footer(text=f'Blanco release {RELEASE}')) @slash_command(name='playlists') async def playlist(self, itx: Interaction): @@ -393,7 +385,7 @@ async def playlist(self, itx: Interaction): try: playlists = spotify.get_user_playlists() except HTTPError as err: - if err.response is not None and err.response.status_code == 403: + if err.response is not None and err.response.status_code == codes.forbidden: return await itx.followup.send( embed=create_error_embed( message=SPOTIFY_403_ERR_MSG.format('get your playlists') @@ -491,7 +483,7 @@ async def queue(self, itx: Interaction): line = f'{line_prefix} {index} :: {title} - {artist}' # Truncate line if necessary - if len(line) > 50: + if len(line) > QUEUE_LINE_LENGTH: line = line[:47] + '...' else: line = f'{line:50.50}' diff --git a/cogs/player/jockey.py b/cogs/player/jockey.py index 8a1ee00..4eb6f67 100644 --- a/cogs/player/jockey.py +++ b/cogs/player/jockey.py @@ -21,6 +21,7 @@ from utils.constants import UNPAUSE_THRESHOLD from utils.embeds import create_error_embed from utils.exceptions import ( + BlancoException, BumpError, BumpNotEnabledError, BumpNotReadyError, @@ -30,12 +31,12 @@ LavalinkSearchError, SpotifyNoResultsError, ) -from utils.musicbrainz import annotate_track from utils.time import human_readable_time from views.now_playing import NowPlayingView from .jockey_helpers import find_lavalink_track, invalidate_lavalink_track, parse_query from .queue import QueueManager +from .scrobbler import ScrobbleHandler if TYPE_CHECKING: from mafic import Track @@ -46,6 +47,10 @@ from utils.blanco import BlancoBot +MAX_PLAYER_CONNECT_WAIT_SEC = 10 +MIN_TRACK_LENGTH_FOR_SCROBBLE_MSEC = 30000 + + class Jockey(Player['BlancoBot']): """ Class that handles music playback for a single guild. @@ -60,6 +65,9 @@ def __init__(self, client: 'BlancoBot', channel: 'Connectable'): if not isinstance(channel, StageChannel) and not isinstance(channel, VoiceChannel): raise TypeError(f'Channel must be a voice channel, not {type(channel)}') + # Scrobble handler + self._scrobbler = ScrobbleHandler(client, channel) + # Database self._db = client.database client.database.init_guild(channel.guild.id) @@ -225,7 +233,7 @@ async def _play(self, item: 'QueueItem', position: Optional[int] = None): item.title, ) while not self.connected: - if wait_time >= 10: + if wait_time >= MAX_PLAYER_CONNECT_WAIT_SEC: raise JockeyError('Timeout while waiting for player to connect') from err # Print wait message only once @@ -253,64 +261,17 @@ async def _scrobble(self, item: 'QueueItem'): :param item: The track to scrobble. """ - get_event_loop().create_task(self._scrobble_impl(item)) + loop = get_event_loop() + loop.create_task(self._scrobble_impl(item)) async def _scrobble_impl(self, item: 'QueueItem'): """ - Scrobbles a track for all users in the channel who have - linked their Last.fm accounts. - - Called by _scrobble() in a separate thread. - - :param item: The track to scrobble. + Wraps the scrobble method for logging purposes. """ - if not isinstance(self.channel, VoiceChannel): - return - - # Check if scrobbling is enabled - assert self._bot.config is not None - if not self._bot.config.lastfm_enabled: - return - - # Check if track can be scrobbled - time_now = int(time()) try: - duration = item.duration - if item.lavalink_track is not None: - duration = item.lavalink_track.length - - if item.start_time is not None and duration is not None: - # Check if track is longer than 30 seconds - if duration < 30000: - raise ValueError('Track is too short') - - # Check if enough time has passed (1/2 duration or 4 min, whichever is less) - elapsed_ms = (time_now - item.start_time) * 1000 - if elapsed_ms < min(duration // 2, 240000): - raise ValueError('Not enough time has passed') - else: - # Default to current time for timestamp - item.start_time = time_now - except ValueError as err: - self._logger.warning("Failed to scrobble `%s': %s", item.title, err.args[0]) - return - - # Lookup MusicBrainz ID if needed - if item.mbid is None: - annotate_track(item) - - # Don't scrobble with no MBID and ISRC, - # as the track probably isn't on Last.fm - if item.mbid is None and item.isrc is None: - self._logger.warning("Not scrobbling `%s': no MusicBrainz ID or ISRC", item.title) - return - - # Scrobble for every user - for member in self.channel.members: - if not member.bot: - scrobbler = self._bot.get_scrobbler(member.id) - if scrobbler is not None: - scrobbler.scrobble(item) + self._scrobbler.scrobble(item) + except BlancoException as e: + self._logger.warning("Failed to scrobble `%s': %s", item.title, e) async def disconnect(self, *, force: bool = False): """ @@ -576,20 +537,18 @@ async def skip(self, *, forward: bool = True, index: int = -1, auto: bool = True return - # Is this autoskipping? - if auto: - # Check if we're looping the current track - if self._queue_mgr.is_looping_one: - # Re-enqueue the current track - try: - await self._enqueue(self._queue_mgr.current_index, auto=auto) - except JockeyError as err: - await self._edit_np_controls(show_controls=True) - await self.status_channel.send( - embed=create_error_embed(f'Unable to loop track: {err}') - ) + # Check if we're looping the current track + if auto and self._queue_mgr.is_looping_one: + # Re-enqueue the current track + try: + await self._enqueue(self._queue_mgr.current_index, auto=auto) + except JockeyError as err: + await self._edit_np_controls(show_controls=True) + await self.status_channel.send( + embed=create_error_embed(f'Unable to loop track: {err}') + ) - return + return # Try to enqueue the next playable track delta = 1 if forward else -1 diff --git a/cogs/player/scrobbler.py b/cogs/player/scrobbler.py new file mode 100644 index 0000000..b07e8a3 --- /dev/null +++ b/cogs/player/scrobbler.py @@ -0,0 +1,89 @@ +from time import time +from typing import TYPE_CHECKING + +from nextcord import VoiceChannel + +from utils.exceptions import BlancoException +from utils.musicbrainz import annotate_track + +if TYPE_CHECKING: + from nextcord.abc import Connectable + + from dataclass.queue_item import QueueItem + from utils.blanco import BlancoBot + + +_SEC_IN_MSEC = 1000 +_MIN_IN_SEC = 60 +MIN_TRACK_LENGTH_MSEC = 30 * _SEC_IN_MSEC +MIN_ELAPSED_MSEC = 4 * _MIN_IN_SEC * _SEC_IN_MSEC + + +class ScrobbleHandler: + """ + Scrobbler class for scrobbling tracks to Last.fm. + """ + + def __init__(self, bot: 'BlancoBot', channel: 'Connectable'): + self._bot = bot + self._channel = channel + + def scrobble(self, track: 'QueueItem'): + try: + self._validate_config() + length = self._validate_track_length(track) + self._validate_elapsed(track, length) + self._ensure_annotations(track) + + self._scrobble_for_humans(track) + except AssertionError as e: + raise BlancoException(f"Cannot scrobble `{track.title}': {e}") + + def _validate_config(self): + assert self._bot.config is not None, 'Config is not loaded.' + if not self._bot.config.lastfm_enabled: + raise BlancoException('Last.fm is not enabled.') + + def _validate_track_length(self, track: 'QueueItem') -> int: + """ + Validate the length of the track to be scrobbled. + + Args: + track (QueueItem): The track to be scrobbled. + + Returns: + int: The length of the track in milliseconds. + """ + length = track.duration + if track.lavalink_track is not None: + length = track.lavalink_track.length + + assert length is not None, 'Cannot scrobble track with no duration.' + assert length >= MIN_TRACK_LENGTH_MSEC, 'Track is too short to scrobble.' + return length + + def _validate_elapsed(self, track: 'QueueItem', duration: int): + now = int(time()) + start_time = track.start_time or now + elapsed_ms = (now - start_time) * _SEC_IN_MSEC + assert elapsed_ms >= min( + duration // 2, MIN_ELAPSED_MSEC + ), 'Not enough time elapsed.' + + def _ensure_annotations(self, track: 'QueueItem'): + annotate_track(track) + + has_mbid = track.mbid is not None + has_isrc = track.isrc is not None + assert has_mbid or has_isrc, 'No MusicBrainz ID or ISRC found.' + + def _scrobble_for_humans(self, track: 'QueueItem'): + assert isinstance(self._channel, VoiceChannel), 'Not in a voice channel.' + + human_members = [m for m in self._channel.members if not m.bot] + assert len(human_members) > 0, 'No human members in the voice channel.' + + for human in human_members: + scrobbler = self._bot.get_scrobbler(human.id) + if scrobbler is not None: + scrobbler.scrobble(track) diff --git a/utils/exceptions.py b/utils/exceptions.py index 1652c78..572e1ba 100644 --- a/utils/exceptions.py +++ b/utils/exceptions.py @@ -2,8 +2,32 @@ Custom exceptions for Blanco """ +from typing import Optional, Union -class EmptyQueueError(Exception): + +class BlancoException(Exception): + """ + Custom exception class for Blanco. + + Args: + - ephemeral (bool): Whether the error message should be ephemeral. + """ + + def __init__(self, message: Union[str, Exception], ephemeral: bool = False): + self.ephemeral = ephemeral + + if isinstance(message, Exception): + self.message = str(message) + else: + self.message = message + + super().__init__(self.message) + + def __str__(self) -> str: + return self.message + + +class EmptyQueueError(BlancoException): """ Raised when the queue is empty. """ @@ -13,25 +37,29 @@ def __init__(self): super().__init__(self.message) -class EndOfQueueError(Exception): +class EndOfQueueError(BlancoException): """ Raised when the end of the queue is reached. """ + def __init__(self, message: Optional[str] = None): + self.message = message or 'End of queue reached.' + super().__init__(self.message) + -class JockeyError(Exception): +class JockeyError(BlancoException): """ Raised when an error warrants disconnection from the voice channel. """ -class JockeyException(Exception): +class JockeyException(BlancoException): """ Raised when an error does not warrant disconnection from the voice channel. """ -class LavalinkInvalidIdentifierError(Exception): +class LavalinkInvalidIdentifierError(BlancoException): """ Raised when an invalid identifier is passed to Lavalink. """ @@ -41,7 +69,7 @@ def __init__(self, url, reason=None): super().__init__(self.message) -class LavalinkSearchError(Exception): +class LavalinkSearchError(BlancoException): """ Raised when Lavalink fails to search for a query. """ @@ -51,7 +79,7 @@ def __init__(self, query, reason=None): super().__init__(self.message) -class SpotifyInvalidURLError(Exception): +class SpotifyInvalidURLError(BlancoException): """ Raised when an invalid Spotify link or URI is passed. """ @@ -61,13 +89,13 @@ def __init__(self, url): super().__init__(self.message) -class SpotifyNoResultsError(Exception): +class SpotifyNoResultsError(BlancoException): """ Raised when no results are found for a Spotify query. """ -class VoiceCommandError(Exception): +class VoiceCommandError(BlancoException): """ Raised when a command that requires a voice channel is invoked outside of one. """ From b958cf199207017b26f95dc62481cb29c778dcdd Mon Sep 17 00:00:00 2001 From: Jared Dantis Date: Sat, 30 Mar 2024 17:26:13 +0800 Subject: [PATCH 06/33] chore: Turn magic values into consts --- utils/time.py | 6 +++++- utils/url.py | 7 +++++-- views/now_playing.py | 3 ++- views/spotify_dropdown.py | 7 +++++-- 4 files changed, 17 insertions(+), 6 deletions(-) diff --git a/utils/time.py b/utils/time.py index dca14b3..0f46487 100644 --- a/utils/time.py +++ b/utils/time.py @@ -5,6 +5,8 @@ from math import floor from typing import Tuple, Union +NUM_COLON_DELIMITED_SEGMENTS = 3 + def get_time_components(msec: Union[int, float]) -> Tuple[int, int, int]: """ @@ -38,5 +40,7 @@ def machine_readable_time(colon_delimited_time: str) -> int: time_segments = colon_delimited_time.split(':') sec = int(time_segments[-1]) minute = int(time_segments[-2]) - hour = int(time_segments[0]) if len(time_segments) == 3 else 0 + hour = ( + int(time_segments[0]) if len(time_segments) == NUM_COLON_DELIMITED_SEGMENTS else 0 + ) return hour * 3600000 + minute * 60000 + sec * 1000 diff --git a/utils/url.py b/utils/url.py index 3a85e09..11a8344 100644 --- a/utils/url.py +++ b/utils/url.py @@ -9,6 +9,9 @@ from .exceptions import LavalinkInvalidIdentifierError, SpotifyInvalidURLError +MIN_SPOTIFY_URL_SEGMENTS = 2 +NUM_SC_TRACK_URL_SEGMENTS = 2 + def check_contains_ytlistid(url: str) -> bool: """ @@ -102,7 +105,7 @@ def get_sctype_from_url(url: str) -> bool: raise LavalinkInvalidIdentifierError( url, reason='SoundCloud URL does not point to a track or set.' ) - if len(path) == 2 and path[1] != 'sets': + if len(path) == NUM_SC_TRACK_URL_SEGMENTS and path[1] != 'sets': return True if path[1] == 'sets': return False @@ -127,7 +130,7 @@ def get_spinfo_from_url(url: str) -> tuple[str, str]: elif re.match(r'^spotify:[a-z]', url): # We are dealing with a Spotify URI parsed_path = url.split(':')[1:] - if len(parsed_path) < 2 or parsed_path[0] not in ( + if len(parsed_path) < MIN_SPOTIFY_URL_SEGMENTS or parsed_path[0] not in ( 'track', 'album', 'playlist', diff --git a/views/now_playing.py b/views/now_playing.py index 65bd079..506f7d2 100644 --- a/views/now_playing.py +++ b/views/now_playing.py @@ -7,6 +7,7 @@ from nextcord import ButtonStyle from nextcord.ui import Button, View, button from requests.exceptions import HTTPError, Timeout +from requests.status_codes import codes from utils.constants import SPOTIFY_403_ERR_MSG from utils.embeds import create_error_embed, create_success_embed @@ -177,7 +178,7 @@ async def like(self, _: 'Button', interaction: 'Interaction'): spotify.save_track(self._spotify_id) except HTTPError as err: if err.response is not None: - if err.response.status_code == 403: + if err.response.status_code == codes.forbidden: message = SPOTIFY_403_ERR_MSG.format('Like this track') else: message = ''.join( diff --git a/views/spotify_dropdown.py b/views/spotify_dropdown.py index 3645ccd..70234a3 100644 --- a/views/spotify_dropdown.py +++ b/views/spotify_dropdown.py @@ -18,6 +18,9 @@ from utils.blanco import BlancoBot +MAX_LINE_LENGTH = 100 + + class SpotifyDropdown(Select): """ Dropdown menu for selecting a Spotify entity. @@ -40,7 +43,7 @@ def __init__( for choice in choices: # Truncate names to 100 characters choice_name = choice.name - if len(choice_name) > 100: + if len(choice_name) > MAX_LINE_LENGTH: choice_name = choice_name[:97] + '...' elif len(choice_name) == 0: # Some playlists have empty names, for example: @@ -49,7 +52,7 @@ def __init__( # Truncate descriptions to 100 characters choice_desc = choice.description - if len(choice_desc) > 100: + if len(choice_desc) > MAX_LINE_LENGTH: choice_desc = choice_desc[:97] + '...' options.append( From 15d204d03f3bbe120048cb1416515fe0079e6688 Mon Sep 17 00:00:00 2001 From: Jared Dantis Date: Sat, 30 Mar 2024 17:38:29 +0800 Subject: [PATCH 07/33] ruff: Turn too-many-branches into TODOs for now --- .pylintrc | 2 - cogs/player/jockey_helpers.py | 72 +++++++++++++++++++++-------------- server/views/discordoauth.py | 4 +- server/views/spotifyoauth.py | 4 +- utils/musicbrainz.py | 7 +++- 5 files changed, 54 insertions(+), 35 deletions(-) delete mode 100644 .pylintrc diff --git a/.pylintrc b/.pylintrc deleted file mode 100644 index cab0349..0000000 --- a/.pylintrc +++ /dev/null @@ -1,2 +0,0 @@ -[MESSAGES CONTROL] -disable=too-many-instance-attributes,too-many-locals,too-many-return-statements,too-few-public-methods,too-many-branches diff --git a/cogs/player/jockey_helpers.py b/cogs/player/jockey_helpers.py index 1656c06..531b9d5 100644 --- a/cogs/player/jockey_helpers.py +++ b/cogs/player/jockey_helpers.py @@ -2,9 +2,10 @@ Helper functions for the music player. """ -from typing import TYPE_CHECKING, List, Tuple, TypeVar +from typing import TYPE_CHECKING, List, Optional, Tuple, TypeVar from mafic import SearchType +from requests.status_codes import codes from spotipy.exceptions import SpotifyException from database.redis import REDIS @@ -91,7 +92,7 @@ def rank_results( return ranked -async def find_lavalink_track( # pylint: disable=too-many-statements +async def find_lavalink_track( # noqa: PLR0912, PLR0915 node: 'Node', item: QueueItem, /, @@ -102,6 +103,8 @@ async def find_lavalink_track( # pylint: disable=too-many-statements """ Finds a matching playable Lavalink track for a QueueItem. + TODO: Split this function into smaller parts. + :param node: The Lavalink node to use for searching. Must be an instance of mafic.Node. :param item: The QueueItem to find a track for. :param deezer_enabled: Whether to use Deezer for searching. @@ -110,27 +113,14 @@ async def find_lavalink_track( # pylint: disable=too-many-statements """ results = [] - # Check Redis if enabled - redis_key = None - redis_key_type = None - if REDIS is not None: - # Determine key type - if item.spotify_id is not None: - redis_key = item.spotify_id - redis_key_type = 'spotify_id' - elif item.isrc is not None: - redis_key = item.isrc - redis_key_type = 'isrc' - - # Get cached Lavalink track - if redis_key is not None and redis_key_type is not None: - encoded = REDIS.get_lavalink_track(redis_key, key_type=redis_key_type) - if encoded is not None: - LOGGER.info('Found cached Lavalink track for Spotify ID %s', item.spotify_id) - if in_place: - item.lavalink_track = await node.decode_track(encoded) - - return await node.decode_track(encoded) + cached, redis_key, redis_key_type = _get_cached_track(item) + if cached is not None: + LOGGER.info('Found cached Lavalink track for Spotify ID %s', item.spotify_id) + track = await node.decode_track(cached) + if in_place: + item.lavalink_track = track + + return track # Annotate track with ISRC and/or MBID if item.isrc is None or lookup_mbid: @@ -218,15 +208,39 @@ async def find_lavalink_track( # pylint: disable=too-many-statements lavalink_track = results[0].lavalink_track if in_place: item.lavalink_track = lavalink_track - - # Save data to Redis if enabled - if REDIS is not None and redis_key_type is not None and redis_key is not None: - # Save Lavalink track - REDIS.set_lavalink_track(redis_key, lavalink_track.id, key_type=redis_key_type) + _set_cached_track(lavalink_track.id, key=redis_key, key_type=redis_key_type) return lavalink_track +def _get_cached_track( + item: QueueItem, +) -> Tuple[Optional[str], Optional[str], Optional[str]]: + redis_key = None + redis_key_type = None + if item.spotify_id is not None: + redis_key = item.spotify_id + redis_key_type = 'spotify_id' + elif item.isrc is not None: + redis_key = item.isrc + redis_key_type = 'isrc' + + cached = None + if REDIS is not None and redis_key is not None and redis_key_type is not None: + cached = REDIS.get_lavalink_track(redis_key, key_type=redis_key_type) + + return cached, redis_key, redis_key_type + + +def _set_cached_track( + lavalink_track: str, + key: Optional[str] = None, + key_type: Optional[str] = None, +): + if REDIS is not None and key_type is not None and key is not None: + REDIS.set_lavalink_track(key, lavalink_track, key_type=key_type) + + def invalidate_lavalink_track(item: QueueItem): """ Removes a cached Lavalink track from Redis. @@ -375,7 +389,7 @@ async def parse_spotify_query( # Get playlist or album tracks from Spotify track_queue = spotify.get_tracks(sp_type, sp_id)[2] except SpotifyException as exc: - if exc.http_status == 404: + if exc.http_status == codes.not_found: # No tracks. raise SpotifyNoResultsError( f'The {sp_type} does not exist or is private.' diff --git a/server/views/discordoauth.py b/server/views/discordoauth.py index 32ad294..0397036 100644 --- a/server/views/discordoauth.py +++ b/server/views/discordoauth.py @@ -13,9 +13,11 @@ from utils.constants import DISCORD_API_BASE_URL, USER_AGENT -async def discordoauth(request: web.Request): +async def discordoauth(request: web.Request): # noqa: PLR0911 """ Exchange the code for an access token and store it in the database. + + TODO: Refactor to have fewer returns. """ # Get session session = await get_session(request) diff --git a/server/views/spotifyoauth.py b/server/views/spotifyoauth.py index 83bed1f..1e098b8 100644 --- a/server/views/spotifyoauth.py +++ b/server/views/spotifyoauth.py @@ -14,9 +14,11 @@ from utils.constants import SPOTIFY_ACCOUNTS_BASE_URL, SPOTIFY_API_BASE_URL, USER_AGENT -async def spotifyoauth(request: web.Request): +async def spotifyoauth(request: web.Request): # noqa: PLR0911 """ Exchange the code for an access token and store it in the database. + + TODO: Refactor to have fewer returns. """ # Get session session = await get_session(request) diff --git a/utils/musicbrainz.py b/utils/musicbrainz.py index 966334e..03503d5 100644 --- a/utils/musicbrainz.py +++ b/utils/musicbrainz.py @@ -6,6 +6,7 @@ from ratelimit import limits, sleep_and_retry from requests import HTTPError, Timeout, get +from requests.status_codes import codes from database.redis import REDIS @@ -22,7 +23,7 @@ @limits(calls=25, period=1) @sleep_and_retry -def annotate_track( +def annotate_track( # noqa: PLR0912 track: 'QueueItem', *, in_place: bool = True ) -> Optional[Tuple[str | None, str | None]]: """ @@ -33,6 +34,8 @@ def annotate_track( but we need to make at most two requests per track (one to search for the track by ISRC, and one to search for it by title and artist if the ISRC search fails). + TODO: Refactor to have fewer branches. + :param track: The track to annotate. Must be an instance of dataclass.queue_item.QueueItem. :param in_place: Whether to modify the track in place. If False, a tuple containing @@ -67,7 +70,7 @@ def annotate_track( try: mbid = mb_lookup_isrc(track) except HTTPError as err: - if err.response is not None and err.response.status_code == 404: + if err.response is not None and err.response.status_code == codes.not_found: mbid, isrc = mb_lookup(track) else: raise From 954992d3d55b1d6d0d4590becc349a2338c85eee Mon Sep 17 00:00:00 2001 From: Jared Dantis Date: Sat, 30 Mar 2024 17:39:37 +0800 Subject: [PATCH 08/33] spotify_client: Include query in NoResultsError --- utils/exceptions.py | 4 ++++ utils/spotify_client.py | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/utils/exceptions.py b/utils/exceptions.py index 572e1ba..b7d8d4d 100644 --- a/utils/exceptions.py +++ b/utils/exceptions.py @@ -94,6 +94,10 @@ class SpotifyNoResultsError(BlancoException): Raised when no results are found for a Spotify query. """ + def __init__(self, query): + self.message = f'No results found for "{query}" on Spotify.' + super().__init__(self.message) + class VoiceCommandError(BlancoException): """ diff --git a/utils/spotify_client.py b/utils/spotify_client.py index 7b71e06..db7ef95 100644 --- a/utils/spotify_client.py +++ b/utils/spotify_client.py @@ -275,7 +275,7 @@ def search_track(self, query, limit: int = 1) -> List[SpotifyTrack]: """ response = self._client.search(query, limit=20, type='track') if response is None or len(response['tracks']['items']) == 0: - raise SpotifyNoResultsError + raise SpotifyNoResultsError(query) # Filter out tracks with blacklisted words not in the original query results = [] @@ -312,7 +312,7 @@ def search(self, query: str, search_type: str) -> List[SpotifyResult]: response = self._client.search(query, limit=10, type=search_type) if response is None or len(response[f'{search_type}s']['items']) == 0: - raise SpotifyNoResultsError + raise SpotifyNoResultsError(query) # Parse results items = response[f'{search_type}s']['items'] From 52651bf0ff001cd0a4ddf03e79a567c4f409a932 Mon Sep 17 00:00:00 2001 From: Jared Dantis Date: Sat, 30 Mar 2024 17:45:08 +0800 Subject: [PATCH 09/33] .github: Use ruff for lint and format --- .github/workflows/pull-request.yml | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index ca72b0e..aa5b2c7 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -12,17 +12,19 @@ jobs: - uses: actions/checkout@v3 - uses: actions/setup-python@v4 with: - python-version: 3.11 - cache: 'pip' + python-version: 3.12 + cache: 'poetry' - name: Install dependencies run: | - python -m pip install --upgrade pip wheel - pip install -r requirements.txt - - name: Install pylint - run: pip install pylint - - name: Run pylint + python -m pip install poetry + poetry env use 3.12 + poetry install + - name: Run linter run: | - pylint --rcfile=.pylintrc --fail-under=9.0 $(git ls-files '*.py') + poetry run ruff check --no-fix --no-cache $(git ls-files '*.py') + - name: Run format checker + run: | + poetry run ruff format --check --no-cache $(git ls-files '*.py') build: uses: ./.github/workflows/build.yml From 06ca9a4ec248d53cd86002fc7aee4009d3f4bd5b Mon Sep 17 00:00:00 2001 From: Jared Dantis Date: Sat, 30 Mar 2024 17:49:17 +0800 Subject: [PATCH 10/33] .github: Install poetry before setting up cache --- .github/workflows/pull-request.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index aa5b2c7..de231e3 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -9,14 +9,15 @@ jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 + - name: Install poetry + run: pipx install poetry - uses: actions/setup-python@v4 with: python-version: 3.12 cache: 'poetry' - name: Install dependencies run: | - python -m pip install poetry poetry env use 3.12 poetry install - name: Run linter From f9bd46834da812627b459f8989d6294391fa16f5 Mon Sep 17 00:00:00 2001 From: Jared Dantis Date: Sat, 30 Mar 2024 17:57:20 +0800 Subject: [PATCH 11/33] chore: Update Dockerfile --- Dockerfile | 31 +++++++++++++++++++------------ 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/Dockerfile b/Dockerfile index 24c4634..9d2df1e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,25 +13,31 @@ RUN npm install -D tailwindcss && \ -o ./server/static/css/main.css --minify -FROM python:3.11 AS dependencies +FROM python:3.12 AS dependencies # Install build-essential for building Python packages RUN apt-get update && apt-get install -y build-essential -# Install pip requirements under virtualenv -RUN python -m venv /opt/venv -ENV PATH="/opt/venv/bin:${PATH}" -COPY requirements.txt . -RUN pip install --upgrade pip wheel && pip install -r requirements.txt +# Install Poetry +RUN pip install poetry==1.8.2 +ENV POETRY_NO_INTERACTION=1 \ + POETRY_VIRTUALENVS_IN_PROJECT=1 \ + POETRY_VIRTUALENVS_CREATE=1 \ + POETRY_CACHE_DIR=/tmp/poetry_cache +# Install dependencies +WORKDIR /app +COPY pyproject.toml poetry.lock ./ +RUN --mount=type=cache,target=$POETRY_CACHE_DIR poetry install --without dev --no-root -FROM python:3.11-slim AS main + +FROM python:3.12-slim AS main ARG RELEASE -COPY --from=dependencies /opt/venv /opt/venv LABEL maintainer="Jared Dantis " # Copy bot files COPY . /opt/app +COPY --from=dependencies /app/.venv /opt/venv COPY --from=tailwind /opt/build/server/static/css/main.css /opt/app/server/static/css/main.css WORKDIR /opt/app @@ -39,8 +45,9 @@ WORKDIR /opt/app RUN sed -i "s/0.0.0-unknown/${RELEASE}/" utils/constants.py # Run bot -ENV PATH="/opt/venv/bin:${PATH}" -ENV PYTHONDONTWRITEBYTECODE=1 -ENV PYTHONUNBUFFERED=1 +ENV PATH="/opt/venv/bin:${PATH}" \ + PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 EXPOSE 8080 -CMD ["python3", "main.py"] +ENTRYPOINT ["python"] +CMD ["-m", "main"] From f6d724483761f606e90535ae727c38fe5b8ebe72 Mon Sep 17 00:00:00 2001 From: Jared Dantis Date: Sat, 30 Mar 2024 18:08:08 +0800 Subject: [PATCH 12/33] Jockey: Log raw BlancoException from scrobbler --- cogs/player/jockey.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cogs/player/jockey.py b/cogs/player/jockey.py index 4eb6f67..370506e 100644 --- a/cogs/player/jockey.py +++ b/cogs/player/jockey.py @@ -271,7 +271,7 @@ async def _scrobble_impl(self, item: 'QueueItem'): try: self._scrobbler.scrobble(item) except BlancoException as e: - self._logger.warning("Failed to scrobble `%s': %s", item.title, e) + self._logger.warning(e) async def disconnect(self, *, force: bool = False): """ From 0a5233e8f0804ed2bb40ae2dbd22a58afe4380e6 Mon Sep 17 00:00:00 2001 From: Jared Dantis Date: Sat, 30 Mar 2024 18:28:59 +0800 Subject: [PATCH 13/33] Dockerfile: Explicitly declare setuptools as dependency --- Dockerfile | 3 ++- poetry.lock | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 9d2df1e..0675fdf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,7 +28,8 @@ ENV POETRY_NO_INTERACTION=1 \ # Install dependencies WORKDIR /app COPY pyproject.toml poetry.lock ./ -RUN --mount=type=cache,target=$POETRY_CACHE_DIR poetry install --without dev --no-root +RUN --mount=type=cache,target=$POETRY_CACHE_DIR poetry install --without dev +RUN --mount=type=cache,target=$POETRY_CACHE_DIR poetry add setuptools FROM python:3.12-slim AS main diff --git a/poetry.lock b/poetry.lock index 6f12eeb..f6ec9a5 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1505,4 +1505,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "569c94d436f2db5b6412292efa2930368ad66067ac9307d1f9081bdb28630b08" +content-hash = "e148f10f169b86c23ef9203c0766c59c1d647ca823913f3ec6c72700d10320c8" From 0470202b9db19c8057dfae5a6927e894f5e17e09 Mon Sep 17 00:00:00 2001 From: Jared Dantis Date: Sun, 31 Mar 2024 01:16:55 +0800 Subject: [PATCH 14/33] Regenerate lockfile --- poetry.lock | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/poetry.lock b/poetry.lock index f6ec9a5..d23b5bc 100644 --- a/poetry.lock +++ b/poetry.lock @@ -945,13 +945,13 @@ virtualenv = ">=20.10.0" [[package]] name = "pycparser" -version = "2.21" +version = "2.22" description = "C parser in Python" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +python-versions = ">=3.8" files = [ - {file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"}, - {file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"}, + {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"}, + {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, ] [[package]] @@ -1505,4 +1505,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "e148f10f169b86c23ef9203c0766c59c1d647ca823913f3ec6c72700d10320c8" +content-hash = "569c94d436f2db5b6412292efa2930368ad66067ac9307d1f9081bdb28630b08" From f578d573d6671113a747c6734691768b91678672 Mon Sep 17 00:00:00 2001 From: Jared Dantis Date: Sun, 31 Mar 2024 01:21:32 +0800 Subject: [PATCH 15/33] .github: Explicitly declare PR job perms --- .github/workflows/pull-request.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index de231e3..6b1bb54 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -5,6 +5,10 @@ on: branches: - 'main' +permissions: + contents: read + packages: write + jobs: lint: runs-on: ubuntu-latest From e51506209c1344c5221829fb1896356c7d469695 Mon Sep 17 00:00:00 2001 From: Jared Dantis Date: Sun, 31 Mar 2024 02:05:51 +0800 Subject: [PATCH 16/33] PlayerCog: Decompose find_lavalink_track --- .vscode/settings.json | 2 +- cogs/player/__init__.py | 2 +- cogs/player/jockey.py | 3 +- cogs/player/jockey_helpers.py | 157 +---------------------- cogs/player/track_finder.py | 226 ++++++++++++++++++++++++++++++++++ utils/blanco.py | 2 +- 6 files changed, 233 insertions(+), 159 deletions(-) create mode 100644 cogs/player/track_finder.py diff --git a/.vscode/settings.json b/.vscode/settings.json index b1800db..8a965d3 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,7 +3,7 @@ "files.exclude": { "**/__pycache__": true }, - "editor.tabSize": 4, + "editor.tabSize": 2, "css.lint.unknownAtRules": "ignore", "pylint.args": [ "--disable=too-many-instance-attributes", diff --git a/cogs/player/__init__.py b/cogs/player/__init__.py index eeafaa9..a8a8165 100644 --- a/cogs/player/__init__.py +++ b/cogs/player/__init__.py @@ -85,7 +85,7 @@ async def on_voice_state_update( and jockey.channel.members[0].id == member.guild.me.id # type: ignore and after.channel is None ): - return await self._disconnect(jockey=jockey, reason='You left me alone :(') + return self._disconnect(jockey=jockey, reason='You left me alone :(') # Did we get server undeafened? if member.id == member.guild.me.id and before.deaf and not after.deaf: diff --git a/cogs/player/jockey.py b/cogs/player/jockey.py index 370506e..b29633d 100644 --- a/cogs/player/jockey.py +++ b/cogs/player/jockey.py @@ -34,9 +34,10 @@ from utils.time import human_readable_time from views.now_playing import NowPlayingView -from .jockey_helpers import find_lavalink_track, invalidate_lavalink_track, parse_query +from .jockey_helpers import invalidate_lavalink_track, parse_query from .queue import QueueManager from .scrobbler import ScrobbleHandler +from .track_finder import find_lavalink_track if TYPE_CHECKING: from mafic import Track diff --git a/cogs/player/jockey_helpers.py b/cogs/player/jockey_helpers.py index 531b9d5..7bdab12 100644 --- a/cogs/player/jockey_helpers.py +++ b/cogs/player/jockey_helpers.py @@ -2,7 +2,7 @@ Helper functions for the music player. """ -from typing import TYPE_CHECKING, List, Optional, Tuple, TypeVar +from typing import TYPE_CHECKING, List, Tuple, TypeVar from mafic import SearchType from requests.status_codes import codes @@ -14,12 +14,10 @@ from utils.exceptions import ( JockeyException, LavalinkInvalidIdentifierError, - LavalinkSearchError, SpotifyNoResultsError, ) from utils.fuzzy import check_similarity_weighted from utils.logger import create_logger -from utils.musicbrainz import annotate_track from utils.spotify_client import Spotify from utils.url import ( check_sc_url, @@ -35,14 +33,12 @@ ) from .lavalink_client import ( - get_deezer_matches, - get_deezer_track, get_soundcloud_matches, get_youtube_matches, ) if TYPE_CHECKING: - from mafic import Node, Track + from mafic import Node from dataclass.spotify import SpotifyTrack @@ -92,155 +88,6 @@ def rank_results( return ranked -async def find_lavalink_track( # noqa: PLR0912, PLR0915 - node: 'Node', - item: QueueItem, - /, - deezer_enabled: bool = False, - in_place: bool = False, - lookup_mbid: bool = False, -) -> 'Track': - """ - Finds a matching playable Lavalink track for a QueueItem. - - TODO: Split this function into smaller parts. - - :param node: The Lavalink node to use for searching. Must be an instance of mafic.Node. - :param item: The QueueItem to find a track for. - :param deezer_enabled: Whether to use Deezer for searching. - :param in_place: Whether to modify the QueueItem in place. - :param lookup_mbid: Whether to look up the MBID for the track. - """ - results = [] - - cached, redis_key, redis_key_type = _get_cached_track(item) - if cached is not None: - LOGGER.info('Found cached Lavalink track for Spotify ID %s', item.spotify_id) - track = await node.decode_track(cached) - if in_place: - item.lavalink_track = track - - return track - - # Annotate track with ISRC and/or MBID - if item.isrc is None or lookup_mbid: - annotate_track(item) - - # Use ISRC if present - if item.isrc is not None: - # Try to match ISRC on Deezer if enabled - if deezer_enabled: - try: - result = await get_deezer_track(node, item.isrc) - except LavalinkSearchError: - LOGGER.warning("No Deezer match for ISRC %s `%s'", item.isrc, item.title) - else: - results.append(result) - LOGGER.debug("Matched ISRC %s `%s' on Deezer", item.isrc, item.title) - - # Try to match ISRC on YouTube - if len(results) == 0: - try: - results = await get_youtube_matches( - node, f'"{item.isrc}"', desired_duration_ms=item.duration - ) - except LavalinkSearchError: - LOGGER.warning("No YouTube match for ISRC %s `%s'", item.isrc, item.title) - else: - LOGGER.debug("Matched ISRC %s `%s' on YouTube", item.isrc, item.title) - else: - LOGGER.warning( - "`%s' has no ISRC. Scrobbling might fail for this track.", item.title - ) - item.is_imperfect = True - - # Fallback to metadata search - if len(results) == 0: - query = f'{item.title} {item.artist}' - - if item.isrc is not None: - LOGGER.warning( - "No ISRC match for `%s'. Falling back to metadata search.", item.title - ) - - # Try to match on Deezer if enabled - if deezer_enabled: - try: - dz_results = await get_deezer_matches( - node, query, desired_duration_ms=item.duration, auto_filter=True - ) - except LavalinkSearchError: - LOGGER.warning("No Deezer results for `%s'", item.title) - else: - # Use top result if it's good enough - ranked = rank_results(query, dz_results, SearchType.DEEZER_SEARCH) - if ranked[0][1] >= CONFIDENCE_THRESHOLD: - LOGGER.warning( - "Using Deezer result `%s' (%s) for `%s'", - ranked[0][0].title, - ranked[0][0].lavalink_track.identifier, - item.title, - ) - results.append(ranked[0][0]) - else: - LOGGER.warning("No similar Deezer results for `%s'", item.title) - - if len(results) == 0: - try: - yt_results = await get_youtube_matches( - node, query, desired_duration_ms=item.duration - ) - except LavalinkSearchError as err: - LOGGER.error(err.message) - raise - - # Use top result - ranked = rank_results(query, yt_results, SearchType.YOUTUBE) - LOGGER.warning( - "Using YouTube result `%s' (%s) for `%s'", - ranked[0][0].title, - ranked[0][0].lavalink_track.identifier, - item.title, - ) - results.append(ranked[0][0]) - - # Save Lavalink result - lavalink_track = results[0].lavalink_track - if in_place: - item.lavalink_track = lavalink_track - _set_cached_track(lavalink_track.id, key=redis_key, key_type=redis_key_type) - - return lavalink_track - - -def _get_cached_track( - item: QueueItem, -) -> Tuple[Optional[str], Optional[str], Optional[str]]: - redis_key = None - redis_key_type = None - if item.spotify_id is not None: - redis_key = item.spotify_id - redis_key_type = 'spotify_id' - elif item.isrc is not None: - redis_key = item.isrc - redis_key_type = 'isrc' - - cached = None - if REDIS is not None and redis_key is not None and redis_key_type is not None: - cached = REDIS.get_lavalink_track(redis_key, key_type=redis_key_type) - - return cached, redis_key, redis_key_type - - -def _set_cached_track( - lavalink_track: str, - key: Optional[str] = None, - key_type: Optional[str] = None, -): - if REDIS is not None and key_type is not None and key is not None: - REDIS.set_lavalink_track(key, lavalink_track, key_type=key_type) - - def invalidate_lavalink_track(item: QueueItem): """ Removes a cached Lavalink track from Redis. diff --git a/cogs/player/track_finder.py b/cogs/player/track_finder.py new file mode 100644 index 0000000..0c61f59 --- /dev/null +++ b/cogs/player/track_finder.py @@ -0,0 +1,226 @@ +from typing import TYPE_CHECKING, List, Optional, Tuple + +from mafic import SearchType + +from database.redis import REDIS +from utils.constants import CONFIDENCE_THRESHOLD +from utils.exceptions import LavalinkSearchError +from utils.logger import create_logger +from utils.musicbrainz import annotate_track + +from .jockey_helpers import rank_results +from .lavalink_client import get_deezer_matches, get_deezer_track, get_youtube_matches + +if TYPE_CHECKING: + from mafic import Node, Track + + from dataclass.lavalink_result import LavalinkResult + from dataclass.queue_item import QueueItem + + from .lavalink_client import LavalinkSearchError + +LOGGER = create_logger('track_finder') + + +async def find_lavalink_track( + node: 'Node', + item: QueueItem, + /, + deezer_enabled: bool = False, + in_place: bool = False, + lookup_mbid: bool = False, +) -> 'Track': + """ + Finds a matching playable Lavalink track for a QueueItem. + + :param node: The Lavalink node to use for searching. Must be an instance of mafic.Node. + :param item: The QueueItem to find a track for. + :param deezer_enabled: Whether to use Deezer for searching. + :param in_place: Whether to modify the QueueItem in place. + :param lookup_mbid: Whether to look up the MBID for the track. + """ + results = [] + + cached, redis_key, redis_key_type = _get_cached_track(item) + if cached is not None: + LOGGER.info('Found cached Lavalink track for Spotify ID %s', item.spotify_id) + track = await node.decode_track(cached) + if in_place: + item.lavalink_track = track + + return track + + if item.isrc is None or lookup_mbid: + annotate_track(item) + + if item.isrc is not None: + if deezer_enabled: + await _append_deezer_results_for_isrc( + results=results, + node=node, + isrc=item.isrc, + title=item.title, + ) + + await _append_youtube_results_for_isrc( + results=results, + node=node, + isrc=item.isrc, + title=item.title, + duration_ms=item.duration, + ) + else: + LOGGER.warning( + "`%s' has no ISRC. Scrobbling might fail for this track.", item.title + ) + item.is_imperfect = True + + # Fallback to metadata search + if len(results) == 0: + query = f'{item.title} {item.artist}' + LOGGER.warning( + "No matches for ISRC %s `%s'. Falling back to metadata search.", + item.isrc, + item.title, + ) + + if deezer_enabled: + await _append_deezer_results_for_metadata( + results=results, + node=node, + query=query, + title=item.title, + duration_ms=item.duration, + ) + + await _append_youtube_results_for_metadata( + results=results, + node=node, + query=query, + title=item.title, + duration_ms=item.duration, + ) + + if len(results) == 0: + raise LavalinkSearchError('No results found') + + lavalink_track = results[0].lavalink_track + if in_place: + item.lavalink_track = lavalink_track + _set_cached_track(lavalink_track.id, key=redis_key, key_type=redis_key_type) + + return lavalink_track + + +def _get_cached_track( + item: QueueItem, +) -> Tuple[Optional[str], Optional[str], Optional[str]]: + redis_key = None + redis_key_type = None + if item.spotify_id is not None: + redis_key = item.spotify_id + redis_key_type = 'spotify_id' + elif item.isrc is not None: + redis_key = item.isrc + redis_key_type = 'isrc' + + cached = None + if REDIS is not None and redis_key is not None and redis_key_type is not None: + cached = REDIS.get_lavalink_track(redis_key, key_type=redis_key_type) + + return cached, redis_key, redis_key_type + + +def _set_cached_track( + lavalink_track: str, + key: Optional[str] = None, + key_type: Optional[str] = None, +): + if REDIS is not None and key_type is not None and key is not None: + REDIS.set_lavalink_track(key, lavalink_track, key_type=key_type) + + +async def _append_deezer_results_for_isrc( + results: List['LavalinkResult'], + node: 'Node', + isrc: str, + title: Optional[str] = None, +) -> List['LavalinkResult']: + try: + result = await get_deezer_track(node, isrc) + except LavalinkSearchError: + LOGGER.warning("No Deezer match for ISRC %s `%s'", isrc, title) + else: + results.append(result) + LOGGER.debug("Matched ISRC %s `%s' on Deezer", isrc, title) + + return results + + +async def _append_deezer_results_for_metadata( + results: List['LavalinkResult'], + node: 'Node', + query: str, + title: Optional[str] = None, + duration_ms: Optional[int] = None, +): + try: + dz_results = await get_deezer_matches( + node, query, desired_duration_ms=duration_ms, auto_filter=True + ) + except LavalinkSearchError: + LOGGER.warning("No Deezer results for `%s'", title) + else: + ranked = rank_results(query, dz_results, SearchType.DEEZER_SEARCH) + if ranked[0][1] >= CONFIDENCE_THRESHOLD: + LOGGER.warning( + "Using Deezer result `%s' (%s) for `%s'", + ranked[0][0].title, + ranked[0][0].lavalink_track.identifier, + title, + ) + results.append(ranked[0][0]) + else: + LOGGER.warning("No similar Deezer results for `%s'", title) + + +async def _append_youtube_results_for_isrc( + results: List['LavalinkResult'], + node: 'Node', + isrc: str, + title: Optional[str] = None, + duration_ms: Optional[int] = None, +): + if len(results) > 0: + return + + try: + results.extend( + await get_youtube_matches(node, f'"{isrc}"', desired_duration_ms=duration_ms) + ) + except LavalinkSearchError: + LOGGER.warning("No YouTube match for ISRC %s `%s'", isrc, title) + else: + LOGGER.debug("Matched ISRC %s `%s' on YouTube", isrc, title) + + +async def _append_youtube_results_for_metadata( + results: List['LavalinkResult'], + node: 'Node', + query: str, + title: Optional[str] = None, + duration_ms: Optional[int] = None, +): + try: + yt_results = await get_youtube_matches(node, query, desired_duration_ms=duration_ms) + except LavalinkSearchError: + LOGGER.warning("No YouTube results for `%s'", title) + else: + ranked = rank_results(query, yt_results, SearchType.YOUTUBE) + LOGGER.warning( + "Using YouTube result `%s' (%s) for `%s'", + ranked[0][0].title, + ranked[0][0].lavalink_track.identifier, + title, + ) + results.append(ranked[0][0]) diff --git a/utils/blanco.py b/utils/blanco.py index 06ad11d..e3f7f47 100644 --- a/utils/blanco.py +++ b/utils/blanco.py @@ -24,7 +24,7 @@ ) from nextcord.ext.commands import Bot, ExtensionNotLoaded -from cogs.player.jockey_helpers import find_lavalink_track +from cogs.player.track_finder import find_lavalink_track from database import Database from views.now_playing import NowPlayingView From 854052b7467964b94e719f0275dd87ff845f3553 Mon Sep 17 00:00:00 2001 From: Jared Dantis Date: Sun, 31 Mar 2024 02:21:37 +0800 Subject: [PATCH 17/33] Move bot files into new root folder --- Makefile | 4 +- {dataclass => bot}/__init__.py | 0 {cogs => bot/cogs}/__init__.py | 2 +- {cogs => bot/cogs}/bumps.py | 15 +- {cogs => bot/cogs}/debug.py | 12 +- {cogs => bot/cogs}/player/__init__.py | 29 ++- {cogs => bot/cogs}/player/jockey.py | 16 +- {cogs => bot/cogs}/player/jockey_helpers.py | 19 +- {cogs => bot/cogs}/player/lavalink_client.py | 8 +- {cogs => bot/cogs}/player/queue.py | 6 +- {cogs => bot/cogs}/player/scrobbler.py | 8 +- {cogs => bot/cogs}/player/track_finder.py | 14 +- {database => bot/database}/__init__.py | 174 +----------------- .../database}/migrations/0000-create.py | 0 .../migrations/0001-lavalink-sessionid.py | 0 .../migrations/0002-statuschannel.py | 0 .../database}/migrations/0003-oauth.py | 0 .../database}/migrations/0004-loop-all.py | 0 .../database}/migrations/0005-bumps.py | 0 .../database}/migrations/__init__.py | 0 {database => bot/database}/redis.py | 6 +- {utils => bot/dataclass}/__init__.py | 0 bot/dataclass/bump.py | 17 ++ {dataclass => bot/dataclass}/config.py | 0 {dataclass => bot/dataclass}/custom_embed.py | 0 .../dataclass}/lavalink_result.py | 0 {dataclass => bot/dataclass}/oauth.py | 0 {dataclass => bot/dataclass}/queue_item.py | 0 {dataclass => bot/dataclass}/spotify.py | 0 dev_server.py => bot/dev_server.py | 5 +- main.py => bot/main.py | 8 +- {server => bot/server}/__init__.py | 2 +- {server => bot/server}/main.py | 4 +- {server => bot/server}/routes.py | 0 {server => bot/server}/static/css/.gitignore | 0 {server => bot/server}/static/css/base.css | 0 .../images/favicon/android-chrome-192x192.png | Bin .../images/favicon/android-chrome-512x512.png | Bin .../images/favicon/apple-touch-icon.png | Bin .../static/images/favicon/favicon-16x16.png | Bin .../static/images/favicon/favicon-32x32.png | Bin .../server}/static/images/favicon/favicon.ico | Bin .../static/images/favicon/site.webmanifest | 0 {server => bot/server}/static/images/logo.svg | 0 {server => bot/server}/templates/base.html | 0 .../server}/templates/dashboard.html | 0 .../server}/templates/homepage.html | 0 {server => bot/server}/views/dashboard.py | 2 +- {server => bot/server}/views/deleteaccount.py | 0 {server => bot/server}/views/discordoauth.py | 4 +- {server => bot/server}/views/homepage.py | 0 {server => bot/server}/views/lastfmtoken.py | 4 +- {server => bot/server}/views/linklastfm.py | 0 {server => bot/server}/views/linkspotify.py | 0 {server => bot/server}/views/login.py | 0 {server => bot/server}/views/logout.py | 0 {server => bot/server}/views/robotstxt.py | 0 {server => bot/server}/views/spotifyoauth.py | 8 +- {server => bot/server}/views/unlink.py | 0 {views => bot/utils}/__init__.py | 0 {utils => bot/utils}/blanco.py | 11 +- {utils => bot/utils}/config.py | 2 +- {utils => bot/utils}/constants.py | 0 {utils => bot/utils}/embeds.py | 2 +- {utils => bot/utils}/exceptions.py | 0 {utils => bot/utils}/fuzzy.py | 0 {utils => bot/utils}/logger.py | 0 {utils => bot/utils}/musicbrainz.py | 4 +- {utils => bot/utils}/paginator.py | 2 +- {utils => bot/utils}/player_checks.py | 4 +- {utils => bot/utils}/scrobbler.py | 6 +- {utils => bot/utils}/spotify_client.py | 4 +- {utils => bot/utils}/spotify_private.py | 6 +- {utils => bot/utils}/time.py | 0 {utils => bot/utils}/url.py | 0 bot/views/__init__.py | 0 {views => bot/views}/now_playing.py | 14 +- {views => bot/views}/paginator.py | 0 {views => bot/views}/spotify_dropdown.py | 8 +- dataclass/bump.py | 17 -- 80 files changed, 144 insertions(+), 303 deletions(-) rename {dataclass => bot}/__init__.py (100%) rename {cogs => bot/cogs}/__init__.py (91%) rename {cogs => bot/cogs}/bumps.py (94%) rename {cogs => bot/cogs}/debug.py (94%) rename {cogs => bot/cogs}/player/__init__.py (96%) rename {cogs => bot/cogs}/player/jockey.py (98%) rename {cogs => bot/cogs}/player/jockey_helpers.py (95%) rename {cogs => bot/cogs}/player/lavalink_client.py (96%) rename {cogs => bot/cogs}/player/queue.py (98%) rename {cogs => bot/cogs}/player/scrobbler.py (93%) rename {cogs => bot/cogs}/player/track_finder.py (94%) rename {database => bot/database}/__init__.py (61%) rename {database => bot/database}/migrations/0000-create.py (100%) rename {database => bot/database}/migrations/0001-lavalink-sessionid.py (100%) rename {database => bot/database}/migrations/0002-statuschannel.py (100%) rename {database => bot/database}/migrations/0003-oauth.py (100%) rename {database => bot/database}/migrations/0004-loop-all.py (100%) rename {database => bot/database}/migrations/0005-bumps.py (100%) rename {database => bot/database}/migrations/__init__.py (100%) rename {database => bot/database}/redis.py (97%) rename {utils => bot/dataclass}/__init__.py (100%) create mode 100644 bot/dataclass/bump.py rename {dataclass => bot/dataclass}/config.py (100%) rename {dataclass => bot/dataclass}/custom_embed.py (100%) rename {dataclass => bot/dataclass}/lavalink_result.py (100%) rename {dataclass => bot/dataclass}/oauth.py (100%) rename {dataclass => bot/dataclass}/queue_item.py (100%) rename {dataclass => bot/dataclass}/spotify.py (100%) rename dev_server.py => bot/dev_server.py (92%) rename main.py => bot/main.py (94%) rename {server => bot/server}/__init__.py (87%) rename {server => bot/server}/main.py (95%) rename {server => bot/server}/routes.py (100%) rename {server => bot/server}/static/css/.gitignore (100%) rename {server => bot/server}/static/css/base.css (100%) rename {server => bot/server}/static/images/favicon/android-chrome-192x192.png (100%) rename {server => bot/server}/static/images/favicon/android-chrome-512x512.png (100%) rename {server => bot/server}/static/images/favicon/apple-touch-icon.png (100%) rename {server => bot/server}/static/images/favicon/favicon-16x16.png (100%) rename {server => bot/server}/static/images/favicon/favicon-32x32.png (100%) rename {server => bot/server}/static/images/favicon/favicon.ico (100%) rename {server => bot/server}/static/images/favicon/site.webmanifest (100%) rename {server => bot/server}/static/images/logo.svg (100%) rename {server => bot/server}/templates/base.html (100%) rename {server => bot/server}/templates/dashboard.html (100%) rename {server => bot/server}/templates/homepage.html (100%) rename {server => bot/server}/views/dashboard.py (95%) rename {server => bot/server}/views/deleteaccount.py (100%) rename {server => bot/server}/views/discordoauth.py (96%) rename {server => bot/server}/views/homepage.py (100%) rename {server => bot/server}/views/lastfmtoken.py (95%) rename {server => bot/server}/views/linklastfm.py (100%) rename {server => bot/server}/views/linkspotify.py (100%) rename {server => bot/server}/views/login.py (100%) rename {server => bot/server}/views/logout.py (100%) rename {server => bot/server}/views/robotstxt.py (100%) rename {server => bot/server}/views/spotifyoauth.py (95%) rename {server => bot/server}/views/unlink.py (100%) rename {views => bot/utils}/__init__.py (100%) rename {utils => bot/utils}/blanco.py (98%) rename {utils => bot/utils}/config.py (99%) rename {utils => bot/utils}/constants.py (100%) rename {utils => bot/utils}/embeds.py (93%) rename {utils => bot/utils}/exceptions.py (100%) rename {utils => bot/utils}/fuzzy.py (100%) rename {utils => bot/utils}/logger.py (100%) rename {utils => bot/utils}/musicbrainz.py (98%) rename {utils => bot/utils}/paginator.py (98%) rename {utils => bot/utils}/player_checks.py (96%) rename {utils => bot/utils}/scrobbler.py (93%) rename {utils => bot/utils}/spotify_client.py (99%) rename {utils => bot/utils}/spotify_private.py (97%) rename {utils => bot/utils}/time.py (100%) rename {utils => bot/utils}/url.py (100%) create mode 100644 bot/views/__init__.py rename {views => bot/views}/now_playing.py (94%) rename {views => bot/views}/paginator.py (100%) rename {views => bot/views}/spotify_dropdown.py (94%) delete mode 100644 dataclass/bump.py diff --git a/Makefile b/Makefile index 3cf7576..4936392 100644 --- a/Makefile +++ b/Makefile @@ -6,10 +6,10 @@ install: poetry run pre-commit install dev-frontend: config.yml blanco.db - poetry run python dev_server.py + poetry run python -m bot.dev_server dev: config.yml blanco.db - poetry run python main.py + poetry run python -m bot.main precommit: poetry run pre-commit run --all-files diff --git a/dataclass/__init__.py b/bot/__init__.py similarity index 100% rename from dataclass/__init__.py rename to bot/__init__.py diff --git a/cogs/__init__.py b/bot/cogs/__init__.py similarity index 91% rename from cogs/__init__.py rename to bot/cogs/__init__.py index b221547..e2a6f38 100644 --- a/cogs/__init__.py +++ b/bot/cogs/__init__.py @@ -10,7 +10,7 @@ from .bumps import BumpCog if TYPE_CHECKING: - from utils.blanco import BlancoBot + from bot.utils.blanco import BlancoBot def setup(bot: 'BlancoBot'): diff --git a/cogs/bumps.py b/bot/cogs/bumps.py similarity index 94% rename from cogs/bumps.py rename to bot/cogs/bumps.py index b06b39c..ef25d5f 100644 --- a/cogs/bumps.py +++ b/bot/cogs/bumps.py @@ -4,18 +4,17 @@ from typing import TYPE_CHECKING -from nextcord import (Color, Permissions, Interaction, SlashOption, slash_command) +from nextcord import Color, Interaction, Permissions, SlashOption, slash_command from nextcord.ext.commands import Cog -from dataclass.bump import Bump - -from utils.url import check_url -from utils.embeds import CustomEmbed, create_error_embed, create_success_embed -from utils.logger import create_logger -from utils.paginator import Paginator, list_chunks +from bot.dataclass.bump import Bump +from bot.utils.embeds import CustomEmbed, create_error_embed, create_success_embed +from bot.utils.logger import create_logger +from bot.utils.paginator import Paginator, list_chunks +from bot.utils.url import check_url if TYPE_CHECKING: - from utils.blanco import BlancoBot + from bot.utils.blanco import BlancoBot class BumpCog(Cog): diff --git a/cogs/debug.py b/bot/cogs/debug.py similarity index 94% rename from cogs/debug.py rename to bot/cogs/debug.py index 64c3940..e7dd3cf 100644 --- a/cogs/debug.py +++ b/bot/cogs/debug.py @@ -8,13 +8,13 @@ from nextcord.ext import application_checks from nextcord.ext.commands import Cog -from dataclass.custom_embed import CustomEmbed -from utils.embeds import create_success_embed -from utils.logger import create_logger -from utils.paginator import Paginator +from bot.dataclass.custom_embed import CustomEmbed +from bot.utils.embeds import create_success_embed +from bot.utils.logger import create_logger +from bot.utils.paginator import Paginator if TYPE_CHECKING: - from utils.blanco import BlancoBot + from bot.utils.blanco import BlancoBot STATS_FORMAT = """ ```asciidoc @@ -89,7 +89,7 @@ async def announce( @application_checks.is_owner() async def reload(self, itx: Interaction): """ - Reloads all cogs. + Reloads all bot.cogs. """ # Reload cogs self._bot.unload_extension('cogs') diff --git a/cogs/player/__init__.py b/bot/cogs/player/__init__.py similarity index 96% rename from cogs/player/__init__.py rename to bot/cogs/player/__init__.py index a8a8165..d9fcfb8 100644 --- a/cogs/player/__init__.py +++ b/bot/cogs/player/__init__.py @@ -3,6 +3,7 @@ """ from asyncio import TimeoutError as AsyncioTimeoutError +from itertools import islice from typing import TYPE_CHECKING, Any, Generator, List, Optional from mafic import PlayerNotConnected @@ -22,10 +23,10 @@ from nextcord.ext.commands import Cog from requests import HTTPError, codes -from dataclass.custom_embed import CustomEmbed -from utils.constants import RELEASE, SPOTIFY_403_ERR_MSG -from utils.embeds import create_error_embed, create_success_embed -from utils.exceptions import ( +from bot.dataclass.custom_embed import CustomEmbed +from bot.utils.constants import RELEASE, SPOTIFY_403_ERR_MSG +from bot.utils.embeds import create_error_embed, create_success_embed +from bot.utils.exceptions import ( BlancoException, EmptyQueueError, EndOfQueueError, @@ -33,21 +34,29 @@ JockeyException, SpotifyNoResultsError, ) -from utils.logger import create_logger -from utils.paginator import Paginator, list_chunks -from utils.player_checks import check_mutual_voice -from views.spotify_dropdown import SpotifyDropdownView +from bot.utils.logger import create_logger +from bot.utils.paginator import Paginator +from bot.utils.player_checks import check_mutual_voice +from bot.views.spotify_dropdown import SpotifyDropdownView from .jockey import Jockey if TYPE_CHECKING: - from dataclass.queue_item import QueueItem - from utils.blanco import BlancoBot + from bot.dataclass.queue_item import QueueItem + from bot.utils.blanco import BlancoBot QUEUE_LINE_LENGTH = 50 +def list_chunks(data: List[Any]) -> Generator[List[Any], Any, Any]: + """ + Yield 10-element chunks of a list. Used for pagination. + """ + for i in range(0, len(data), 10): + yield list(islice(data, i, i + 10)) + + class PlayerCog(Cog): """ Cog for creating, controlling, and destroying music players for guilds. diff --git a/cogs/player/jockey.py b/bot/cogs/player/jockey.py similarity index 98% rename from cogs/player/jockey.py rename to bot/cogs/player/jockey.py index b29633d..ba81e4c 100644 --- a/cogs/player/jockey.py +++ b/bot/cogs/player/jockey.py @@ -17,10 +17,10 @@ VoiceChannel, ) -from dataclass.custom_embed import CustomEmbed -from utils.constants import UNPAUSE_THRESHOLD -from utils.embeds import create_error_embed -from utils.exceptions import ( +from bot.dataclass.custom_embed import CustomEmbed +from bot.utils.constants import UNPAUSE_THRESHOLD +from bot.utils.embeds import create_error_embed +from bot.utils.exceptions import ( BlancoException, BumpError, BumpNotEnabledError, @@ -31,8 +31,8 @@ LavalinkSearchError, SpotifyNoResultsError, ) -from utils.time import human_readable_time -from views.now_playing import NowPlayingView +from bot.utils.time import human_readable_time +from bot.views.now_playing import NowPlayingView from .jockey_helpers import invalidate_lavalink_track, parse_query from .queue import QueueManager @@ -44,8 +44,8 @@ from nextcord import Embed from nextcord.abc import Connectable, Messageable - from dataclass.queue_item import QueueItem - from utils.blanco import BlancoBot + from bot.dataclass.queue_item import QueueItem + from bot.utils.blanco import BlancoBot MAX_PLAYER_CONNECT_WAIT_SEC = 10 diff --git a/cogs/player/jockey_helpers.py b/bot/cogs/player/jockey_helpers.py similarity index 95% rename from cogs/player/jockey_helpers.py rename to bot/cogs/player/jockey_helpers.py index 7bdab12..05fc204 100644 --- a/cogs/player/jockey_helpers.py +++ b/bot/cogs/player/jockey_helpers.py @@ -8,18 +8,18 @@ from requests.status_codes import codes from spotipy.exceptions import SpotifyException -from database.redis import REDIS -from dataclass.queue_item import QueueItem -from utils.constants import CONFIDENCE_THRESHOLD -from utils.exceptions import ( +from bot.database.redis import REDIS +from bot.dataclass.queue_item import QueueItem +from bot.utils.constants import CONFIDENCE_THRESHOLD +from bot.utils.exceptions import ( JockeyException, LavalinkInvalidIdentifierError, SpotifyNoResultsError, ) -from utils.fuzzy import check_similarity_weighted -from utils.logger import create_logger -from utils.spotify_client import Spotify -from utils.url import ( +from bot.utils.fuzzy import check_similarity_weighted +from bot.utils.logger import create_logger +from bot.utils.spotify_client import Spotify +from bot.utils.url import ( check_sc_url, check_spotify_url, check_url, @@ -40,8 +40,7 @@ if TYPE_CHECKING: from mafic import Node - from dataclass.spotify import SpotifyTrack - +from bot.dataclass.spotify import SpotifyTrack LOGGER = create_logger('jockey_helpers') T = TypeVar('T') diff --git a/cogs/player/lavalink_client.py b/bot/cogs/player/lavalink_client.py similarity index 96% rename from cogs/player/lavalink_client.py rename to bot/cogs/player/lavalink_client.py index c763c9b..0daf7d7 100644 --- a/cogs/player/lavalink_client.py +++ b/bot/cogs/player/lavalink_client.py @@ -8,10 +8,10 @@ from mafic import Playlist, SearchType, TrackLoadException -from dataclass.lavalink_result import LavalinkResult -from utils.constants import BLACKLIST -from utils.exceptions import LavalinkSearchError -from utils.fuzzy import check_similarity +from bot.dataclass.lavalink_result import LavalinkResult +from bot.utils.constants import BLACKLIST +from bot.utils.exceptions import LavalinkSearchError +from bot.utils.fuzzy import check_similarity if TYPE_CHECKING: from mafic import Node, Track diff --git a/cogs/player/queue.py b/bot/cogs/player/queue.py similarity index 98% rename from cogs/player/queue.py rename to bot/cogs/player/queue.py index 9ef240e..a52d1ec 100644 --- a/cogs/player/queue.py +++ b/bot/cogs/player/queue.py @@ -5,9 +5,9 @@ from random import shuffle from typing import TYPE_CHECKING, List, Tuple -from dataclass.queue_item import QueueItem -from utils.exceptions import EmptyQueueError, EndOfQueueError -from utils.logger import create_logger +from bot.dataclass.queue_item import QueueItem +from bot.utils.exceptions import EmptyQueueError, EndOfQueueError +from bot.utils.logger import create_logger if TYPE_CHECKING: from database import Database diff --git a/cogs/player/scrobbler.py b/bot/cogs/player/scrobbler.py similarity index 93% rename from cogs/player/scrobbler.py rename to bot/cogs/player/scrobbler.py index b07e8a3..14d4f67 100644 --- a/cogs/player/scrobbler.py +++ b/bot/cogs/player/scrobbler.py @@ -3,14 +3,14 @@ from nextcord import VoiceChannel -from utils.exceptions import BlancoException -from utils.musicbrainz import annotate_track +from bot.utils.exceptions import BlancoException +from bot.utils.musicbrainz import annotate_track if TYPE_CHECKING: from nextcord.abc import Connectable - from dataclass.queue_item import QueueItem - from utils.blanco import BlancoBot + from bot.dataclass.queue_item import QueueItem + from bot.utils.blanco import BlancoBot _SEC_IN_MSEC = 1000 diff --git a/cogs/player/track_finder.py b/bot/cogs/player/track_finder.py similarity index 94% rename from cogs/player/track_finder.py rename to bot/cogs/player/track_finder.py index 0c61f59..64c9308 100644 --- a/cogs/player/track_finder.py +++ b/bot/cogs/player/track_finder.py @@ -2,11 +2,11 @@ from mafic import SearchType -from database.redis import REDIS -from utils.constants import CONFIDENCE_THRESHOLD -from utils.exceptions import LavalinkSearchError -from utils.logger import create_logger -from utils.musicbrainz import annotate_track +from bot.database.redis import REDIS +from bot.utils.constants import CONFIDENCE_THRESHOLD +from bot.utils.exceptions import LavalinkSearchError +from bot.utils.logger import create_logger +from bot.utils.musicbrainz import annotate_track from .jockey_helpers import rank_results from .lavalink_client import get_deezer_matches, get_deezer_track, get_youtube_matches @@ -14,8 +14,8 @@ if TYPE_CHECKING: from mafic import Node, Track - from dataclass.lavalink_result import LavalinkResult - from dataclass.queue_item import QueueItem + from bot.dataclass.lavalink_result import LavalinkResult + from bot.dataclass.queue_item import QueueItem from .lavalink_client import LavalinkSearchError diff --git a/database/__init__.py b/bot/database/__init__.py similarity index 61% rename from database/__init__.py rename to bot/database/__init__.py index 61bd0d5..3588dfe 100644 --- a/database/__init__.py +++ b/bot/database/__init__.py @@ -4,11 +4,9 @@ import sqlite3 as sql from typing import List, Optional -import time -from dataclass.oauth import LastfmAuth, OAuth -from dataclass.bump import Bump -from utils.logger import create_logger +from bot.dataclass.oauth import LastfmAuth, OAuth +from bot.utils.logger import create_logger from .migrations import run_migrations @@ -122,59 +120,6 @@ def set_status_channel(self, guild_id: int, channel_id: int): ) self._con.commit() - def set_last_bump(self, guild_id: int): - """ - Set the last bump for a guild. - """ - seconds = int(time.time()) - self._cur.execute( - f'UPDATE player_settings SET last_bump = {seconds} WHERE guild_id = {guild_id}' - ) - self._con.commit() - - def get_last_bump(self, guild_id: int) -> int: - """ - Get the last bump for a guild. - """ - self._cur.execute(f'SELECT last_bump FROM player_settings WHERE guild_id = {guild_id}') - return self._cur.fetchone()[0] - - def set_bumps_enabled(self, guild_id: int, enabled: bool): - """ - Set whether bumps are enabled for a guild. - """ - self._cur.execute( - f'UPDATE player_settings SET bumps_enabled = {int(enabled)} WHERE guild_id = {guild_id}' - ) - self._con.commit() - - def get_bumps_enabled(self, guild_id: int) -> bool: - """ - Get whether bumps are enabled for a guild. - """ - self._cur.execute( - f'SELECT bumps_enabled FROM player_settings WHERE guild_id = {guild_id}' - ) - return self._cur.fetchone()[0] == 1 - - def set_bump_interval(self, guild_id: int, interval: int): - """ - Set the bump interval for a guild. - """ - self._cur.execute( - f'UPDATE player_settings SET bump_interval = {interval} WHERE guild_id = {guild_id}' - ) - self._con.commit() - - def get_bump_interval(self, guild_id: int) -> int: - """ - Get the bump interval for a guild. - """ - self._cur.execute( - f'SELECT bump_interval FROM player_settings WHERE guild_id = {guild_id}' - ) - return self._cur.fetchone()[0] - def get_session_id(self, node_id: str) -> str: """ Get the session ID for a Lavalink node. @@ -286,118 +231,3 @@ def get_spotify_scopes(self, user_id: int) -> List[str]: """ self._cur.execute(f'SELECT scopes FROM spotify_oauth WHERE user_id = {user_id}') return self._cur.fetchone()[0].split(',') - - def add_bump(self, guild_id: int, url: str, title: str, author: str): - """ - Set a bump for a guild. - """ - self._cur.execute(f'SELECT MAX(idx) FROM bumps WHERE guild_id = {guild_id}') - idx = self._cur.fetchone()[0] - if idx is None: - idx = 0 - idx += 1 - self._cur.execute(f''' - INSERT INTO bumps ( - guild_id, - idx, - url, - title, - author - ) VALUES ( - {guild_id}, - {idx}, - "{url}", - "{title}", - "{author}" - ) - ''' - ) - self._con.commit() - - def get_bumps(self, guild_id: int) -> Optional[List[Bump]]: - """ - Get every bump for a guild. - """ - self._cur.execute(f'''SELECT idx, guild_id, url, title, author - FROM bumps WHERE guild_id = {guild_id}''') - rows = self._cur.fetchall() - if len(rows) == 0: - return None - - return [ - Bump( - idx=row[0], - guild_id=row[1], - url=row[2], - title=row[3], - author=row[4] - ) - for row in rows - ] - - def get_bump(self, guild_id: int, idx: int) -> Optional[Bump]: - """ - Get a guild bump by its index. - """ - self._cur.execute( - f'''SELECT idx, guild_id, url, title, author FROM bumps - WHERE guild_id = {guild_id} AND idx = {idx} - ''' - ) - row = self._cur.fetchone() - if row is None: - return None - return Bump( - idx=row[0], - guild_id=row[1], - url=row[2], - title=row[3], - author=row[4] - ) - - def get_bump_by_url(self, guild_id: int, url: str) -> Optional[Bump]: - """ - Get a guild bump by its URL. - """ - self._cur.execute( - f'''SELECT idx, guild_id, url, title, author FROM bumps - WHERE guild_id = {guild_id} AND url = "{url}" - ''' - ) - row = self._cur.fetchone() - if row is None: - return None - return Bump( - idx=row[0], - guild_id=row[1], - url=row[2], - title=row[3], - author=row[4] - ) - - def get_random_bump(self, guild_id: int) -> Optional[Bump]: - """ - Get a random guild bump. - """ - self._cur.execute( - f'''SELECT idx, guild_id, url, title, author FROM bumps WHERE - guild_id = {guild_id} ORDER BY RANDOM() LIMIT 1 - ''' - ) - row = self._cur.fetchone() - if row is None: - return None - return Bump( - idx=row[0], - guild_id=row[1], - url=row[2], - title=row[3], - author=row[4] - ) - - def delete_bump(self, guild_id: int, idx: int): - """ - Delete a guild bump by its index. - """ - self._cur.execute(f'DELETE FROM bumps WHERE guild_id = {guild_id} AND idx = {idx}') - self._con.commit() diff --git a/database/migrations/0000-create.py b/bot/database/migrations/0000-create.py similarity index 100% rename from database/migrations/0000-create.py rename to bot/database/migrations/0000-create.py diff --git a/database/migrations/0001-lavalink-sessionid.py b/bot/database/migrations/0001-lavalink-sessionid.py similarity index 100% rename from database/migrations/0001-lavalink-sessionid.py rename to bot/database/migrations/0001-lavalink-sessionid.py diff --git a/database/migrations/0002-statuschannel.py b/bot/database/migrations/0002-statuschannel.py similarity index 100% rename from database/migrations/0002-statuschannel.py rename to bot/database/migrations/0002-statuschannel.py diff --git a/database/migrations/0003-oauth.py b/bot/database/migrations/0003-oauth.py similarity index 100% rename from database/migrations/0003-oauth.py rename to bot/database/migrations/0003-oauth.py diff --git a/database/migrations/0004-loop-all.py b/bot/database/migrations/0004-loop-all.py similarity index 100% rename from database/migrations/0004-loop-all.py rename to bot/database/migrations/0004-loop-all.py diff --git a/database/migrations/0005-bumps.py b/bot/database/migrations/0005-bumps.py similarity index 100% rename from database/migrations/0005-bumps.py rename to bot/database/migrations/0005-bumps.py diff --git a/database/migrations/__init__.py b/bot/database/migrations/__init__.py similarity index 100% rename from database/migrations/__init__.py rename to bot/database/migrations/__init__.py diff --git a/database/redis.py b/bot/database/redis.py similarity index 97% rename from database/redis.py rename to bot/database/redis.py index 45c909d..2914966 100644 --- a/database/redis.py +++ b/bot/database/redis.py @@ -6,9 +6,9 @@ import redis -from dataclass.spotify import SpotifyTrack -from utils.config import REDIS_HOST, REDIS_PASSWORD, REDIS_PORT -from utils.logger import create_logger +from bot.dataclass.spotify import SpotifyTrack +from bot.utils.config import REDIS_HOST, REDIS_PASSWORD, REDIS_PORT +from bot.utils.logger import create_logger class RedisClient: diff --git a/utils/__init__.py b/bot/dataclass/__init__.py similarity index 100% rename from utils/__init__.py rename to bot/dataclass/__init__.py diff --git a/bot/dataclass/bump.py b/bot/dataclass/bump.py new file mode 100644 index 0000000..38c09ca --- /dev/null +++ b/bot/dataclass/bump.py @@ -0,0 +1,17 @@ +""" +Dataclass for guild bumps. +""" +from dataclasses import dataclass + + +@dataclass +class Bump: + """ + Dataclass for guild bumps. + """ + + idx: int + guild_id: int + url: str + title: str + author: str diff --git a/dataclass/config.py b/bot/dataclass/config.py similarity index 100% rename from dataclass/config.py rename to bot/dataclass/config.py diff --git a/dataclass/custom_embed.py b/bot/dataclass/custom_embed.py similarity index 100% rename from dataclass/custom_embed.py rename to bot/dataclass/custom_embed.py diff --git a/dataclass/lavalink_result.py b/bot/dataclass/lavalink_result.py similarity index 100% rename from dataclass/lavalink_result.py rename to bot/dataclass/lavalink_result.py diff --git a/dataclass/oauth.py b/bot/dataclass/oauth.py similarity index 100% rename from dataclass/oauth.py rename to bot/dataclass/oauth.py diff --git a/dataclass/queue_item.py b/bot/dataclass/queue_item.py similarity index 100% rename from dataclass/queue_item.py rename to bot/dataclass/queue_item.py diff --git a/dataclass/spotify.py b/bot/dataclass/spotify.py similarity index 100% rename from dataclass/spotify.py rename to bot/dataclass/spotify.py diff --git a/dev_server.py b/bot/dev_server.py similarity index 92% rename from dev_server.py rename to bot/dev_server.py index 1066f50..f05b36d 100644 --- a/dev_server.py +++ b/bot/dev_server.py @@ -10,8 +10,9 @@ from subprocess import run from database import Database -from server.main import run_app -from utils.config import config + +from bot.server.main import run_app +from bot.utils.config import config def run_tailwind(): diff --git a/main.py b/bot/main.py similarity index 94% rename from main.py rename to bot/main.py index 0386b96..ff53189 100644 --- a/main.py +++ b/bot/main.py @@ -4,8 +4,8 @@ from nextcord import Intents -from utils.blanco import BlancoBot -from utils.config import ( +from bot.utils.blanco import BlancoBot +from bot.utils.config import ( REDIS_HOST, REDIS_PASSWORD, REDIS_PORT, @@ -13,8 +13,8 @@ SENTRY_ENV, config, ) -from utils.constants import RELEASE -from utils.logger import create_logger +from bot.utils.constants import RELEASE +from bot.utils.logger import create_logger if __name__ == '__main__': logger = create_logger('main') diff --git a/server/__init__.py b/bot/server/__init__.py similarity index 87% rename from server/__init__.py rename to bot/server/__init__.py index 191fca2..09d70ec 100644 --- a/server/__init__.py +++ b/bot/server/__init__.py @@ -2,7 +2,7 @@ Nextcord extension that runs the server for the bot. """ -from utils.blanco import BlancoBot +from bot.utils.blanco import BlancoBot from .main import run_app diff --git a/server/main.py b/bot/server/main.py similarity index 95% rename from server/main.py rename to bot/server/main.py index dda0272..81672b3 100644 --- a/server/main.py +++ b/bot/server/main.py @@ -13,13 +13,13 @@ from aiohttp_session.cookie_storage import EncryptedCookieStorage from cryptography.fernet import Fernet -from utils.logger import create_logger +from bot.utils.logger import create_logger from .routes import setup_routes if TYPE_CHECKING: from database import Database - from dataclass.config import Config +from bot.dataclass.config import Config class AccessLogger(AbstractAccessLogger): diff --git a/server/routes.py b/bot/server/routes.py similarity index 100% rename from server/routes.py rename to bot/server/routes.py diff --git a/server/static/css/.gitignore b/bot/server/static/css/.gitignore similarity index 100% rename from server/static/css/.gitignore rename to bot/server/static/css/.gitignore diff --git a/server/static/css/base.css b/bot/server/static/css/base.css similarity index 100% rename from server/static/css/base.css rename to bot/server/static/css/base.css diff --git a/server/static/images/favicon/android-chrome-192x192.png b/bot/server/static/images/favicon/android-chrome-192x192.png similarity index 100% rename from server/static/images/favicon/android-chrome-192x192.png rename to bot/server/static/images/favicon/android-chrome-192x192.png diff --git a/server/static/images/favicon/android-chrome-512x512.png b/bot/server/static/images/favicon/android-chrome-512x512.png similarity index 100% rename from server/static/images/favicon/android-chrome-512x512.png rename to bot/server/static/images/favicon/android-chrome-512x512.png diff --git a/server/static/images/favicon/apple-touch-icon.png b/bot/server/static/images/favicon/apple-touch-icon.png similarity index 100% rename from server/static/images/favicon/apple-touch-icon.png rename to bot/server/static/images/favicon/apple-touch-icon.png diff --git a/server/static/images/favicon/favicon-16x16.png b/bot/server/static/images/favicon/favicon-16x16.png similarity index 100% rename from server/static/images/favicon/favicon-16x16.png rename to bot/server/static/images/favicon/favicon-16x16.png diff --git a/server/static/images/favicon/favicon-32x32.png b/bot/server/static/images/favicon/favicon-32x32.png similarity index 100% rename from server/static/images/favicon/favicon-32x32.png rename to bot/server/static/images/favicon/favicon-32x32.png diff --git a/server/static/images/favicon/favicon.ico b/bot/server/static/images/favicon/favicon.ico similarity index 100% rename from server/static/images/favicon/favicon.ico rename to bot/server/static/images/favicon/favicon.ico diff --git a/server/static/images/favicon/site.webmanifest b/bot/server/static/images/favicon/site.webmanifest similarity index 100% rename from server/static/images/favicon/site.webmanifest rename to bot/server/static/images/favicon/site.webmanifest diff --git a/server/static/images/logo.svg b/bot/server/static/images/logo.svg similarity index 100% rename from server/static/images/logo.svg rename to bot/server/static/images/logo.svg diff --git a/server/templates/base.html b/bot/server/templates/base.html similarity index 100% rename from server/templates/base.html rename to bot/server/templates/base.html diff --git a/server/templates/dashboard.html b/bot/server/templates/dashboard.html similarity index 100% rename from server/templates/dashboard.html rename to bot/server/templates/dashboard.html diff --git a/server/templates/homepage.html b/bot/server/templates/homepage.html similarity index 100% rename from server/templates/homepage.html rename to bot/server/templates/homepage.html diff --git a/server/views/dashboard.py b/bot/server/views/dashboard.py similarity index 95% rename from server/views/dashboard.py rename to bot/server/views/dashboard.py index 78fa129..c09f4a9 100644 --- a/server/views/dashboard.py +++ b/bot/server/views/dashboard.py @@ -9,7 +9,7 @@ from aiohttp_session import get_session if TYPE_CHECKING: - from dataclass.oauth import LastfmAuth, OAuth + from bot.dataclass.oauth import LastfmAuth, OAuth @aiohttp_jinja2.template('dashboard.html') diff --git a/server/views/deleteaccount.py b/bot/server/views/deleteaccount.py similarity index 100% rename from server/views/deleteaccount.py rename to bot/server/views/deleteaccount.py diff --git a/server/views/discordoauth.py b/bot/server/views/discordoauth.py similarity index 96% rename from server/views/discordoauth.py rename to bot/server/views/discordoauth.py index 0397036..4c6a0e9 100644 --- a/server/views/discordoauth.py +++ b/bot/server/views/discordoauth.py @@ -9,8 +9,8 @@ from aiohttp_session import get_session from requests.exceptions import HTTPError, Timeout -from dataclass.oauth import OAuth -from utils.constants import DISCORD_API_BASE_URL, USER_AGENT +from bot.dataclass.oauth import OAuth +from bot.utils.constants import DISCORD_API_BASE_URL, USER_AGENT async def discordoauth(request: web.Request): # noqa: PLR0911 diff --git a/server/views/homepage.py b/bot/server/views/homepage.py similarity index 100% rename from server/views/homepage.py rename to bot/server/views/homepage.py diff --git a/server/views/lastfmtoken.py b/bot/server/views/lastfmtoken.py similarity index 95% rename from server/views/lastfmtoken.py rename to bot/server/views/lastfmtoken.py index 2de1b4e..eb15555 100644 --- a/server/views/lastfmtoken.py +++ b/bot/server/views/lastfmtoken.py @@ -9,8 +9,8 @@ from aiohttp_session import get_session from requests.exceptions import HTTPError, Timeout -from dataclass.oauth import LastfmAuth -from utils.constants import LASTFM_API_BASE_URL, USER_AGENT +from bot.dataclass.oauth import LastfmAuth +from bot.utils.constants import LASTFM_API_BASE_URL, USER_AGENT async def lastfm_token(request: web.Request): diff --git a/server/views/linklastfm.py b/bot/server/views/linklastfm.py similarity index 100% rename from server/views/linklastfm.py rename to bot/server/views/linklastfm.py diff --git a/server/views/linkspotify.py b/bot/server/views/linkspotify.py similarity index 100% rename from server/views/linkspotify.py rename to bot/server/views/linkspotify.py diff --git a/server/views/login.py b/bot/server/views/login.py similarity index 100% rename from server/views/login.py rename to bot/server/views/login.py diff --git a/server/views/logout.py b/bot/server/views/logout.py similarity index 100% rename from server/views/logout.py rename to bot/server/views/logout.py diff --git a/server/views/robotstxt.py b/bot/server/views/robotstxt.py similarity index 100% rename from server/views/robotstxt.py rename to bot/server/views/robotstxt.py diff --git a/server/views/spotifyoauth.py b/bot/server/views/spotifyoauth.py similarity index 95% rename from server/views/spotifyoauth.py rename to bot/server/views/spotifyoauth.py index 1e098b8..30d540d 100644 --- a/server/views/spotifyoauth.py +++ b/bot/server/views/spotifyoauth.py @@ -10,8 +10,12 @@ from aiohttp_session import get_session from requests.exceptions import HTTPError, Timeout -from dataclass.oauth import OAuth -from utils.constants import SPOTIFY_ACCOUNTS_BASE_URL, SPOTIFY_API_BASE_URL, USER_AGENT +from bot.dataclass.oauth import OAuth +from bot.utils.constants import ( + SPOTIFY_ACCOUNTS_BASE_URL, + SPOTIFY_API_BASE_URL, + USER_AGENT, +) async def spotifyoauth(request: web.Request): # noqa: PLR0911 diff --git a/server/views/unlink.py b/bot/server/views/unlink.py similarity index 100% rename from server/views/unlink.py rename to bot/server/views/unlink.py diff --git a/views/__init__.py b/bot/utils/__init__.py similarity index 100% rename from views/__init__.py rename to bot/utils/__init__.py diff --git a/utils/blanco.py b/bot/utils/blanco.py similarity index 98% rename from utils/blanco.py rename to bot/utils/blanco.py index e3f7f47..347d152 100644 --- a/utils/blanco.py +++ b/bot/utils/blanco.py @@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, Dict, List, Optional, Union from aiohttp.client_exceptions import ClientConnectorError +from database import Database from mafic import EndReason, NodePool, VoiceRegion from nextcord import ( Activity, @@ -24,9 +25,8 @@ ) from nextcord.ext.commands import Bot, ExtensionNotLoaded -from cogs.player.track_finder import find_lavalink_track -from database import Database -from views.now_playing import NowPlayingView +from bot.cogs.player.track_finder import find_lavalink_track +from bot.views.now_playing import NowPlayingView from .embeds import create_error_embed from .exceptions import EndOfQueueError, LavalinkSearchError @@ -41,9 +41,8 @@ from mafic import Node, TrackEndEvent, TrackStartEvent - from cogs.player.jockey import Jockey - from dataclass.config import Config - + from bot.cogs.player.jockey import Jockey +from bot.dataclass.config import Config StatusChannel = Union[ PartialMessageable, VoiceChannel, TextChannel, StageChannel, Thread diff --git a/utils/config.py b/bot/utils/config.py similarity index 99% rename from utils/config.py rename to bot/utils/config.py index c74dda3..d9d7c61 100644 --- a/utils/config.py +++ b/bot/utils/config.py @@ -12,7 +12,7 @@ from yaml import safe_load -from dataclass.config import Config, LavalinkNode +from bot.dataclass.config import Config, LavalinkNode DATABASE_FILE = None DISCORD_TOKEN = None diff --git a/utils/constants.py b/bot/utils/constants.py similarity index 100% rename from utils/constants.py rename to bot/utils/constants.py diff --git a/utils/embeds.py b/bot/utils/embeds.py similarity index 93% rename from utils/embeds.py rename to bot/utils/embeds.py index 6744f57..83c9336 100644 --- a/utils/embeds.py +++ b/bot/utils/embeds.py @@ -6,7 +6,7 @@ from nextcord import Colour, Embed -from dataclass.custom_embed import CustomEmbed +from bot.dataclass.custom_embed import CustomEmbed def create_error_embed(message: str) -> Embed: diff --git a/utils/exceptions.py b/bot/utils/exceptions.py similarity index 100% rename from utils/exceptions.py rename to bot/utils/exceptions.py diff --git a/utils/fuzzy.py b/bot/utils/fuzzy.py similarity index 100% rename from utils/fuzzy.py rename to bot/utils/fuzzy.py diff --git a/utils/logger.py b/bot/utils/logger.py similarity index 100% rename from utils/logger.py rename to bot/utils/logger.py diff --git a/utils/musicbrainz.py b/bot/utils/musicbrainz.py similarity index 98% rename from utils/musicbrainz.py rename to bot/utils/musicbrainz.py index 03503d5..8af36ed 100644 --- a/utils/musicbrainz.py +++ b/bot/utils/musicbrainz.py @@ -8,14 +8,14 @@ from requests import HTTPError, Timeout, get from requests.status_codes import codes -from database.redis import REDIS +from bot.database.redis import REDIS from .constants import DURATION_THRESHOLD, MUSICBRAINZ_API_BASE_URL, USER_AGENT from .fuzzy import check_similarity_weighted from .logger import create_logger if TYPE_CHECKING: - from dataclass.queue_item import QueueItem + from bot.dataclass.queue_item import QueueItem LOGGER = create_logger('musicbrainz') diff --git a/utils/paginator.py b/bot/utils/paginator.py similarity index 98% rename from utils/paginator.py rename to bot/utils/paginator.py index 765e391..2d7688f 100644 --- a/utils/paginator.py +++ b/bot/utils/paginator.py @@ -11,7 +11,7 @@ from nextcord import Embed, Forbidden, HTTPException, Interaction -from views.paginator import PaginatorView +from bot.views.paginator import PaginatorView if TYPE_CHECKING: from nextcord import Message diff --git a/utils/player_checks.py b/bot/utils/player_checks.py similarity index 96% rename from utils/player_checks.py rename to bot/utils/player_checks.py index e1bb48f..18d2243 100644 --- a/utils/player_checks.py +++ b/bot/utils/player_checks.py @@ -8,10 +8,10 @@ from nextcord import Interaction, Member -from utils.exceptions import VoiceCommandError +from bot.utils.exceptions import VoiceCommandError if TYPE_CHECKING: - from cogs.player.jockey import Jockey + from bot.cogs.player.jockey import Jockey def check_mutual_voice(itx: Interaction, slash: bool = True) -> bool: diff --git a/utils/scrobbler.py b/bot/utils/scrobbler.py similarity index 93% rename from utils/scrobbler.py rename to bot/utils/scrobbler.py index cc39343..a706a8c 100644 --- a/utils/scrobbler.py +++ b/bot/utils/scrobbler.py @@ -11,9 +11,9 @@ if TYPE_CHECKING: from logging import Logger - from dataclass.config import Config - from dataclass.oauth import LastfmAuth - from dataclass.queue_item import QueueItem +from bot.dataclass.config import Config +from bot.dataclass.oauth import LastfmAuth +from bot.dataclass.queue_item import QueueItem class Scrobbler: diff --git a/utils/spotify_client.py b/bot/utils/spotify_client.py similarity index 99% rename from utils/spotify_client.py rename to bot/utils/spotify_client.py index db7ef95..da0d5f9 100644 --- a/utils/spotify_client.py +++ b/bot/utils/spotify_client.py @@ -15,8 +15,8 @@ wait_random, ) -from database.redis import REDIS -from dataclass.spotify import SpotifyResult, SpotifyTrack +from bot.database.redis import REDIS +from bot.dataclass.spotify import SpotifyResult, SpotifyTrack from .constants import BLACKLIST from .exceptions import SpotifyInvalidURLError, SpotifyNoResultsError diff --git a/utils/spotify_private.py b/bot/utils/spotify_private.py similarity index 97% rename from utils/spotify_private.py rename to bot/utils/spotify_private.py index 136596a..295917f 100644 --- a/utils/spotify_private.py +++ b/bot/utils/spotify_private.py @@ -12,15 +12,15 @@ import requests from requests import HTTPError, Timeout -from dataclass.oauth import OAuth -from dataclass.spotify import SpotifyResult +from bot.dataclass.oauth import OAuth +from bot.dataclass.spotify import SpotifyResult from .constants import SPOTIFY_ACCOUNTS_BASE_URL, SPOTIFY_API_BASE_URL, USER_AGENT from .logger import create_logger if TYPE_CHECKING: from database import Database - from dataclass.config import Config +from bot.dataclass.config import Config class PrivateSpotify: diff --git a/utils/time.py b/bot/utils/time.py similarity index 100% rename from utils/time.py rename to bot/utils/time.py diff --git a/utils/url.py b/bot/utils/url.py similarity index 100% rename from utils/url.py rename to bot/utils/url.py diff --git a/bot/views/__init__.py b/bot/views/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/views/now_playing.py b/bot/views/now_playing.py similarity index 94% rename from views/now_playing.py rename to bot/views/now_playing.py index 506f7d2..a5b5e6b 100644 --- a/views/now_playing.py +++ b/bot/views/now_playing.py @@ -9,17 +9,17 @@ from requests.exceptions import HTTPError, Timeout from requests.status_codes import codes -from utils.constants import SPOTIFY_403_ERR_MSG -from utils.embeds import create_error_embed, create_success_embed -from utils.exceptions import VoiceCommandError -from utils.player_checks import check_mutual_voice +from bot.utils.constants import SPOTIFY_403_ERR_MSG +from bot.utils.embeds import create_error_embed, create_success_embed +from bot.utils.exceptions import VoiceCommandError +from bot.utils.player_checks import check_mutual_voice if TYPE_CHECKING: from nextcord import Interaction - from cogs.player import PlayerCog - from cogs.player.jockey import Jockey - from utils.blanco import BlancoBot + from bot.cogs.player import PlayerCog + from bot.cogs.player.jockey import Jockey + from bot.utils.blanco import BlancoBot class ShuffleButton(Button): diff --git a/views/paginator.py b/bot/views/paginator.py similarity index 100% rename from views/paginator.py rename to bot/views/paginator.py diff --git a/views/spotify_dropdown.py b/bot/views/spotify_dropdown.py similarity index 94% rename from views/spotify_dropdown.py rename to bot/views/spotify_dropdown.py index 70234a3..e229648 100644 --- a/views/spotify_dropdown.py +++ b/bot/views/spotify_dropdown.py @@ -8,14 +8,14 @@ from nextcord import Colour, SelectOption from nextcord.ui import Select, View -from dataclass.custom_embed import CustomEmbed +from bot.dataclass.custom_embed import CustomEmbed if TYPE_CHECKING: from nextcord import Interaction - from cogs.player import PlayerCog - from dataclass.spotify import SpotifyResult - from utils.blanco import BlancoBot + from bot.cogs.player import PlayerCog + from bot.dataclass.spotify import SpotifyResult + from bot.utils.blanco import BlancoBot MAX_LINE_LENGTH = 100 diff --git a/dataclass/bump.py b/dataclass/bump.py deleted file mode 100644 index 28d9a54..0000000 --- a/dataclass/bump.py +++ /dev/null @@ -1,17 +0,0 @@ -""" -Dataclass for guild bumps. -""" -from dataclasses import dataclass - - -@dataclass -class Bump: - """ - Dataclass for guild bumps. - """ - - idx: int - guild_id: int - url: str - title: str - author: str From 5899fbbb618f02d01a07e91c50e1a869df6ee6fd Mon Sep 17 00:00:00 2001 From: Jared Dantis Date: Sun, 31 Mar 2024 02:31:30 +0800 Subject: [PATCH 18/33] feat: Use mypy --- .pre-commit-config.yaml | 41 +++++++++++++++++++++++++---------------- mypy.ini | 3 +++ poetry.lock | 2 +- pyproject.toml | 2 ++ 4 files changed, 31 insertions(+), 17 deletions(-) create mode 100644 mypy.ini diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d2e55ef..1866dc0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,17 +1,26 @@ repos: -- repo: https://github.com/pre-commit/pre-commit-hooks - rev: v2.3.0 - hooks: - - id: double-quote-string-fixer - - id: end-of-file-fixer - - id: trailing-whitespace - - id: no-commit-to-branch - args: ['--branch', 'main'] - - id: mixed-line-ending - - id: check-merge-conflict -- repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.3.4 - hooks: - - id: ruff - args: [ --fix ] - - id: ruff-format + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v2.3.0 + hooks: + - id: double-quote-string-fixer + - id: end-of-file-fixer + - id: trailing-whitespace + - id: no-commit-to-branch + args: ['--branch', 'main'] + - id: mixed-line-ending + - id: check-merge-conflict + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.3.4 + hooks: + - id: ruff + args: [ --fix ] + - id: ruff-format + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.9.0 + hooks: + - id: mypy + args: ['--config-file=mypy.ini', '--check-untyped-defs'] + additional_dependencies: + - types-pyyaml==6.0.1 + - types-redis==4.6.0.20240311 + - types-requests==2.31.0 diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 0000000..78d8341 --- /dev/null +++ b/mypy.ini @@ -0,0 +1,3 @@ +[mypy] +files = bot +ignore_missing_imports = True diff --git a/poetry.lock b/poetry.lock index d23b5bc..d4fd374 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1505,4 +1505,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "569c94d436f2db5b6412292efa2930368ad66067ac9307d1f9081bdb28630b08" +content-hash = "6669e891772b3782169b86a6f3090483e2e1abcb0de389294dbf70148d319525" diff --git a/pyproject.toml b/pyproject.toml index db24019..c9a5226 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,8 +15,10 @@ mafic = "^2.10.0" nextcord = "^2.6.0" pylast = "^5.2.0" python = "^3.12" +pyyaml = "^6.0.1" ratelimit = "^2.2.1" redis = "^5.0.3" +requests = "^2.31.0" sentry-sdk = "^1.44.0" spotipy = "^2.23.0" tenacity = "^8.2.3" From 485ef6785918292a34140f6dc8e2c54bf2256451 Mon Sep 17 00:00:00 2001 From: Jared Dantis Date: Sun, 31 Mar 2024 02:31:37 +0800 Subject: [PATCH 19/33] chore: Make mypy happy --- bot/cogs/player/jockey.py | 4 ++-- bot/cogs/player/queue.py | 20 ++++++++++---------- bot/cogs/player/track_finder.py | 2 +- bot/utils/blanco.py | 2 +- bot/utils/musicbrainz.py | 2 +- bot/utils/paginator.py | 20 +++++++++++--------- 6 files changed, 26 insertions(+), 24 deletions(-) diff --git a/bot/cogs/player/jockey.py b/bot/cogs/player/jockey.py index ba81e4c..86fb46d 100644 --- a/bot/cogs/player/jockey.py +++ b/bot/cogs/player/jockey.py @@ -228,7 +228,7 @@ async def _play(self, item: 'QueueItem', position: Optional[int] = None): raise JockeyError(err.args[0]) from err # Wait until we're connected - wait_time = 0 + wait_time = 0.0 self._logger.warning( "PlayerNotConnected raised while trying to play `%s', retrying...", item.title, @@ -238,7 +238,7 @@ async def _play(self, item: 'QueueItem', position: Optional[int] = None): raise JockeyError('Timeout while waiting for player to connect') from err # Print wait message only once - if wait_time == 0: + if wait_time == 0.0: self._logger.debug('Waiting 10 sec for player to connect...') await sleep(0.1) wait_time += 0.1 diff --git a/bot/cogs/player/queue.py b/bot/cogs/player/queue.py index a52d1ec..7f4dc3d 100644 --- a/bot/cogs/player/queue.py +++ b/bot/cogs/player/queue.py @@ -118,16 +118,6 @@ def current_index(self) -> int: """ return self._i - @property - def current_shuffled_index(self) -> int: - """ - Returns the current track index, accounting for shuffling. - This is the index of the current track in self._shuf_i. - """ - if not self.is_shuffling: - return self.current_index - return self._shuf_i.index(self.current_index) - @current_index.setter def current_index(self, i: int): """ @@ -140,6 +130,16 @@ def current_index(self, i: int): """ self._i = i + @property + def current_shuffled_index(self) -> int: + """ + Returns the current track index, accounting for shuffling. + This is the index of the current track in self._shuf_i. + """ + if not self.is_shuffling: + return self.current_index + return self._shuf_i.index(self.current_index) + @property def next_track(self) -> Tuple[int, QueueItem]: """ diff --git a/bot/cogs/player/track_finder.py b/bot/cogs/player/track_finder.py index 64c9308..cea9f8d 100644 --- a/bot/cogs/player/track_finder.py +++ b/bot/cogs/player/track_finder.py @@ -39,7 +39,7 @@ async def find_lavalink_track( :param in_place: Whether to modify the QueueItem in place. :param lookup_mbid: Whether to look up the MBID for the track. """ - results = [] + results: List['LavalinkResult'] = [] cached, redis_key, redis_key_type = _get_cached_track(item) if cached is not None: diff --git a/bot/utils/blanco.py b/bot/utils/blanco.py index 347d152..9bbe986 100644 --- a/bot/utils/blanco.py +++ b/bot/utils/blanco.py @@ -429,7 +429,7 @@ def get_status_channel(self, guild_id: int) -> Optional['StatusChannel']: channel = self.get_channel(channel_id) if channel is None: self._logger.error('Failed to get status channel for guild %d', guild_id) - elif not isinstance(channel, StatusChannel): + elif not isinstance(channel, (TextChannel, StageChannel, Thread)): self._logger.error('Status channel for guild %d is not Messageable', guild_id) else: self._status_channels[guild_id] = channel diff --git a/bot/utils/musicbrainz.py b/bot/utils/musicbrainz.py index 8af36ed..ce908bc 100644 --- a/bot/utils/musicbrainz.py +++ b/bot/utils/musicbrainz.py @@ -128,7 +128,7 @@ def mb_lookup(track: 'QueueItem') -> Tuple[str | None, str | None]: response = get( str(MUSICBRAINZ_API_BASE_URL / 'recording'), headers={'User-Agent': USER_AGENT, 'Accept': 'application/json'}, - params={'query': query, 'limit': 10, 'inc': 'isrcs', 'fmt': 'json'}, + params={'query': query, 'limit': '10', 'inc': 'isrcs', 'fmt': 'json'}, timeout=5.0, ) try: diff --git a/bot/utils/paginator.py b/bot/utils/paginator.py index 2d7688f..a3f1cf7 100644 --- a/bot/utils/paginator.py +++ b/bot/utils/paginator.py @@ -32,7 +32,7 @@ class Paginator: def __init__(self, itx: Interaction): self.current = 0 - self.embeds = [] + self.embeds: List[Embed] = [] self.home = 0 self.itx = itx self.msg: Optional['Message'] = None @@ -84,15 +84,17 @@ async def run( return await self.msg.edit(view=None) async def _switch_page(self, new_page: int) -> Optional['Message']: + if self.msg is None: + return None + self.current = new_page - if self.msg is not None: - try: - msg = await self.msg.edit(embed=self.embeds[self.current]) - except (Forbidden, HTTPException): - return None - - self.timeout = self.original_timeout - return msg + try: + msg = await self.msg.edit(embed=self.embeds[self.current]) + except (Forbidden, HTTPException): + return None + + self.timeout = self.original_timeout + return msg async def first_page(self): """ From 2d5b0bc0efbe76cadfa2857890a102a852826f24 Mon Sep 17 00:00:00 2001 From: Jared Dantis Date: Sun, 31 Mar 2024 02:43:28 +0800 Subject: [PATCH 20/33] bot: Move server static files out --- Dockerfile | 12 ++++++------ bot/cogs/player/queue.py | 4 ++-- bot/cogs/player/track_finder.py | 4 ++-- bot/database/migrations/__init__.py | 2 +- bot/dev_server.py | 7 +++---- bot/server/main.py | 4 ++-- bot/server/routes.py | 2 +- bot/utils/blanco.py | 16 ++++++++-------- bot/utils/spotify_private.py | 2 +- {bot/server => dashboard}/static/css/.gitignore | 0 {bot/server => dashboard}/static/css/base.css | 0 .../images/favicon/android-chrome-192x192.png | Bin .../images/favicon/android-chrome-512x512.png | Bin .../static/images/favicon/apple-touch-icon.png | Bin .../static/images/favicon/favicon-16x16.png | Bin .../static/images/favicon/favicon-32x32.png | Bin .../static/images/favicon/favicon.ico | Bin .../static/images/favicon/site.webmanifest | 0 .../server => dashboard}/static/images/logo.svg | 0 {bot/server => dashboard}/templates/base.html | 0 .../templates/dashboard.html | 0 .../templates/homepage.html | 0 tailwind.config.js | 2 +- 23 files changed, 27 insertions(+), 28 deletions(-) rename {bot/server => dashboard}/static/css/.gitignore (100%) rename {bot/server => dashboard}/static/css/base.css (100%) rename {bot/server => dashboard}/static/images/favicon/android-chrome-192x192.png (100%) rename {bot/server => dashboard}/static/images/favicon/android-chrome-512x512.png (100%) rename {bot/server => dashboard}/static/images/favicon/apple-touch-icon.png (100%) rename {bot/server => dashboard}/static/images/favicon/favicon-16x16.png (100%) rename {bot/server => dashboard}/static/images/favicon/favicon-32x32.png (100%) rename {bot/server => dashboard}/static/images/favicon/favicon.ico (100%) rename {bot/server => dashboard}/static/images/favicon/site.webmanifest (100%) rename {bot/server => dashboard}/static/images/logo.svg (100%) rename {bot/server => dashboard}/templates/base.html (100%) rename {bot/server => dashboard}/templates/dashboard.html (100%) rename {bot/server => dashboard}/templates/homepage.html (100%) diff --git a/Dockerfile b/Dockerfile index 0675fdf..f017366 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,11 +6,11 @@ FROM node:lts-alpine AS tailwind # Compile Tailwind CSS RUN mkdir -p /opt/build COPY tailwind.config.js /opt/build/ -COPY server/ /opt/build/server +COPY dashboard/ /opt/build/dashboard WORKDIR /opt/build RUN npm install -D tailwindcss && \ - npx tailwindcss -i ./server/static/css/base.css \ - -o ./server/static/css/main.css --minify + npx tailwindcss -i ./dashboard/static/css/base.css \ + -o ./dashboard/static/css/main.css --minify FROM python:3.12 AS dependencies @@ -39,11 +39,11 @@ LABEL maintainer="Jared Dantis " # Copy bot files COPY . /opt/app COPY --from=dependencies /app/.venv /opt/venv -COPY --from=tailwind /opt/build/server/static/css/main.css /opt/app/server/static/css/main.css +COPY --from=tailwind /opt/build/dashboard/static/css/main.css /opt/app/dashboard/static/css/main.css WORKDIR /opt/app # Set release -RUN sed -i "s/0.0.0-unknown/${RELEASE}/" utils/constants.py +RUN sed -i "s/0.0.0-unknown/${RELEASE}/" bot/utils/constants.py # Run bot ENV PATH="/opt/venv/bin:${PATH}" \ @@ -51,4 +51,4 @@ ENV PATH="/opt/venv/bin:${PATH}" \ PYTHONUNBUFFERED=1 EXPOSE 8080 ENTRYPOINT ["python"] -CMD ["-m", "main"] +CMD ["-m", "bot.main"] diff --git a/bot/cogs/player/queue.py b/bot/cogs/player/queue.py index 7f4dc3d..59c5245 100644 --- a/bot/cogs/player/queue.py +++ b/bot/cogs/player/queue.py @@ -10,7 +10,7 @@ from bot.utils.logger import create_logger if TYPE_CHECKING: - from database import Database + from bot.database import Database class QueueManager: @@ -23,7 +23,7 @@ def __init__(self, guild_id: int, database: 'Database', /): self._queue: List[QueueItem] = [] self._shuf_i: List[int] = [] - # Restore loop preferences from database + # Restore loop preferences from bot.database self._db = database self._loop_one = database.get_loop(guild_id) self._loop_all = database.get_loop_all(guild_id) diff --git a/bot/cogs/player/track_finder.py b/bot/cogs/player/track_finder.py index cea9f8d..167c487 100644 --- a/bot/cogs/player/track_finder.py +++ b/bot/cogs/player/track_finder.py @@ -24,7 +24,7 @@ async def find_lavalink_track( node: 'Node', - item: QueueItem, + item: 'QueueItem', /, deezer_enabled: bool = False, in_place: bool = False, @@ -113,7 +113,7 @@ async def find_lavalink_track( def _get_cached_track( - item: QueueItem, + item: 'QueueItem', ) -> Tuple[Optional[str], Optional[str], Optional[str]]: redis_key = None redis_key_type = None diff --git a/bot/database/migrations/__init__.py b/bot/database/migrations/__init__.py index 849938d..2d0a1e6 100644 --- a/bot/database/migrations/__init__.py +++ b/bot/database/migrations/__init__.py @@ -23,7 +23,7 @@ def run_migrations(logger: 'Logger', con: 'Connection'): for file in sorted(listdir(path.dirname(__file__))): if file != path.basename(__file__) and file.endswith('.py'): logger.debug('Running migration: %s', file) - migration = import_module(f'database.migrations.{file[:-3]}') + migration = import_module(f'bot.database.migrations.{file[:-3]}') try: migration.run(con) diff --git a/bot/dev_server.py b/bot/dev_server.py index f05b36d..8657439 100644 --- a/bot/dev_server.py +++ b/bot/dev_server.py @@ -9,8 +9,7 @@ import threading from subprocess import run -from database import Database - +from bot.database import Database from bot.server.main import run_app from bot.utils.config import config @@ -24,9 +23,9 @@ def run_tailwind(): [ 'tailwindcss', '-i', - './server/static/css/base.css', + './dashboard/static/css/base.css', '-o', - './server/static/css/main.css', + './dashboard/static/css/main.css', '--watch', ] ), diff --git a/bot/server/main.py b/bot/server/main.py index 81672b3..e6f3d1e 100644 --- a/bot/server/main.py +++ b/bot/server/main.py @@ -18,7 +18,7 @@ from .routes import setup_routes if TYPE_CHECKING: - from database import Database + from bot.database import Database from bot.dataclass.config import Config @@ -52,7 +52,7 @@ async def run_app(database: 'Database', config: 'Config'): setup_sessions(app, EncryptedCookieStorage(urlsafe_b64decode(fernet_key))) # Setup templates and routes - aiohttp_jinja2.setup(app, loader=jinja2.FileSystemLoader('server/templates')) + aiohttp_jinja2.setup(app, loader=jinja2.FileSystemLoader('dashboard/templates')) setup_routes(app) # Run app diff --git a/bot/server/routes.py b/bot/server/routes.py index bd3a1c9..3bc310f 100644 --- a/bot/server/routes.py +++ b/bot/server/routes.py @@ -37,4 +37,4 @@ def setup_routes(app: 'Application'): app.router.add_get('/robots.txt', robotstxt) app.router.add_get('/spotifyoauth', spotifyoauth) app.router.add_get('/unlink', unlink) - app.router.add_static('/static/', path='server/static', name='static') + app.router.add_static('/static/', path='dashboard/static', name='static') diff --git a/bot/utils/blanco.py b/bot/utils/blanco.py index 9bbe986..1302b64 100644 --- a/bot/utils/blanco.py +++ b/bot/utils/blanco.py @@ -7,7 +7,6 @@ from typing import TYPE_CHECKING, Dict, List, Optional, Union from aiohttp.client_exceptions import ClientConnectorError -from database import Database from mafic import EndReason, NodePool, VoiceRegion from nextcord import ( Activity, @@ -26,6 +25,7 @@ from nextcord.ext.commands import Bot, ExtensionNotLoaded from bot.cogs.player.track_finder import find_lavalink_track +from bot.database import Database from bot.views.now_playing import NowPlayingView from .embeds import create_error_embed @@ -166,20 +166,20 @@ async def on_ready(self): # Try to unload cogs first if the bot was restarted try: - self.unload_extension('cogs') + self.unload_extension('bot.cogs') except ExtensionNotLoaded: pass - self.load_extension('cogs') + self.load_extension('bot.cogs') # Load server extension if server is enabled if self._config.enable_server: # Try to unload server first if the bot was restarted try: - self.unload_extension('server') + self.unload_extension('bot.server') except ExtensionNotLoaded: pass self._logger.info('Starting web server...') - self.load_extension('server') + self.load_extension('bot.server') elif self._config.base_url is not None: self._logger.warning( 'Server is disabled, but base URL is set to %s', @@ -415,13 +415,13 @@ def get_status_channel(self, guild_id: int) -> Optional['StatusChannel']: if guild_id in self._status_channels: return self._status_channels[guild_id] - # Get status channel ID from database + # Get status channel ID from bot.database channel_id = -1 try: channel_id = self.database.get_status_channel(guild_id) except OperationalError: self._logger.warning( - 'Failed to get status channel ID for guild %d from database', guild_id + 'Failed to get status channel ID for guild %d from bot.database', guild_id ) # Get status channel from ID @@ -463,7 +463,7 @@ async def init_pool(self): for region in node.regions: regions.append(VoiceRegion(region)) - # Get session ID from database + # Get session ID from bot.database try: session_id = self.database.get_session_id(node.id) except (OperationalError, TypeError): diff --git a/bot/utils/spotify_private.py b/bot/utils/spotify_private.py index 295917f..ba75660 100644 --- a/bot/utils/spotify_private.py +++ b/bot/utils/spotify_private.py @@ -19,7 +19,7 @@ from .logger import create_logger if TYPE_CHECKING: - from database import Database + from bot.database import Database from bot.dataclass.config import Config diff --git a/bot/server/static/css/.gitignore b/dashboard/static/css/.gitignore similarity index 100% rename from bot/server/static/css/.gitignore rename to dashboard/static/css/.gitignore diff --git a/bot/server/static/css/base.css b/dashboard/static/css/base.css similarity index 100% rename from bot/server/static/css/base.css rename to dashboard/static/css/base.css diff --git a/bot/server/static/images/favicon/android-chrome-192x192.png b/dashboard/static/images/favicon/android-chrome-192x192.png similarity index 100% rename from bot/server/static/images/favicon/android-chrome-192x192.png rename to dashboard/static/images/favicon/android-chrome-192x192.png diff --git a/bot/server/static/images/favicon/android-chrome-512x512.png b/dashboard/static/images/favicon/android-chrome-512x512.png similarity index 100% rename from bot/server/static/images/favicon/android-chrome-512x512.png rename to dashboard/static/images/favicon/android-chrome-512x512.png diff --git a/bot/server/static/images/favicon/apple-touch-icon.png b/dashboard/static/images/favicon/apple-touch-icon.png similarity index 100% rename from bot/server/static/images/favicon/apple-touch-icon.png rename to dashboard/static/images/favicon/apple-touch-icon.png diff --git a/bot/server/static/images/favicon/favicon-16x16.png b/dashboard/static/images/favicon/favicon-16x16.png similarity index 100% rename from bot/server/static/images/favicon/favicon-16x16.png rename to dashboard/static/images/favicon/favicon-16x16.png diff --git a/bot/server/static/images/favicon/favicon-32x32.png b/dashboard/static/images/favicon/favicon-32x32.png similarity index 100% rename from bot/server/static/images/favicon/favicon-32x32.png rename to dashboard/static/images/favicon/favicon-32x32.png diff --git a/bot/server/static/images/favicon/favicon.ico b/dashboard/static/images/favicon/favicon.ico similarity index 100% rename from bot/server/static/images/favicon/favicon.ico rename to dashboard/static/images/favicon/favicon.ico diff --git a/bot/server/static/images/favicon/site.webmanifest b/dashboard/static/images/favicon/site.webmanifest similarity index 100% rename from bot/server/static/images/favicon/site.webmanifest rename to dashboard/static/images/favicon/site.webmanifest diff --git a/bot/server/static/images/logo.svg b/dashboard/static/images/logo.svg similarity index 100% rename from bot/server/static/images/logo.svg rename to dashboard/static/images/logo.svg diff --git a/bot/server/templates/base.html b/dashboard/templates/base.html similarity index 100% rename from bot/server/templates/base.html rename to dashboard/templates/base.html diff --git a/bot/server/templates/dashboard.html b/dashboard/templates/dashboard.html similarity index 100% rename from bot/server/templates/dashboard.html rename to dashboard/templates/dashboard.html diff --git a/bot/server/templates/homepage.html b/dashboard/templates/homepage.html similarity index 100% rename from bot/server/templates/homepage.html rename to dashboard/templates/homepage.html diff --git a/tailwind.config.js b/tailwind.config.js index 1adbbf9..6147345 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -1,6 +1,6 @@ /** @type {import('tailwindcss').Config} */ module.exports = { - content: ["./server/templates/*.html"], + content: ["./dashboard/templates/*.html"], theme: { fontFamily: { sans: ['IBM Plex Mono', 'sans-serif'], From 788b02117429617fc74e2047c34d5aa8fba7ab1e Mon Sep 17 00:00:00 2001 From: Jared Dantis Date: Sun, 31 Mar 2024 02:43:53 +0800 Subject: [PATCH 21/33] poetry: Source now lives in bot folder --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index c9a5226..c23f761 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ description = "Simple, no-frills Discord music bot" authors = ["Jared Dantis "] license = "MIT" readme = "README.md" -package-mode = false +packages = [{ include = "bot" }] [tool.poetry.dependencies] aiohttp-jinja2 = "^1.6" From a8499d8b00c85b3f0a4e5bb233713ed2745f5043 Mon Sep 17 00:00:00 2001 From: Jared Dantis Date: Sun, 31 Mar 2024 02:44:04 +0800 Subject: [PATCH 22/33] Makefile: Add targets for local Docker img --- Makefile | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/Makefile b/Makefile index 4936392..1bb679a 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,6 @@ +MAKEFLAGS += --jobs=2 .PHONY: install dev +all: install dev-frontend dev precommit image dev-image install: poetry env use 3.12 @@ -13,3 +15,12 @@ dev: config.yml blanco.db precommit: poetry run pre-commit run --all-files + +image: + docker build -t blanco-bot . + +dev-image: config.yml blanco.db image + docker run --rm -it \ + -v $(PWD):/opt/app \ + -p 8080:8080 \ + blanco-bot From 2d22b34209b565c3a645a34fb4c09521101d8ab3 Mon Sep 17 00:00:00 2001 From: Jared Dantis Date: Sun, 31 Mar 2024 02:46:43 +0800 Subject: [PATCH 23/33] .github: Just invoke pre-commit directly --- .github/workflows/pull-request.yml | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 6b1bb54..8de25cc 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -15,21 +15,19 @@ jobs: steps: - uses: actions/checkout@v4 - name: Install poetry - run: pipx install poetry + run: pipx install poetry==1.8.2 - uses: actions/setup-python@v4 with: python-version: 3.12 cache: 'poetry' + cache-dependency-path: poetry.lock - name: Install dependencies run: | poetry env use 3.12 poetry install - - name: Run linter + - name: Run precommit hooks run: | - poetry run ruff check --no-fix --no-cache $(git ls-files '*.py') - - name: Run format checker - run: | - poetry run ruff format --check --no-cache $(git ls-files '*.py') + poetry run pre-commit run --all-files build: uses: ./.github/workflows/build.yml From 2e39e577099e69a459b5fdde2d01c16d793c5fe5 Mon Sep 17 00:00:00 2001 From: Jared Dantis Date: Sun, 31 Mar 2024 12:48:01 +0800 Subject: [PATCH 24/33] Downgrade back to Python 3.11 --- Makefile | 4 ++-- bot/server/views/discordoauth.py | 2 +- bot/server/views/spotifyoauth.py | 2 +- bot/utils/musicbrainz.py | 2 +- bot/utils/spotify_client.py | 18 +++++++++--------- bot/utils/spotify_private.py | 2 +- poetry.lock | 18 ++++++++++++++++-- pyproject.toml | 2 +- 8 files changed, 32 insertions(+), 18 deletions(-) diff --git a/Makefile b/Makefile index 1bb679a..284db20 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ MAKEFLAGS += --jobs=2 all: install dev-frontend dev precommit image dev-image install: - poetry env use 3.12 + poetry env use 3.11 poetry install poetry run pre-commit install @@ -17,7 +17,7 @@ precommit: poetry run pre-commit run --all-files image: - docker build -t blanco-bot . + docker buildx build -t blanco-bot . dev-image: config.yml blanco.db image docker run --rm -it \ diff --git a/bot/server/views/discordoauth.py b/bot/server/views/discordoauth.py index 4c6a0e9..8305cdd 100644 --- a/bot/server/views/discordoauth.py +++ b/bot/server/views/discordoauth.py @@ -71,7 +71,7 @@ async def discordoauth(request: web.Request): # noqa: PLR0911 user_info = requests.get( str(DISCORD_API_BASE_URL / 'users/@me'), headers={ - 'Authorization': f'Bearer {parsed['access_token']}', + 'Authorization': f"Bearer {parsed['access_token']}", 'User-Agent': USER_AGENT, }, timeout=5, diff --git a/bot/server/views/spotifyoauth.py b/bot/server/views/spotifyoauth.py index 30d540d..1e204f4 100644 --- a/bot/server/views/spotifyoauth.py +++ b/bot/server/views/spotifyoauth.py @@ -78,7 +78,7 @@ async def spotifyoauth(request: web.Request): # noqa: PLR0911 user_info = requests.get( str(SPOTIFY_API_BASE_URL / 'me'), headers={ - 'Authorization': f'Bearer {parsed['access_token']}', + 'Authorization': f"Bearer {parsed['access_token']}", 'User-Agent': USER_AGENT, }, timeout=5, diff --git a/bot/utils/musicbrainz.py b/bot/utils/musicbrainz.py index ce908bc..1d22703 100644 --- a/bot/utils/musicbrainz.py +++ b/bot/utils/musicbrainz.py @@ -169,7 +169,7 @@ def mb_lookup(track: 'QueueItem') -> Tuple[str | None, str | None]: similarities = [ check_similarity_weighted( query, - f'{result['title']} {result['artist-credit'][0]['name']}', + f"{result['title']} {result['artist-credit'][0]['name']}", result['score'], ) for result in results diff --git a/bot/utils/spotify_client.py b/bot/utils/spotify_client.py index da0d5f9..d58a7b3 100644 --- a/bot/utils/spotify_client.py +++ b/bot/utils/spotify_client.py @@ -322,7 +322,7 @@ def search(self, query: str, search_type: str) -> List[SpotifyResult]: results = [ SpotifyResult( name=entity['name'], - description=f'{entity['followers']['total']} followers', + description=f"{entity['followers']['total']} followers", spotify_id=entity['id'], ) for entity in items @@ -332,9 +332,9 @@ def search(self, query: str, search_type: str) -> List[SpotifyResult]: results = [ SpotifyResult( name=entity['name'], - description=f'{entity['artists'][0]['name']} ' - f'({entity['total_tracks']} tracks, ' - f'released {entity['release_date']})', + description=f"{entity['artists'][0]['name']} " + f"({entity['total_tracks']} tracks, " + f"released {entity['release_date']})", spotify_id=entity['id'], ) for entity in items @@ -344,8 +344,8 @@ def search(self, query: str, search_type: str) -> List[SpotifyResult]: results = [ SpotifyResult( name=entity['name'], - description=f'{entity['owner']['display_name']} ' - f'({entity['tracks']['total']} tracks)', + description=f"{entity['owner']['display_name']} " + f"({entity['tracks']['total']} tracks)", spotify_id=entity['id'], ) for entity in items @@ -354,9 +354,9 @@ def search(self, query: str, search_type: str) -> List[SpotifyResult]: # Include artist name and release date in track results results = [ SpotifyResult( - name=f'{entity['name']} ' f'({human_readable_time(entity['duration_ms'])})', - description=f'{entity['artists'][0]['name']} - ' - f'{entity['album']['name']} ', + name=f"{entity['name']} ' f'({human_readable_time(entity['duration_ms'])})", + description=f"{entity['artists'][0]['name']} - " + f"{entity['album']['name']} ", spotify_id=entity['id'], ) for entity in items diff --git a/bot/utils/spotify_private.py b/bot/utils/spotify_private.py index ba75660..44d4e85 100644 --- a/bot/utils/spotify_private.py +++ b/bot/utils/spotify_private.py @@ -134,7 +134,7 @@ def get_user_playlists(self) -> List[SpotifyResult]: return [ SpotifyResult( name=playlist['name'], - description=f'{playlist['tracks']['total']} tracks', + description=f"{playlist['tracks']['total']} tracks", spotify_id=playlist['id'], ) for playlist in parsed['items'] diff --git a/poetry.lock b/poetry.lock index d4fd374..6da4ea2 100644 --- a/poetry.lock +++ b/poetry.lock @@ -165,6 +165,17 @@ doc = ["Sphinx (>=7)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphin test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"] trio = ["trio (>=0.23)"] +[[package]] +name = "async-timeout" +version = "4.0.3" +description = "Timeout context manager for asyncio programs" +optional = false +python-versions = ">=3.7" +files = [ + {file = "async-timeout-4.0.3.tar.gz", hash = "sha256:4640d96be84d82d02ed59ea2b7105a0f7b33abe8703703cd0ab0bf87c427522f"}, + {file = "async_timeout-4.0.3-py3-none-any.whl", hash = "sha256:7405140ff1230c310e51dc27b3145b9092d659ce68ff733fb0cefe3ee42be028"}, +] + [[package]] name = "attrs" version = "23.2.0" @@ -1154,6 +1165,9 @@ files = [ {file = "redis-5.0.3.tar.gz", hash = "sha256:4973bae7444c0fbed64a06b87446f79361cb7e4ec1538c022d696ed7a5015580"}, ] +[package.dependencies] +async-timeout = {version = ">=4.0.3", markers = "python_full_version < \"3.11.3\""} + [package.extras] hiredis = ["hiredis (>=1.0.0)"] ocsp = ["cryptography (>=36.0.1)", "pyopenssl (==20.0.1)", "requests (>=2.26.0)"] @@ -1504,5 +1518,5 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" -python-versions = "^3.12" -content-hash = "6669e891772b3782169b86a6f3090483e2e1abcb0de389294dbf70148d319525" +python-versions = "^3.11" +content-hash = "8e65772950ff7bb26644a9da909bae1a60e9a51822650b1a9dd84a4421bbaff3" diff --git a/pyproject.toml b/pyproject.toml index c23f761..c8988b0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,13 +8,13 @@ readme = "README.md" packages = [{ include = "bot" }] [tool.poetry.dependencies] +python = "^3.11" aiohttp-jinja2 = "^1.6" aiohttp-session = "^2.12.0" cryptography = "^42.0.5" mafic = "^2.10.0" nextcord = "^2.6.0" pylast = "^5.2.0" -python = "^3.12" pyyaml = "^6.0.1" ratelimit = "^2.2.1" redis = "^5.0.3" From 2265f277c5617a036bb0ee6f6fad2531a3f93c64 Mon Sep 17 00:00:00 2001 From: Jared Dantis Date: Sun, 31 Mar 2024 12:48:21 +0800 Subject: [PATCH 25/33] Dockerfile: Use Tailwind CLI for faster build --- Dockerfile | 41 +++++++++++++++++++++-------------------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/Dockerfile b/Dockerfile index f017366..4d622c0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,22 +1,11 @@ ARG RELEASE="0.0.0-unknown" -FROM node:lts-alpine AS tailwind - -# Compile Tailwind CSS -RUN mkdir -p /opt/build -COPY tailwind.config.js /opt/build/ -COPY dashboard/ /opt/build/dashboard -WORKDIR /opt/build -RUN npm install -D tailwindcss && \ - npx tailwindcss -i ./dashboard/static/css/base.css \ - -o ./dashboard/static/css/main.css --minify - - -FROM python:3.12 AS dependencies +FROM --platform=$BUILDPLATFORM python:3.11 AS dependencies +ARG TARGETARCH # Install build-essential for building Python packages -RUN apt-get update && apt-get install -y build-essential +RUN apt-get update && apt-get install -y build-essential curl # Install Poetry RUN pip install poetry==1.8.2 @@ -25,21 +14,33 @@ ENV POETRY_NO_INTERACTION=1 \ POETRY_VIRTUALENVS_CREATE=1 \ POETRY_CACHE_DIR=/tmp/poetry_cache -# Install dependencies +# Copy files WORKDIR /app -COPY pyproject.toml poetry.lock ./ -RUN --mount=type=cache,target=$POETRY_CACHE_DIR poetry install --without dev -RUN --mount=type=cache,target=$POETRY_CACHE_DIR poetry add setuptools +COPY pyproject.toml poetry.lock tailwind.config.js dashboard/static/css/base.css ./ +# Install dependencies +RUN --mount=type=cache,target=$POETRY_CACHE_DIR poetry install --without dev -FROM python:3.12-slim AS main +# Compile Tailwind CSS +RUN echo "Downloading Tailwind CLI for ${TARGETARCH}" && \ + if [ "${TARGETARCH}" = "arm64" ]; then \ + curl -sL https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-linux-arm64 -o ./tailwindcss; \ + else \ + echo "Downloading amd64 version of Tailwind CSS"; \ + curl -sL https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-linux-amd64 -o ./tailwindcss; \ + fi && \ + chmod +x ./tailwindcss && \ + ./tailwindcss -i ./base.css -o ./main.css --minify + + +FROM python:3.11-slim AS main ARG RELEASE LABEL maintainer="Jared Dantis " # Copy bot files COPY . /opt/app COPY --from=dependencies /app/.venv /opt/venv -COPY --from=tailwind /opt/build/dashboard/static/css/main.css /opt/app/dashboard/static/css/main.css +COPY --from=dependencies /app/main.css /opt/app/dashboard/static/css/main.css WORKDIR /opt/app # Set release From 327620cedfb6c62533475343d0d68c4dc63a7237 Mon Sep 17 00:00:00 2001 From: Jared Dantis Date: Sun, 31 Mar 2024 12:52:40 +0800 Subject: [PATCH 26/33] Dockerfile: Fix Tailwind CLI link for amd64 --- Dockerfile | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index 4d622c0..ab0ce57 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,11 +23,10 @@ RUN --mount=type=cache,target=$POETRY_CACHE_DIR poetry install --without dev # Compile Tailwind CSS RUN echo "Downloading Tailwind CLI for ${TARGETARCH}" && \ - if [ "${TARGETARCH}" = "arm64" ]; then \ - curl -sL https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-linux-arm64 -o ./tailwindcss; \ + if [ "${TARGETARCH}" = "amd64" ]; then \ + curl -sL https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-linux-x64 -o ./tailwindcss; \ else \ - echo "Downloading amd64 version of Tailwind CSS"; \ - curl -sL https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-linux-amd64 -o ./tailwindcss; \ + curl -sL https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-linux-${TARGETARCH} -o ./tailwindcss; \ fi && \ chmod +x ./tailwindcss && \ ./tailwindcss -i ./base.css -o ./main.css --minify From 664221f3aa2bbbbdc1e5f3336ed4cf6c37d4cc71 Mon Sep 17 00:00:00 2001 From: Jared Dantis Date: Sun, 31 Mar 2024 13:40:28 +0800 Subject: [PATCH 27/33] Cleanup --- .vscode/settings.json | 3 +- bot/cogs/{debug.py => debug/__init__.py} | 2 +- bot/cogs/player/__init__.py | 4 +- .../lavalink_search.py} | 2 +- .../lavalink_track.py} | 76 ++++++++++++++--- .../{jockey_helpers.py => helpers/parsers.py} | 81 ++----------------- .../{queue.py => helpers/queue_manager.py} | 2 +- .../scrobble_handler.py} | 2 +- bot/cogs/player/jockey.py | 14 ++-- bot/database/__init__.py | 2 +- bot/database/redis.py | 2 +- bot/dataclass/__init__.py | 0 bot/{dataclass => models}/bump.py | 0 bot/{dataclass => models}/config.py | 0 bot/{dataclass => models}/custom_embed.py | 0 bot/{dataclass => models}/lavalink_result.py | 0 bot/{dataclass => models}/oauth.py | 0 bot/{dataclass => models}/queue_item.py | 0 bot/{dataclass => models}/spotify.py | 0 bot/server/main.py | 2 +- bot/server/views/dashboard.py | 2 +- bot/server/views/discordoauth.py | 2 +- bot/server/views/lastfmtoken.py | 2 +- bot/server/views/spotifyoauth.py | 2 +- bot/utils/__init__.py | 0 bot/utils/blanco.py | 4 +- bot/utils/config.py | 2 +- bot/utils/embeds.py | 2 +- bot/utils/fuzzy.py | 48 +++++++++++ bot/utils/musicbrainz.py | 2 +- bot/utils/scrobbler.py | 6 +- bot/utils/spotify_client.py | 2 +- bot/utils/spotify_private.py | 6 +- bot/views/spotify_dropdown.py | 4 +- 34 files changed, 153 insertions(+), 123 deletions(-) rename bot/cogs/{debug.py => debug/__init__.py} (98%) rename bot/cogs/player/{lavalink_client.py => helpers/lavalink_search.py} (98%) rename bot/cogs/player/{track_finder.py => helpers/lavalink_track.py} (79%) rename bot/cogs/player/{jockey_helpers.py => helpers/parsers.py} (75%) rename bot/cogs/player/{queue.py => helpers/queue_manager.py} (99%) rename bot/cogs/player/{scrobbler.py => helpers/scrobble_handler.py} (98%) delete mode 100644 bot/dataclass/__init__.py rename bot/{dataclass => models}/bump.py (100%) rename bot/{dataclass => models}/config.py (100%) rename bot/{dataclass => models}/custom_embed.py (100%) rename bot/{dataclass => models}/lavalink_result.py (100%) rename bot/{dataclass => models}/oauth.py (100%) rename bot/{dataclass => models}/queue_item.py (100%) rename bot/{dataclass => models}/spotify.py (100%) delete mode 100644 bot/utils/__init__.py diff --git a/.vscode/settings.json b/.vscode/settings.json index 8a965d3..d75a1a5 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -11,5 +11,6 @@ "--disable=too-few-public-methods", "--disable=too-many-return-statements", "--disable=too-many-branches" - ] + ], + "editor.formatOnSave": true } diff --git a/bot/cogs/debug.py b/bot/cogs/debug/__init__.py similarity index 98% rename from bot/cogs/debug.py rename to bot/cogs/debug/__init__.py index e7dd3cf..f6f979d 100644 --- a/bot/cogs/debug.py +++ b/bot/cogs/debug/__init__.py @@ -8,7 +8,7 @@ from nextcord.ext import application_checks from nextcord.ext.commands import Cog -from bot.dataclass.custom_embed import CustomEmbed +from bot.models.custom_embed import CustomEmbed from bot.utils.embeds import create_success_embed from bot.utils.logger import create_logger from bot.utils.paginator import Paginator diff --git a/bot/cogs/player/__init__.py b/bot/cogs/player/__init__.py index d9fcfb8..6740e5b 100644 --- a/bot/cogs/player/__init__.py +++ b/bot/cogs/player/__init__.py @@ -23,7 +23,7 @@ from nextcord.ext.commands import Cog from requests import HTTPError, codes -from bot.dataclass.custom_embed import CustomEmbed +from bot.models.custom_embed import CustomEmbed from bot.utils.constants import RELEASE, SPOTIFY_403_ERR_MSG from bot.utils.embeds import create_error_embed, create_success_embed from bot.utils.exceptions import ( @@ -42,7 +42,7 @@ from .jockey import Jockey if TYPE_CHECKING: - from bot.dataclass.queue_item import QueueItem + from bot.models.queue_item import QueueItem from bot.utils.blanco import BlancoBot diff --git a/bot/cogs/player/lavalink_client.py b/bot/cogs/player/helpers/lavalink_search.py similarity index 98% rename from bot/cogs/player/lavalink_client.py rename to bot/cogs/player/helpers/lavalink_search.py index 0daf7d7..b278b72 100644 --- a/bot/cogs/player/lavalink_client.py +++ b/bot/cogs/player/helpers/lavalink_search.py @@ -8,7 +8,7 @@ from mafic import Playlist, SearchType, TrackLoadException -from bot.dataclass.lavalink_result import LavalinkResult +from bot.models.lavalink_result import LavalinkResult from bot.utils.constants import BLACKLIST from bot.utils.exceptions import LavalinkSearchError from bot.utils.fuzzy import check_similarity diff --git a/bot/cogs/player/track_finder.py b/bot/cogs/player/helpers/lavalink_track.py similarity index 79% rename from bot/cogs/player/track_finder.py rename to bot/cogs/player/helpers/lavalink_track.py index 167c487..4f3001c 100644 --- a/bot/cogs/player/track_finder.py +++ b/bot/cogs/player/helpers/lavalink_track.py @@ -1,3 +1,9 @@ +""" +Lavalink track helpers, which take care of finding a matching +playable Lavalink track for a QueueItem, caching it, and +invalidating it when necessary. +""" + from typing import TYPE_CHECKING, List, Optional, Tuple from mafic import SearchType @@ -5,19 +11,19 @@ from bot.database.redis import REDIS from bot.utils.constants import CONFIDENCE_THRESHOLD from bot.utils.exceptions import LavalinkSearchError +from bot.utils.fuzzy import rank_results from bot.utils.logger import create_logger from bot.utils.musicbrainz import annotate_track -from .jockey_helpers import rank_results -from .lavalink_client import get_deezer_matches, get_deezer_track, get_youtube_matches +from .lavalink_search import get_deezer_matches, get_deezer_track, get_youtube_matches if TYPE_CHECKING: from mafic import Node, Track - from bot.dataclass.lavalink_result import LavalinkResult - from bot.dataclass.queue_item import QueueItem + from bot.models.lavalink_result import LavalinkResult + from bot.models.queue_item import QueueItem - from .lavalink_client import LavalinkSearchError + from .lavalink_search import LavalinkSearchError LOGGER = create_logger('track_finder') @@ -112,19 +118,36 @@ async def find_lavalink_track( return lavalink_track +def invalidate_cached_track(item: 'QueueItem'): + """ + Removes a cached Lavalink track from Redis. + + :param item: The QueueItem to invalidate the track for. + """ + if REDIS is None: + return + + redis_key, redis_key_type = _determine_cache_key(item) + + # Invalidate cached Lavalink track + if redis_key is not None and redis_key_type is not None: + REDIS.invalidate_lavalink_track(redis_key, key_type=redis_key_type) + else: + LOGGER.warning("Could not invalidate cached track for `%s': no key", item.title) + + def _get_cached_track( item: 'QueueItem', ) -> Tuple[Optional[str], Optional[str], Optional[str]]: - redis_key = None - redis_key_type = None - if item.spotify_id is not None: - redis_key = item.spotify_id - redis_key_type = 'spotify_id' - elif item.isrc is not None: - redis_key = item.isrc - redis_key_type = 'isrc' + """ + Gets a cached Lavalink track from Redis. + + :param item: The QueueItem to get the cached track for. + """ + redis_key, redis_key_type = _determine_cache_key(item) cached = None + if REDIS is not None and redis_key is not None and redis_key_type is not None: cached = REDIS.get_lavalink_track(redis_key, key_type=redis_key_type) @@ -136,10 +159,37 @@ def _set_cached_track( key: Optional[str] = None, key_type: Optional[str] = None, ): + """ + Caches a Lavalink track in Redis. + + :param lavalink_track: The Lavalink track to cache. + :param key: The key to cache the track under. + :param key_type: The type of key to cache the track under. + """ if REDIS is not None and key_type is not None and key is not None: REDIS.set_lavalink_track(key, lavalink_track, key_type=key_type) +def _determine_cache_key(item: 'QueueItem') -> Tuple[Optional[str], Optional[str]]: + """ + Determines the Redis key and key type for caching a Lavalink track. + + :param item: The QueueItem to determine the cache key for. + """ + + redis_key = None + redis_key_type = None + + if item.spotify_id is not None: + redis_key = item.spotify_id + redis_key_type = 'spotify_id' + elif item.isrc is not None: + redis_key = item.isrc + redis_key_type = 'isrc' + + return redis_key, redis_key_type + + async def _append_deezer_results_for_isrc( results: List['LavalinkResult'], node: 'Node', diff --git a/bot/cogs/player/jockey_helpers.py b/bot/cogs/player/helpers/parsers.py similarity index 75% rename from bot/cogs/player/jockey_helpers.py rename to bot/cogs/player/helpers/parsers.py index 05fc204..a265791 100644 --- a/bot/cogs/player/jockey_helpers.py +++ b/bot/cogs/player/helpers/parsers.py @@ -1,22 +1,21 @@ """ -Helper functions for the music player. +Playback query parsers for the Player cog. """ -from typing import TYPE_CHECKING, List, Tuple, TypeVar +from typing import TYPE_CHECKING, List from mafic import SearchType from requests.status_codes import codes from spotipy.exceptions import SpotifyException -from bot.database.redis import REDIS -from bot.dataclass.queue_item import QueueItem +from bot.models.queue_item import QueueItem from bot.utils.constants import CONFIDENCE_THRESHOLD from bot.utils.exceptions import ( JockeyException, LavalinkInvalidIdentifierError, SpotifyNoResultsError, ) -from bot.utils.fuzzy import check_similarity_weighted +from bot.utils.fuzzy import rank_results from bot.utils.logger import create_logger from bot.utils.spotify_client import Spotify from bot.utils.url import ( @@ -32,7 +31,7 @@ get_ytlistid_from_url, ) -from .lavalink_client import ( +from .lavalink_search import ( get_soundcloud_matches, get_youtube_matches, ) @@ -40,77 +39,9 @@ if TYPE_CHECKING: from mafic import Node -from bot.dataclass.spotify import SpotifyTrack +from bot.models.spotify import SpotifyTrack LOGGER = create_logger('jockey_helpers') -T = TypeVar('T') - - -def rank_results( - query: str, results: List[T], result_type: SearchType -) -> List[Tuple[T, int]]: - """ - Ranks search results based on similarity to a fuzzy query. - - :param query: The query to check against. - :param results: The results to rank. Can be mafic.Track, dataclass.SpotifyTrack, - or any object with a title and author string attribute. - :param result_type: The type of result. See ResultType. - :return: A list of tuples containing the result and its similarity to the query. - """ - # Rank results - similarities = [ - check_similarity_weighted( - query, - f'{result.title} {result.author}', # type: ignore - int(100 * (0.8**i)), - ) - for i, result in enumerate(results) - ] - ranked = sorted(zip(results, similarities), key=lambda x: x[1], reverse=True) - - # Print confidences for debugging - type_name = 'YouTube' - if result_type == SearchType.SPOTIFY_SEARCH: - type_name = 'Spotify' - elif result_type == SearchType.DEEZER_SEARCH: - type_name = 'Deezer' - LOGGER.debug('%s results and confidences for "%s":', type_name, query) - for result, confidence in ranked: - LOGGER.debug( - ' %3d %-20s %-25s', - confidence, - result.author[:20], # type: ignore - result.title[:25], # type: ignore - ) - - return ranked - - -def invalidate_lavalink_track(item: QueueItem): - """ - Removes a cached Lavalink track from Redis. - - :param item: The QueueItem to invalidate the track for. - """ - if REDIS is None: - return - - # Determine key type - redis_key = None - redis_key_type = None - if item.spotify_id is not None: - redis_key = item.spotify_id - redis_key_type = 'spotify_id' - elif item.isrc is not None: - redis_key = item.isrc - redis_key_type = 'isrc' - - # Invalidate cached Lavalink track - if redis_key is not None and redis_key_type is not None: - REDIS.invalidate_lavalink_track(redis_key, key_type=redis_key_type) - else: - LOGGER.warning("Could not invalidate cached track for `%s': no key", item.title) async def parse_query( diff --git a/bot/cogs/player/queue.py b/bot/cogs/player/helpers/queue_manager.py similarity index 99% rename from bot/cogs/player/queue.py rename to bot/cogs/player/helpers/queue_manager.py index 59c5245..6f58e69 100644 --- a/bot/cogs/player/queue.py +++ b/bot/cogs/player/helpers/queue_manager.py @@ -5,7 +5,7 @@ from random import shuffle from typing import TYPE_CHECKING, List, Tuple -from bot.dataclass.queue_item import QueueItem +from bot.models.queue_item import QueueItem from bot.utils.exceptions import EmptyQueueError, EndOfQueueError from bot.utils.logger import create_logger diff --git a/bot/cogs/player/scrobbler.py b/bot/cogs/player/helpers/scrobble_handler.py similarity index 98% rename from bot/cogs/player/scrobbler.py rename to bot/cogs/player/helpers/scrobble_handler.py index 14d4f67..c20e0c2 100644 --- a/bot/cogs/player/scrobbler.py +++ b/bot/cogs/player/helpers/scrobble_handler.py @@ -9,7 +9,7 @@ if TYPE_CHECKING: from nextcord.abc import Connectable - from bot.dataclass.queue_item import QueueItem + from bot.models.queue_item import QueueItem from bot.utils.blanco import BlancoBot diff --git a/bot/cogs/player/jockey.py b/bot/cogs/player/jockey.py index 86fb46d..524e891 100644 --- a/bot/cogs/player/jockey.py +++ b/bot/cogs/player/jockey.py @@ -17,7 +17,7 @@ VoiceChannel, ) -from bot.dataclass.custom_embed import CustomEmbed +from bot.models.custom_embed import CustomEmbed from bot.utils.constants import UNPAUSE_THRESHOLD from bot.utils.embeds import create_error_embed from bot.utils.exceptions import ( @@ -34,17 +34,17 @@ from bot.utils.time import human_readable_time from bot.views.now_playing import NowPlayingView -from .jockey_helpers import invalidate_lavalink_track, parse_query -from .queue import QueueManager -from .scrobbler import ScrobbleHandler -from .track_finder import find_lavalink_track +from .helpers.lavalink_track import find_lavalink_track, invalidate_cached_track +from .helpers.parsers import parse_query +from .helpers.queue_manager import QueueManager +from .helpers.scrobble_handler import ScrobbleHandler if TYPE_CHECKING: from mafic import Track from nextcord import Embed from nextcord.abc import Connectable, Messageable - from bot.dataclass.queue_item import QueueItem + from bot.models.queue_item import QueueItem from bot.utils.blanco import BlancoBot @@ -244,7 +244,7 @@ async def _play(self, item: 'QueueItem', position: Optional[int] = None): wait_time += 0.1 # Remove cached Lavalink track and try again - invalidate_lavalink_track(item) + invalidate_cached_track(item) has_retried = True else: # Clear pause timestamp for new track diff --git a/bot/database/__init__.py b/bot/database/__init__.py index 3588dfe..05ac498 100644 --- a/bot/database/__init__.py +++ b/bot/database/__init__.py @@ -5,7 +5,7 @@ import sqlite3 as sql from typing import List, Optional -from bot.dataclass.oauth import LastfmAuth, OAuth +from bot.models.oauth import LastfmAuth, OAuth from bot.utils.logger import create_logger from .migrations import run_migrations diff --git a/bot/database/redis.py b/bot/database/redis.py index 2914966..d5a2c68 100644 --- a/bot/database/redis.py +++ b/bot/database/redis.py @@ -6,7 +6,7 @@ import redis -from bot.dataclass.spotify import SpotifyTrack +from bot.models.spotify import SpotifyTrack from bot.utils.config import REDIS_HOST, REDIS_PASSWORD, REDIS_PORT from bot.utils.logger import create_logger diff --git a/bot/dataclass/__init__.py b/bot/dataclass/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/bot/dataclass/bump.py b/bot/models/bump.py similarity index 100% rename from bot/dataclass/bump.py rename to bot/models/bump.py diff --git a/bot/dataclass/config.py b/bot/models/config.py similarity index 100% rename from bot/dataclass/config.py rename to bot/models/config.py diff --git a/bot/dataclass/custom_embed.py b/bot/models/custom_embed.py similarity index 100% rename from bot/dataclass/custom_embed.py rename to bot/models/custom_embed.py diff --git a/bot/dataclass/lavalink_result.py b/bot/models/lavalink_result.py similarity index 100% rename from bot/dataclass/lavalink_result.py rename to bot/models/lavalink_result.py diff --git a/bot/dataclass/oauth.py b/bot/models/oauth.py similarity index 100% rename from bot/dataclass/oauth.py rename to bot/models/oauth.py diff --git a/bot/dataclass/queue_item.py b/bot/models/queue_item.py similarity index 100% rename from bot/dataclass/queue_item.py rename to bot/models/queue_item.py diff --git a/bot/dataclass/spotify.py b/bot/models/spotify.py similarity index 100% rename from bot/dataclass/spotify.py rename to bot/models/spotify.py diff --git a/bot/server/main.py b/bot/server/main.py index e6f3d1e..ee33259 100644 --- a/bot/server/main.py +++ b/bot/server/main.py @@ -19,7 +19,7 @@ if TYPE_CHECKING: from bot.database import Database -from bot.dataclass.config import Config +from bot.models.config import Config class AccessLogger(AbstractAccessLogger): diff --git a/bot/server/views/dashboard.py b/bot/server/views/dashboard.py index c09f4a9..203cc58 100644 --- a/bot/server/views/dashboard.py +++ b/bot/server/views/dashboard.py @@ -9,7 +9,7 @@ from aiohttp_session import get_session if TYPE_CHECKING: - from bot.dataclass.oauth import LastfmAuth, OAuth + from bot.models.oauth import LastfmAuth, OAuth @aiohttp_jinja2.template('dashboard.html') diff --git a/bot/server/views/discordoauth.py b/bot/server/views/discordoauth.py index 8305cdd..be6932d 100644 --- a/bot/server/views/discordoauth.py +++ b/bot/server/views/discordoauth.py @@ -9,7 +9,7 @@ from aiohttp_session import get_session from requests.exceptions import HTTPError, Timeout -from bot.dataclass.oauth import OAuth +from bot.models.oauth import OAuth from bot.utils.constants import DISCORD_API_BASE_URL, USER_AGENT diff --git a/bot/server/views/lastfmtoken.py b/bot/server/views/lastfmtoken.py index eb15555..7c89879 100644 --- a/bot/server/views/lastfmtoken.py +++ b/bot/server/views/lastfmtoken.py @@ -9,7 +9,7 @@ from aiohttp_session import get_session from requests.exceptions import HTTPError, Timeout -from bot.dataclass.oauth import LastfmAuth +from bot.models.oauth import LastfmAuth from bot.utils.constants import LASTFM_API_BASE_URL, USER_AGENT diff --git a/bot/server/views/spotifyoauth.py b/bot/server/views/spotifyoauth.py index 1e204f4..0d0b7f8 100644 --- a/bot/server/views/spotifyoauth.py +++ b/bot/server/views/spotifyoauth.py @@ -10,7 +10,7 @@ from aiohttp_session import get_session from requests.exceptions import HTTPError, Timeout -from bot.dataclass.oauth import OAuth +from bot.models.oauth import OAuth from bot.utils.constants import ( SPOTIFY_ACCOUNTS_BASE_URL, SPOTIFY_API_BASE_URL, diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/bot/utils/blanco.py b/bot/utils/blanco.py index 1302b64..ecd6288 100644 --- a/bot/utils/blanco.py +++ b/bot/utils/blanco.py @@ -24,7 +24,7 @@ ) from nextcord.ext.commands import Bot, ExtensionNotLoaded -from bot.cogs.player.track_finder import find_lavalink_track +from bot.cogs.player.helpers.lavalink_track import find_lavalink_track from bot.database import Database from bot.views.now_playing import NowPlayingView @@ -42,7 +42,7 @@ from mafic import Node, TrackEndEvent, TrackStartEvent from bot.cogs.player.jockey import Jockey -from bot.dataclass.config import Config +from bot.models.config import Config StatusChannel = Union[ PartialMessageable, VoiceChannel, TextChannel, StageChannel, Thread diff --git a/bot/utils/config.py b/bot/utils/config.py index d9d7c61..e8e4c5e 100644 --- a/bot/utils/config.py +++ b/bot/utils/config.py @@ -12,7 +12,7 @@ from yaml import safe_load -from bot.dataclass.config import Config, LavalinkNode +from bot.models.config import Config, LavalinkNode DATABASE_FILE = None DISCORD_TOKEN = None diff --git a/bot/utils/embeds.py b/bot/utils/embeds.py index 83c9336..2b0e36e 100644 --- a/bot/utils/embeds.py +++ b/bot/utils/embeds.py @@ -6,7 +6,7 @@ from nextcord import Colour, Embed -from bot.dataclass.custom_embed import CustomEmbed +from bot.models.custom_embed import CustomEmbed def create_error_embed(message: str) -> Embed: diff --git a/bot/utils/fuzzy.py b/bot/utils/fuzzy.py index c54b48f..e530ab2 100644 --- a/bot/utils/fuzzy.py +++ b/bot/utils/fuzzy.py @@ -3,9 +3,16 @@ """ from difflib import get_close_matches +from typing import List, Tuple, TypeVar +from mafic import SearchType from thefuzz import fuzz +from .logger import create_logger + +LOGGER = create_logger('fuzzy') +T = TypeVar('T') + def check_similarity(actual: str, candidate: str) -> float: """ @@ -55,3 +62,44 @@ def check_similarity_weighted(actual: str, candidate: str, candidate_rank: int) + (tsor * 0.06) + (ptsr * 0.04) ) + + +def rank_results( + query: str, results: List[T], result_type: SearchType +) -> List[Tuple[T, int]]: + """ + Ranks search results based on similarity to a fuzzy query. + + :param query: The query to check against. + :param results: The results to rank. Can be mafic.Track, dataclass.SpotifyTrack, + or any object with a title and author string attribute. + :param result_type: The type of result. See ResultType. + :return: A list of tuples containing the result and its similarity to the query. + """ + # Rank results + similarities = [ + check_similarity_weighted( + query, + f'{result.title} {result.author}', # type: ignore + int(100 * (0.8**i)), + ) + for i, result in enumerate(results) + ] + ranked = sorted(zip(results, similarities), key=lambda x: x[1], reverse=True) + + # Print confidences for debugging + type_name = 'YouTube' + if result_type == SearchType.SPOTIFY_SEARCH: + type_name = 'Spotify' + elif result_type == SearchType.DEEZER_SEARCH: + type_name = 'Deezer' + LOGGER.debug('%s results and confidences for "%s":', type_name, query) + for result, confidence in ranked: + LOGGER.debug( + ' %3d %-20s %-25s', + confidence, + result.author[:20], # type: ignore + result.title[:25], # type: ignore + ) + + return ranked diff --git a/bot/utils/musicbrainz.py b/bot/utils/musicbrainz.py index 1d22703..d910e44 100644 --- a/bot/utils/musicbrainz.py +++ b/bot/utils/musicbrainz.py @@ -15,7 +15,7 @@ from .logger import create_logger if TYPE_CHECKING: - from bot.dataclass.queue_item import QueueItem + from bot.models.queue_item import QueueItem LOGGER = create_logger('musicbrainz') diff --git a/bot/utils/scrobbler.py b/bot/utils/scrobbler.py index a706a8c..c385d76 100644 --- a/bot/utils/scrobbler.py +++ b/bot/utils/scrobbler.py @@ -11,9 +11,9 @@ if TYPE_CHECKING: from logging import Logger -from bot.dataclass.config import Config -from bot.dataclass.oauth import LastfmAuth -from bot.dataclass.queue_item import QueueItem +from bot.models.config import Config +from bot.models.oauth import LastfmAuth +from bot.models.queue_item import QueueItem class Scrobbler: diff --git a/bot/utils/spotify_client.py b/bot/utils/spotify_client.py index d58a7b3..9f4ba4c 100644 --- a/bot/utils/spotify_client.py +++ b/bot/utils/spotify_client.py @@ -16,7 +16,7 @@ ) from bot.database.redis import REDIS -from bot.dataclass.spotify import SpotifyResult, SpotifyTrack +from bot.models.spotify import SpotifyResult, SpotifyTrack from .constants import BLACKLIST from .exceptions import SpotifyInvalidURLError, SpotifyNoResultsError diff --git a/bot/utils/spotify_private.py b/bot/utils/spotify_private.py index 44d4e85..538c3ac 100644 --- a/bot/utils/spotify_private.py +++ b/bot/utils/spotify_private.py @@ -12,15 +12,15 @@ import requests from requests import HTTPError, Timeout -from bot.dataclass.oauth import OAuth -from bot.dataclass.spotify import SpotifyResult +from bot.models.oauth import OAuth +from bot.models.spotify import SpotifyResult from .constants import SPOTIFY_ACCOUNTS_BASE_URL, SPOTIFY_API_BASE_URL, USER_AGENT from .logger import create_logger if TYPE_CHECKING: from bot.database import Database -from bot.dataclass.config import Config +from bot.models.config import Config class PrivateSpotify: diff --git a/bot/views/spotify_dropdown.py b/bot/views/spotify_dropdown.py index e229648..3c4aaf1 100644 --- a/bot/views/spotify_dropdown.py +++ b/bot/views/spotify_dropdown.py @@ -8,13 +8,13 @@ from nextcord import Colour, SelectOption from nextcord.ui import Select, View -from bot.dataclass.custom_embed import CustomEmbed +from bot.models.custom_embed import CustomEmbed if TYPE_CHECKING: from nextcord import Interaction from bot.cogs.player import PlayerCog - from bot.dataclass.spotify import SpotifyResult + from bot.models.spotify import SpotifyResult from bot.utils.blanco import BlancoBot From 4bb0c2c3dc161dc5ea0b27a8cfaf98bdabea60c3 Mon Sep 17 00:00:00 2001 From: Jared Dantis Date: Sun, 31 Mar 2024 16:34:37 +0800 Subject: [PATCH 28/33] bot: Start implementing new FastAPI-based server --- .vscode/settings.json | 3 +- Makefile | 3 + bot/api/depends/database.py | 33 ++ bot/api/depends/session.py | 58 ++++ bot/api/extension.py | 17 + bot/api/main.py | 93 ++++++ bot/api/models/account.py | 19 ++ bot/api/models/oauth.py | 17 + bot/api/models/session.py | 7 + bot/api/routes/account/__init__.py | 8 + bot/api/routes/account/login.py | 39 +++ bot/api/routes/account/me.py | 38 +++ bot/api/routes/oauth/__init__.py | 6 + bot/api/routes/oauth/discord.py | 163 ++++++++++ bot/api/utils/session.py | 108 +++++++ bot/dev_server.py | 44 --- bot/models/config.py | 1 + bot/server/main.py | 2 +- bot/utils/blanco.py | 4 +- bot/utils/config.py | 11 +- bot/utils/logger.py | 7 +- bot/views/__init__.py | 0 poetry.lock | 500 ++++++++++++++++++++++++++++- pyproject.toml | 4 + 24 files changed, 1132 insertions(+), 53 deletions(-) create mode 100644 bot/api/depends/database.py create mode 100644 bot/api/depends/session.py create mode 100644 bot/api/extension.py create mode 100644 bot/api/main.py create mode 100644 bot/api/models/account.py create mode 100644 bot/api/models/oauth.py create mode 100644 bot/api/models/session.py create mode 100644 bot/api/routes/account/__init__.py create mode 100644 bot/api/routes/account/login.py create mode 100644 bot/api/routes/account/me.py create mode 100644 bot/api/routes/oauth/__init__.py create mode 100644 bot/api/routes/oauth/discord.py create mode 100644 bot/api/utils/session.py delete mode 100644 bot/dev_server.py delete mode 100644 bot/views/__init__.py diff --git a/.vscode/settings.json b/.vscode/settings.json index d75a1a5..1ea600d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -12,5 +12,6 @@ "--disable=too-many-return-statements", "--disable=too-many-branches" ], - "editor.formatOnSave": true + "editor.formatOnSave": true, + "editor.defaultFormatter": "charliermarsh.ruff" } diff --git a/Makefile b/Makefile index 284db20..1b5119c 100644 --- a/Makefile +++ b/Makefile @@ -10,6 +10,9 @@ install: dev-frontend: config.yml blanco.db poetry run python -m bot.dev_server +dev-backend: config.yml blanco.db + poetry run python -m bot.api.main + dev: config.yml blanco.db poetry run python -m bot.main diff --git a/bot/api/depends/database.py b/bot/api/depends/database.py new file mode 100644 index 0000000..83ad74b --- /dev/null +++ b/bot/api/depends/database.py @@ -0,0 +1,33 @@ +from typing import TYPE_CHECKING + +from fastapi import HTTPException, Request +from starlette.status import HTTP_500_INTERNAL_SERVER_ERROR + +if TYPE_CHECKING: + from bot.database import Database + + +def database_dependency(request: Request) -> 'Database': + """ + FastAPI dependency to get the database object. + + Args: + request (web.Request): The request. + + Returns: + Database: The database object. + """ + + state = request.app.state + if not hasattr(state, 'database'): + raise HTTPException( + status_code=HTTP_500_INTERNAL_SERVER_ERROR, detail='No database connection' + ) + + database: 'Database' = state.database + if database is None: + raise HTTPException( + status_code=HTTP_500_INTERNAL_SERVER_ERROR, detail='No database connection' + ) + + return database diff --git a/bot/api/depends/session.py b/bot/api/depends/session.py new file mode 100644 index 0000000..da69a1e --- /dev/null +++ b/bot/api/depends/session.py @@ -0,0 +1,58 @@ +from typing import TYPE_CHECKING, Optional + +from fastapi import Depends, HTTPException, Request +from starlette.status import HTTP_401_UNAUTHORIZED + +from .database import database_dependency + +if TYPE_CHECKING: + from bot.api.utils.session import SessionManager + from bot.database import Database + from bot.models.oauth import OAuth + + +EXPECTED_AUTH_SCHEME = 'Bearer' +EXPECTED_AUTH_PARTS = 2 + + +def session_dependency( + request: Request, db: 'Database' = Depends(database_dependency) +) -> 'OAuth': + """ + FastAPI dependency to get the requesting user's info. + + Args: + request (web.Request): The request. + + Returns: + OAuth: The info for the current Discord user. + """ + + authorization = request.headers.get('Authorization') + if authorization is None: + raise HTTPException( + status_code=HTTP_401_UNAUTHORIZED, detail='No authorization header' + ) + + parts = authorization.split() + if len(parts) != EXPECTED_AUTH_PARTS: + raise HTTPException( + status_code=HTTP_401_UNAUTHORIZED, detail='Invalid authorization header' + ) + + scheme, token = parts + if scheme != EXPECTED_AUTH_SCHEME: + raise HTTPException( + status_code=HTTP_401_UNAUTHORIZED, detail='Invalid authorization scheme' + ) + + session_manager: 'SessionManager' = request.app.state.session_manager + session = session_manager.decode_session(token) + if session is None: + raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail='Invalid session') + + user: Optional['OAuth'] = db.get_oauth('discord', session.user_id) + if user is None: + raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail='User not found') + + return user diff --git a/bot/api/extension.py b/bot/api/extension.py new file mode 100644 index 0000000..3ed286c --- /dev/null +++ b/bot/api/extension.py @@ -0,0 +1,17 @@ +""" +Nextcord extension that runs the API server for the bot +""" + +from typing import TYPE_CHECKING + +from .main import run_app + +if TYPE_CHECKING: + from bot.utils.blanco import BlancoBot + + +def setup(bot: 'BlancoBot'): + """ + Run the API server within the bot's existing event loop. + """ + run_app(bot.loop, bot.database) diff --git a/bot/api/main.py b/bot/api/main.py new file mode 100644 index 0000000..5ee4a69 --- /dev/null +++ b/bot/api/main.py @@ -0,0 +1,93 @@ +""" +Main module for the API server. +""" + +from asyncio import set_event_loop +from contextlib import asynccontextmanager +from logging import INFO +from typing import TYPE_CHECKING, Any, Optional + +from fastapi import FastAPI +from uvicorn import Config, Server, run +from uvicorn.config import LOGGING_CONFIG + +from bot.database import Database +from bot.utils.config import config as bot_config +from bot.utils.logger import DATE_FMT_STR, LOG_FMT_COLOR, create_logger + +from .routes.account import account_router +from .routes.oauth import oauth_router +from .utils.session import SessionManager + +if TYPE_CHECKING: + from asyncio import AbstractEventLoop + + +_database: Optional[Database] = None + + +@asynccontextmanager +async def lifespan(app: FastAPI): + logger = create_logger('api.lifespan') + + if _database is None: + logger.warn('Manually creating database connection') + database = Database(bot_config.db_file) + else: + logger.info('Connecting to database from FastAPI') + database = _database + + app.state.database = database + app.state.session_manager = SessionManager(database) + yield + + +app = FastAPI(lifespan=lifespan) +app.include_router(account_router) +app.include_router(oauth_router) + + +@app.get('/') +async def health_check(): + return {'status': 'ok'} + + +def _get_log_config() -> dict[str, Any]: + log_config = LOGGING_CONFIG + log_config['formatters']['default']['fmt'] = LOG_FMT_COLOR[INFO] + log_config['formatters']['default']['datefmt'] = DATE_FMT_STR + log_config['formatters']['access']['fmt'] = LOG_FMT_COLOR[INFO] + + return log_config + + +def run_app(loop: 'AbstractEventLoop', db: Database): + """ + Run the API server in the bot's event loop. + """ + global _database # noqa: PLW0603 + _database = db + + set_event_loop(loop) + + config = Config( + app=app, + loop=loop, # type: ignore + host='0.0.0.0', + port=bot_config.server_port, + log_config=_get_log_config(), + ) + server = Server(config) + + loop.create_task(server.serve()) + + +if __name__ == '__main__': + run( + app='bot.api.main:app', + host='127.0.0.1', + port=bot_config.server_port, + reload=True, + reload_dirs=['bot/api'], + log_config=_get_log_config(), + ) diff --git a/bot/api/models/account.py b/bot/api/models/account.py new file mode 100644 index 0000000..fa54bb8 --- /dev/null +++ b/bot/api/models/account.py @@ -0,0 +1,19 @@ +from typing import Optional + +from pydantic import BaseModel, Field + + +class AccountResponse(BaseModel): + username: str = Field(description="The user's username.") + spotify_logged_in: bool = Field( + description='Whether the user is logged in to Spotify.' + ) + spotify_username: Optional[str] = Field( + default=None, description="The user's Spotify username, if logged in." + ) + lastfm_logged_in: bool = Field( + description='Whether the user is logged in to Last.fm.' + ) + lastfm_username: Optional[str] = Field( + default=None, description="The user's Last.fm username, if logged in." + ) diff --git a/bot/api/models/oauth.py b/bot/api/models/oauth.py new file mode 100644 index 0000000..bba5d48 --- /dev/null +++ b/bot/api/models/oauth.py @@ -0,0 +1,17 @@ +from typing import Optional + +from pydantic import BaseModel, Field + + +class OAuthResponse(BaseModel): + session_id: str = Field(description='The session ID for the user.') + jwt: str = Field(description='The JSON Web Token for the user.') + + +class DiscordUser(BaseModel): + id: int = Field(description='The user ID.') + username: str = Field(description='The username.') + discriminator: str = Field(description='The discriminator.') + avatar: Optional[str] = Field( + default=None, description='The avatar hash, if the user has one.' + ) diff --git a/bot/api/models/session.py b/bot/api/models/session.py new file mode 100644 index 0000000..83e69c9 --- /dev/null +++ b/bot/api/models/session.py @@ -0,0 +1,7 @@ +from pydantic import BaseModel + + +class Session(BaseModel): + user_id: int + session_id: str + expiration_time: int diff --git a/bot/api/routes/account/__init__.py b/bot/api/routes/account/__init__.py new file mode 100644 index 0000000..fe7a92b --- /dev/null +++ b/bot/api/routes/account/__init__.py @@ -0,0 +1,8 @@ +from fastapi import APIRouter + +from .login import get_login_url as route_login +from .me import get_logged_in_user as route_me + +account_router = APIRouter(prefix='/account', tags=['account']) +account_router.add_api_route('/login', route_login, methods=['GET']) +account_router.add_api_route('/me', route_me, methods=['GET']) diff --git a/bot/api/routes/account/login.py b/bot/api/routes/account/login.py new file mode 100644 index 0000000..21d67f7 --- /dev/null +++ b/bot/api/routes/account/login.py @@ -0,0 +1,39 @@ +from secrets import token_urlsafe + +from fastapi import HTTPException +from fastapi.responses import RedirectResponse +from starlette.status import HTTP_500_INTERNAL_SERVER_ERROR +from yarl import URL + +from bot.utils.config import config as bot_config + + +async def get_login_url() -> RedirectResponse: + oauth_id = bot_config.discord_oauth_id + base_url = bot_config.base_url + + if oauth_id is None or base_url is None: + raise HTTPException( + status_code=HTTP_500_INTERNAL_SERVER_ERROR, + detail='Missing Discord OAuth ID or base URL', + ) + + state = token_urlsafe(16) + + url = URL.build( + scheme='https', + host='discord.com', + path='/api/oauth2/authorize', + query={ + 'client_id': oauth_id, + 'response_type': 'code', + 'scope': 'identify guilds email', + 'redirect_uri': f'{base_url}/oauth/discord', + 'state': state, + 'prompt': 'none', + }, + ) + + response = RedirectResponse(url=str(url)) + response.set_cookie('state', state, httponly=True, samesite='lax') + return response diff --git a/bot/api/routes/account/me.py b/bot/api/routes/account/me.py new file mode 100644 index 0000000..da9d306 --- /dev/null +++ b/bot/api/routes/account/me.py @@ -0,0 +1,38 @@ +""" +Route for getting the current user's account information. +""" + +from typing import TYPE_CHECKING, Optional + +from fastapi import Depends + +from bot.api.depends.database import database_dependency +from bot.api.depends.session import session_dependency +from bot.api.models.account import AccountResponse + +if TYPE_CHECKING: + from bot.database import Database + from bot.models.oauth import LastfmAuth, OAuth + + +async def get_logged_in_user( + user: 'OAuth' = Depends(session_dependency), + db: 'Database' = Depends(database_dependency), +) -> AccountResponse: + spotify_username = None + spotify: Optional['OAuth'] = db.get_oauth('spotify', user.user_id) + if spotify is not None: + spotify_username = spotify.username + + lastfm_username = None + lastfm: Optional['LastfmAuth'] = db.get_lastfm_credentials(user.user_id) + if lastfm is not None: + lastfm_username = lastfm.username + + return AccountResponse( + username=user.username, + spotify_logged_in=spotify is not None, + spotify_username=spotify_username, + lastfm_logged_in=lastfm is not None, + lastfm_username=lastfm_username, + ) diff --git a/bot/api/routes/oauth/__init__.py b/bot/api/routes/oauth/__init__.py new file mode 100644 index 0000000..9ae9cce --- /dev/null +++ b/bot/api/routes/oauth/__init__.py @@ -0,0 +1,6 @@ +from fastapi import APIRouter + +from .discord import discord_oauth as route_discord + +oauth_router = APIRouter(prefix='/oauth', tags=['oauth']) +oauth_router.add_api_route('/discord', route_discord, methods=['GET']) diff --git a/bot/api/routes/oauth/discord.py b/bot/api/routes/oauth/discord.py new file mode 100644 index 0000000..e1ba9e7 --- /dev/null +++ b/bot/api/routes/oauth/discord.py @@ -0,0 +1,163 @@ +from datetime import UTC, datetime +from typing import TYPE_CHECKING, Tuple + +from fastapi import Depends, HTTPException, Request, Response +from requests import HTTPError, Timeout, get, post +from starlette.status import HTTP_400_BAD_REQUEST, HTTP_500_INTERNAL_SERVER_ERROR + +from bot.api.depends.database import database_dependency +from bot.api.models.oauth import DiscordUser, OAuthResponse +from bot.models.oauth import OAuth +from bot.utils.config import config as bot_config +from bot.utils.constants import DISCORD_API_BASE_URL, USER_AGENT + +if TYPE_CHECKING: + from bot.api.utils.session import SessionManager + from bot.database import Database + + +async def discord_oauth( + request: Request, + response: Response, + code: str, + state: str, + db: 'Database' = Depends(database_dependency), +) -> OAuthResponse: + _validate_state(request, response, state=state) + + oauth_id = bot_config.discord_oauth_id + oauth_secret = bot_config.discord_oauth_secret + base_url = bot_config.base_url + if oauth_id is None or oauth_secret is None or base_url is None: + raise HTTPException( + status_code=HTTP_500_INTERNAL_SERVER_ERROR, + detail='Missing Discord OAuth ID, secret, or base URL', + ) + + access_token, refresh_token, expiration_time = _exchange_code_for_token( + oauth_id, oauth_secret, base_url, code + ) + user = _get_user_info(access_token) + _store_user_info(db, user, access_token, refresh_token, expiration_time) + + session_manager: 'SessionManager' = request.app.state.session_manager + session_id = session_manager.create_session(user.id) + jwt = session_manager.encode_session(session_id) + + return OAuthResponse(session_id=session_id, jwt=jwt) + + +def _validate_state(request: Request, response: Response, state: str) -> str: + expected_state = request.cookies.get('state') + if expected_state is None: + raise HTTPException( + status_code=HTTP_400_BAD_REQUEST, + detail='Missing state cookie', + ) + + if state != expected_state: + raise HTTPException( + status_code=HTTP_400_BAD_REQUEST, + detail='Invalid state', + ) + + response.delete_cookie('state') + return state + + +def _exchange_code_for_token( + client_id: str, client_secret: str, base_url: str, code: str +) -> Tuple[str, str, int]: + """ + Exchange the code for an access token. + + Returns: + Tuple[str, str, int]: The access token, refresh token, + and the time at which the access token expires. + """ + + response = post( + str(DISCORD_API_BASE_URL / 'oauth2/token'), + data={ + 'client_id': client_id, + 'client_secret': client_secret, + 'grant_type': 'authorization_code', + 'code': code, + 'redirect_uri': f'{base_url}/oauth/discord', + }, + headers={ + 'Content-Type': 'application/x-www-form-urlencoded', + 'User-Agent': USER_AGENT, + }, + timeout=5, + ) + + try: + response.raise_for_status() + except HTTPError as err: + raise HTTPException( + status_code=HTTP_400_BAD_REQUEST, detail=f'Error getting access token: {err}' + ) + except Timeout: + raise HTTPException( + status_code=HTTP_500_INTERNAL_SERVER_ERROR, + detail='Timed out while requesting access token', + ) + + data = response.json() + access_token = data['access_token'] + refresh_token = data['refresh_token'] + expires_in = data['expires_in'] + expiration_time = int(datetime.now(UTC).timestamp()) + expires_in + + return access_token, refresh_token, expiration_time + + +def _get_user_info(access_token: str) -> DiscordUser: + response = get( + str(DISCORD_API_BASE_URL / 'users/@me'), + headers={ + 'Authorization': f'Bearer {access_token}', + 'User-Agent': USER_AGENT, + }, + timeout=5, + ) + + try: + response.raise_for_status() + except HTTPError as err: + raise HTTPException( + status_code=HTTP_400_BAD_REQUEST, detail=f'Error getting user info: {err}' + ) + except Timeout: + raise HTTPException( + status_code=HTTP_500_INTERNAL_SERVER_ERROR, + detail='Timed out while requesting user info', + ) + + data = response.json() + return DiscordUser( + id=data['id'], + username=data['username'], + discriminator=data['discriminator'], + avatar=data.get('avatar'), + ) + + +def _store_user_info( + db: 'Database', + user: DiscordUser, + access_token: str, + refresh_token: str, + expiration_time: int, +): + db.set_oauth( + 'discord', + OAuth( + user_id=user.id, + username=user.username, + access_token=access_token, + refresh_token=refresh_token, + expires_at=expiration_time, + ), + ) diff --git a/bot/api/utils/session.py b/bot/api/utils/session.py new file mode 100644 index 0000000..ad28865 --- /dev/null +++ b/bot/api/utils/session.py @@ -0,0 +1,108 @@ +from datetime import UTC, datetime +from typing import TYPE_CHECKING, Dict, Optional +from uuid import uuid4 + +import jwt + +from bot.api.models.session import Session +from bot.utils.config import config as bot_config +from bot.utils.logger import create_logger + +if TYPE_CHECKING: + from bot.database import Database + from bot.models.oauth import OAuth + +_MIN_IN_SECONDS = 60 +SESSION_LIFETIME = 60 * _MIN_IN_SECONDS + + +class SessionManager: + """ + Manages user sessions. + """ + + def __init__(self, database: 'Database'): + self._database = database + self._logger = create_logger('api.session') + self._sessions: Dict[str, Session] = {} + self._secret = bot_config.jwt_secret + + def create_session(self, user_id: int) -> str: + """ + Create a new session. + + Returns: + str: The session ID. + """ + + user: Optional['OAuth'] = self._database.get_oauth('discord', user_id) + if user is None: + raise ValueError('User not found') + + session_id = str(uuid4()) + expiration_time = int(datetime.now(tz=UTC).timestamp()) + SESSION_LIFETIME + session = Session( + session_id=session_id, user_id=user_id, expiration_time=expiration_time + ) + self._sessions[session_id] = session + + return session_id + + def get_session(self, session_id: str) -> Optional[Session]: + """ + Get a session by its ID. + + Returns: + Optional[Session]: The session, if it exists. + """ + return self._sessions.get(session_id) + + def delete_session(self, session_id: str): + """ + Delete a session by its ID. + """ + if session_id in self._sessions: + del self._sessions[session_id] + + def encode_session(self, session_id: str) -> str: + """ + Encode a session into a JWT. + + Returns: + str: The JWT. + """ + + if self._secret is None: + raise ValueError('JWT secret not set') + + session = self.get_session(session_id) + if session is None: + raise ValueError('Session not found') + + return jwt.encode( + payload=session.model_dump(), + key=self._secret, + algorithm='HS256', + ) + + def decode_session(self, token: str) -> Optional[Session]: + """ + Decode a JWT into a session. + + Returns: + Optional[Session]: The session, if the token is valid. + """ + if self._secret is None: + raise ValueError('JWT secret not set') + + try: + payload = jwt.decode( + jwt=token, + key=self._secret, + algorithms=['HS256'], + ) + except jwt.PyJWTError as e: + self._logger.error(f'Error decoding JWT: {e}') + return None + + return Session(**payload) diff --git a/bot/dev_server.py b/bot/dev_server.py deleted file mode 100644 index 8657439..0000000 --- a/bot/dev_server.py +++ /dev/null @@ -1,44 +0,0 @@ -""" -This file is used to run the webserver without running the bot, -along with spawning the TailwindCSS compiler. This is useful for -development, as it allows you to see changes to the webserver -without having to restart the bot. -""" - -import asyncio -import threading -from subprocess import run - -from bot.database import Database -from bot.server.main import run_app -from bot.utils.config import config - - -def run_tailwind(): - """ - Run the TailwindCSS compiler. - """ - run( - ' '.join( - [ - 'tailwindcss', - '-i', - './dashboard/static/css/base.css', - '-o', - './dashboard/static/css/main.css', - '--watch', - ] - ), - check=False, - shell=True, - ) - - -if __name__ == '__main__': - thread = threading.Thread(target=run_tailwind) - thread.start() - - db = Database(config.db_file) - loop = asyncio.new_event_loop() - loop.create_task(run_app(db, config)) - loop.run_forever() diff --git a/bot/models/config.py b/bot/models/config.py index 91ab2a2..6d24b61 100644 --- a/bot/models/config.py +++ b/bot/models/config.py @@ -66,6 +66,7 @@ class Config: base_url: Optional[str] = None discord_oauth_id: Optional[str] = None discord_oauth_secret: Optional[str] = None + jwt_secret: Optional[str] = None lastfm_api_key: Optional[str] = None lastfm_shared_secret: Optional[str] = None match_ahead: bool = False diff --git a/bot/server/main.py b/bot/server/main.py index ee33259..4169bb1 100644 --- a/bot/server/main.py +++ b/bot/server/main.py @@ -19,7 +19,7 @@ if TYPE_CHECKING: from bot.database import Database -from bot.models.config import Config + from bot.models.config import Config class AccessLogger(AbstractAccessLogger): diff --git a/bot/utils/blanco.py b/bot/utils/blanco.py index ecd6288..9bc16f4 100644 --- a/bot/utils/blanco.py +++ b/bot/utils/blanco.py @@ -175,11 +175,11 @@ async def on_ready(self): if self._config.enable_server: # Try to unload server first if the bot was restarted try: - self.unload_extension('bot.server') + self.unload_extension('bot.api.extension') except ExtensionNotLoaded: pass self._logger.info('Starting web server...') - self.load_extension('bot.server') + self.load_extension('bot.api.extension') elif self._config.base_url is not None: self._logger.warning( 'Server is disabled, but base URL is set to %s', diff --git a/bot/utils/config.py b/bot/utils/config.py index e8e4c5e..8c7330e 100644 --- a/bot/utils/config.py +++ b/bot/utils/config.py @@ -22,6 +22,7 @@ ENABLE_SERVER = False SERVER_PORT = 8080 SERVER_BASE_URL = None +SERVER_JWT_SECRET = None DISCORD_OAUTH_ID = None DISCORD_OAUTH_SECRET = None LASTFM_API_KEY = None @@ -76,6 +77,7 @@ ENABLE_SERVER = config_file['server']['enabled'] SERVER_PORT = config_file['server'].get('port', 8080) SERVER_BASE_URL = config_file['server'].get('base_url', None) + SERVER_JWT_SECRET = config_file['server'].get('jwt_secret', None) DISCORD_OAUTH_ID = config_file['server'].get('oauth_id', None) DISCORD_OAUTH_SECRET = config_file['server'].get('oauth_secret', None) if 'lastfm' in config_file: @@ -118,6 +120,7 @@ ENABLE_SERVER = environ['BLANCO_ENABLE_SERVER'].lower() == 'true' SERVER_PORT = int(environ.get('BLANCO_SERVER_PORT', SERVER_PORT)) SERVER_BASE_URL = environ.get('BLANCO_BASE_URL', SERVER_BASE_URL) + SERVER_JWT_SECRET = environ.get('BLANCO_JWT_SECRET', SERVER_JWT_SECRET) DISCORD_OAUTH_ID = environ.get('BLANCO_OAUTH_ID', DISCORD_OAUTH_ID) DISCORD_OAUTH_SECRET = environ.get('BLANCO_OAUTH_SECRET', DISCORD_OAUTH_SECRET) @@ -167,10 +170,13 @@ if SPOTIFY_CLIENT_SECRET is None: raise ValueError('No Spotify client secret specified') if ENABLE_SERVER and ( - DISCORD_OAUTH_ID is None or DISCORD_OAUTH_SECRET is None or SERVER_BASE_URL is None + DISCORD_OAUTH_ID is None + or DISCORD_OAUTH_SECRET is None + or SERVER_BASE_URL is None + or SERVER_JWT_SECRET is None ): raise ValueError( - 'Discord OAuth ID, secret, and base URL must be specified to enable server' + 'Discord OAuth ID, secret, base URL, and JWT secret must be specified to enable server' ) @@ -189,6 +195,7 @@ base_url=SERVER_BASE_URL, discord_oauth_id=DISCORD_OAUTH_ID, discord_oauth_secret=DISCORD_OAUTH_SECRET, + jwt_secret=SERVER_JWT_SECRET, lastfm_api_key=LASTFM_API_KEY, lastfm_shared_secret=LASTFM_SHARED_SECRET, reenqueue_paused=REENQUEUE_PAUSED, diff --git a/bot/utils/logger.py b/bot/utils/logger.py index 3edc6ec..e003dd8 100644 --- a/bot/utils/logger.py +++ b/bot/utils/logger.py @@ -12,7 +12,10 @@ from .constants import RELEASE # Log line format -LOG_FMT_STR = '{0}%(asctime)s.%(msecs)03d {1}[%(levelname)s]{2} %(message)s (%(filename)s:%(lineno)d)' # pylint: disable=line-too-long +DATE_FMT_STR = '%Y-%m-%d %H:%M:%S' +LOG_FMT_STR = ( + '{0}%(asctime)s {1}[%(levelname)s]{2} %(message)s (%(filename)s:%(lineno)d)' +) # ANSI terminal colors (for logging) ANSI_BLUE = '\x1b[36;20m' @@ -50,7 +53,7 @@ def __init__(self, fmt: Optional[str] = None, datefmt: Optional[str] = None): def format(self, record: logging.LogRecord): log_fmt = LOG_FMT_COLOR.get(record.levelno) - formatter = logging.Formatter(fmt=log_fmt, datefmt='%Y-%m-%d %H:%M:%S') + formatter = logging.Formatter(fmt=log_fmt, datefmt=DATE_FMT_STR) return formatter.format(record) diff --git a/bot/views/__init__.py b/bot/views/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/poetry.lock b/poetry.lock index 6da4ea2..034bce0 100644 --- a/poetry.lock +++ b/poetry.lock @@ -145,6 +145,17 @@ files = [ [package.dependencies] frozenlist = ">=1.1.0" +[[package]] +name = "annotated-types" +version = "0.6.0" +description = "Reusable constraint types to use with typing.Annotated" +optional = false +python-versions = ">=3.8" +files = [ + {file = "annotated_types-0.6.0-py3-none-any.whl", hash = "sha256:0641064de18ba7a25dee8f96403ebc39113d0cb953a01429249d5c7564666a43"}, + {file = "annotated_types-0.6.0.tar.gz", hash = "sha256:563339e807e53ffd9c267e99fc6d9ea23eb8443c08f112651963e24e22f84a5d"}, +] + [[package]] name = "anyio" version = "4.3.0" @@ -380,6 +391,31 @@ files = [ {file = "charset_normalizer-3.3.2-py3-none-any.whl", hash = "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc"}, ] +[[package]] +name = "click" +version = "8.1.7" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.7" +files = [ + {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, + {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + [[package]] name = "cryptography" version = "42.0.5" @@ -445,6 +481,25 @@ files = [ {file = "distlib-0.3.8.tar.gz", hash = "sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64"}, ] +[[package]] +name = "fastapi" +version = "0.110.0" +description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" +optional = false +python-versions = ">=3.8" +files = [ + {file = "fastapi-0.110.0-py3-none-any.whl", hash = "sha256:87a1f6fb632a218222c5984be540055346a8f5d8a68e8f6fb647b1dc9934de4b"}, + {file = "fastapi-0.110.0.tar.gz", hash = "sha256:266775f0dcc95af9d3ef39bad55cff525329a931d5fd51930aadd4f428bf7ff3"}, +] + +[package.dependencies] +pydantic = ">=1.7.4,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0 || >2.0.0,<2.0.1 || >2.0.1,<2.1.0 || >2.1.0,<3.0.0" +starlette = ">=0.36.3,<0.37.0" +typing-extensions = ">=4.8.0" + +[package.extras] +all = ["email-validator (>=2.0.0)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=2.11.2)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.7)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"] + [[package]] name = "filelock" version = "3.13.3" @@ -579,6 +634,54 @@ http2 = ["h2 (>=3,<5)"] socks = ["socksio (==1.*)"] trio = ["trio (>=0.22.0,<0.26.0)"] +[[package]] +name = "httptools" +version = "0.6.1" +description = "A collection of framework independent HTTP protocol utils." +optional = false +python-versions = ">=3.8.0" +files = [ + {file = "httptools-0.6.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d2f6c3c4cb1948d912538217838f6e9960bc4a521d7f9b323b3da579cd14532f"}, + {file = "httptools-0.6.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:00d5d4b68a717765b1fabfd9ca755bd12bf44105eeb806c03d1962acd9b8e563"}, + {file = "httptools-0.6.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:639dc4f381a870c9ec860ce5c45921db50205a37cc3334e756269736ff0aac58"}, + {file = "httptools-0.6.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e57997ac7fb7ee43140cc03664de5f268813a481dff6245e0075925adc6aa185"}, + {file = "httptools-0.6.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:0ac5a0ae3d9f4fe004318d64b8a854edd85ab76cffbf7ef5e32920faef62f142"}, + {file = "httptools-0.6.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:3f30d3ce413088a98b9db71c60a6ada2001a08945cb42dd65a9a9fe228627658"}, + {file = "httptools-0.6.1-cp310-cp310-win_amd64.whl", hash = "sha256:1ed99a373e327f0107cb513b61820102ee4f3675656a37a50083eda05dc9541b"}, + {file = "httptools-0.6.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7a7ea483c1a4485c71cb5f38be9db078f8b0e8b4c4dc0210f531cdd2ddac1ef1"}, + {file = "httptools-0.6.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:85ed077c995e942b6f1b07583e4eb0a8d324d418954fc6af913d36db7c05a5a0"}, + {file = "httptools-0.6.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b0bb634338334385351a1600a73e558ce619af390c2b38386206ac6a27fecfc"}, + {file = "httptools-0.6.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d9ceb2c957320def533671fc9c715a80c47025139c8d1f3797477decbc6edd2"}, + {file = "httptools-0.6.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4f0f8271c0a4db459f9dc807acd0eadd4839934a4b9b892f6f160e94da309837"}, + {file = "httptools-0.6.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:6a4f5ccead6d18ec072ac0b84420e95d27c1cdf5c9f1bc8fbd8daf86bd94f43d"}, + {file = "httptools-0.6.1-cp311-cp311-win_amd64.whl", hash = "sha256:5cceac09f164bcba55c0500a18fe3c47df29b62353198e4f37bbcc5d591172c3"}, + {file = "httptools-0.6.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:75c8022dca7935cba14741a42744eee13ba05db00b27a4b940f0d646bd4d56d0"}, + {file = "httptools-0.6.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:48ed8129cd9a0d62cf4d1575fcf90fb37e3ff7d5654d3a5814eb3d55f36478c2"}, + {file = "httptools-0.6.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f58e335a1402fb5a650e271e8c2d03cfa7cea46ae124649346d17bd30d59c90"}, + {file = "httptools-0.6.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:93ad80d7176aa5788902f207a4e79885f0576134695dfb0fefc15b7a4648d503"}, + {file = "httptools-0.6.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:9bb68d3a085c2174c2477eb3ffe84ae9fb4fde8792edb7bcd09a1d8467e30a84"}, + {file = "httptools-0.6.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:b512aa728bc02354e5ac086ce76c3ce635b62f5fbc32ab7082b5e582d27867bb"}, + {file = "httptools-0.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:97662ce7fb196c785344d00d638fc9ad69e18ee4bfb4000b35a52efe5adcc949"}, + {file = "httptools-0.6.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:8e216a038d2d52ea13fdd9b9c9c7459fb80d78302b257828285eca1c773b99b3"}, + {file = "httptools-0.6.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:3e802e0b2378ade99cd666b5bffb8b2a7cc8f3d28988685dc300469ea8dd86cb"}, + {file = "httptools-0.6.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4bd3e488b447046e386a30f07af05f9b38d3d368d1f7b4d8f7e10af85393db97"}, + {file = "httptools-0.6.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe467eb086d80217b7584e61313ebadc8d187a4d95bb62031b7bab4b205c3ba3"}, + {file = "httptools-0.6.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:3c3b214ce057c54675b00108ac42bacf2ab8f85c58e3f324a4e963bbc46424f4"}, + {file = "httptools-0.6.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8ae5b97f690badd2ca27cbf668494ee1b6d34cf1c464271ef7bfa9ca6b83ffaf"}, + {file = "httptools-0.6.1-cp38-cp38-win_amd64.whl", hash = "sha256:405784577ba6540fa7d6ff49e37daf104e04f4b4ff2d1ac0469eaa6a20fde084"}, + {file = "httptools-0.6.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:95fb92dd3649f9cb139e9c56604cc2d7c7bf0fc2e7c8d7fbd58f96e35eddd2a3"}, + {file = "httptools-0.6.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:dcbab042cc3ef272adc11220517278519adf8f53fd3056d0e68f0a6f891ba94e"}, + {file = "httptools-0.6.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cf2372e98406efb42e93bfe10f2948e467edfd792b015f1b4ecd897903d3e8d"}, + {file = "httptools-0.6.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:678fcbae74477a17d103b7cae78b74800d795d702083867ce160fc202104d0da"}, + {file = "httptools-0.6.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:e0b281cf5a125c35f7f6722b65d8542d2e57331be573e9e88bc8b0115c4a7a81"}, + {file = "httptools-0.6.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:95658c342529bba4e1d3d2b1a874db16c7cca435e8827422154c9da76ac4e13a"}, + {file = "httptools-0.6.1-cp39-cp39-win_amd64.whl", hash = "sha256:7ebaec1bf683e4bf5e9fbb49b8cc36da482033596a415b3e4ebab5a4c0d7ec5e"}, + {file = "httptools-0.6.1.tar.gz", hash = "sha256:c6e26c30455600b95d94b1b836085138e82f177351454ee841c148f93a9bad5a"}, +] + +[package.extras] +test = ["Cython (>=0.29.24,<0.30.0)"] + [[package]] name = "httpx" version = "0.27.0" @@ -965,6 +1068,133 @@ files = [ {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, ] +[[package]] +name = "pydantic" +version = "2.6.4" +description = "Data validation using Python type hints" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pydantic-2.6.4-py3-none-any.whl", hash = "sha256:cc46fce86607580867bdc3361ad462bab9c222ef042d3da86f2fb333e1d916c5"}, + {file = "pydantic-2.6.4.tar.gz", hash = "sha256:b1704e0847db01817624a6b86766967f552dd9dbf3afba4004409f908dcc84e6"}, +] + +[package.dependencies] +annotated-types = ">=0.4.0" +pydantic-core = "2.16.3" +typing-extensions = ">=4.6.1" + +[package.extras] +email = ["email-validator (>=2.0.0)"] + +[[package]] +name = "pydantic-core" +version = "2.16.3" +description = "" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pydantic_core-2.16.3-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:75b81e678d1c1ede0785c7f46690621e4c6e63ccd9192af1f0bd9d504bbb6bf4"}, + {file = "pydantic_core-2.16.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9c865a7ee6f93783bd5d781af5a4c43dadc37053a5b42f7d18dc019f8c9d2bd1"}, + {file = "pydantic_core-2.16.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:162e498303d2b1c036b957a1278fa0899d02b2842f1ff901b6395104c5554a45"}, + {file = "pydantic_core-2.16.3-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2f583bd01bbfbff4eaee0868e6fc607efdfcc2b03c1c766b06a707abbc856187"}, + {file = "pydantic_core-2.16.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b926dd38db1519ed3043a4de50214e0d600d404099c3392f098a7f9d75029ff8"}, + {file = "pydantic_core-2.16.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:716b542728d4c742353448765aa7cdaa519a7b82f9564130e2b3f6766018c9ec"}, + {file = "pydantic_core-2.16.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc4ad7f7ee1a13d9cb49d8198cd7d7e3aa93e425f371a68235f784e99741561f"}, + {file = "pydantic_core-2.16.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bd87f48924f360e5d1c5f770d6155ce0e7d83f7b4e10c2f9ec001c73cf475c99"}, + {file = "pydantic_core-2.16.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:0df446663464884297c793874573549229f9eca73b59360878f382a0fc085979"}, + {file = "pydantic_core-2.16.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4df8a199d9f6afc5ae9a65f8f95ee52cae389a8c6b20163762bde0426275b7db"}, + {file = "pydantic_core-2.16.3-cp310-none-win32.whl", hash = "sha256:456855f57b413f077dff513a5a28ed838dbbb15082ba00f80750377eed23d132"}, + {file = "pydantic_core-2.16.3-cp310-none-win_amd64.whl", hash = "sha256:732da3243e1b8d3eab8c6ae23ae6a58548849d2e4a4e03a1924c8ddf71a387cb"}, + {file = "pydantic_core-2.16.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:519ae0312616026bf4cedc0fe459e982734f3ca82ee8c7246c19b650b60a5ee4"}, + {file = "pydantic_core-2.16.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b3992a322a5617ded0a9f23fd06dbc1e4bd7cf39bc4ccf344b10f80af58beacd"}, + {file = "pydantic_core-2.16.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8d62da299c6ecb04df729e4b5c52dc0d53f4f8430b4492b93aa8de1f541c4aac"}, + {file = "pydantic_core-2.16.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2acca2be4bb2f2147ada8cac612f8a98fc09f41c89f87add7256ad27332c2fda"}, + {file = "pydantic_core-2.16.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1b662180108c55dfbf1280d865b2d116633d436cfc0bba82323554873967b340"}, + {file = "pydantic_core-2.16.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e7c6ed0dc9d8e65f24f5824291550139fe6f37fac03788d4580da0d33bc00c97"}, + {file = "pydantic_core-2.16.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a6b1bb0827f56654b4437955555dc3aeeebeddc47c2d7ed575477f082622c49e"}, + {file = "pydantic_core-2.16.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e56f8186d6210ac7ece503193ec84104da7ceb98f68ce18c07282fcc2452e76f"}, + {file = "pydantic_core-2.16.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:936e5db01dd49476fa8f4383c259b8b1303d5dd5fb34c97de194560698cc2c5e"}, + {file = "pydantic_core-2.16.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:33809aebac276089b78db106ee692bdc9044710e26f24a9a2eaa35a0f9fa70ba"}, + {file = "pydantic_core-2.16.3-cp311-none-win32.whl", hash = "sha256:ded1c35f15c9dea16ead9bffcde9bb5c7c031bff076355dc58dcb1cb436c4721"}, + {file = "pydantic_core-2.16.3-cp311-none-win_amd64.whl", hash = "sha256:d89ca19cdd0dd5f31606a9329e309d4fcbb3df860960acec32630297d61820df"}, + {file = "pydantic_core-2.16.3-cp311-none-win_arm64.whl", hash = "sha256:6162f8d2dc27ba21027f261e4fa26f8bcb3cf9784b7f9499466a311ac284b5b9"}, + {file = "pydantic_core-2.16.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0f56ae86b60ea987ae8bcd6654a887238fd53d1384f9b222ac457070b7ac4cff"}, + {file = "pydantic_core-2.16.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c9bd22a2a639e26171068f8ebb5400ce2c1bc7d17959f60a3b753ae13c632975"}, + {file = "pydantic_core-2.16.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4204e773b4b408062960e65468d5346bdfe139247ee5f1ca2a378983e11388a2"}, + {file = "pydantic_core-2.16.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f651dd19363c632f4abe3480a7c87a9773be27cfe1341aef06e8759599454120"}, + {file = "pydantic_core-2.16.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aaf09e615a0bf98d406657e0008e4a8701b11481840be7d31755dc9f97c44053"}, + {file = "pydantic_core-2.16.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8e47755d8152c1ab5b55928ab422a76e2e7b22b5ed8e90a7d584268dd49e9c6b"}, + {file = "pydantic_core-2.16.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:500960cb3a0543a724a81ba859da816e8cf01b0e6aaeedf2c3775d12ee49cade"}, + {file = "pydantic_core-2.16.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cf6204fe865da605285c34cf1172879d0314ff267b1c35ff59de7154f35fdc2e"}, + {file = "pydantic_core-2.16.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d33dd21f572545649f90c38c227cc8631268ba25c460b5569abebdd0ec5974ca"}, + {file = "pydantic_core-2.16.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:49d5d58abd4b83fb8ce763be7794d09b2f50f10aa65c0f0c1696c677edeb7cbf"}, + {file = "pydantic_core-2.16.3-cp312-none-win32.whl", hash = "sha256:f53aace168a2a10582e570b7736cc5bef12cae9cf21775e3eafac597e8551fbe"}, + {file = "pydantic_core-2.16.3-cp312-none-win_amd64.whl", hash = "sha256:0d32576b1de5a30d9a97f300cc6a3f4694c428d956adbc7e6e2f9cad279e45ed"}, + {file = "pydantic_core-2.16.3-cp312-none-win_arm64.whl", hash = "sha256:ec08be75bb268473677edb83ba71e7e74b43c008e4a7b1907c6d57e940bf34b6"}, + {file = "pydantic_core-2.16.3-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:b1f6f5938d63c6139860f044e2538baeee6f0b251a1816e7adb6cbce106a1f01"}, + {file = "pydantic_core-2.16.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:2a1ef6a36fdbf71538142ed604ad19b82f67b05749512e47f247a6ddd06afdc7"}, + {file = "pydantic_core-2.16.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:704d35ecc7e9c31d48926150afada60401c55efa3b46cd1ded5a01bdffaf1d48"}, + {file = "pydantic_core-2.16.3-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d937653a696465677ed583124b94a4b2d79f5e30b2c46115a68e482c6a591c8a"}, + {file = "pydantic_core-2.16.3-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c9803edf8e29bd825f43481f19c37f50d2b01899448273b3a7758441b512acf8"}, + {file = "pydantic_core-2.16.3-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:72282ad4892a9fb2da25defeac8c2e84352c108705c972db82ab121d15f14e6d"}, + {file = "pydantic_core-2.16.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f752826b5b8361193df55afcdf8ca6a57d0232653494ba473630a83ba50d8c9"}, + {file = "pydantic_core-2.16.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4384a8f68ddb31a0b0c3deae88765f5868a1b9148939c3f4121233314ad5532c"}, + {file = "pydantic_core-2.16.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:a4b2bf78342c40b3dc830880106f54328928ff03e357935ad26c7128bbd66ce8"}, + {file = "pydantic_core-2.16.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:13dcc4802961b5f843a9385fc821a0b0135e8c07fc3d9949fd49627c1a5e6ae5"}, + {file = "pydantic_core-2.16.3-cp38-none-win32.whl", hash = "sha256:e3e70c94a0c3841e6aa831edab1619ad5c511199be94d0c11ba75fe06efe107a"}, + {file = "pydantic_core-2.16.3-cp38-none-win_amd64.whl", hash = "sha256:ecdf6bf5f578615f2e985a5e1f6572e23aa632c4bd1dc67f8f406d445ac115ed"}, + {file = "pydantic_core-2.16.3-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:bda1ee3e08252b8d41fa5537413ffdddd58fa73107171a126d3b9ff001b9b820"}, + {file = "pydantic_core-2.16.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:21b888c973e4f26b7a96491c0965a8a312e13be108022ee510248fe379a5fa23"}, + {file = "pydantic_core-2.16.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be0ec334369316fa73448cc8c982c01e5d2a81c95969d58b8f6e272884df0074"}, + {file = "pydantic_core-2.16.3-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5b6079cc452a7c53dd378c6f881ac528246b3ac9aae0f8eef98498a75657805"}, + {file = "pydantic_core-2.16.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ee8d5f878dccb6d499ba4d30d757111847b6849ae07acdd1205fffa1fc1253c"}, + {file = "pydantic_core-2.16.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7233d65d9d651242a68801159763d09e9ec96e8a158dbf118dc090cd77a104c9"}, + {file = "pydantic_core-2.16.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c6119dc90483a5cb50a1306adb8d52c66e447da88ea44f323e0ae1a5fcb14256"}, + {file = "pydantic_core-2.16.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:578114bc803a4c1ff9946d977c221e4376620a46cf78da267d946397dc9514a8"}, + {file = "pydantic_core-2.16.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d8f99b147ff3fcf6b3cc60cb0c39ea443884d5559a30b1481e92495f2310ff2b"}, + {file = "pydantic_core-2.16.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:4ac6b4ce1e7283d715c4b729d8f9dab9627586dafce81d9eaa009dd7f25dd972"}, + {file = "pydantic_core-2.16.3-cp39-none-win32.whl", hash = "sha256:e7774b570e61cb998490c5235740d475413a1f6de823169b4cf94e2fe9e9f6b2"}, + {file = "pydantic_core-2.16.3-cp39-none-win_amd64.whl", hash = "sha256:9091632a25b8b87b9a605ec0e61f241c456e9248bfdcf7abdf344fdb169c81cf"}, + {file = "pydantic_core-2.16.3-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:36fa178aacbc277bc6b62a2c3da95226520da4f4e9e206fdf076484363895d2c"}, + {file = "pydantic_core-2.16.3-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:dcca5d2bf65c6fb591fff92da03f94cd4f315972f97c21975398bd4bd046854a"}, + {file = "pydantic_core-2.16.3-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2a72fb9963cba4cd5793854fd12f4cfee731e86df140f59ff52a49b3552db241"}, + {file = "pydantic_core-2.16.3-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b60cc1a081f80a2105a59385b92d82278b15d80ebb3adb200542ae165cd7d183"}, + {file = "pydantic_core-2.16.3-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cbcc558401de90a746d02ef330c528f2e668c83350f045833543cd57ecead1ad"}, + {file = "pydantic_core-2.16.3-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:fee427241c2d9fb7192b658190f9f5fd6dfe41e02f3c1489d2ec1e6a5ab1e04a"}, + {file = "pydantic_core-2.16.3-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f4cb85f693044e0f71f394ff76c98ddc1bc0953e48c061725e540396d5c8a2e1"}, + {file = "pydantic_core-2.16.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:b29eeb887aa931c2fcef5aa515d9d176d25006794610c264ddc114c053bf96fe"}, + {file = "pydantic_core-2.16.3-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:a425479ee40ff021f8216c9d07a6a3b54b31c8267c6e17aa88b70d7ebd0e5e5b"}, + {file = "pydantic_core-2.16.3-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:5c5cbc703168d1b7a838668998308018a2718c2130595e8e190220238addc96f"}, + {file = "pydantic_core-2.16.3-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:99b6add4c0b39a513d323d3b93bc173dac663c27b99860dd5bf491b240d26137"}, + {file = "pydantic_core-2.16.3-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75f76ee558751746d6a38f89d60b6228fa174e5172d143886af0f85aa306fd89"}, + {file = "pydantic_core-2.16.3-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:00ee1c97b5364b84cb0bd82e9bbf645d5e2871fb8c58059d158412fee2d33d8a"}, + {file = "pydantic_core-2.16.3-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:287073c66748f624be4cef893ef9174e3eb88fe0b8a78dc22e88eca4bc357ca6"}, + {file = "pydantic_core-2.16.3-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:ed25e1835c00a332cb10c683cd39da96a719ab1dfc08427d476bce41b92531fc"}, + {file = "pydantic_core-2.16.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:86b3d0033580bd6bbe07590152007275bd7af95f98eaa5bd36f3da219dcd93da"}, + {file = "pydantic_core-2.16.3.tar.gz", hash = "sha256:1cac689f80a3abab2d3c0048b29eea5751114054f032a941a32de4c852c59cad"}, +] + +[package.dependencies] +typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" + +[[package]] +name = "pyjwt" +version = "2.8.0" +description = "JSON Web Token implementation in Python" +optional = false +python-versions = ">=3.7" +files = [ + {file = "PyJWT-2.8.0-py3-none-any.whl", hash = "sha256:59127c392cc44c2da5bb3192169a91f429924e17aff6534d70fdc02ab3e04320"}, + {file = "PyJWT-2.8.0.tar.gz", hash = "sha256:57e28d156e3d5c10088e0c68abb90bfac3df82b40a71bd0daa20c65ccd5c23de"}, +] + +[package.extras] +crypto = ["cryptography (>=3.4.0)"] +dev = ["coverage[toml] (==5.0.4)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=6.0.0,<7.0.0)", "sphinx (>=4.5.0,<5.0.0)", "sphinx-rtd-theme", "zope.interface"] +docs = ["sphinx (>=4.5.0,<5.0.0)", "sphinx-rtd-theme", "zope.interface"] +tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"] + [[package]] name = "pylast" version = "5.2.0" @@ -982,6 +1212,20 @@ httpx = "*" [package.extras] tests = ["flaky", "pytest", "pytest-cov", "pytest-random-order", "pyyaml"] +[[package]] +name = "python-dotenv" +version = "1.0.1" +description = "Read key-value pairs from a .env file and set them as environment variables" +optional = false +python-versions = ">=3.8" +files = [ + {file = "python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca"}, + {file = "python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a"}, +] + +[package.extras] +cli = ["click (>=5.0)"] + [[package]] name = "pyyaml" version = "6.0.1" @@ -1326,6 +1570,23 @@ urllib3 = ">=1.26.0" doc = ["Sphinx (>=1.5.2)"] test = ["mock (==2.0.0)"] +[[package]] +name = "starlette" +version = "0.36.3" +description = "The little ASGI library that shines." +optional = false +python-versions = ">=3.8" +files = [ + {file = "starlette-0.36.3-py3-none-any.whl", hash = "sha256:13d429aa93a61dc40bf503e8c801db1f1bca3dc706b10ef2434a36123568f044"}, + {file = "starlette-0.36.3.tar.gz", hash = "sha256:90a671733cfb35771d8cc605e0b679d23b992f8dcfad48cc60b38cb29aeb7080"}, +] + +[package.dependencies] +anyio = ">=3.4.0,<5" + +[package.extras] +full = ["httpx (>=0.22.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.7)", "pyyaml"] + [[package]] name = "tenacity" version = "8.2.3" @@ -1382,6 +1643,75 @@ h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] zstd = ["zstandard (>=0.18.0)"] +[[package]] +name = "uvicorn" +version = "0.29.0" +description = "The lightning-fast ASGI server." +optional = false +python-versions = ">=3.8" +files = [ + {file = "uvicorn-0.29.0-py3-none-any.whl", hash = "sha256:2c2aac7ff4f4365c206fd773a39bf4ebd1047c238f8b8268ad996829323473de"}, + {file = "uvicorn-0.29.0.tar.gz", hash = "sha256:6a69214c0b6a087462412670b3ef21224fa48cae0e452b5883e8e8bdfdd11dd0"}, +] + +[package.dependencies] +click = ">=7.0" +colorama = {version = ">=0.4", optional = true, markers = "sys_platform == \"win32\" and extra == \"standard\""} +h11 = ">=0.8" +httptools = {version = ">=0.5.0", optional = true, markers = "extra == \"standard\""} +python-dotenv = {version = ">=0.13", optional = true, markers = "extra == \"standard\""} +pyyaml = {version = ">=5.1", optional = true, markers = "extra == \"standard\""} +uvloop = {version = ">=0.14.0,<0.15.0 || >0.15.0,<0.15.1 || >0.15.1", optional = true, markers = "(sys_platform != \"win32\" and sys_platform != \"cygwin\") and platform_python_implementation != \"PyPy\" and extra == \"standard\""} +watchfiles = {version = ">=0.13", optional = true, markers = "extra == \"standard\""} +websockets = {version = ">=10.4", optional = true, markers = "extra == \"standard\""} + +[package.extras] +standard = ["colorama (>=0.4)", "httptools (>=0.5.0)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1)", "watchfiles (>=0.13)", "websockets (>=10.4)"] + +[[package]] +name = "uvloop" +version = "0.19.0" +description = "Fast implementation of asyncio event loop on top of libuv" +optional = false +python-versions = ">=3.8.0" +files = [ + {file = "uvloop-0.19.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:de4313d7f575474c8f5a12e163f6d89c0a878bc49219641d49e6f1444369a90e"}, + {file = "uvloop-0.19.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5588bd21cf1fcf06bded085f37e43ce0e00424197e7c10e77afd4bbefffef428"}, + {file = "uvloop-0.19.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b1fd71c3843327f3bbc3237bedcdb6504fd50368ab3e04d0410e52ec293f5b8"}, + {file = "uvloop-0.19.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5a05128d315e2912791de6088c34136bfcdd0c7cbc1cf85fd6fd1bb321b7c849"}, + {file = "uvloop-0.19.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:cd81bdc2b8219cb4b2556eea39d2e36bfa375a2dd021404f90a62e44efaaf957"}, + {file = "uvloop-0.19.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:5f17766fb6da94135526273080f3455a112f82570b2ee5daa64d682387fe0dcd"}, + {file = "uvloop-0.19.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4ce6b0af8f2729a02a5d1575feacb2a94fc7b2e983868b009d51c9a9d2149bef"}, + {file = "uvloop-0.19.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:31e672bb38b45abc4f26e273be83b72a0d28d074d5b370fc4dcf4c4eb15417d2"}, + {file = "uvloop-0.19.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:570fc0ed613883d8d30ee40397b79207eedd2624891692471808a95069a007c1"}, + {file = "uvloop-0.19.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5138821e40b0c3e6c9478643b4660bd44372ae1e16a322b8fc07478f92684e24"}, + {file = "uvloop-0.19.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:91ab01c6cd00e39cde50173ba4ec68a1e578fee9279ba64f5221810a9e786533"}, + {file = "uvloop-0.19.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:47bf3e9312f63684efe283f7342afb414eea4d3011542155c7e625cd799c3b12"}, + {file = "uvloop-0.19.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:da8435a3bd498419ee8c13c34b89b5005130a476bda1d6ca8cfdde3de35cd650"}, + {file = "uvloop-0.19.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:02506dc23a5d90e04d4f65c7791e65cf44bd91b37f24cfc3ef6cf2aff05dc7ec"}, + {file = "uvloop-0.19.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2693049be9d36fef81741fddb3f441673ba12a34a704e7b4361efb75cf30befc"}, + {file = "uvloop-0.19.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7010271303961c6f0fe37731004335401eb9075a12680738731e9c92ddd96ad6"}, + {file = "uvloop-0.19.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:5daa304d2161d2918fa9a17d5635099a2f78ae5b5960e742b2fcfbb7aefaa593"}, + {file = "uvloop-0.19.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:7207272c9520203fea9b93843bb775d03e1cf88a80a936ce760f60bb5add92f3"}, + {file = "uvloop-0.19.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:78ab247f0b5671cc887c31d33f9b3abfb88d2614b84e4303f1a63b46c046c8bd"}, + {file = "uvloop-0.19.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:472d61143059c84947aa8bb74eabbace30d577a03a1805b77933d6bd13ddebbd"}, + {file = "uvloop-0.19.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45bf4c24c19fb8a50902ae37c5de50da81de4922af65baf760f7c0c42e1088be"}, + {file = "uvloop-0.19.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:271718e26b3e17906b28b67314c45d19106112067205119dddbd834c2b7ce797"}, + {file = "uvloop-0.19.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:34175c9fd2a4bc3adc1380e1261f60306344e3407c20a4d684fd5f3be010fa3d"}, + {file = "uvloop-0.19.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:e27f100e1ff17f6feeb1f33968bc185bf8ce41ca557deee9d9bbbffeb72030b7"}, + {file = "uvloop-0.19.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:13dfdf492af0aa0a0edf66807d2b465607d11c4fa48f4a1fd41cbea5b18e8e8b"}, + {file = "uvloop-0.19.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6e3d4e85ac060e2342ff85e90d0c04157acb210b9ce508e784a944f852a40e67"}, + {file = "uvloop-0.19.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8ca4956c9ab567d87d59d49fa3704cf29e37109ad348f2d5223c9bf761a332e7"}, + {file = "uvloop-0.19.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f467a5fd23b4fc43ed86342641f3936a68ded707f4627622fa3f82a120e18256"}, + {file = "uvloop-0.19.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:492e2c32c2af3f971473bc22f086513cedfc66a130756145a931a90c3958cb17"}, + {file = "uvloop-0.19.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:2df95fca285a9f5bfe730e51945ffe2fa71ccbfdde3b0da5772b4ee4f2e770d5"}, + {file = "uvloop-0.19.0.tar.gz", hash = "sha256:0246f4fd1bf2bf702e06b0d45ee91677ee5c31242f39aab4ea6fe0c51aedd0fd"}, +] + +[package.extras] +docs = ["Sphinx (>=4.1.2,<4.2.0)", "sphinx-rtd-theme (>=0.5.2,<0.6.0)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)"] +test = ["Cython (>=0.29.36,<0.30.0)", "aiohttp (==3.9.0b0)", "aiohttp (>=3.8.1)", "flake8 (>=5.0,<6.0)", "mypy (>=0.800)", "psutil", "pyOpenSSL (>=23.0.0,<23.1.0)", "pycodestyle (>=2.9.0,<2.10.0)"] + [[package]] name = "validators" version = "0.24.0" @@ -1413,6 +1743,174 @@ platformdirs = ">=3.9.1,<5" docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"] +[[package]] +name = "watchfiles" +version = "0.21.0" +description = "Simple, modern and high performance file watching and code reload in python." +optional = false +python-versions = ">=3.8" +files = [ + {file = "watchfiles-0.21.0-cp310-cp310-macosx_10_7_x86_64.whl", hash = "sha256:27b4035013f1ea49c6c0b42d983133b136637a527e48c132d368eb19bf1ac6aa"}, + {file = "watchfiles-0.21.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c81818595eff6e92535ff32825f31c116f867f64ff8cdf6562cd1d6b2e1e8f3e"}, + {file = "watchfiles-0.21.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:6c107ea3cf2bd07199d66f156e3ea756d1b84dfd43b542b2d870b77868c98c03"}, + {file = "watchfiles-0.21.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d9ac347653ebd95839a7c607608703b20bc07e577e870d824fa4801bc1cb124"}, + {file = "watchfiles-0.21.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5eb86c6acb498208e7663ca22dbe68ca2cf42ab5bf1c776670a50919a56e64ab"}, + {file = "watchfiles-0.21.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f564bf68404144ea6b87a78a3f910cc8de216c6b12a4cf0b27718bf4ec38d303"}, + {file = "watchfiles-0.21.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d0f32ebfaa9c6011f8454994f86108c2eb9c79b8b7de00b36d558cadcedaa3d"}, + {file = "watchfiles-0.21.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b6d45d9b699ecbac6c7bd8e0a2609767491540403610962968d258fd6405c17c"}, + {file = "watchfiles-0.21.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:aff06b2cac3ef4616e26ba17a9c250c1fe9dd8a5d907d0193f84c499b1b6e6a9"}, + {file = "watchfiles-0.21.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d9792dff410f266051025ecfaa927078b94cc7478954b06796a9756ccc7e14a9"}, + {file = "watchfiles-0.21.0-cp310-none-win32.whl", hash = "sha256:214cee7f9e09150d4fb42e24919a1e74d8c9b8a9306ed1474ecaddcd5479c293"}, + {file = "watchfiles-0.21.0-cp310-none-win_amd64.whl", hash = "sha256:1ad7247d79f9f55bb25ab1778fd47f32d70cf36053941f07de0b7c4e96b5d235"}, + {file = "watchfiles-0.21.0-cp311-cp311-macosx_10_7_x86_64.whl", hash = "sha256:668c265d90de8ae914f860d3eeb164534ba2e836811f91fecc7050416ee70aa7"}, + {file = "watchfiles-0.21.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3a23092a992e61c3a6a70f350a56db7197242f3490da9c87b500f389b2d01eef"}, + {file = "watchfiles-0.21.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:e7941bbcfdded9c26b0bf720cb7e6fd803d95a55d2c14b4bd1f6a2772230c586"}, + {file = "watchfiles-0.21.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:11cd0c3100e2233e9c53106265da31d574355c288e15259c0d40a4405cbae317"}, + {file = "watchfiles-0.21.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d78f30cbe8b2ce770160d3c08cff01b2ae9306fe66ce899b73f0409dc1846c1b"}, + {file = "watchfiles-0.21.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6674b00b9756b0af620aa2a3346b01f8e2a3dc729d25617e1b89cf6af4a54eb1"}, + {file = "watchfiles-0.21.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fd7ac678b92b29ba630d8c842d8ad6c555abda1b9ef044d6cc092dacbfc9719d"}, + {file = "watchfiles-0.21.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c873345680c1b87f1e09e0eaf8cf6c891b9851d8b4d3645e7efe2ec20a20cc7"}, + {file = "watchfiles-0.21.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:49f56e6ecc2503e7dbe233fa328b2be1a7797d31548e7a193237dcdf1ad0eee0"}, + {file = "watchfiles-0.21.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:02d91cbac553a3ad141db016e3350b03184deaafeba09b9d6439826ee594b365"}, + {file = "watchfiles-0.21.0-cp311-none-win32.whl", hash = "sha256:ebe684d7d26239e23d102a2bad2a358dedf18e462e8808778703427d1f584400"}, + {file = "watchfiles-0.21.0-cp311-none-win_amd64.whl", hash = "sha256:4566006aa44cb0d21b8ab53baf4b9c667a0ed23efe4aaad8c227bfba0bf15cbe"}, + {file = "watchfiles-0.21.0-cp311-none-win_arm64.whl", hash = "sha256:c550a56bf209a3d987d5a975cdf2063b3389a5d16caf29db4bdddeae49f22078"}, + {file = "watchfiles-0.21.0-cp312-cp312-macosx_10_7_x86_64.whl", hash = "sha256:51ddac60b96a42c15d24fbdc7a4bfcd02b5a29c047b7f8bf63d3f6f5a860949a"}, + {file = "watchfiles-0.21.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:511f0b034120cd1989932bf1e9081aa9fb00f1f949fbd2d9cab6264916ae89b1"}, + {file = "watchfiles-0.21.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:cfb92d49dbb95ec7a07511bc9efb0faff8fe24ef3805662b8d6808ba8409a71a"}, + {file = "watchfiles-0.21.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f92944efc564867bbf841c823c8b71bb0be75e06b8ce45c084b46411475a915"}, + {file = "watchfiles-0.21.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:642d66b75eda909fd1112d35c53816d59789a4b38c141a96d62f50a3ef9b3360"}, + {file = "watchfiles-0.21.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d23bcd6c8eaa6324fe109d8cac01b41fe9a54b8c498af9ce464c1aeeb99903d6"}, + {file = "watchfiles-0.21.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:18d5b4da8cf3e41895b34e8c37d13c9ed294954907929aacd95153508d5d89d7"}, + {file = "watchfiles-0.21.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1b8d1eae0f65441963d805f766c7e9cd092f91e0c600c820c764a4ff71a0764c"}, + {file = "watchfiles-0.21.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:1fd9a5205139f3c6bb60d11f6072e0552f0a20b712c85f43d42342d162be1235"}, + {file = "watchfiles-0.21.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a1e3014a625bcf107fbf38eece0e47fa0190e52e45dc6eee5a8265ddc6dc5ea7"}, + {file = "watchfiles-0.21.0-cp312-none-win32.whl", hash = "sha256:9d09869f2c5a6f2d9df50ce3064b3391d3ecb6dced708ad64467b9e4f2c9bef3"}, + {file = "watchfiles-0.21.0-cp312-none-win_amd64.whl", hash = "sha256:18722b50783b5e30a18a8a5db3006bab146d2b705c92eb9a94f78c72beb94094"}, + {file = "watchfiles-0.21.0-cp312-none-win_arm64.whl", hash = "sha256:a3b9bec9579a15fb3ca2d9878deae789df72f2b0fdaf90ad49ee389cad5edab6"}, + {file = "watchfiles-0.21.0-cp38-cp38-macosx_10_7_x86_64.whl", hash = "sha256:4ea10a29aa5de67de02256a28d1bf53d21322295cb00bd2d57fcd19b850ebd99"}, + {file = "watchfiles-0.21.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:40bca549fdc929b470dd1dbfcb47b3295cb46a6d2c90e50588b0a1b3bd98f429"}, + {file = "watchfiles-0.21.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:9b37a7ba223b2f26122c148bb8d09a9ff312afca998c48c725ff5a0a632145f7"}, + {file = "watchfiles-0.21.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec8c8900dc5c83650a63dd48c4d1d245343f904c4b64b48798c67a3767d7e165"}, + {file = "watchfiles-0.21.0-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8ad3fe0a3567c2f0f629d800409cd528cb6251da12e81a1f765e5c5345fd0137"}, + {file = "watchfiles-0.21.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d353c4cfda586db2a176ce42c88f2fc31ec25e50212650c89fdd0f560ee507b"}, + {file = "watchfiles-0.21.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:83a696da8922314ff2aec02987eefb03784f473281d740bf9170181829133765"}, + {file = "watchfiles-0.21.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5a03651352fc20975ee2a707cd2d74a386cd303cc688f407296064ad1e6d1562"}, + {file = "watchfiles-0.21.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:3ad692bc7792be8c32918c699638b660c0de078a6cbe464c46e1340dadb94c19"}, + {file = "watchfiles-0.21.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:06247538e8253975bdb328e7683f8515ff5ff041f43be6c40bff62d989b7d0b0"}, + {file = "watchfiles-0.21.0-cp38-none-win32.whl", hash = "sha256:9a0aa47f94ea9a0b39dd30850b0adf2e1cd32a8b4f9c7aa443d852aacf9ca214"}, + {file = "watchfiles-0.21.0-cp38-none-win_amd64.whl", hash = "sha256:8d5f400326840934e3507701f9f7269247f7c026d1b6cfd49477d2be0933cfca"}, + {file = "watchfiles-0.21.0-cp39-cp39-macosx_10_7_x86_64.whl", hash = "sha256:7f762a1a85a12cc3484f77eee7be87b10f8c50b0b787bb02f4e357403cad0c0e"}, + {file = "watchfiles-0.21.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6e9be3ef84e2bb9710f3f777accce25556f4a71e15d2b73223788d528fcc2052"}, + {file = "watchfiles-0.21.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:4c48a10d17571d1275701e14a601e36959ffada3add8cdbc9e5061a6e3579a5d"}, + {file = "watchfiles-0.21.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c889025f59884423428c261f212e04d438de865beda0b1e1babab85ef4c0f01"}, + {file = "watchfiles-0.21.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:66fac0c238ab9a2e72d026b5fb91cb902c146202bbd29a9a1a44e8db7b710b6f"}, + {file = "watchfiles-0.21.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b4a21f71885aa2744719459951819e7bf5a906a6448a6b2bbce8e9cc9f2c8128"}, + {file = "watchfiles-0.21.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c9198c989f47898b2c22201756f73249de3748e0fc9de44adaf54a8b259cc0c"}, + {file = "watchfiles-0.21.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d8f57c4461cd24fda22493109c45b3980863c58a25b8bec885ca8bea6b8d4b28"}, + {file = "watchfiles-0.21.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:853853cbf7bf9408b404754b92512ebe3e3a83587503d766d23e6bf83d092ee6"}, + {file = "watchfiles-0.21.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:d5b1dc0e708fad9f92c296ab2f948af403bf201db8fb2eb4c8179db143732e49"}, + {file = "watchfiles-0.21.0-cp39-none-win32.whl", hash = "sha256:59137c0c6826bd56c710d1d2bda81553b5e6b7c84d5a676747d80caf0409ad94"}, + {file = "watchfiles-0.21.0-cp39-none-win_amd64.whl", hash = "sha256:6cb8fdc044909e2078c248986f2fc76f911f72b51ea4a4fbbf472e01d14faa58"}, + {file = "watchfiles-0.21.0-pp310-pypy310_pp73-macosx_10_7_x86_64.whl", hash = "sha256:ab03a90b305d2588e8352168e8c5a1520b721d2d367f31e9332c4235b30b8994"}, + {file = "watchfiles-0.21.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:927c589500f9f41e370b0125c12ac9e7d3a2fd166b89e9ee2828b3dda20bfe6f"}, + {file = "watchfiles-0.21.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1bd467213195e76f838caf2c28cd65e58302d0254e636e7c0fca81efa4a2e62c"}, + {file = "watchfiles-0.21.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:02b73130687bc3f6bb79d8a170959042eb56eb3a42df3671c79b428cd73f17cc"}, + {file = "watchfiles-0.21.0-pp38-pypy38_pp73-macosx_10_7_x86_64.whl", hash = "sha256:08dca260e85ffae975448e344834d765983237ad6dc308231aa16e7933db763e"}, + {file = "watchfiles-0.21.0-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:3ccceb50c611c433145502735e0370877cced72a6c70fd2410238bcbc7fe51d8"}, + {file = "watchfiles-0.21.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:57d430f5fb63fea141ab71ca9c064e80de3a20b427ca2febcbfcef70ff0ce895"}, + {file = "watchfiles-0.21.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0dd5fad9b9c0dd89904bbdea978ce89a2b692a7ee8a0ce19b940e538c88a809c"}, + {file = "watchfiles-0.21.0-pp39-pypy39_pp73-macosx_10_7_x86_64.whl", hash = "sha256:be6dd5d52b73018b21adc1c5d28ac0c68184a64769052dfeb0c5d9998e7f56a2"}, + {file = "watchfiles-0.21.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:b3cab0e06143768499384a8a5efb9c4dc53e19382952859e4802f294214f36ec"}, + {file = "watchfiles-0.21.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c6ed10c2497e5fedadf61e465b3ca12a19f96004c15dcffe4bd442ebadc2d85"}, + {file = "watchfiles-0.21.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:43babacef21c519bc6631c5fce2a61eccdfc011b4bcb9047255e9620732c8097"}, + {file = "watchfiles-0.21.0.tar.gz", hash = "sha256:c76c635fabf542bb78524905718c39f736a98e5ab25b23ec6d4abede1a85a6a3"}, +] + +[package.dependencies] +anyio = ">=3.0.0" + +[[package]] +name = "websockets" +version = "12.0" +description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" +optional = false +python-versions = ">=3.8" +files = [ + {file = "websockets-12.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d554236b2a2006e0ce16315c16eaa0d628dab009c33b63ea03f41c6107958374"}, + {file = "websockets-12.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2d225bb6886591b1746b17c0573e29804619c8f755b5598d875bb4235ea639be"}, + {file = "websockets-12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:eb809e816916a3b210bed3c82fb88eaf16e8afcf9c115ebb2bacede1797d2547"}, + {file = "websockets-12.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c588f6abc13f78a67044c6b1273a99e1cf31038ad51815b3b016ce699f0d75c2"}, + {file = "websockets-12.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5aa9348186d79a5f232115ed3fa9020eab66d6c3437d72f9d2c8ac0c6858c558"}, + {file = "websockets-12.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6350b14a40c95ddd53e775dbdbbbc59b124a5c8ecd6fbb09c2e52029f7a9f480"}, + {file = "websockets-12.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:70ec754cc2a769bcd218ed8d7209055667b30860ffecb8633a834dde27d6307c"}, + {file = "websockets-12.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6e96f5ed1b83a8ddb07909b45bd94833b0710f738115751cdaa9da1fb0cb66e8"}, + {file = "websockets-12.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4d87be612cbef86f994178d5186add3d94e9f31cc3cb499a0482b866ec477603"}, + {file = "websockets-12.0-cp310-cp310-win32.whl", hash = "sha256:befe90632d66caaf72e8b2ed4d7f02b348913813c8b0a32fae1cc5fe3730902f"}, + {file = "websockets-12.0-cp310-cp310-win_amd64.whl", hash = "sha256:363f57ca8bc8576195d0540c648aa58ac18cf85b76ad5202b9f976918f4219cf"}, + {file = "websockets-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5d873c7de42dea355d73f170be0f23788cf3fa9f7bed718fd2830eefedce01b4"}, + {file = "websockets-12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3f61726cae9f65b872502ff3c1496abc93ffbe31b278455c418492016e2afc8f"}, + {file = "websockets-12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ed2fcf7a07334c77fc8a230755c2209223a7cc44fc27597729b8ef5425aa61a3"}, + {file = "websockets-12.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e332c210b14b57904869ca9f9bf4ca32f5427a03eeb625da9b616c85a3a506c"}, + {file = "websockets-12.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5693ef74233122f8ebab026817b1b37fe25c411ecfca084b29bc7d6efc548f45"}, + {file = "websockets-12.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e9e7db18b4539a29cc5ad8c8b252738a30e2b13f033c2d6e9d0549b45841c04"}, + {file = "websockets-12.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:6e2df67b8014767d0f785baa98393725739287684b9f8d8a1001eb2839031447"}, + {file = "websockets-12.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:bea88d71630c5900690fcb03161ab18f8f244805c59e2e0dc4ffadae0a7ee0ca"}, + {file = "websockets-12.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:dff6cdf35e31d1315790149fee351f9e52978130cef6c87c4b6c9b3baf78bc53"}, + {file = "websockets-12.0-cp311-cp311-win32.whl", hash = "sha256:3e3aa8c468af01d70332a382350ee95f6986db479ce7af14d5e81ec52aa2b402"}, + {file = "websockets-12.0-cp311-cp311-win_amd64.whl", hash = "sha256:25eb766c8ad27da0f79420b2af4b85d29914ba0edf69f547cc4f06ca6f1d403b"}, + {file = "websockets-12.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0e6e2711d5a8e6e482cacb927a49a3d432345dfe7dea8ace7b5790df5932e4df"}, + {file = "websockets-12.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:dbcf72a37f0b3316e993e13ecf32f10c0e1259c28ffd0a85cee26e8549595fbc"}, + {file = "websockets-12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:12743ab88ab2af1d17dd4acb4645677cb7063ef4db93abffbf164218a5d54c6b"}, + {file = "websockets-12.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b645f491f3c48d3f8a00d1fce07445fab7347fec54a3e65f0725d730d5b99cb"}, + {file = "websockets-12.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9893d1aa45a7f8b3bc4510f6ccf8db8c3b62120917af15e3de247f0780294b92"}, + {file = "websockets-12.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f38a7b376117ef7aff996e737583172bdf535932c9ca021746573bce40165ed"}, + {file = "websockets-12.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:f764ba54e33daf20e167915edc443b6f88956f37fb606449b4a5b10ba42235a5"}, + {file = "websockets-12.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:1e4b3f8ea6a9cfa8be8484c9221ec0257508e3a1ec43c36acdefb2a9c3b00aa2"}, + {file = "websockets-12.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:9fdf06fd06c32205a07e47328ab49c40fc1407cdec801d698a7c41167ea45113"}, + {file = "websockets-12.0-cp312-cp312-win32.whl", hash = "sha256:baa386875b70cbd81798fa9f71be689c1bf484f65fd6fb08d051a0ee4e79924d"}, + {file = "websockets-12.0-cp312-cp312-win_amd64.whl", hash = "sha256:ae0a5da8f35a5be197f328d4727dbcfafa53d1824fac3d96cdd3a642fe09394f"}, + {file = "websockets-12.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:5f6ffe2c6598f7f7207eef9a1228b6f5c818f9f4d53ee920aacd35cec8110438"}, + {file = "websockets-12.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:9edf3fc590cc2ec20dc9d7a45108b5bbaf21c0d89f9fd3fd1685e223771dc0b2"}, + {file = "websockets-12.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:8572132c7be52632201a35f5e08348137f658e5ffd21f51f94572ca6c05ea81d"}, + {file = "websockets-12.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:604428d1b87edbf02b233e2c207d7d528460fa978f9e391bd8aaf9c8311de137"}, + {file = "websockets-12.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1a9d160fd080c6285e202327aba140fc9a0d910b09e423afff4ae5cbbf1c7205"}, + {file = "websockets-12.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87b4aafed34653e465eb77b7c93ef058516cb5acf3eb21e42f33928616172def"}, + {file = "websockets-12.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b2ee7288b85959797970114deae81ab41b731f19ebcd3bd499ae9ca0e3f1d2c8"}, + {file = "websockets-12.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:7fa3d25e81bfe6a89718e9791128398a50dec6d57faf23770787ff441d851967"}, + {file = "websockets-12.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:a571f035a47212288e3b3519944f6bf4ac7bc7553243e41eac50dd48552b6df7"}, + {file = "websockets-12.0-cp38-cp38-win32.whl", hash = "sha256:3c6cc1360c10c17463aadd29dd3af332d4a1adaa8796f6b0e9f9df1fdb0bad62"}, + {file = "websockets-12.0-cp38-cp38-win_amd64.whl", hash = "sha256:1bf386089178ea69d720f8db6199a0504a406209a0fc23e603b27b300fdd6892"}, + {file = "websockets-12.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:ab3d732ad50a4fbd04a4490ef08acd0517b6ae6b77eb967251f4c263011a990d"}, + {file = "websockets-12.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a1d9697f3337a89691e3bd8dc56dea45a6f6d975f92e7d5f773bc715c15dde28"}, + {file = "websockets-12.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1df2fbd2c8a98d38a66f5238484405b8d1d16f929bb7a33ed73e4801222a6f53"}, + {file = "websockets-12.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23509452b3bc38e3a057382c2e941d5ac2e01e251acce7adc74011d7d8de434c"}, + {file = "websockets-12.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2e5fc14ec6ea568200ea4ef46545073da81900a2b67b3e666f04adf53ad452ec"}, + {file = "websockets-12.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46e71dbbd12850224243f5d2aeec90f0aaa0f2dde5aeeb8fc8df21e04d99eff9"}, + {file = "websockets-12.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b81f90dcc6c85a9b7f29873beb56c94c85d6f0dac2ea8b60d995bd18bf3e2aae"}, + {file = "websockets-12.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:a02413bc474feda2849c59ed2dfb2cddb4cd3d2f03a2fedec51d6e959d9b608b"}, + {file = "websockets-12.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:bbe6013f9f791944ed31ca08b077e26249309639313fff132bfbf3ba105673b9"}, + {file = "websockets-12.0-cp39-cp39-win32.whl", hash = "sha256:cbe83a6bbdf207ff0541de01e11904827540aa069293696dd528a6640bd6a5f6"}, + {file = "websockets-12.0-cp39-cp39-win_amd64.whl", hash = "sha256:fc4e7fa5414512b481a2483775a8e8be7803a35b30ca805afa4998a84f9fd9e8"}, + {file = "websockets-12.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:248d8e2446e13c1d4326e0a6a4e9629cb13a11195051a73acf414812700badbd"}, + {file = "websockets-12.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f44069528d45a933997a6fef143030d8ca8042f0dfaad753e2906398290e2870"}, + {file = "websockets-12.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c4e37d36f0d19f0a4413d3e18c0d03d0c268ada2061868c1e6f5ab1a6d575077"}, + {file = "websockets-12.0-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d829f975fc2e527a3ef2f9c8f25e553eb7bc779c6665e8e1d52aa22800bb38b"}, + {file = "websockets-12.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:2c71bd45a777433dd9113847af751aae36e448bc6b8c361a566cb043eda6ec30"}, + {file = "websockets-12.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:0bee75f400895aef54157b36ed6d3b308fcab62e5260703add87f44cee9c82a6"}, + {file = "websockets-12.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:423fc1ed29f7512fceb727e2d2aecb952c46aa34895e9ed96071821309951123"}, + {file = "websockets-12.0-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:27a5e9964ef509016759f2ef3f2c1e13f403725a5e6a1775555994966a66e931"}, + {file = "websockets-12.0-pp38-pypy38_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c3181df4583c4d3994d31fb235dc681d2aaad744fbdbf94c4802485ececdecf2"}, + {file = "websockets-12.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:b067cb952ce8bf40115f6c19f478dc71c5e719b7fbaa511359795dfd9d1a6468"}, + {file = "websockets-12.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:00700340c6c7ab788f176d118775202aadea7602c5cc6be6ae127761c16d6b0b"}, + {file = "websockets-12.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e469d01137942849cff40517c97a30a93ae79917752b34029f0ec72df6b46399"}, + {file = "websockets-12.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffefa1374cd508d633646d51a8e9277763a9b78ae71324183693959cf94635a7"}, + {file = "websockets-12.0-pp39-pypy39_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba0cab91b3956dfa9f512147860783a1829a8d905ee218a9837c18f683239611"}, + {file = "websockets-12.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2cb388a5bfb56df4d9a406783b7f9dbefb888c09b71629351cc6b036e9259370"}, + {file = "websockets-12.0-py3-none-any.whl", hash = "sha256:dc284bbc8d7c78a6c69e0c7325ab46ee5e40bb4d50e494d8131a07ef47500e9e"}, + {file = "websockets-12.0.tar.gz", hash = "sha256:81df9cbcbb6c260de1e007e58c011bfebe2dafc8435107b0537f393dd38c8b1b"}, +] + [[package]] name = "yarl" version = "1.9.4" @@ -1519,4 +2017,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "8e65772950ff7bb26644a9da909bae1a60e9a51822650b1a9dd84a4421bbaff3" +content-hash = "1153e82398e9ba4378633e0f3b899529335b31a5b0936af0c8dd84eccfebdfbb" diff --git a/pyproject.toml b/pyproject.toml index c8988b0..c4c2550 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,11 +24,15 @@ spotipy = "^2.23.0" tenacity = "^8.2.3" thefuzz = "^0.22.1" validators = "^0.24.0" +fastapi = "^0.110.0" +uvicorn = {extras = ["standard"], version = "^0.29.0"} +pyjwt = "^2.8.0" [tool.poetry.group.dev.dependencies] mypy = "^1.9.0" pre-commit = "^3.7.0" ruff = "^0.3.4" +watchfiles = "^0.21.0" [tool.ruff] indent-width = 2 From 33fe647aec2d234be37df783851d01a81a8fcb3b Mon Sep 17 00:00:00 2001 From: Jared Dantis Date: Sun, 14 Apr 2024 13:16:25 +0800 Subject: [PATCH 29/33] bot: Fix bump merge --- bot/cogs/__init__.py | 2 +- bot/cogs/bumps.py | 402 +++++++++++++------------- bot/cogs/player/jockey.py | 38 ++- bot/database/__init__.py | 147 ++++++++++ bot/database/migrations/0005-bumps.py | 52 ++-- bot/models/bump.py | 1 + bot/utils/exceptions.py | 5 +- bot/utils/paginator.py | 12 +- 8 files changed, 424 insertions(+), 235 deletions(-) diff --git a/bot/cogs/__init__.py b/bot/cogs/__init__.py index e2a6f38..661efcd 100644 --- a/bot/cogs/__init__.py +++ b/bot/cogs/__init__.py @@ -5,10 +5,10 @@ from typing import TYPE_CHECKING +from .bumps import BumpCog from .debug import DebugCog from .player import PlayerCog -from .bumps import BumpCog if TYPE_CHECKING: from bot.utils.blanco import BlancoBot diff --git a/bot/cogs/bumps.py b/bot/cogs/bumps.py index ef25d5f..482c7eb 100644 --- a/bot/cogs/bumps.py +++ b/bot/cogs/bumps.py @@ -7,221 +7,223 @@ from nextcord import Color, Interaction, Permissions, SlashOption, slash_command from nextcord.ext.commands import Cog -from bot.dataclass.bump import Bump +from bot.models.bump import Bump from bot.utils.embeds import CustomEmbed, create_error_embed, create_success_embed from bot.utils.logger import create_logger from bot.utils.paginator import Paginator, list_chunks from bot.utils.url import check_url if TYPE_CHECKING: - from bot.utils.blanco import BlancoBot + from bot.utils.blanco import BlancoBot + + +MAX_BUMP_METADATA_LENGTH = 32 class BumpCog(Cog): + """ + Cog for guild bumps. + """ + + def __init__(self, bot: 'BlancoBot'): """ - Cog for guild bumps. + Constructor for BumpCog. + """ + self._bot = bot + self._logger = create_logger(self.__class__.__name__) + self._logger.info('Loaded BumpCog') + + @slash_command( + name='bump', + dm_permission=False, + default_member_permissions=Permissions(manage_guild=True), + ) + async def bump(self, itx: Interaction): + """ + Base slash command for bumps. """ - def __init__(self, bot: 'BlancoBot'): - """ - Constructor for BumpCog. - """ - self._bot = bot - self._logger = create_logger(self.__class__.__name__) - self._logger.info('Loaded BumpCog') - - @slash_command( - name='bump', - dm_permission=False, - default_member_permissions=Permissions(manage_guild=True) - ) - async def bump(self, itx: Interaction): - """ - Base slash command for bumps. - """ - - - @bump.subcommand(name='toggle', description='Toggle the playback of bumps.') - async def bump_toggle( - self, - itx: Interaction, - toggle: bool = SlashOption( - name='toggle', - description='Turn bumps on or off?', - required=False - ) - ): - """ - Subcommand for toggling bumps. - """ - if itx.guild is None: - raise RuntimeError('[bump::toggle] itx.guild is None') - - if toggle is None: - enabled = self._bot.database.get_bumps_enabled(itx.guild.id) - status = "Bump playback is currently enabled." if enabled \ - else "Bump playback is currently disabled." - return await itx.response.send_message( - embed=create_success_embed( - title="Bumps status", - body=status, - ) - ) - - self._bot.database.set_bumps_enabled(itx.guild.id, toggle) - status = "Bump playback has been enabled." if toggle \ - else "Bump playback has been disabled." - return await itx.response.send_message( - embed=create_success_embed( - title="Bumps toggled", - body=status, - ) - ) - @bump.subcommand(name='add', description='Add a bump.') - async def bump_add( - self, - itx: Interaction, - title: str = SlashOption(name='title', description='Title of bump.', required=True), - author: str = SlashOption(name='author', description='Author of bump.', required=True), - url: str = SlashOption(name='url', description='URL to add.', required=True), - ): - """ - Subcommand for adding a bump. - """ - if itx.guild is None: - raise RuntimeError('[bump::add] itx.guild is None') - - if len(title) > 32 or len(author) > 32: - return await itx.response.send_message( - embed=create_error_embed( - message='Titles/authors cannot exceed 32 characters in length.' - ) - ) - - if not check_url(url): - return await itx.response.send_message( - embed=create_error_embed( - message='The given URL is not valid.' - ) - ) - - bump = self._bot.database.get_bump_by_url(itx.guild.id, url) - if bump is not None: - return await itx.response.send_message( - embed=create_error_embed( - message='A bump with the given URL already exists.' - ) - ) - - self._bot.database.add_bump(itx.guild.id, url, title, author) - return await itx.response.send_message( - embed=create_success_embed( - title='Bump added', - body='Bump has been successfully added to the database.' - ) + @bump.subcommand(name='toggle', description='Toggle the playback of bumps.') + async def bump_toggle( + self, + itx: Interaction, + toggle: bool = SlashOption( + name='toggle', description='Turn bumps on or off?', required=False + ), + ): + """ + Subcommand for toggling bumps. + """ + if itx.guild is None: + raise RuntimeError('[bump::toggle] itx.guild is None') + + if toggle is None: + enabled = self._bot.database.get_bumps_enabled(itx.guild.id) + status = ( + 'Bump playback is currently enabled.' + if enabled + else 'Bump playback is currently disabled.' + ) + return await itx.response.send_message( + embed=create_success_embed( + title='Bumps status', + body=status, ) + ) + + self._bot.database.set_bumps_enabled(itx.guild.id, toggle) + status = ( + 'Bump playback has been enabled.' + if toggle + else 'Bump playback has been disabled.' + ) + return await itx.response.send_message( + embed=create_success_embed( + title='Bumps toggled', + body=status, + ) + ) + + @bump.subcommand(name='add', description='Add a bump.') + async def bump_add( + self, + itx: Interaction, + title: str = SlashOption(name='title', description='Title of bump.', required=True), + author: str = SlashOption( + name='author', description='Author of bump.', required=True + ), + url: str = SlashOption(name='url', description='URL to add.', required=True), + ): + """ + Subcommand for adding a bump. + """ + if itx.guild is None: + raise RuntimeError('[bump::add] itx.guild is None') - @bump.subcommand(name='remove', description='Remove a bump.') - async def bump_remove( - self, - itx: Interaction, - idx: int = SlashOption(name='index', description='Index of bump.', required=True) - ): - """ - Subcommand for removing a bump. - """ - if itx.guild is None: - raise RuntimeError('[bump::remove] itx.guild is None') - - bump = self._bot.database.get_bump(itx.guild.id, idx) - if bump is None: - return await itx.response.send_message( - embed=create_error_embed( - message='There is no bump at that index for this guild.' - ) - ) - - self._bot.database.delete_bump(itx.guild.id, idx) - return await itx.response.send_message( - embed=create_success_embed( - title='Bump removed', - body='Bump has successfully been removed from the database.' - ) + if len(title) > MAX_BUMP_METADATA_LENGTH or len(author) > MAX_BUMP_METADATA_LENGTH: + return await itx.response.send_message( + embed=create_error_embed( + message='Titles/authors cannot exceed 32 characters in length.' ) + ) + + if not check_url(url): + return await itx.response.send_message( + embed=create_error_embed(message='The given URL is not valid.') + ) + + bump = self._bot.database.get_bump_by_url(itx.guild.id, url) + if bump is not None: + return await itx.response.send_message( + embed=create_error_embed(message='A bump with the given URL already exists.') + ) + + self._bot.database.add_bump(itx.guild.id, url, title, author) + return await itx.response.send_message( + embed=create_success_embed( + title='Bump added', body='Bump has been successfully added to the database.' + ) + ) - @bump.subcommand(name='list', description='List every bump.') - async def bump_list( - self, - itx: Interaction, - ): - """ - Subcommand for listing bumps. - """ - if itx.guild is None: - raise RuntimeError('[bump::list] itx.guild is None') - await itx.response.defer() - - bumps = self._bot.database.get_bumps(itx.guild.id) - if bumps is None: - return await itx.response.send_message( - embed=create_error_embed( - message='This guild has no bumps.' - ) - ) - - pages = [] - count = 1 - for _, chunk in enumerate(list_chunks(bumps)): - chunk_bumps = [] - - bump: Bump - for bump in chunk: - line = f'{bump.idx} :: [{bump.title}]({bump.url}) by {bump.author}' - chunk_bumps.append(line) - count += 1 - - embed = CustomEmbed( - title=f'Bumps for {itx.guild.name}', - description='\n'.join(chunk_bumps), - color=Color.lighter_gray() - ) - pages.append(embed.get()) - - paginator = Paginator(itx) - return await paginator.run(pages) - - @bump.subcommand(name='interval', description='Set or get the bump interval.') - async def bump_interval( - self, - itx: Interaction, - interval: int = SlashOption( - name='interval', - description='The new interval bumps will play at', - required=False, - min_value=1, - max_value=60 + @bump.subcommand(name='remove', description='Remove a bump.') + async def bump_remove( + self, + itx: Interaction, + idx: int = SlashOption(name='index', description='Index of bump.', required=True), + ): + """ + Subcommand for removing a bump. + """ + if itx.guild is None: + raise RuntimeError('[bump::remove] itx.guild is None') + + bump = self._bot.database.get_bump(itx.guild.id, idx) + if bump is None: + return await itx.response.send_message( + embed=create_error_embed( + message='There is no bump at that index for this guild.' ) - ): - """ - Subcommand for changing/checking the bump interval. - """ - - if itx.guild is None: - raise RuntimeError('[bump::interval] itx.guild is None') - - if interval is None: - curr_interval = self._bot.database.get_bump_interval(itx.guild.id) - return await itx.response.send_message( - embed=create_success_embed( - title='Current Interval', - body=f'A bump will play once at least every {curr_interval} minute(s).' - ) - ) - - self._bot.database.set_bump_interval(itx.guild.id, interval) - return await itx.response.send_message( - embed=create_success_embed( - title='Interval Changed', - body=f'The bump interval has been set to {interval} minute(s).' - ) + ) + + self._bot.database.delete_bump(itx.guild.id, idx) + return await itx.response.send_message( + embed=create_success_embed( + title='Bump removed', + body='Bump has successfully been removed from the database.', + ) + ) + + @bump.subcommand(name='list', description='List every bump.') + async def bump_list( + self, + itx: Interaction, + ): + """ + Subcommand for listing bumps. + """ + if itx.guild is None: + raise RuntimeError('[bump::list] itx.guild is None') + await itx.response.defer() + + bumps = self._bot.database.get_bumps(itx.guild.id) + if bumps is None: + return await itx.response.send_message( + embed=create_error_embed(message='This guild has no bumps.') + ) + + pages = [] + count = 1 + for _, chunk in enumerate(list_chunks(bumps)): + chunk_bumps = [] + + bump: Bump + for bump in chunk: + line = f'{bump.idx} :: [{bump.title}]({bump.url}) by {bump.author}' + chunk_bumps.append(line) + count += 1 + + embed = CustomEmbed( + title=f'Bumps for {itx.guild.name}', + description='\n'.join(chunk_bumps), + color=Color.lighter_gray(), + ) + pages.append(embed.get()) + + paginator = Paginator(itx) + return await paginator.run(pages) + + @bump.subcommand(name='interval', description='Set or get the bump interval.') + async def bump_interval( + self, + itx: Interaction, + interval: int = SlashOption( + name='interval', + description='The new interval bumps will play at', + required=False, + min_value=1, + max_value=60, + ), + ): + """ + Subcommand for changing/checking the bump interval. + """ + + if itx.guild is None: + raise RuntimeError('[bump::interval] itx.guild is None') + + if interval is None: + curr_interval = self._bot.database.get_bump_interval(itx.guild.id) + return await itx.response.send_message( + embed=create_success_embed( + title='Current Interval', + body=f'A bump will play once at least every {curr_interval} minute(s).', ) + ) + + self._bot.database.set_bump_interval(itx.guild.id, interval) + return await itx.response.send_message( + embed=create_success_embed( + title='Interval Changed', + body=f'The bump interval has been set to {interval} minute(s).', + ) + ) diff --git a/bot/cogs/player/jockey.py b/bot/cogs/player/jockey.py index 524e891..09813ab 100644 --- a/bot/cogs/player/jockey.py +++ b/bot/cogs/player/jockey.py @@ -405,6 +405,42 @@ async def pause(self, pause: bool = True): # Store pause timestamp self._pause_ts = int(time()) + async def play_bump(self): + """ + Check and attempt to play a bump if it's been long enough. + """ + + enabled = self._db.get_bumps_enabled(self.guild.id) + if not enabled: + raise BumpNotEnabledError + + interval = self._db.get_bump_interval(self.guild.id) * 60 + last_bump = self._db.get_last_bump(self.guild.id) + + if last_bump == 0: + self._db.set_last_bump(self.guild.id) + raise BumpNotReadyError + + if int(time()) - last_bump < interval: + raise BumpNotReadyError + + bump = self._db.get_random_bump(self.guild.id) + if bump is None: + raise BumpError('Guild has no bumps.') + + requester = self._bot.user.id if self._bot.user is not None else self.guild.me.id + + try: + tracks = await parse_query(self.node, self._bot.spotify, bump.url, requester) + except (JockeyException, SpotifyNoResultsError): + raise + + if len(tracks) == 0: + raise BumpError('Unable to parse bump URL into tracks.') + + await self._play(tracks[0]) + self._db.set_last_bump(self.guild.id) + async def play_impl(self, query: str, requester: int) -> str: """ Adds an item to the player queue and begins playback if necessary. @@ -498,7 +534,7 @@ async def set_volume(self, volume: int, /): await super().set_volume(volume) self.volume = volume - async def skip(self, *, forward: bool = True, index: int = -1, auto: bool = True): + async def skip(self, *, forward: bool = True, index: int = -1, auto: bool = True): # noqa: PLR0912 """ Skips the current track and plays the next one in the queue. diff --git a/bot/database/__init__.py b/bot/database/__init__.py index 05ac498..37ddf6b 100644 --- a/bot/database/__init__.py +++ b/bot/database/__init__.py @@ -3,8 +3,10 @@ """ import sqlite3 as sql +from time import time from typing import List, Optional +from bot.models.bump import Bump from bot.models.oauth import LastfmAuth, OAuth from bot.utils.logger import create_logger @@ -231,3 +233,148 @@ def get_spotify_scopes(self, user_id: int) -> List[str]: """ self._cur.execute(f'SELECT scopes FROM spotify_oauth WHERE user_id = {user_id}') return self._cur.fetchone()[0].split(',') + + def set_last_bump(self, guild_id: int): + """ + Set the last bump for a guild. + """ + seconds = int(time()) + self._cur.execute( + f'UPDATE player_settings SET last_bump = {seconds} WHERE guild_id = {guild_id}' + ) + self._con.commit() + + def get_last_bump(self, guild_id: int) -> int: + """ + Get the last bump for a guild. + """ + self._cur.execute( + f'SELECT last_bump FROM player_settings WHERE guild_id = {guild_id}' + ) + return self._cur.fetchone()[0] + + def set_bumps_enabled(self, guild_id: int, enabled: bool): + """ + Set whether bumps are enabled for a guild. + """ + self._cur.execute( + f'UPDATE player_settings SET bumps_enabled = {int(enabled)} WHERE guild_id = {guild_id}' + ) + self._con.commit() + + def get_bumps_enabled(self, guild_id: int) -> bool: + """ + Get whether bumps are enabled for a guild. + """ + self._cur.execute( + f'SELECT bumps_enabled FROM player_settings WHERE guild_id = {guild_id}' + ) + return self._cur.fetchone()[0] == 1 + + def set_bump_interval(self, guild_id: int, interval: int): + """ + Set the bump interval for a guild. + """ + self._cur.execute( + f'UPDATE player_settings SET bump_interval = {interval} WHERE guild_id = {guild_id}' + ) + self._con.commit() + + def get_bump_interval(self, guild_id: int) -> int: + """ + Get the bump interval for a guild. + """ + self._cur.execute( + f'SELECT bump_interval FROM player_settings WHERE guild_id = {guild_id}' + ) + return self._cur.fetchone()[0] + + def add_bump(self, guild_id: int, url: str, title: str, author: str): + """ + Set a bump for a guild. + """ + self._cur.execute(f'SELECT MAX(idx) FROM bumps WHERE guild_id = {guild_id}') + idx = self._cur.fetchone()[0] + if idx is None: + idx = 0 + idx += 1 + self._cur.execute(f""" + INSERT INTO bumps ( + guild_id, + idx, + url, + title, + author + ) VALUES ( + {guild_id}, + {idx}, + "{url}", + "{title}", + "{author}" + ) + """) + self._con.commit() + + def get_bumps(self, guild_id: int) -> Optional[List[Bump]]: + """ + Get every bump for a guild. + """ + self._cur.execute(f"""SELECT idx, guild_id, url, title, author + FROM bumps WHERE guild_id = {guild_id}""") + rows = self._cur.fetchall() + if len(rows) == 0: + return None + + return [ + Bump(idx=row[0], guild_id=row[1], url=row[2], title=row[3], author=row[4]) + for row in rows + ] + + def get_bump(self, guild_id: int, idx: int) -> Optional[Bump]: + """ + Get a guild bump by its index. + """ + self._cur.execute( + f"""SELECT idx, guild_id, url, title, author FROM bumps + WHERE guild_id = {guild_id} AND idx = {idx} + """ + ) + row = self._cur.fetchone() + if row is None: + return None + return Bump(idx=row[0], guild_id=row[1], url=row[2], title=row[3], author=row[4]) + + def get_bump_by_url(self, guild_id: int, url: str) -> Optional[Bump]: + """ + Get a guild bump by its URL. + """ + self._cur.execute( + f"""SELECT idx, guild_id, url, title, author FROM bumps + WHERE guild_id = {guild_id} AND url = "{url}" + """ + ) + row = self._cur.fetchone() + if row is None: + return None + return Bump(idx=row[0], guild_id=row[1], url=row[2], title=row[3], author=row[4]) + + def get_random_bump(self, guild_id: int) -> Optional[Bump]: + """ + Get a random guild bump. + """ + self._cur.execute( + f"""SELECT idx, guild_id, url, title, author FROM bumps WHERE + guild_id = {guild_id} ORDER BY RANDOM() LIMIT 1 + """ + ) + row = self._cur.fetchone() + if row is None: + return None + return Bump(idx=row[0], guild_id=row[1], url=row[2], title=row[3], author=row[4]) + + def delete_bump(self, guild_id: int, idx: int): + """ + Delete a guild bump by its index. + """ + self._cur.execute(f'DELETE FROM bumps WHERE guild_id = {guild_id} AND idx = {idx}') + self._con.commit() diff --git a/bot/database/migrations/0005-bumps.py b/bot/database/migrations/0005-bumps.py index 3b1f53d..41bde9f 100644 --- a/bot/database/migrations/0005-bumps.py +++ b/bot/database/migrations/0005-bumps.py @@ -8,16 +8,16 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from sqlite3 import Connection + from sqlite3 import Connection def run(con: 'Connection'): - """ - Run the migration. - """ - cur = con.cursor() + """ + Run the migration. + """ + cur = con.cursor() - cur.execute(''' + cur.execute(""" CREATE TABLE IF NOT EXISTS bumps ( id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, idx INTEGER NOT NULL, @@ -28,33 +28,33 @@ def run(con: 'Connection'): UNIQUE(guild_id, idx) ) - ''') + """) - con.commit() + con.commit() - try: - cur.execute(''' + try: + cur.execute(""" ALTER TABLE player_settings ADD COLUMN bump_interval INTEGER NOT NULL DEFAULT 20 - ''') + """) - con.commit() - except OperationalError: - pass + con.commit() + except OperationalError: + pass - try: - cur.execute(''' + try: + cur.execute(""" ALTER TABLE player_settings ADD COLUMN last_bump INTEGER NOT NULL DEFAULT 0 - ''') + """) - con.commit() - except OperationalError: - pass + con.commit() + except OperationalError: + pass - try: - cur.execute(''' + try: + cur.execute(""" ALTER TABLE player_settings ADD COLUMN bumps_enabled INTEGER NOT NULL DEFAULT 0 - ''') + """) - con.commit() - except OperationalError: - pass + con.commit() + except OperationalError: + pass diff --git a/bot/models/bump.py b/bot/models/bump.py index 38c09ca..f41a14c 100644 --- a/bot/models/bump.py +++ b/bot/models/bump.py @@ -1,6 +1,7 @@ """ Dataclass for guild bumps. """ + from dataclasses import dataclass diff --git a/bot/utils/exceptions.py b/bot/utils/exceptions.py index b7d8d4d..2526cbf 100644 --- a/bot/utils/exceptions.py +++ b/bot/utils/exceptions.py @@ -104,17 +104,20 @@ class VoiceCommandError(BlancoException): Raised when a command that requires a voice channel is invoked outside of one. """ + class BumpError(Exception): """ Raised when encountering an error while playing a bump. """ + class BumpNotReadyError(Exception): """ Raised when it hasn't been long enough between bumps. """ + class BumpNotEnabledError(Exception): """ Raised when bumps are not enabled in a guild. - """ \ No newline at end of file + """ diff --git a/bot/utils/paginator.py b/bot/utils/paginator.py index a3f1cf7..90b50e6 100644 --- a/bot/utils/paginator.py +++ b/bot/utils/paginator.py @@ -7,7 +7,7 @@ from asyncio import sleep from itertools import islice -from typing import TYPE_CHECKING, Callable, List, Optional, Any, Generator +from typing import TYPE_CHECKING, Any, Callable, Generator, List, Optional from nextcord import Embed, Forbidden, HTTPException, Interaction @@ -18,11 +18,11 @@ def list_chunks(data: List[Any]) -> Generator[List[Any], Any, Any]: - """ - Yield 10-element chunks of a list. Used for pagination. - """ - for i in range(0, len(data), 10): - yield list(islice(data, i, i + 10)) + """ + Yield 10-element chunks of a list. Used for pagination. + """ + for i in range(0, len(data), 10): + yield list(islice(data, i, i + 10)) class Paginator: From 5dee52ddb4deb2ae5eecda28ccf2581cadc7c566 Mon Sep 17 00:00:00 2001 From: Jared Dantis Date: Sun, 14 Apr 2024 13:17:15 +0800 Subject: [PATCH 30/33] precommit: Update repos --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1866dc0..6255c90 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v2.3.0 + rev: v4.6.0 hooks: - id: double-quote-string-fixer - id: end-of-file-fixer @@ -10,7 +10,7 @@ repos: - id: mixed-line-ending - id: check-merge-conflict - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.3.4 + rev: v0.3.7 hooks: - id: ruff args: [ --fix ] From 589a94269f7516173dff4327c0ee454020387c16 Mon Sep 17 00:00:00 2001 From: Jared Dantis Date: Sun, 14 Apr 2024 16:38:28 +0800 Subject: [PATCH 31/33] api: Implement the rest of the routes --- bot/api/depends/session.py | 24 +--- bot/api/depends/user.py | 30 ++++ bot/api/main.py | 4 +- bot/api/models/account.py | 7 + bot/api/routes/account/__init__.py | 18 ++- bot/api/routes/account/delete.py | 23 +++ bot/api/routes/account/lastfm.py | 46 ++++++ bot/api/routes/account/login.py | 35 ++++- bot/api/routes/account/logout.py | 20 +++ bot/api/routes/account/me.py | 4 +- bot/api/routes/account/spotify.py | 56 ++++++++ bot/api/routes/account/unlink.py | 29 ++++ bot/api/routes/callback/__init__.py | 10 ++ bot/api/routes/{oauth => callback}/discord.py | 4 +- bot/api/routes/callback/lastfm.py | 97 +++++++++++++ bot/api/routes/callback/spotify.py | 136 ++++++++++++++++++ bot/api/routes/oauth/__init__.py | 6 - bot/api/utils/constants.py | 7 + 18 files changed, 517 insertions(+), 39 deletions(-) create mode 100644 bot/api/depends/user.py create mode 100644 bot/api/routes/account/delete.py create mode 100644 bot/api/routes/account/lastfm.py create mode 100644 bot/api/routes/account/logout.py create mode 100644 bot/api/routes/account/spotify.py create mode 100644 bot/api/routes/account/unlink.py create mode 100644 bot/api/routes/callback/__init__.py rename bot/api/routes/{oauth => callback}/discord.py (98%) create mode 100644 bot/api/routes/callback/lastfm.py create mode 100644 bot/api/routes/callback/spotify.py delete mode 100644 bot/api/routes/oauth/__init__.py create mode 100644 bot/api/utils/constants.py diff --git a/bot/api/depends/session.py b/bot/api/depends/session.py index da69a1e..9ff4817 100644 --- a/bot/api/depends/session.py +++ b/bot/api/depends/session.py @@ -1,31 +1,25 @@ -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING -from fastapi import Depends, HTTPException, Request +from fastapi import HTTPException, Request from starlette.status import HTTP_401_UNAUTHORIZED -from .database import database_dependency - if TYPE_CHECKING: + from bot.api.models.session import Session from bot.api.utils.session import SessionManager - from bot.database import Database - from bot.models.oauth import OAuth - EXPECTED_AUTH_SCHEME = 'Bearer' EXPECTED_AUTH_PARTS = 2 -def session_dependency( - request: Request, db: 'Database' = Depends(database_dependency) -) -> 'OAuth': +def session_dependency(request: Request) -> 'Session': """ - FastAPI dependency to get the requesting user's info. + FastAPI dependency to get the requesting user's session object. Args: request (web.Request): The request. Returns: - OAuth: The info for the current Discord user. + Session: The session object for the current Discord user. """ authorization = request.headers.get('Authorization') @@ -51,8 +45,4 @@ def session_dependency( if session is None: raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail='Invalid session') - user: Optional['OAuth'] = db.get_oauth('discord', session.user_id) - if user is None: - raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail='User not found') - - return user + return session diff --git a/bot/api/depends/user.py b/bot/api/depends/user.py new file mode 100644 index 0000000..6da600c --- /dev/null +++ b/bot/api/depends/user.py @@ -0,0 +1,30 @@ +from typing import TYPE_CHECKING, Optional + +from fastapi import Depends, HTTPException +from starlette.status import HTTP_401_UNAUTHORIZED + +from .database import database_dependency +from .session import session_dependency + +if TYPE_CHECKING: + from bot.api.models.session import Session + from bot.database import Database + from bot.models.oauth import OAuth + + +def user_dependency( + db: 'Database' = Depends(database_dependency), + session: 'Session' = Depends(session_dependency), +) -> 'OAuth': + """ + FastAPI dependency to get the requesting user's info. + + Returns: + OAuth: The info for the current Discord user. + """ + + user: Optional['OAuth'] = db.get_oauth('discord', session.user_id) + if user is None: + raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail='User not found') + + return user diff --git a/bot/api/main.py b/bot/api/main.py index 5ee4a69..eb48849 100644 --- a/bot/api/main.py +++ b/bot/api/main.py @@ -16,7 +16,7 @@ from bot.utils.logger import DATE_FMT_STR, LOG_FMT_COLOR, create_logger from .routes.account import account_router -from .routes.oauth import oauth_router +from .routes.callback import callback_router from .utils.session import SessionManager if TYPE_CHECKING: @@ -44,7 +44,7 @@ async def lifespan(app: FastAPI): app = FastAPI(lifespan=lifespan) app.include_router(account_router) -app.include_router(oauth_router) +app.include_router(callback_router) @app.get('/') diff --git a/bot/api/models/account.py b/bot/api/models/account.py index fa54bb8..64f320b 100644 --- a/bot/api/models/account.py +++ b/bot/api/models/account.py @@ -17,3 +17,10 @@ class AccountResponse(BaseModel): lastfm_username: Optional[str] = Field( default=None, description="The user's Last.fm username, if logged in." ) + + +class UnlinkRequest(BaseModel): + service: str = Field( + description='The service to unlink from the user account.', + examples=['spotify', 'lastfm'], + ) diff --git a/bot/api/routes/account/__init__.py b/bot/api/routes/account/__init__.py index fe7a92b..c2caedd 100644 --- a/bot/api/routes/account/__init__.py +++ b/bot/api/routes/account/__init__.py @@ -1,8 +1,18 @@ from fastapi import APIRouter -from .login import get_login_url as route_login -from .me import get_logged_in_user as route_me +from .delete import delete_account +from .lastfm import redirect_to_lastfm_login +from .login import redirect_to_login +from .logout import logout +from .me import get_logged_in_user +from .spotify import redirect_to_spotify_login +from .unlink import unlink_service account_router = APIRouter(prefix='/account', tags=['account']) -account_router.add_api_route('/login', route_login, methods=['GET']) -account_router.add_api_route('/me', route_me, methods=['GET']) +account_router.add_api_route('/delete', delete_account, methods=['GET']) +account_router.add_api_route('/lastfm', redirect_to_lastfm_login, methods=['GET']) +account_router.add_api_route('/login', redirect_to_login, methods=['GET']) +account_router.add_api_route('/logout', logout, methods=['GET']) +account_router.add_api_route('/me', get_logged_in_user, methods=['GET']) +account_router.add_api_route('/spotify', redirect_to_spotify_login, methods=['GET']) +account_router.add_api_route('/unlink', unlink_service, methods=['POST']) diff --git a/bot/api/routes/account/delete.py b/bot/api/routes/account/delete.py new file mode 100644 index 0000000..959e98f --- /dev/null +++ b/bot/api/routes/account/delete.py @@ -0,0 +1,23 @@ +from typing import TYPE_CHECKING + +from fastapi import Depends +from fastapi.responses import RedirectResponse + +from bot.api.depends.database import database_dependency +from bot.api.depends.session import session_dependency + +if TYPE_CHECKING: + from bot.api.models.session import Session + from bot.database import Database + + +async def delete_account( + db: 'Database' = Depends(database_dependency), + session: 'Session' = Depends(session_dependency), +) -> RedirectResponse: + user_id = session.user_id + db.delete_oauth('lastfm', user_id) + db.delete_oauth('spotify', user_id) + db.delete_oauth('discord', user_id) + + return RedirectResponse(url='/account/logout') diff --git a/bot/api/routes/account/lastfm.py b/bot/api/routes/account/lastfm.py new file mode 100644 index 0000000..4b06377 --- /dev/null +++ b/bot/api/routes/account/lastfm.py @@ -0,0 +1,46 @@ +from fastapi import HTTPException +from fastapi.responses import RedirectResponse +from starlette.status import HTTP_500_INTERNAL_SERVER_ERROR +from yarl import URL + +from bot.utils.config import config as bot_config + + +async def redirect_to_lastfm_login() -> RedirectResponse: + api_key = bot_config.lastfm_api_key + base_url = bot_config.base_url + + if api_key is None or base_url is None: + raise HTTPException( + status_code=HTTP_500_INTERNAL_SERVER_ERROR, + detail='Missing Last.fm API key or base URL', + ) + + url = _build_url(api_key, base_url) + response = RedirectResponse(url=str(url)) + return response + + +def _build_url(api_key: str, base_url: str) -> str: + """ + Generate a state token and build the URL for the login redirect. + + Args: + api_key: The Last.fm API key + base_url: The base URL of the bot. + + Returns: + str: The URL. + """ + + url = URL.build( + scheme='https', + host='www.last.fm', + path='/api/auth', + query={ + 'api_key': api_key, + 'cb': f'{base_url}/callback/lastfm', + }, + ) + + return str(url) diff --git a/bot/api/routes/account/login.py b/bot/api/routes/account/login.py index 21d67f7..74fc126 100644 --- a/bot/api/routes/account/login.py +++ b/bot/api/routes/account/login.py @@ -1,4 +1,5 @@ from secrets import token_urlsafe +from typing import Tuple from fastapi import HTTPException from fastapi.responses import RedirectResponse @@ -7,8 +8,14 @@ from bot.utils.config import config as bot_config +DISCORD_OAUTH_SCOPES = [ + 'identify', + 'guilds', + 'email', +] -async def get_login_url() -> RedirectResponse: + +async def redirect_to_login() -> RedirectResponse: oauth_id = bot_config.discord_oauth_id base_url = bot_config.base_url @@ -18,6 +25,24 @@ async def get_login_url() -> RedirectResponse: detail='Missing Discord OAuth ID or base URL', ) + state, url = _build_url(oauth_id, base_url) + response = RedirectResponse(url=str(url)) + response.set_cookie('state', state, httponly=True, samesite='lax') + return response + + +def _build_url(oauth_id: str, base_url: str) -> Tuple[str, str]: + """ + Generate a state token and build the URL for the OAuth redirect. + + Args: + oauth_id: The Discord OAuth client ID. + base_url: The base URL of the bot. + + Returns: + Tuple[str, str]: The state token and URL. + """ + state = token_urlsafe(16) url = URL.build( @@ -27,13 +52,11 @@ async def get_login_url() -> RedirectResponse: query={ 'client_id': oauth_id, 'response_type': 'code', - 'scope': 'identify guilds email', - 'redirect_uri': f'{base_url}/oauth/discord', + 'scope': ' '.join(DISCORD_OAUTH_SCOPES), + 'redirect_uri': f'{base_url}/callback/discord', 'state': state, 'prompt': 'none', }, ) - response = RedirectResponse(url=str(url)) - response.set_cookie('state', state, httponly=True, samesite='lax') - return response + return state, str(url) diff --git a/bot/api/routes/account/logout.py b/bot/api/routes/account/logout.py new file mode 100644 index 0000000..62d3f8d --- /dev/null +++ b/bot/api/routes/account/logout.py @@ -0,0 +1,20 @@ +from typing import TYPE_CHECKING + +from fastapi import Depends, Request +from fastapi.responses import RedirectResponse + +from bot.api.depends.session import session_dependency + +if TYPE_CHECKING: + from bot.api.models.session import Session + from bot.api.utils.session import SessionManager + + +async def logout( + request: Request, + session: 'Session' = Depends(session_dependency), +) -> RedirectResponse: + session_manager: 'SessionManager' = request.app.state.session_manager + session_manager.delete_session(session.session_id) + + return RedirectResponse(url='/') diff --git a/bot/api/routes/account/me.py b/bot/api/routes/account/me.py index da9d306..64444ab 100644 --- a/bot/api/routes/account/me.py +++ b/bot/api/routes/account/me.py @@ -7,7 +7,7 @@ from fastapi import Depends from bot.api.depends.database import database_dependency -from bot.api.depends.session import session_dependency +from bot.api.depends.user import user_dependency from bot.api.models.account import AccountResponse if TYPE_CHECKING: @@ -16,7 +16,7 @@ async def get_logged_in_user( - user: 'OAuth' = Depends(session_dependency), + user: 'OAuth' = Depends(user_dependency), db: 'Database' = Depends(database_dependency), ) -> AccountResponse: spotify_username = None diff --git a/bot/api/routes/account/spotify.py b/bot/api/routes/account/spotify.py new file mode 100644 index 0000000..e645f09 --- /dev/null +++ b/bot/api/routes/account/spotify.py @@ -0,0 +1,56 @@ +from secrets import token_urlsafe +from typing import Tuple + +from fastapi import HTTPException +from fastapi.responses import RedirectResponse +from starlette.status import HTTP_500_INTERNAL_SERVER_ERROR +from yarl import URL + +from bot.api.utils.constants import SPOTIFY_OAUTH_SCOPES +from bot.utils.config import config as bot_config + + +async def redirect_to_spotify_login() -> RedirectResponse: + oauth_id = bot_config.spotify_client_id + base_url = bot_config.base_url + + if oauth_id is None or base_url is None: + raise HTTPException( + status_code=HTTP_500_INTERNAL_SERVER_ERROR, + detail='Missing Spotify OAuth ID or base URL', + ) + + state, url = _build_url(oauth_id, base_url) + response = RedirectResponse(url=str(url)) + response.set_cookie('state', state, httponly=True, samesite='lax') + return response + + +def _build_url(oauth_id: str, base_url: str) -> Tuple[str, str]: + """ + Generate a state token and build the URL for the OAuth redirect. + + Args: + oauth_id: The Spotify OAuth client ID. + base_url: The base URL of the bot. + + Returns: + Tuple[str, str]: The state token and URL. + """ + + state = token_urlsafe(16) + + url = URL.build( + scheme='https', + host='accounts.spotify.com', + path='/authorize', + query={ + 'client_id': oauth_id, + 'response_type': 'code', + 'scope': ' '.join(SPOTIFY_OAUTH_SCOPES), + 'redirect_uri': f'{base_url}/callback/spotify', + 'state': state, + }, + ) + + return state, str(url) diff --git a/bot/api/routes/account/unlink.py b/bot/api/routes/account/unlink.py new file mode 100644 index 0000000..370a924 --- /dev/null +++ b/bot/api/routes/account/unlink.py @@ -0,0 +1,29 @@ +from typing import TYPE_CHECKING + +from fastapi import Depends, HTTPException +from fastapi.responses import Response +from starlette.status import HTTP_204_NO_CONTENT, HTTP_400_BAD_REQUEST + +from bot.api.depends.database import database_dependency +from bot.api.depends.user import user_dependency +from bot.api.models.account import UnlinkRequest + +if TYPE_CHECKING: + from bot.database import Database + from bot.models.oauth import OAuth + +VALID_SERVICES = ('lastfm', 'spotify') + + +async def unlink_service( + request: UnlinkRequest, + db: 'Database' = Depends(database_dependency), + user: 'OAuth' = Depends(user_dependency), +) -> Response: + service = request.service + if service not in VALID_SERVICES: + raise HTTPException(status_code=HTTP_400_BAD_REQUEST, detail='Invalid service') + + db.delete_oauth(service, user.user_id) + + return Response(status_code=HTTP_204_NO_CONTENT) diff --git a/bot/api/routes/callback/__init__.py b/bot/api/routes/callback/__init__.py new file mode 100644 index 0000000..b5ae7c6 --- /dev/null +++ b/bot/api/routes/callback/__init__.py @@ -0,0 +1,10 @@ +from fastapi import APIRouter + +from .discord import discord_callback +from .lastfm import lastfm_callback +from .spotify import spotify_callback + +callback_router = APIRouter(prefix='/callback', tags=['oauth']) +callback_router.add_api_route('/discord', discord_callback, methods=['GET']) +callback_router.add_api_route('/lastfm', lastfm_callback, methods=['GET']) +callback_router.add_api_route('/spotify', spotify_callback, methods=['GET']) diff --git a/bot/api/routes/oauth/discord.py b/bot/api/routes/callback/discord.py similarity index 98% rename from bot/api/routes/oauth/discord.py rename to bot/api/routes/callback/discord.py index e1ba9e7..4202dd3 100644 --- a/bot/api/routes/oauth/discord.py +++ b/bot/api/routes/callback/discord.py @@ -16,7 +16,7 @@ from bot.database import Database -async def discord_oauth( +async def discord_callback( request: Request, response: Response, code: str, @@ -83,7 +83,7 @@ def _exchange_code_for_token( 'client_secret': client_secret, 'grant_type': 'authorization_code', 'code': code, - 'redirect_uri': f'{base_url}/oauth/discord', + 'redirect_uri': f'{base_url}/callback/discord', }, headers={ 'Content-Type': 'application/x-www-form-urlencoded', diff --git a/bot/api/routes/callback/lastfm.py b/bot/api/routes/callback/lastfm.py new file mode 100644 index 0000000..a175b0c --- /dev/null +++ b/bot/api/routes/callback/lastfm.py @@ -0,0 +1,97 @@ +from hashlib import md5 +from typing import TYPE_CHECKING, Tuple + +from fastapi import Depends, HTTPException +from fastapi.responses import RedirectResponse +from requests import HTTPError, Timeout, request +from starlette.status import HTTP_400_BAD_REQUEST, HTTP_500_INTERNAL_SERVER_ERROR + +from bot.api.depends.database import database_dependency +from bot.api.depends.user import user_dependency +from bot.models.oauth import LastfmAuth, OAuth +from bot.utils.config import config as bot_config +from bot.utils.constants import LASTFM_API_BASE_URL, USER_AGENT + +if TYPE_CHECKING: + from bot.database import Database + + +async def lastfm_callback( + token: str, + db: 'Database' = Depends(database_dependency), + user: OAuth = Depends(user_dependency), +) -> RedirectResponse: + api_key = bot_config.lastfm_api_key + secret = bot_config.lastfm_shared_secret + + if api_key is None or secret is None: + raise HTTPException( + status_code=HTTP_500_INTERNAL_SERVER_ERROR, + detail='Missing Last.fm API key or shared secret', + ) + + session_key_url = _create_session_key_url(api_key, token, secret) + session_key, username = _get_session_key(session_key_url) + _store_user_info(db, user, session_key, username) + + return RedirectResponse(url='/') + + +def _create_session_key_url(api_key: str, token: str, secret: str) -> str: + signature = ''.join( + ['api_key', api_key, 'method', 'auth.getSession', 'token', token, secret] + ) + + hashed = md5(signature.encode('utf-8')).hexdigest() + + url = LASTFM_API_BASE_URL.with_query( + { + 'method': 'auth.getSession', + 'api_key': api_key, + 'token': token, + 'api_sig': hashed, + 'format': 'json', + } + ) + + return str(url) + + +def _get_session_key(url: str) -> Tuple[str, str]: + response = request( + 'GET', + url, + headers={ + 'User-Agent': USER_AGENT, + }, + timeout=5, + ) + + try: + response.raise_for_status() + except HTTPError as err: + raise HTTPException( + status_code=HTTP_400_BAD_REQUEST, + detail=f'Error getting session key: {err}', + ) + except Timeout: + raise HTTPException( + status_code=HTTP_500_INTERNAL_SERVER_ERROR, + detail='Timed out while requesting access token', + ) + + data = response.json() + session_key = data['session']['key'] + username = data['session']['name'] + + return session_key, username + + +def _store_user_info(db: 'Database', user: OAuth, session_key: str, username: str): + db.set_lastfm_credentials( + LastfmAuth( + user_id=user.user_id, + username=username, + session_key=session_key, + ), + ) diff --git a/bot/api/routes/callback/spotify.py b/bot/api/routes/callback/spotify.py new file mode 100644 index 0000000..dc86e89 --- /dev/null +++ b/bot/api/routes/callback/spotify.py @@ -0,0 +1,136 @@ +from base64 import b64encode +from datetime import UTC, datetime +from typing import TYPE_CHECKING, Tuple + +from fastapi import Depends, HTTPException, Request, Response +from fastapi.responses import RedirectResponse +from requests import HTTPError, Timeout, post +from starlette.status import HTTP_400_BAD_REQUEST, HTTP_500_INTERNAL_SERVER_ERROR + +from bot.api.depends.database import database_dependency +from bot.api.depends.user import user_dependency +from bot.models.oauth import OAuth +from bot.utils.config import config as bot_config +from bot.utils.constants import DISCORD_API_BASE_URL, USER_AGENT + +if TYPE_CHECKING: + from bot.database import Database + + +async def spotify_callback( # noqa: PLR0913 + request: Request, + response: Response, + code: str, + state: str, + db: 'Database' = Depends(database_dependency), + user: OAuth = Depends(user_dependency), +) -> RedirectResponse: + _validate_state(request, response, state=state) + + oauth_id = bot_config.spotify_client_id + oauth_secret = bot_config.spotify_client_secret + base_url = bot_config.base_url + if oauth_id is None or oauth_secret is None or base_url is None: + raise HTTPException( + status_code=HTTP_500_INTERNAL_SERVER_ERROR, + detail='Missing Spotify OAuth ID, secret, or base URL', + ) + + access_token, refresh_token, expiration_time, scopes = _exchange_code_for_token( + oauth_id, oauth_secret, base_url, code + ) + _store_user_info(db, user, access_token, refresh_token, expiration_time, scopes) + + return RedirectResponse(url=base_url) + + +def _validate_state(request: Request, response: Response, state: str) -> str: + expected_state = request.cookies.get('state') + if expected_state is None: + raise HTTPException( + status_code=HTTP_400_BAD_REQUEST, + detail='Missing state cookie', + ) + + if state != expected_state: + raise HTTPException( + status_code=HTTP_400_BAD_REQUEST, + detail='Invalid state', + ) + + response.delete_cookie('state') + return state + + +def _exchange_code_for_token( + client_id: str, client_secret: str, base_url: str, code: str +) -> Tuple[str, str, int, str]: + """ + Exchange the code for an access token. + + Returns: + Tuple[str, str, int, str]: The access token, refresh token, + the token expiration timestamp, and the list of authorized + scopes. + """ + + response = post( + str(DISCORD_API_BASE_URL / 'token'), + data={ + 'grant_type': 'authorization_code', + 'code': code, + 'redirect_uri': f'{base_url}/callback/spotify', + }, + headers={ + 'Authorization': f'Basic {b64encode(f"{client_id}:{client_secret}".encode()).decode()}', + 'Content-Type': 'application/x-www-form-urlencoded', + 'User-Agent': USER_AGENT, + }, + timeout=5, + ) + + try: + response.raise_for_status() + except HTTPError as err: + raise HTTPException( + status_code=HTTP_400_BAD_REQUEST, detail=f'Error getting access token: {err}' + ) + except Timeout: + raise HTTPException( + status_code=HTTP_500_INTERNAL_SERVER_ERROR, + detail='Timed out while requesting access token', + ) + + data = response.json() + access_token = data['access_token'] + refresh_token = data['refresh_token'] + expires_in = data['expires_in'] + scopes = data['scope'] + expiration_time = int(datetime.now(UTC).timestamp()) + expires_in + + return access_token, refresh_token, expiration_time, scopes + + +def _store_user_info( # noqa: PLR0913 + db: 'Database', + user: OAuth, + access_token: str, + refresh_token: str, + expiration_time: int, + scopes: str, +): + db.set_oauth( + 'spotify', + OAuth( + user_id=user.user_id, + username=user.username, + access_token=access_token, + refresh_token=refresh_token, + expires_at=expiration_time, + ), + ) + + db.set_spotify_scopes( + user.user_id, + scopes.split(' '), + ) diff --git a/bot/api/routes/oauth/__init__.py b/bot/api/routes/oauth/__init__.py deleted file mode 100644 index 9ae9cce..0000000 --- a/bot/api/routes/oauth/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -from fastapi import APIRouter - -from .discord import discord_oauth as route_discord - -oauth_router = APIRouter(prefix='/oauth', tags=['oauth']) -oauth_router.add_api_route('/discord', route_discord, methods=['GET']) diff --git a/bot/api/utils/constants.py b/bot/api/utils/constants.py new file mode 100644 index 0000000..144fc07 --- /dev/null +++ b/bot/api/utils/constants.py @@ -0,0 +1,7 @@ +SPOTIFY_OAUTH_SCOPES = [ + 'user-read-private', # Get username + 'user-read-email', # Also for username, weirdly + 'user-library-modify', # Add/remove Liked Songs + 'user-top-read', # Get top tracks, for recommendations/radio + 'playlist-read-private', # Get owned playlists +] From 017206702747b924c695052ad9396b6f32e4355b Mon Sep 17 00:00:00 2001 From: Jared Dantis Date: Sun, 14 Apr 2024 16:38:38 +0800 Subject: [PATCH 32/33] spotify_client: Fix format string --- bot/utils/spotify_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/utils/spotify_client.py b/bot/utils/spotify_client.py index 9f4ba4c..e070db9 100644 --- a/bot/utils/spotify_client.py +++ b/bot/utils/spotify_client.py @@ -354,7 +354,7 @@ def search(self, query: str, search_type: str) -> List[SpotifyResult]: # Include artist name and release date in track results results = [ SpotifyResult( - name=f"{entity['name']} ' f'({human_readable_time(entity['duration_ms'])})", + name=f"{entity['name']} ({human_readable_time(entity['duration_ms'])})", description=f"{entity['artists'][0]['name']} - " f"{entity['album']['name']} ", spotify_id=entity['id'], From ba67eac8b87b1c799a3d29d5a25c30979a3738e2 Mon Sep 17 00:00:00 2001 From: Jared Dantis Date: Sun, 14 Apr 2024 16:39:51 +0800 Subject: [PATCH 33/33] bot: Remove old aiohttp server --- bot/server/__init__.py | 15 ---- bot/server/main.py | 64 --------------- bot/server/routes.py | 40 ---------- bot/server/views/dashboard.py | 50 ------------ bot/server/views/deleteaccount.py | 25 ------ bot/server/views/discordoauth.py | 106 ------------------------- bot/server/views/homepage.py | 20 ----- bot/server/views/lastfmtoken.py | 79 ------------------- bot/server/views/linklastfm.py | 32 -------- bot/server/views/linkspotify.py | 54 ------------- bot/server/views/login.py | 44 ----------- bot/server/views/logout.py | 20 ----- bot/server/views/robotstxt.py | 20 ----- bot/server/views/spotifyoauth.py | 113 --------------------------- bot/server/views/unlink.py | 37 --------- poetry.lock | 124 +----------------------------- pyproject.toml | 2 - 17 files changed, 1 insertion(+), 844 deletions(-) delete mode 100644 bot/server/__init__.py delete mode 100644 bot/server/main.py delete mode 100644 bot/server/routes.py delete mode 100644 bot/server/views/dashboard.py delete mode 100644 bot/server/views/deleteaccount.py delete mode 100644 bot/server/views/discordoauth.py delete mode 100644 bot/server/views/homepage.py delete mode 100644 bot/server/views/lastfmtoken.py delete mode 100644 bot/server/views/linklastfm.py delete mode 100644 bot/server/views/linkspotify.py delete mode 100644 bot/server/views/login.py delete mode 100644 bot/server/views/logout.py delete mode 100644 bot/server/views/robotstxt.py delete mode 100644 bot/server/views/spotifyoauth.py delete mode 100644 bot/server/views/unlink.py diff --git a/bot/server/__init__.py b/bot/server/__init__.py deleted file mode 100644 index 09d70ec..0000000 --- a/bot/server/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -""" -Nextcord extension that runs the server for the bot. -""" - -from bot.utils.blanco import BlancoBot - -from .main import run_app - - -def setup(bot: 'BlancoBot'): - """ - Run the web server as an async task. - """ - assert bot.config is not None - bot.loop.create_task(run_app(bot.database, bot.config)) diff --git a/bot/server/main.py b/bot/server/main.py deleted file mode 100644 index 4169bb1..0000000 --- a/bot/server/main.py +++ /dev/null @@ -1,64 +0,0 @@ -""" -Main module for the web server. -""" - -from base64 import urlsafe_b64decode -from typing import TYPE_CHECKING - -import aiohttp_jinja2 -import jinja2 -from aiohttp import web -from aiohttp.abc import AbstractAccessLogger -from aiohttp_session import setup as setup_sessions -from aiohttp_session.cookie_storage import EncryptedCookieStorage -from cryptography.fernet import Fernet - -from bot.utils.logger import create_logger - -from .routes import setup_routes - -if TYPE_CHECKING: - from bot.database import Database - from bot.models.config import Config - - -class AccessLogger(AbstractAccessLogger): - """ - Custom access logger that logs the response status code, request method, - path, and time taken to process the request. - """ - - def log(self, request, response, time): - log_fmt = 'Server: %s %s %s (took %.2f ms)' - self.logger.info( - log_fmt, response.status, request.method, request.path, time * 1000 - ) - - -async def run_app(database: 'Database', config: 'Config'): - """ - Run the web server. - """ - # Create logger - logger = create_logger('server') - - # Create app - app = web.Application() - app['db'] = database - app['config'] = config - - # Setup sessions - fernet_key = Fernet.generate_key() - setup_sessions(app, EncryptedCookieStorage(urlsafe_b64decode(fernet_key))) - - # Setup templates and routes - aiohttp_jinja2.setup(app, loader=jinja2.FileSystemLoader('dashboard/templates')) - setup_routes(app) - - # Run app - runner = web.AppRunner(app, access_log=logger, access_log_class=AccessLogger) - await runner.setup() - site = web.TCPSite(runner, port=config.server_port) - await site.start() - - logger.info('Web server listening on %s', config.base_url) diff --git a/bot/server/routes.py b/bot/server/routes.py deleted file mode 100644 index 3bc310f..0000000 --- a/bot/server/routes.py +++ /dev/null @@ -1,40 +0,0 @@ -""" -Adds routes to the application. -""" - -from typing import TYPE_CHECKING - -from .views.dashboard import dashboard -from .views.deleteaccount import delete_account -from .views.discordoauth import discordoauth -from .views.homepage import homepage -from .views.lastfmtoken import lastfm_token -from .views.linklastfm import link_lastfm -from .views.linkspotify import link_spotify -from .views.login import login -from .views.logout import logout -from .views.robotstxt import robotstxt -from .views.spotifyoauth import spotifyoauth -from .views.unlink import unlink - -if TYPE_CHECKING: - from aiohttp.web import Application - - -def setup_routes(app: 'Application'): - """ - Add all available routes to the application. - """ - app.router.add_get('/', homepage) - app.router.add_get('/dashboard', dashboard) - app.router.add_get('/deleteaccount', delete_account) - app.router.add_get('/discordoauth', discordoauth) - app.router.add_get('/lastfmtoken', lastfm_token) - app.router.add_get('/linklastfm', link_lastfm) - app.router.add_get('/linkspotify', link_spotify) - app.router.add_get('/login', login) - app.router.add_get('/logout', logout) - app.router.add_get('/robots.txt', robotstxt) - app.router.add_get('/spotifyoauth', spotifyoauth) - app.router.add_get('/unlink', unlink) - app.router.add_static('/static/', path='dashboard/static', name='static') diff --git a/bot/server/views/dashboard.py b/bot/server/views/dashboard.py deleted file mode 100644 index 203cc58..0000000 --- a/bot/server/views/dashboard.py +++ /dev/null @@ -1,50 +0,0 @@ -""" -Dashboard view. -""" - -from typing import TYPE_CHECKING - -import aiohttp_jinja2 -from aiohttp import web -from aiohttp_session import get_session - -if TYPE_CHECKING: - from bot.models.oauth import LastfmAuth, OAuth - - -@aiohttp_jinja2.template('dashboard.html') -async def dashboard(request: web.Request): - """ - Render the dashboard. - """ - # Get session - session = await get_session(request) - if 'user_id' not in session: - return web.HTTPFound('/login') - - # Get user info - database = request.app['db'] - user: OAuth = database.get_oauth('discord', session['user_id']) - if user is None: - return web.HTTPFound('/login') - - # Get Spotify info - spotify_username = None - spotify: OAuth = database.get_oauth('spotify', session['user_id']) - if spotify is not None: - spotify_username = spotify.username - - # Get Last.fm info - lastfm_username = None - lastfm: LastfmAuth = database.get_lastfm_credentials(session['user_id']) - if lastfm is not None: - lastfm_username = lastfm.username - - # Render template - return { - 'username': user.username, - 'spotify_logged_in': spotify is not None, - 'spotify_username': spotify_username, - 'lastfm_logged_in': lastfm is not None, - 'lastfm_username': lastfm_username, - } diff --git a/bot/server/views/deleteaccount.py b/bot/server/views/deleteaccount.py deleted file mode 100644 index d11f7dc..0000000 --- a/bot/server/views/deleteaccount.py +++ /dev/null @@ -1,25 +0,0 @@ -""" -Delete account view (empty, redirects to logout). -""" - -from aiohttp import web -from aiohttp_session import get_session - - -async def delete_account(request: web.Request): - """ - Delete user data from all tables and redirect to logout. - """ - # Get session - session = await get_session(request) - if 'user_id' not in session: - return web.HTTPFound('/login') - - # Delete user data from all tables - database = request.app['db'] - database.delete_oauth('discord', session['user_id']) - database.delete_oauth('spotify', session['user_id']) - database.delete_oauth('lastfm', session['user_id']) - - # Redirect to logout - return web.HTTPFound('/logout') diff --git a/bot/server/views/discordoauth.py b/bot/server/views/discordoauth.py deleted file mode 100644 index be6932d..0000000 --- a/bot/server/views/discordoauth.py +++ /dev/null @@ -1,106 +0,0 @@ -""" -Discord OAuth2 token view. Displayed on redirect from Discord auth flow. -""" - -from time import time - -import requests -from aiohttp import web -from aiohttp_session import get_session -from requests.exceptions import HTTPError, Timeout - -from bot.models.oauth import OAuth -from bot.utils.constants import DISCORD_API_BASE_URL, USER_AGENT - - -async def discordoauth(request: web.Request): # noqa: PLR0911 - """ - Exchange the code for an access token and store it in the database. - - TODO: Refactor to have fewer returns. - """ - # Get session - session = await get_session(request) - - # Get state - if 'state' not in session: - return web.HTTPBadRequest(text='Missing state, try logging in again.') - state = session['state'] - - # Get OAuth ID, secret, and base URL - oauth_id = request.app['config'].discord_oauth_id - oauth_secret = request.app['config'].discord_oauth_secret - base_url = request.app['config'].base_url - - # Get code - try: - code = request.query['code'] - state = request.query['state'] - except KeyError as err: - return web.HTTPBadRequest(text=f'Missing parameter: {err.args[0]}') - - # Check state - if state != session['state']: - return web.HTTPBadRequest(text='Invalid state, try logging in again.') - - # Get access token - response = requests.post( - str(DISCORD_API_BASE_URL / 'oauth2/token'), - data={ - 'client_id': oauth_id, - 'client_secret': oauth_secret, - 'grant_type': 'authorization_code', - 'code': code, - 'redirect_uri': f'{base_url}/discordoauth', - }, - headers={ - 'Content-Type': 'application/x-www-form-urlencoded', - 'User-Agent': USER_AGENT, - }, - timeout=5, - ) - try: - response.raise_for_status() - except HTTPError as err: - return web.HTTPBadRequest(text=f'Error getting access token: {err}') - except Timeout: - return web.HTTPBadRequest(text='Timed out while requesting access token') - - # Get user info - parsed = response.json() - user_info = requests.get( - str(DISCORD_API_BASE_URL / 'users/@me'), - headers={ - 'Authorization': f"Bearer {parsed['access_token']}", - 'User-Agent': USER_AGENT, - }, - timeout=5, - ) - try: - user_info.raise_for_status() - except HTTPError as err: - return web.HTTPBadRequest(text=f'Error getting user info: {err}') - except Timeout: - return web.HTTPBadRequest(text='Timed out while requesting user info') - - # Calculate expiry timestamp - user_parsed = user_info.json() - expires_at = int(time()) + parsed['expires_in'] - - # Store user info in DB - database = request.app['db'] - database.set_oauth( - 'discord', - OAuth( - user_id=user_parsed['id'], - username=user_parsed['username'], - access_token=parsed['access_token'], - refresh_token=parsed['refresh_token'], - expires_at=expires_at, - ), - ) - - # Redirect to dashboard - del session['state'] - session['user_id'] = user_parsed['id'] - return web.HTTPFound('/dashboard') diff --git a/bot/server/views/homepage.py b/bot/server/views/homepage.py deleted file mode 100644 index 0ba0a63..0000000 --- a/bot/server/views/homepage.py +++ /dev/null @@ -1,20 +0,0 @@ -""" -Homepage view. -""" - -import aiohttp_jinja2 -from aiohttp import web -from aiohttp_session import get_session - - -@aiohttp_jinja2.template('homepage.html') -async def homepage(request: web.Request): - """ - Render the homepage, or redirect to the dashboard if the user is logged in. - """ - # Get session - session = await get_session(request) - if 'user_id' in session: - return web.HTTPFound('/dashboard') - - return {} diff --git a/bot/server/views/lastfmtoken.py b/bot/server/views/lastfmtoken.py deleted file mode 100644 index 7c89879..0000000 --- a/bot/server/views/lastfmtoken.py +++ /dev/null @@ -1,79 +0,0 @@ -""" -Last.fm token view. Displayed on redirect from Last.fm auth flow. -""" - -from hashlib import md5 - -import requests -from aiohttp import web -from aiohttp_session import get_session -from requests.exceptions import HTTPError, Timeout - -from bot.models.oauth import LastfmAuth -from bot.utils.constants import LASTFM_API_BASE_URL, USER_AGENT - - -async def lastfm_token(request: web.Request): - """ - Exchange the token for a session key and store it in the database. - """ - # Get session - session = await get_session(request) - - # Get state and Discord user ID - if 'user_id' not in session: - return web.HTTPBadRequest(text='You are not logged into Blanco with Discord.') - user_id = session['user_id'] - - # Get API key and secret - api_key = request.app['config'].lastfm_api_key - secret = request.app['config'].lastfm_shared_secret - - # Get token - try: - token = request.query['token'] - except KeyError: - return web.HTTPBadRequest(text='Missing token, try logging in again.') - - # Create signature - signature = ''.join( - ['api_key', api_key, 'method', 'auth.getSession', 'token', token, secret] - ) - hashed = md5(signature.encode('utf-8')).hexdigest() - - # Get session key - url = LASTFM_API_BASE_URL.with_query( - { - 'method': 'auth.getSession', - 'api_key': api_key, - 'token': token, - 'api_sig': hashed, - 'format': 'json', - } - ) - - # Get response - response = requests.get(str(url), headers={'User-Agent': USER_AGENT}, timeout=5) - try: - response.raise_for_status() - except HTTPError as err: - return web.HTTPBadRequest(text=f'Error logging into Last.fm: {err}') - except Timeout: - return web.HTTPBadRequest(text='Timed out while requesting session key') - - # Get JSON - json = response.json() - try: - session_key = json['session']['key'] - username = json['session']['name'] - except KeyError as err: - return web.HTTPBadRequest(text=f'Error logging into Last.fm: missing {err.args[0]}') - - # Store user info in DB - database = request.app['db'] - database.set_lastfm_credentials( - LastfmAuth(user_id=user_id, username=username, session_key=session_key) - ) - - # Redirect to dashboard - return web.HTTPFound('/dashboard') diff --git a/bot/server/views/linklastfm.py b/bot/server/views/linklastfm.py deleted file mode 100644 index cdc41b4..0000000 --- a/bot/server/views/linklastfm.py +++ /dev/null @@ -1,32 +0,0 @@ -""" -Last.fm auth view. -""" - -from aiohttp import web -from aiohttp_session import get_session -from yarl import URL - - -async def link_lastfm(request: web.Request): - """ - Redirect to Last.fm auth flow. - """ - # Create session - session = await get_session(request) - - # Check if user is logged in - if 'user_id' not in session: - return web.HTTPFound('/login') - - # Get API key and base URL - api_key = request.app['config'].lastfm_api_key - base_url = request.app['config'].base_url - - # Redirect to Last.fm - url = URL.build( - scheme='https', - host='www.last.fm', - path='/api/auth', - query={'api_key': api_key, 'cb': f'{base_url}/lastfmtoken'}, - ) - return web.HTTPFound(url) diff --git a/bot/server/views/linkspotify.py b/bot/server/views/linkspotify.py deleted file mode 100644 index 50eb910..0000000 --- a/bot/server/views/linkspotify.py +++ /dev/null @@ -1,54 +0,0 @@ -""" -Spotify auth view. -""" - -from secrets import choice -from string import ascii_letters, digits - -from aiohttp import web -from aiohttp_session import get_session -from yarl import URL - - -async def link_spotify(request: web.Request): - """ - Construct a Spotify OAuth2 authorization URL and redirect to it. - """ - # Create session - session = await get_session(request) - - # Check if user is logged in - if 'user_id' not in session: - return web.HTTPFound('/login') - - # Get OAuth ID and base URL - oauth_id = request.app['config'].spotify_client_id - base_url = request.app['config'].base_url - - # Generate and store state - state = ''.join(choice(ascii_letters + digits) for _ in range(16)) - session['state'] = state - - # Build URL - scopes = [ - 'user-read-private', # Get username - 'user-read-email', # Also for username, weirdly - 'user-library-modify', # Add/remove Liked Songs - 'user-top-read', # Get top tracks, for recommendations/radio - 'playlist-read-private', # Get owned playlists - ] - url = URL.build( - scheme='https', - host='accounts.spotify.com', - path='/authorize', - query={ - 'client_id': oauth_id, - 'response_type': 'code', - 'scope': ' '.join(scopes), - 'redirect_uri': f'{base_url}/spotifyoauth', - 'state': state, - }, - ) - - # Redirect to Discord - return web.HTTPFound(url) diff --git a/bot/server/views/login.py b/bot/server/views/login.py deleted file mode 100644 index 80dfbbe..0000000 --- a/bot/server/views/login.py +++ /dev/null @@ -1,44 +0,0 @@ -""" -Login view. -""" - -from secrets import choice -from string import ascii_letters, digits - -from aiohttp import web -from aiohttp_session import get_session -from yarl import URL - - -async def login(request: web.Request): - """ - Construct a Discord OAuth2 authorization URL and redirect to it. - """ - # Create session - session = await get_session(request) - - # Get OAuth ID and base URL - oauth_id = request.app['config'].discord_oauth_id - base_url = request.app['config'].base_url - - # Generate and store state - state = ''.join(choice(ascii_letters + digits) for _ in range(16)) - session['state'] = state - - # Build URL - url = URL.build( - scheme='https', - host='discord.com', - path='/api/oauth2/authorize', - query={ - 'client_id': oauth_id, - 'response_type': 'code', - 'scope': 'identify guilds email', - 'redirect_uri': f'{base_url}/discordoauth', - 'state': state, - 'prompt': 'none', - }, - ) - - # Redirect to Discord - return web.HTTPFound(url) diff --git a/bot/server/views/logout.py b/bot/server/views/logout.py deleted file mode 100644 index d7399f6..0000000 --- a/bot/server/views/logout.py +++ /dev/null @@ -1,20 +0,0 @@ -""" -Logout view. -""" - -from aiohttp import web -from aiohttp_session import get_session - - -async def logout(request: web.Request): - """ - Clear the session and redirect to home. - """ - # Get session - session = await get_session(request) - - # Clear session - session.clear() - - # Redirect to home - return web.HTTPFound('/') diff --git a/bot/server/views/robotstxt.py b/bot/server/views/robotstxt.py deleted file mode 100644 index 7e7f932..0000000 --- a/bot/server/views/robotstxt.py +++ /dev/null @@ -1,20 +0,0 @@ -""" -View for robots.txt -""" - -from aiohttp import web - - -async def robotstxt(_: web.Request): - """ - Return robots.txt - """ - return web.Response( - text='\n'.join( - [ - 'User-agent: *', - 'Allow: /$', # Allow homepage - 'Disallow: /', # Disallow everything else - ] - ) - ) diff --git a/bot/server/views/spotifyoauth.py b/bot/server/views/spotifyoauth.py deleted file mode 100644 index 0d0b7f8..0000000 --- a/bot/server/views/spotifyoauth.py +++ /dev/null @@ -1,113 +0,0 @@ -""" -Spotify OAuth view. Displayed on redirect from Spotify auth flow. -""" - -from base64 import b64encode -from time import time - -import requests -from aiohttp import web -from aiohttp_session import get_session -from requests.exceptions import HTTPError, Timeout - -from bot.models.oauth import OAuth -from bot.utils.constants import ( - SPOTIFY_ACCOUNTS_BASE_URL, - SPOTIFY_API_BASE_URL, - USER_AGENT, -) - - -async def spotifyoauth(request: web.Request): # noqa: PLR0911 - """ - Exchange the code for an access token and store it in the database. - - TODO: Refactor to have fewer returns. - """ - # Get session - session = await get_session(request) - - # Get state and Discord user ID - if 'state' not in session: - return web.HTTPBadRequest(text='Missing state, try logging in again.') - if 'user_id' not in session: - return web.HTTPBadRequest(text='You are not logged into Blanco with Discord.') - state = session['state'] - user_id = session['user_id'] - - # Get OAuth ID, secret, and base URL - oauth_id = request.app['config'].spotify_client_id - oauth_secret = request.app['config'].spotify_client_secret - base_url = request.app['config'].base_url - - # Get code - try: - code = request.query['code'] - state = request.query['state'] - except KeyError as err: - return web.HTTPBadRequest(text=f'Missing parameter: {err.args[0]}') - - # Check state - if state != session['state']: - return web.HTTPBadRequest(text='Invalid state, try logging in again.') - - # Get access token - response = requests.post( - str(SPOTIFY_ACCOUNTS_BASE_URL / 'token'), - data={ - 'grant_type': 'authorization_code', - 'code': code, - 'redirect_uri': f'{base_url}/spotifyoauth', - }, - headers={ - 'Authorization': f'Basic {b64encode(f"{oauth_id}:{oauth_secret}".encode()).decode()}', - 'Content-Type': 'application/x-www-form-urlencoded', - 'User-Agent': USER_AGENT, - }, - timeout=5, - ) - try: - response.raise_for_status() - except HTTPError as err: - return web.HTTPBadRequest(text=f'Error getting Spotify access token: {err}') - except Timeout: - return web.HTTPBadRequest(text='Timed out while requesting Spotify access token') - - # Get user info - parsed = response.json() - user_info = requests.get( - str(SPOTIFY_API_BASE_URL / 'me'), - headers={ - 'Authorization': f"Bearer {parsed['access_token']}", - 'User-Agent': USER_AGENT, - }, - timeout=5, - ) - try: - user_info.raise_for_status() - except HTTPError as err: - return web.HTTPBadRequest(text=f'Error getting Spotify user info: {err}') - except Timeout: - return web.HTTPBadRequest(text='Timed out while requesting Spotify user info') - - # Calculate expiry timestamp - user_parsed = user_info.json() - expires_at = int(time()) + parsed['expires_in'] - - # Store user info in DB - database = request.app['db'] - database.set_oauth( - 'spotify', - OAuth( - user_id=user_id, - username=user_parsed['id'], - access_token=parsed['access_token'], - refresh_token=parsed['refresh_token'], - expires_at=expires_at, - ), - ) - database.set_spotify_scopes(user_id, parsed['scope'].split(' ')) - - # Redirect to dashboard - del session['state'] - return web.HTTPFound('/dashboard') diff --git a/bot/server/views/unlink.py b/bot/server/views/unlink.py deleted file mode 100644 index 0190f17..0000000 --- a/bot/server/views/unlink.py +++ /dev/null @@ -1,37 +0,0 @@ -""" -Account unlinking view. -""" - -from aiohttp import web -from aiohttp_session import get_session - - -async def unlink(request: web.Request): - """ - Delete authentication data for the user from the specified service. - """ - # Get session - session = await get_session(request) - if 'user_id' not in session: - return web.HTTPFound('/login') - user_id = session['user_id'] - - # Get user info - database = request.app['db'] - user = database.get_oauth('discord', user_id) - if user is None: - return web.HTTPFound('/login') - - # Which service to unlink? - try: - service = request.query['service'] - except KeyError as err: - return web.HTTPBadRequest(text=f'Missing parameter: {err.args[0]}') - - if service not in ('lastfm', 'spotify'): - raise web.HTTPBadRequest(text=f'Unknown service: {service}') - - database.delete_oauth(service, user_id) - - # Redirect to dashboard - return web.HTTPFound('/dashboard') diff --git a/poetry.lock b/poetry.lock index 034bce0..ca1dda9 100644 --- a/poetry.lock +++ b/poetry.lock @@ -95,42 +95,6 @@ yarl = ">=1.0,<2.0" [package.extras] speedups = ["Brotli", "aiodns", "brotlicffi"] -[[package]] -name = "aiohttp-jinja2" -version = "1.6" -description = "jinja2 template renderer for aiohttp.web (http server for asyncio)" -optional = false -python-versions = ">=3.8" -files = [ - {file = "aiohttp-jinja2-1.6.tar.gz", hash = "sha256:a3a7ff5264e5bca52e8ae547bbfd0761b72495230d438d05b6c0915be619b0e2"}, - {file = "aiohttp_jinja2-1.6-py3-none-any.whl", hash = "sha256:0df405ee6ad1b58e5a068a105407dc7dcc1704544c559f1938babde954f945c7"}, -] - -[package.dependencies] -aiohttp = ">=3.9.0" -jinja2 = ">=3.0.0" - -[[package]] -name = "aiohttp-session" -version = "2.12.0" -description = "sessions for aiohttp.web" -optional = false -python-versions = ">=3.7" -files = [ - {file = "aiohttp-session-2.12.0.tar.gz", hash = "sha256:0ccd11a7c77cb9e5a61f4daacdc9170d561112f9cfaf9e9a2d9867c0587d1950"}, - {file = "aiohttp_session-2.12.0-py3-none-any.whl", hash = "sha256:f0bf0caa2f5b5a56cb50a45f98d61f60d8523322099a2857410530149706f5e5"}, -] - -[package.dependencies] -aiohttp = ">=3.8" - -[package.extras] -aiomcache = ["aiomcache (>=0.5.2)"] -aioredis = ["redis (>=4.3.1)"] -pycrypto = ["cryptography"] -pynacl = ["pynacl"] -secure = ["cryptography"] - [[package]] name = "aiosignal" version = "1.3.1" @@ -731,23 +695,6 @@ files = [ {file = "idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca"}, ] -[[package]] -name = "jinja2" -version = "3.1.3" -description = "A very fast and expressive template engine." -optional = false -python-versions = ">=3.7" -files = [ - {file = "Jinja2-3.1.3-py3-none-any.whl", hash = "sha256:7d6d50dd97d52cbc355597bd845fabfbac3f551e1f99619e39a35ce8c370b5fa"}, - {file = "Jinja2-3.1.3.tar.gz", hash = "sha256:ac8bd6544d4bb2c9792bf3a159e80bba8fda7f07e81bc3aed565432d5925ba90"}, -] - -[package.dependencies] -MarkupSafe = ">=2.0" - -[package.extras] -i18n = ["Babel (>=2.7)"] - [[package]] name = "mafic" version = "2.10.0" @@ -766,75 +713,6 @@ yarl = ">=1.0.0,<2.0.0" [package.extras] speedups = ["orjson (>=3.8.0,<4.0.0)"] -[[package]] -name = "markupsafe" -version = "2.1.5" -description = "Safely add untrusted strings to HTML/XML markup." -optional = false -python-versions = ">=3.7" -files = [ - {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc"}, - {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5"}, - {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61659ba32cf2cf1481e575d0462554625196a1f2fc06a1c777d3f48e8865d46"}, - {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2174c595a0d73a3080ca3257b40096db99799265e1c27cc5a610743acd86d62f"}, - {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae2ad8ae6ebee9d2d94b17fb62763125f3f374c25618198f40cbb8b525411900"}, - {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:075202fa5b72c86ad32dc7d0b56024ebdbcf2048c0ba09f1cde31bfdd57bcfff"}, - {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:598e3276b64aff0e7b3451b72e94fa3c238d452e7ddcd893c3ab324717456bad"}, - {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fce659a462a1be54d2ffcacea5e3ba2d74daa74f30f5f143fe0c58636e355fdd"}, - {file = "MarkupSafe-2.1.5-cp310-cp310-win32.whl", hash = "sha256:d9fad5155d72433c921b782e58892377c44bd6252b5af2f67f16b194987338a4"}, - {file = "MarkupSafe-2.1.5-cp310-cp310-win_amd64.whl", hash = "sha256:bf50cd79a75d181c9181df03572cdce0fbb75cc353bc350712073108cba98de5"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:629ddd2ca402ae6dbedfceeba9c46d5f7b2a61d9749597d4307f943ef198fc1f"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5b7b716f97b52c5a14bffdf688f971b2d5ef4029127f1ad7a513973cfd818df2"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ec585f69cec0aa07d945b20805be741395e28ac1627333b1c5b0105962ffced"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b91c037585eba9095565a3556f611e3cbfaa42ca1e865f7b8015fe5c7336d5a5"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7502934a33b54030eaf1194c21c692a534196063db72176b0c4028e140f8f32c"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0e397ac966fdf721b2c528cf028494e86172b4feba51d65f81ffd65c63798f3f"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c061bb86a71b42465156a3ee7bd58c8c2ceacdbeb95d05a99893e08b8467359a"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3a57fdd7ce31c7ff06cdfbf31dafa96cc533c21e443d57f5b1ecc6cdc668ec7f"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-win32.whl", hash = "sha256:397081c1a0bfb5124355710fe79478cdbeb39626492b15d399526ae53422b906"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-win_amd64.whl", hash = "sha256:2b7c57a4dfc4f16f7142221afe5ba4e093e09e728ca65c51f5620c9aaeb9a617"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-win32.whl", hash = "sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-win_amd64.whl", hash = "sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb"}, - {file = "MarkupSafe-2.1.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c8b29db45f8fe46ad280a7294f5c3ec36dbac9491f2d1c17345be8e69cc5928f"}, - {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec6a563cff360b50eed26f13adc43e61bc0c04d94b8be985e6fb24b81f6dcfdf"}, - {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a549b9c31bec33820e885335b451286e2969a2d9e24879f83fe904a5ce59d70a"}, - {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4f11aa001c540f62c6166c7726f71f7573b52c68c31f014c25cc7901deea0b52"}, - {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:7b2e5a267c855eea6b4283940daa6e88a285f5f2a67f2220203786dfa59b37e9"}, - {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:2d2d793e36e230fd32babe143b04cec8a8b3eb8a3122d2aceb4a371e6b09b8df"}, - {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:ce409136744f6521e39fd8e2a24c53fa18ad67aa5bc7c2cf83645cce5b5c4e50"}, - {file = "MarkupSafe-2.1.5-cp37-cp37m-win32.whl", hash = "sha256:4096e9de5c6fdf43fb4f04c26fb114f61ef0bf2e5604b6ee3019d51b69e8c371"}, - {file = "MarkupSafe-2.1.5-cp37-cp37m-win_amd64.whl", hash = "sha256:4275d846e41ecefa46e2015117a9f491e57a71ddd59bbead77e904dc02b1bed2"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:656f7526c69fac7f600bd1f400991cc282b417d17539a1b228617081106feb4a"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:97cafb1f3cbcd3fd2b6fbfb99ae11cdb14deea0736fc2b0952ee177f2b813a46"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f3fbcb7ef1f16e48246f704ab79d79da8a46891e2da03f8783a5b6fa41a9532"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa9db3f79de01457b03d4f01b34cf91bc0048eb2c3846ff26f66687c2f6d16ab"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffee1f21e5ef0d712f9033568f8344d5da8cc2869dbd08d87c84656e6a2d2f68"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:5dedb4db619ba5a2787a94d877bc8ffc0566f92a01c0ef214865e54ecc9ee5e0"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:30b600cf0a7ac9234b2638fbc0fb6158ba5bdcdf46aeb631ead21248b9affbc4"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8dd717634f5a044f860435c1d8c16a270ddf0ef8588d4887037c5028b859b0c3"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-win32.whl", hash = "sha256:daa4ee5a243f0f20d528d939d06670a298dd39b1ad5f8a72a4275124a7819eff"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-win_amd64.whl", hash = "sha256:619bc166c4f2de5caa5a633b8b7326fbe98e0ccbfacabd87268a2b15ff73a029"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7a68b554d356a91cce1236aa7682dc01df0edba8d043fd1ce607c49dd3c1edcf"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:db0b55e0f3cc0be60c1f19efdde9a637c32740486004f20d1cff53c3c0ece4d2"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e53af139f8579a6d5f7b76549125f0d94d7e630761a2111bc431fd820e163b8"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17b950fccb810b3293638215058e432159d2b71005c74371d784862b7e4683f3"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c31f53cdae6ecfa91a77820e8b151dba54ab528ba65dfd235c80b086d68a465"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:bff1b4290a66b490a2f4719358c0cdcd9bafb6b8f061e45c7a2460866bf50c2e"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:bc1667f8b83f48511b94671e0e441401371dfd0f0a795c7daa4a3cd1dde55bea"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5049256f536511ee3f7e1b3f87d1d1209d327e818e6ae1365e8653d7e3abb6a6"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-win32.whl", hash = "sha256:00e046b6dd71aa03a41079792f8473dc494d564611a8f89bbbd7cb93295ebdcf"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-win_amd64.whl", hash = "sha256:fa173ec60341d6bb97a89f5ea19c85c5643c1e7dedebc22f5181eb73573142c5"}, - {file = "MarkupSafe-2.1.5.tar.gz", hash = "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b"}, -] - [[package]] name = "multidict" version = "6.0.5" @@ -2017,4 +1895,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "1153e82398e9ba4378633e0f3b899529335b31a5b0936af0c8dd84eccfebdfbb" +content-hash = "ebec7f6e9df00834d9935209d7b743946ce08fff493214ca083fc977b752fcbd" diff --git a/pyproject.toml b/pyproject.toml index c4c2550..20f1ff2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,8 +9,6 @@ packages = [{ include = "bot" }] [tool.poetry.dependencies] python = "^3.11" -aiohttp-jinja2 = "^1.6" -aiohttp-session = "^2.12.0" cryptography = "^42.0.5" mafic = "^2.10.0" nextcord = "^2.6.0"