Compare commits

..

422 Commits
1.0.1 ... main

Author SHA1 Message Date
cloudwithax 9bffdebe25 2.10.0 2025-10-04 00:00:57 -04:00
cloudwithax 720ba187ab 2.10.0 2025-10-04 00:00:01 -04:00
cloudwithax 855bf4e0d7 2.9.2 2024-11-21 21:11:24 -05:00
cloudwithax cd579becad fixed file playing and recursion issue in queue looping 2024-11-21 21:06:32 -05:00
cloudwithax 3a1ecf9eec 2.9.1 2024-08-23 21:18:25 -04:00
clxud 5227962228
Merge pull request #69 from ZandercraftGames/fix/other-sources
Fix Support for Other Source and Playlist Types
2024-08-23 21:04:04 -04:00
Zander be7106616b
Typing fix from NiceAesth
Co-authored-by: Andrei Baciu <8437201+NiceAesth@users.noreply.github.com>
2024-08-18 00:34:43 -04:00
Zander ba9534bc27
Typing fix from NiceAesth
Co-authored-by: Andrei Baciu <8437201+NiceAesth@users.noreply.github.com>
2024-08-18 00:34:32 -04:00
pre-commit-ci[bot] 2e0f5b365a [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2024-08-17 05:44:34 +00:00
Zander M. 851f00aa97
Add support for other unsupported playlist types 2024-08-17 01:23:59 -04:00
Zander M. 817295d321
Add support for other unsupported source types 2024-08-17 00:46:32 -04:00
Zander M. 8ab3ae9ccd
Add websockets dependency to Pipenv 2024-08-17 00:44:51 -04:00
cloudwithax 094f2be181 refactor: set original track if search type is not defined in Player's play_track method 2024-06-10 21:54:21 -04:00
cloudwithax b60a6aec18 refactor: guard check for search type to prevent nulled search types getting searched 2024-06-10 21:30:57 -04:00
cloudwithax 80f7b77cd3 refactor: update search_type handling in Player and Node classes to be nullish to support lavasrc 2024-06-10 21:20:59 -04:00
cloudwithax 8679d6d125 Merge branch 'main' of https://github.com/cloudwithax/pomice 2024-06-10 21:17:57 -04:00
cloudwithax ad01407fff refactor: update query handling in Node class 2024-06-10 21:17:53 -04:00
Clxud f1609f7049
Merge pull request #67 from ZandercraftGames/fix/load-exceptions
Fix KeyError in exception handling on error when loading a track.
2024-03-27 22:10:37 -04:00
pre-commit-ci[bot] 5fcfc73901 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2024-03-28 02:08:22 +00:00
Zander M. 86b35106b2
Fix KeyError in exception handling on error when loading a track. 2024-03-27 22:04:39 -04:00
Clxud 519a14fbde
Merge pull request #66 from ZandercraftGames/main
Fix build_track failure with Lavalink v4 decodetrack format
2024-03-13 10:16:31 -04:00
Zander M. 9a42093f64
Merge remote-tracking branch 'origin/main' 2024-03-11 13:50:22 -04:00
Zander M. 347a6e0b96
Refactor sourceName to use track_info object. 2024-03-11 13:50:07 -04:00
pre-commit-ci[bot] 83d5add134 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2024-03-11 17:29:47 +00:00
Zander M. bb12e33584
Python Black Format 2024-03-11 13:24:54 -04:00
Zander M. 6817cd8e07
Fix build_track failure with Lavalink v4 decodetrack format. 2024-03-11 13:23:42 -04:00
Clxud 179472bd6e
Merge pull request #63 from NiceAesth/fix-assert
fix: remove unnecessary assert
2024-02-22 11:00:05 -05:00
NiceAesth ba761743b9 fix: remove unnecessary assert
Hit this in production (https://sunnycord.sentry.io/share/issue/e39efaaa16d64b4fbf4e3ec409406971/)
The assert is unnecessary since track is typed as optional either way.
2024-02-19 20:43:58 +02:00
cloudwithax b3795102b8 2.9.0 2024-02-06 17:32:17 -05:00
cloudwithax 2a492c793f fix issues related to loading files/http links, added spotify recommendation querying, changed loglevel enum behavior 2024-02-06 17:31:51 -05:00
cloudwithax 705ac9feab 2.8.1 2024-02-01 22:03:52 -05:00
cloudwithax a926616028 actually make logging optional lol 2024-02-01 22:03:30 -05:00
cloudwithax 4507b50b8b 2.8.0 2024-02-01 21:12:34 -05:00
cloudwithax bd78f47585 fix logging and made spotify + apple music optional because of v4 2024-02-01 21:11:42 -05:00
cloudwithax 9b18759864 update voice channel on state update + dont make logging enabled by default 2024-01-28 15:59:41 -05:00
Clxud 001b801a15
Merge pull request #57 from cloudwithax/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2023-11-05 20:33:45 -05:00
pre-commit-ci[bot] db1c66dd40 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2023-10-30 17:50:55 +00:00
pre-commit-ci[bot] 341164a0d2
[pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/pre-commit/pre-commit-hooks: v4.4.0 → v4.5.0](https://github.com/pre-commit/pre-commit-hooks/compare/v4.4.0...v4.5.0)
- [github.com/psf/black: 23.7.0 → 23.10.1](https://github.com/psf/black/compare/23.7.0...23.10.1)
- [github.com/asottile/blacken-docs: 1.15.0 → 1.16.0](https://github.com/asottile/blacken-docs/compare/1.15.0...1.16.0)
- [github.com/asottile/pyupgrade: v3.10.1 → v3.15.0](https://github.com/asottile/pyupgrade/compare/v3.10.1...v3.15.0)
- [github.com/asottile/reorder-python-imports: v3.10.0 → v3.12.0](https://github.com/asottile/reorder-python-imports/compare/v3.10.0...v3.12.0)
- [github.com/asottile/add-trailing-comma: v3.0.1 → v3.1.0](https://github.com/asottile/add-trailing-comma/compare/v3.0.1...v3.1.0)
- [github.com/hadialqattan/pycln: v2.2.2 → v2.3.0](https://github.com/hadialqattan/pycln/compare/v2.2.2...v2.3.0)
2023-10-30 17:50:45 +00:00
Clxud 7829086ae3
Merge pull request #59 from corpnewt/patch-1
Account for Lavalink v4 changes when loading YT playlists
2023-09-17 11:33:42 -04:00
pre-commit-ci[bot] f9cb48c48f [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2023-09-15 23:28:59 +00:00
CorpNewt 3401b669e8
Fix for YT URL searches on Lavalink v4
Since the prior code for v3 uses list comprehension to build the tracks returned, we can check if we're using v4 and if the data[data_type] is a dictionary, and wrap it in a list to ensure the same behavior.
2023-09-15 18:28:50 -05:00
CorpNewt d7a7efb051
Account for Lavalink v4 changes when loading YT playlists 2023-09-15 10:35:56 -05:00
cloudwithax 0904196979 ver bump 2023-08-23 13:15:54 -04:00
Clxud 7617ecf2d1
Merge pull request #58 from NiceAesth/fix-resume
fix: undefined data in `_configure_resuming`
2023-08-23 13:14:56 -04:00
NiceAesth 1acc594467 fix: undefined data in `_configure_resuming` 2023-08-23 20:00:36 +03:00
cloudwithax e48c31b7a9 Merge branch 'main' of https://github.com/cloudwithax/pomice 2023-08-23 10:45:04 -04:00
cloudwithax f3c5461854 patch load types and track events for v4 2023-08-23 10:44:51 -04:00
Clxud aa826c7da2
Merge pull request #56 from cloudwithax/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2023-08-16 01:26:36 -04:00
pre-commit-ci[bot] bc71088092
[pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/hadialqattan/pycln: v2.2.1 → v2.2.2](https://github.com/hadialqattan/pycln/compare/v2.2.1...v2.2.2)
2023-08-14 21:23:03 +00:00
cloudwithax 7eca4724da Merge branch 'main' of https://github.com/cloudwithax/pomice 2023-08-08 16:28:20 -04:00
cloudwithax b098b681be add missing dep for rtd 2023-08-08 16:28:11 -04:00
Clxud c52a379b87
Merge pull request #55 from cloudwithax/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2023-08-07 20:51:22 -04:00
pre-commit-ci[bot] 50b5eab860 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2023-08-07 21:21:47 +00:00
pre-commit-ci[bot] 18fed3a089
[pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/hadialqattan/pycln: v2.2.0 → v2.2.1](https://github.com/hadialqattan/pycln/compare/v2.2.0...v2.2.1)
2023-08-07 21:21:38 +00:00
Clxud ab432cc8e6
Merge pull request #54 from cloudwithax/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2023-07-31 20:11:56 -04:00
pre-commit-ci[bot] 223be29384 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2023-07-31 21:42:43 +00:00
pre-commit-ci[bot] c5f8ded0b1
[pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/psf/black: 23.3.0 → 23.7.0](https://github.com/psf/black/compare/23.3.0...23.7.0)
- [github.com/asottile/blacken-docs: 1.14.0 → 1.15.0](https://github.com/asottile/blacken-docs/compare/1.14.0...1.15.0)
- [github.com/asottile/pyupgrade: v3.7.0 → v3.10.1](https://github.com/asottile/pyupgrade/compare/v3.7.0...v3.10.1)
- [github.com/asottile/add-trailing-comma: v2.5.1 → v3.0.1](https://github.com/asottile/add-trailing-comma/compare/v2.5.1...v3.0.1)
- [github.com/hadialqattan/pycln: v2.1.5 → v2.2.0](https://github.com/hadialqattan/pycln/compare/v2.1.5...v2.2.0)
2023-07-31 21:42:32 +00:00
Clxud 0b1d36cf64
Merge pull request #52 from cloudwithax/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2023-06-19 17:09:32 -04:00
pre-commit-ci[bot] 1f20ebf6c6
[pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/asottile/blacken-docs: 1.13.0 → 1.14.0](https://github.com/asottile/blacken-docs/compare/1.13.0...1.14.0)
- [github.com/asottile/pyupgrade: v3.6.0 → v3.7.0](https://github.com/asottile/pyupgrade/compare/v3.6.0...v3.7.0)
- [github.com/asottile/reorder-python-imports: v3.9.0 → v3.10.0](https://github.com/asottile/reorder-python-imports/compare/v3.9.0...v3.10.0)
2023-06-19 21:02:01 +00:00
Clxud af5418c958
Merge pull request #51 from cloudwithax/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2023-06-12 18:10:23 -04:00
pre-commit-ci[bot] 6670da76e8 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2023-06-12 20:45:28 +00:00
pre-commit-ci[bot] 69d3bc9ce1
[pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/asottile/pyupgrade: v3.4.0 → v3.6.0](https://github.com/asottile/pyupgrade/compare/v3.4.0...v3.6.0)
- [github.com/asottile/add-trailing-comma: v2.4.0 → v2.5.1](https://github.com/asottile/add-trailing-comma/compare/v2.4.0...v2.5.1)
- [github.com/hadialqattan/pycln: v2.1.3 → v2.1.5](https://github.com/hadialqattan/pycln/compare/v2.1.3...v2.1.5)
2023-06-12 20:45:17 +00:00
cloudwithax e3fe1b52b2
2.7.0 2023-05-21 10:43:09 -04:00
cloudwithax 02d22f20b5
edit filters, log level matches handler, other fixes 2023-05-21 10:42:43 -04:00
cloudwithax cbb676e004
Merge branch 'main' of https://github.com/cloudwithax/pomice 2023-05-19 19:50:32 -04:00
cloudwithax 2d8acf7800
patch build track to escape chars properly 2023-05-19 19:50:10 -04:00
Clxud 481b2079ed
Merge pull request #50 from cloudwithax/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2023-05-08 17:18:17 -04:00
pre-commit-ci[bot] 952a3eff14
[pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/asottile/pyupgrade: v3.3.2 → v3.4.0](https://github.com/asottile/pyupgrade/compare/v3.3.2...v3.4.0)
- https://github.com/asottile/reorder_python_importshttps://github.com/asottile/reorder-python-imports
2023-05-08 20:53:40 +00:00
cloudwithax 4fc9bd8810
remove ctx comparison check from track __eq__ 2023-05-08 10:41:47 -04:00
cloudwithax 28db38a00e
2.6.0 2023-05-07 19:27:44 -04:00
cloudwithax 00ac166371
fix websocket issue and add node resuming 2023-05-07 19:27:11 -04:00
cloudwithax dd3d43e702
2.5.1 2023-05-03 19:48:48 -04:00
cloudwithax 334d74095e
remove handling of videos in playlists 2023-05-03 19:47:37 -04:00
cloudwithax b461b91587
fix attribute error with spotify/am client 2023-05-03 19:45:46 -04:00
cloudwithax 56843c459c
2.5.0 2023-05-01 21:03:32 -04:00
cloudwithax d5cf16ac63
Merge branch 'main' of https://github.com/cloudwithax/pomice 2023-05-01 21:01:30 -04:00
cloudwithax 394e3a3907
stop using async with and make external clients use node session 2023-05-01 21:01:12 -04:00
Clxud 380266f2c3
Merge pull request #48 from cloudwithax/pre-commit-ci-update-config 2023-05-01 17:40:01 -04:00
pre-commit-ci[bot] 4e720e3dc9
[pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/asottile/pyupgrade: v3.3.1 → v3.3.2](https://github.com/asottile/pyupgrade/compare/v3.3.1...v3.3.2)
2023-05-01 20:39:59 +00:00
cloudwithax b91f6ec04e
add has filter type 2023-05-01 07:42:58 -04:00
cloudwithax 248cce6656
make debug logs for node more verbose 2023-05-01 01:20:10 -04:00
cloudwithax d23fe6b8a4
would be nice if you actually followed semver 2023-05-01 01:04:32 -04:00
cloudwithax 665d6c13a3
fix apple music 2023-05-01 01:02:43 -04:00
cloudwithax f823786029
remove discord.py req 2023-04-29 14:06:44 -04:00
cloudwithax e69349bca8
reflect type changes 2023-04-25 11:19:08 -04:00
Clxud 5445661f42
Merge pull request #46 from SCARTAL/patch-4
`spotify_client_id` data type correction
2023-04-21 19:11:15 -04:00
SCARTAL b75d2f580c
type changes mirrored in function body 2023-04-22 01:14:04 +03:30
pre-commit-ci[bot] bb835fb173 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2023-04-21 21:39:38 +00:00
SCARTAL b0ef03d2d1
`spotify_id` data type correction
Spotify ids contain chars, entering str data will not cause errors but it has to change to avoid confusion.
2023-04-22 01:06:35 +03:30
Clxud f9bf268c89
Update player.md 2023-04-21 12:20:01 -04:00
Clxud dfc516f8bd
Merge pull request #45 from PanIntegralus/patch-1
Port must be an integer
2023-04-21 12:11:27 -04:00
PanIntegralus 00370cfbc7
Port must be an integer 2023-04-21 18:03:56 +02:00
cloudwithax 6ba2ea1d6d
Merge branch 'main' of https://github.com/cloudwithax/pomice 2023-04-21 10:55:30 -04:00
cloudwithax 2cab4cb7d0
update examples 2023-04-21 10:55:11 -04:00
Clxud 77a7246b6a
Merge pull request #42 from SCARTAL/patch-1 2023-04-21 10:23:40 -04:00
SCARTAL 2461cbb831
port must be int 2023-04-21 17:43:18 +03:30
cloudwithax 42d886554e 2.4.1 2023-04-06 20:28:21 -04:00
cloudwithax e7c627dcd2 fix move to and swap node 2023-04-06 20:27:59 -04:00
cloudwithax 5c71e9a562 2.4 2023-04-05 23:00:12 -04:00
cloudwithax ab374d4ba8 added type checking exception for port number in node 2023-04-05 22:58:30 -04:00
cloudwithax 02b62d493f update README 2023-04-05 22:52:41 -04:00
cloudwithax 4caaff8b04 add move_to in player class 2023-04-05 22:15:32 -04:00
cloudwithax 74256dc5ac Merge branch 'main' of https://github.com/cloudwithax/pomice 2023-04-04 22:19:42 -04:00
cloudwithax bf144a783c fix isrcs not being correctly parsed 2023-04-04 22:19:35 -04:00
Clxud 0b0b50f259
Merge pull request #41 from cloudwithax/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2023-04-03 14:54:46 -07:00
pre-commit-ci[bot] cf3834d5c2
[pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/psf/black: 23.1.0 → 23.3.0](https://github.com/psf/black/compare/23.1.0...23.3.0)
2023-04-03 21:24:35 +00:00
Clxud 3a8e622f89
Update events.md 2023-03-30 20:24:33 -04:00
cloudwithax 2ddbb5d91a 2.3.2 2023-03-27 00:10:43 -04:00
cloudwithax b73af37bbf add local sources, fix queue jumping on loop 2023-03-27 00:08:46 -04:00
cloudwithax 6ed2fd961b update docs 2023-03-17 22:15:13 -04:00
cloudwithax c88f020280 2.3.1 2023-03-13 19:45:12 -04:00
cloudwithax 206adbd70b logging was broken if you didnt set a handler 2023-03-13 19:44:46 -04:00
cloudwithax 14ba273d35 fix event tracks 2023-03-13 19:37:29 -04:00
Clxud 6d96a9e53d
Merge pull request #39 from NiceAesth/fix
feat: allow custom logging handler; fix: missing typings
2023-03-13 18:53:28 -04:00
cloudwithax 45d3e611a5 fix track objs in track events to be optional 2023-03-13 18:53:01 -04:00
NiceAesth 9c262c7455 feat: use regex for version matching 2023-03-13 16:21:56 +02:00
NiceAesth a8a586bfb1 fix: clear handlers on add 2023-03-13 15:43:24 +02:00
NiceAesth b0e0bba27b fix: add handler if exists 2023-03-13 15:35:01 +02:00
NiceAesth 0d78b00342 fix: type __call__ function 2023-03-13 15:30:25 +02:00
NiceAesth 367a215b05 feat: allow disabling logging handler; fix: code quality 2023-03-13 15:15:27 +02:00
cloudwithax dde6e3711c 2.3 2023-03-12 23:00:23 -04:00
cloudwithax b561f272a5 update __all__ 2023-03-12 22:58:49 -04:00
cloudwithax b1ec08026d update docs 2023-03-12 22:57:38 -04:00
cloudwithax 5676d35681 make loglevel enum use IntEnum 2023-03-12 22:51:36 -04:00
cloudwithax 3ea426b44b update __slots__ 2023-03-12 22:39:31 -04:00
cloudwithax a7da475c0c add log_level param to create_node, add name to logger 2023-03-12 22:36:36 -04:00
cloudwithax 52a45afd70 add logging and fix other things 2023-03-12 22:30:43 -04:00
cloudwithax c2b952438a Merge branch 'main' of https://github.com/cloudwithax/pomice 2023-03-12 20:51:22 -04:00
cloudwithax c35fd650f7 fix player channel bug and fix endtime 2023-03-12 20:51:05 -04:00
Clxud 7b82d6c81a
Create .github/workflows/python-publish.yml 2023-03-12 18:56:26 -04:00
Clxud 8ee1a39cb5
Merge pull request #37 from NiceAesth/add-rates
feat: add rates
2023-03-12 11:44:00 -04:00
NiceAesth 5b036d843a fix: set channel to none and ignore type errors 2023-03-12 03:37:47 +02:00
NiceAesth cc571f17f2 feat: add adjusted_length 2023-03-12 02:39:26 +02:00
NiceAesth 6c7f06ec25 feat: add rates 2023-03-12 02:37:03 +02:00
Clxud 5aea4bcaf2
Merge pull request #36 from NiceAesth/position-hotfix
fix: clean up `Player.position()`
2023-03-11 11:27:19 -05:00
NiceAesth e8d29f9263 fix: clean up `Player.position()` 2023-03-11 18:24:28 +02:00
cloudwithax 9f102ae3b8 2.2 2023-03-11 10:42:53 -05:00
cloudwithax 4f86e44fec fix formatting 2023-03-11 10:41:46 -05:00
cloudwithax 8860df99de change autopep pre commit to black 2023-03-11 10:24:45 -05:00
Clxud 3949c5b1a6
Merge pull request #35 from NiceAesth/typing
feat: add typing; add makefile; add pipfile
2023-03-11 10:21:23 -05:00
NiceAesth 4ce4c6205f fix: player skipping position 2023-03-11 16:17:16 +02:00
NiceAesth 39bde67d06 fix: docs, slots 2023-03-11 15:57:37 +02:00
NiceAesth e30ec92c34 fix: pre-commit 2023-03-11 15:48:00 +02:00
NiceAesth 987de07fc5 feat: add typing; add makefile; add pipfile 2023-03-11 15:45:09 +02:00
cloudwithax 481e616414 switch formatting to black 2023-03-11 00:22:38 -05:00
cloudwithax a4a49c249e revert error message to original format 2023-03-11 00:07:05 -05:00
cloudwithax 834e5bde62 add nodepool.disconnect() to hdi guide 2023-03-10 23:58:53 -05:00
cloudwithax 19c06301e7 update docs to reflect new changes 2023-03-10 23:51:11 -05:00
cloudwithax 2ded9d6205 add node fallback, wrap up 2.2 2023-03-10 23:44:51 -05:00
cloudwithax 9e0a5e0ad0 add slots to all classes that require them 2023-03-10 21:50:23 -05:00
cloudwithax 9dc8a9098e edit pool.disconnect to be a classmethod 2023-03-10 20:47:03 -05:00
Clxud 145634ce79
Merge pull request #34 from NiceAesth/close-clients
feat: add close to clients; style: formatting pass
2023-03-10 20:44:01 -05:00
NiceAesth 4564e89b4e feat: add `disconnect()` to node pool 2023-03-10 15:57:33 +02:00
NiceAesth c5ca63b014 feat: add close to clients; style: formatting pass 2023-03-10 15:35:41 +02:00
cloudwithax 458b686769 remove import 2023-03-10 06:59:28 -05:00
cloudwithax c58786ed3f fix spotify/am clients and fix grabbing sessionid 2023-03-10 06:54:20 -05:00
cloudwithax d9137f6b29 use own abstract event loop to execute tasks 2023-03-09 23:30:55 -05:00
cloudwithax 7b91e717ac im never using asyncio.create_task again 2023-03-09 23:15:30 -05:00
Clxud 9f30e90da2 revert _update_state to use time.time 2023-03-10 00:25:36 +00:00
Clxud 4c3cd7e81a make websocket version dynamic 2023-03-09 23:08:32 +00:00
Clxud f0726cddde create session class on connect call 2023-03-09 22:50:59 +00:00
Clxud fa8a444bf6 make type optional 2023-03-09 22:42:13 +00:00
Clxud 5c6ae99e80
Update exceptions.py 2023-03-09 17:21:37 -05:00
Clxud 739e926d09 added __all__ to appliciable classes 2023-03-09 17:10:52 +00:00
Clxud c9a331b278 start typehinting and correcting the library 2023-03-09 15:40:50 +00:00
cloudwithax 31d4e1aca2 Merge branch 'main' of https://github.com/cloudwithax/pomice 2023-03-09 08:24:36 -05:00
cloudwithax de7385d8ff fix a couple of outstanding bugs 2023-03-09 08:24:26 -05:00
Clxud d5db276b90
Merge pull request #33 from xemulat/patch-1 2023-03-08 09:38:07 -05:00
xemulat 70eb449d44
Update the lavalink link 2023-03-08 14:28:17 +00:00
Clxud 9d831d3ecd typo 2023-03-03 17:11:02 +00:00
Clxud 0d3a96e82c finally finished with the docs 2023-03-03 16:56:01 +00:00
Clxud 952ffa2e63 correct formatting 2023-03-03 16:17:04 +00:00
Clxud 0ccbe42c06 forgot to add ending block for table 2023-03-03 16:08:02 +00:00
Clxud 38afb3a501 almost finished with new docs 2023-03-03 16:05:22 +00:00
cloudwithax 7fbf201d30 add improved source lines 2023-02-28 20:26:24 -05:00
Clxud e9c2d053d3 fix link code 2023-02-28 18:52:02 +00:00
Clxud f05df85dae add more to events 2023-02-28 18:50:37 +00:00
Clxud e5af15a237 remove build dir 2023-02-28 17:13:38 +00:00
Clxud 8071a85533
Update .gitignore 2023-02-28 12:12:55 -05:00
Clxud 5da841cba8 finsh player and started events 2023-02-28 17:11:35 +00:00
Clxud a1b3d5bb1b
Update .gitignore 2023-02-28 11:42:42 -05:00
Clxud 317e84b234
Update .gitignore 2023-02-28 11:40:33 -05:00
Clxud 90f9242ddb add double dot sys path to conf 2023-02-28 16:31:58 +00:00
cloudwithax c7da354d68 get new docs uploaded to finish later 2023-02-28 11:16:58 -05:00
cloudwithax e0ff5f13a4 bump version to remove debug print from stable 2023-02-21 08:24:09 -05:00
cloudwithax b3799214e6 remove debug print 2023-02-21 08:23:35 -05:00
cloudwithax d63e1f61c5 2.1 2023-02-20 04:07:10 -05:00
cloudwithax 2c380671e9 Merge branch 'main' of https://github.com/cloudwithax/pomice 2023-02-17 23:54:24 -05:00
cloudwithax 12cbccc2a9 add queue jump 2023-02-17 23:54:14 -05:00
Clxud 090111886b
Update README.md 2023-02-15 21:10:59 -05:00
cloudwithax 3de406e684 make minimum python version 3.8 again 2023-02-14 20:13:46 -05:00
cloudwithax f2c0210e05 remove notice 2023-02-13 20:48:55 -05:00
cloudwithax 6374e49d65 make readme look better 2023-02-13 20:46:50 -05:00
cloudwithax d4493dd12c grr i hate rtd 2023-02-10 18:57:01 -05:00
cloudwithax 4d5005256b dammit git 2023-02-10 18:14:51 -05:00
cloudwithax 905b2b01c4 remove other modules from rst 2023-02-10 18:13:46 -05:00
cloudwithax c703478505 Revert "correct rtd config"
This reverts commit 75cce026e2.
2023-02-10 18:10:59 -05:00
cloudwithax 75cce026e2 correct rtd config 2023-02-10 08:29:16 -05:00
cloudwithax 2a07f5cb60 update readthedocs stuff 2023-02-10 08:26:07 -05:00
cloudwithax f3981ecf7f Merge branch 'main' of https://github.com/cloudwithax/pomice 2023-02-09 22:38:58 -05:00
cloudwithax f104c7a2aa fix 'no apple music client' error 2023-02-09 22:38:43 -05:00
Clxud 4da800db75
Update README.md 2023-02-06 18:55:06 -05:00
cloudwithax 3714656f0e add version comparison 2023-02-05 23:16:48 -05:00
cloudwithax 85da3602a6 Merge branch 'main' into v2 2023-02-05 23:02:05 -05:00
cloudwithax 15d0f316a1 cleam up code 2023-02-05 22:56:58 -05:00
cloudwithax 14c82c1b56 wrap up apple music, v2 is done 2023-02-05 22:51:56 -05:00
cloudwithax c1a9d7603f manually sync out of date changes into v2 2023-02-05 13:27:43 -05:00
cloudwithax d08f07ffdc get objects in place for apple music client 2023-02-05 13:08:49 -05:00
cloudwithax 809bb4aa3f get basic code for the apple music client in place and working 2023-02-03 19:22:49 -05:00
cloudwithax 25c6d399e8 fix some issues + add auth to send() 2023-02-01 23:00:55 -05:00
cloudwithax 73dbc35401 correct get url 2023-02-01 22:13:44 -05:00
cloudwithax 0089caea09 whoopsie forgot to make that a rest uri 2023-02-01 22:12:21 -05:00
cloudwithax fc2b12af4e finally done translating all methods over to REST 2023-02-01 22:06:54 -05:00
cloudwithax 6b513d1e67 getting ready for pomice v2 2023-01-24 13:58:51 -05:00
cloudwithax ad91a8e2ae add spotify recommendations 2022-12-27 22:23:38 -05:00
cloudwithax 0d00fe9f36 whoops had that in the wrong place 2022-12-11 21:26:35 -05:00
cloudwithax 507a4b246b remove debug print 2022-12-11 21:22:55 -05:00
cloudwithax b42b360cfe forgot i actually had to set the timestamp variable lol 2022-12-11 21:22:31 -05:00
cloudwithax 0152b4eca0 make queue.get() not explictly return anything 2022-12-11 21:14:39 -05:00
cloudwithax 23fd49be1a add playlist video correction and timestamp retrivial for qualifying videos 2022-12-11 21:06:33 -05:00
cloudwithax 9fde02c2e0 update advanced.py cog 2022-11-19 14:28:15 -05:00
cloudwithax 70815d45ff revert version number for next release 2022-11-19 12:12:39 -05:00
cloudwithax 5bd523c0f8 change version number for hotfix 2022-11-19 12:02:27 -05:00
cloudwithax ec2f2d734f make queue.remove take Track instead of int 2022-11-13 22:01:15 -05:00
cloudwithax 8dfdf9f200 fixing local git 2022-11-13 21:49:14 -05:00
cloudwithax e897a59559 Revert "Add Filters.has_global"
This reverts commit 0e9399af3f.
2022-11-13 21:42:41 -05:00
cloudwithax cab2f2d3a1 remove debug print 2022-10-25 21:33:01 -04:00
cloudwithax c50930bc11 whoops forgot if statement 2022-10-25 21:21:13 -04:00
cloudwithax b6516f2d09 change where filters are set to preload 2022-10-25 21:20:35 -04:00
cloudwithax 4013bf94f3 change filter check 2022-10-25 21:13:47 -04:00
cloudwithax 0e9399af3f Add Filters.has_global 2022-10-25 21:08:16 -04:00
cloudwithax 0ef5db476b Make global filters take precedence over track filter 2022-10-25 21:00:20 -04:00
cloudwithax 8a9425c662 Removed unused enums/params 2022-10-12 18:13:45 -04:00
cloudwithax e504088da1 add function to clear filters on all tracks 2022-10-12 18:12:10 -04:00
cloudwithax 1e12d79d68 add per-track filter applying 2022-10-10 23:25:53 -04:00
cloudwithax c9bba65f48 fix version number 2022-10-06 19:32:58 -04:00
cloudwithax 3687f9b33a Revert "Remove all Spotify client code in preparation for 1.1.8"
This reverts commit ab708a1cfb.
2022-10-06 19:31:38 -04:00
cloudwithax 827ab0a1ef Revert "remove .original from code"
This reverts commit 216c9d8bf5.
2022-10-06 19:31:15 -04:00
cloudwithax f38671c608 Revert "Revert "refactor setup.py because 'build' wants to be a pissy whinybaby""
This reverts commit 01fb718590.
2022-10-06 19:30:23 -04:00
cloudwithax 01fb718590 Revert "refactor setup.py because 'build' wants to be a pissy whinybaby"
This reverts commit 1f7f85df93.
2022-10-06 19:28:37 -04:00
cloudwithax 186567740b update README 2022-09-09 11:23:15 -04:00
cloudwithax fe1becac62 fix docs 2022-09-09 11:17:26 -04:00
Clxud ba404e2008
Merge pull request #31 from ilkergzlkkr/main
DiscordPyOutdated for v2 below, not above
2022-09-03 20:01:14 -04:00
ilkergzlkkr c58ce76ac8 DiscordPyOutdated for v2 or below, not above 2022-09-04 02:06:11 +03:00
cloudwithax 216c9d8bf5 remove .original from code 2022-09-03 12:27:35 -04:00
cloudwithax ab708a1cfb Remove all Spotify client code in preparation for 1.1.8 2022-09-03 10:36:25 -04:00
cloudwithax 1f7f85df93 refactor setup.py because 'build' wants to be a pissy whinybaby 2022-08-29 20:06:12 -04:00
cloudwithax 0c8485971b edit DiscordPyOutdated exception 2022-08-29 19:59:51 -04:00
cloudwithax cfb4217cc0 edit requirements to include discord.py 2022-08-29 19:58:14 -04:00
Clxud e04aa9ba30
Merge pull request #30 from ilkergzlkkr/main
Never search for spotify track that searched before
2022-08-29 19:37:01 -04:00
cloudwithax fd0a2c5c26 Add queue system and fix a few issues 2022-08-09 20:03:37 -04:00
ilkergzlkkr e0e5d4b776 fix: lavalink track_id parser error 2022-07-28 20:03:35 +03:00
ilkergzlkkr 6967b6467f fix: never search for track that searched before 2022-07-21 11:49:37 +03:00
cloudwithax 6a0765d712 Fixed discord py outdated exception 2022-07-05 05:19:58 -04:00
cloudwithax 8928b7b6f2 Added versioning based off of discord.py versioning 2022-07-05 05:17:48 -04:00
cloudwithax 535c3bfe79 Fix issues with Spotify tracks not having ISRCs + others 2022-07-05 05:12:34 -04:00
cloudwithax 2c4c07a349 forgot to add voiceprotocol change 2022-06-15 16:21:37 -04:00
cloudwithax 5d70f50b3d 1.7 update part 1 2022-06-15 16:20:09 -04:00
cloudwithax 9c702876f8 1.7 update part 1 2022-06-15 16:18:40 -04:00
Clxud 99dd658dee
Merge pull request #29 from Rapptz/patch-1
Update VoiceProtocol for latest discord.py change
2022-04-20 08:27:17 -04:00
Danny b045cf3e2b
Update VoiceProtocol for latest discord.py change 2022-04-20 04:51:10 -04:00
cloudwithax 822fa1e3c4 Fix reset_filter function and some minor fixes before PRs 2022-03-28 13:18:45 -04:00
VP c0fd4fe734
Merge pull request #18 from Aakash-kun/patch-2
Added some fixes to the advanced example.
2022-01-08 07:14:08 +02:00
AakashS da2c854c7c
Update advanced.py 2022-01-08 10:34:26 +05:30
AakashS 3d659208db
Update advanced.py 2022-01-08 00:10:38 +05:30
cloudwithax 5c961b9a36 Remove hard requirement for discord.py 2021-12-28 14:23:34 -05:00
VP a228ed4ac1 revert previous commit - redundant 2021-12-28 13:49:10 +02:00
VP 8972a1c913 set _guild on voice state update just to be safe 2021-12-28 13:47:47 +02:00
VP d8388456f2 hopefully fix player.guild bs (+ cleanup) 2021-12-27 10:34:01 +02:00
VP 5acc3625a0 cleanup 2021-12-27 10:33:42 +02:00
VP ab9acdb033 change mentions of _guild to guild 2021-12-22 15:45:10 +02:00
VP 84ebca1c12 remove player._guild 2021-12-22 15:42:19 +02:00
cloudwithax 42dde4ce44 Add new node algo, NodeAlgorithm.by_players 2021-12-21 20:34:06 -05:00
cloudwithax 3ef98104ce Update advanced example to include more commands 2021-12-13 18:04:40 -05:00
cloudwithax d6186cb20d Forgot to call start_nodes in advanced example 2021-12-13 17:48:29 -05:00
cloudwithax 14c4a68af8 Set optional args to None by default 2021-12-11 12:21:44 -05:00
Clxud cd2cfa7a5d
Merge pull request #14 from RPSMain/patch-1 2021-12-08 08:08:43 -05:00
RPS a61220401c
New description 2021-12-08 21:05:42 +08:00
RPS 22be91bb09
Fix 2021-12-08 03:15:19 -08:00
RPS 0a78a8f5c7
Make ``__init__`` better 2021-12-08 02:14:53 -08:00
cloudwithax 50429277ea Fix __init__ to support discord.py forks 2021-12-06 17:58:21 -05:00
cloudwithax d1d2463680 Added new reset_filter() helper function, fixed bug with Spotify tracks not retaining thumbnails and some other QoL features 2021-12-05 16:29:01 -05:00
vveeps fcbca4e8e1 fix invalid equalizer payload 2021-11-26 17:39:52 +02:00
vveeps 148dcf2414 fix self._factory call in Equalizer init 2021-11-26 16:53:04 +02:00
cloudwithax 70d4f5a63d bump versioing for minor bug fix 2021-11-25 11:22:26 -05:00
cloudwithax 18f6f906fe why did i use a generator expression lmao 2021-11-25 11:11:25 -05:00
cloudwithax d249d84f9f added node algos, cleaned up events.py, add node arg to Player() 2021-11-23 13:59:20 -05:00
cloudwithax c3135b7798 Decrease heartbeat interval so that people can stop complaining about it not working on heroku 2021-11-22 15:00:03 -05:00
cloudwithax d5838829c4 whoops forgot to update versioning in setup.py 2021-11-19 11:13:52 -05:00
cloudwithax 106d151157 Finally fixed thumbnails 2021-11-19 11:11:51 -05:00
cloudwithax 063d2d08af why was the node.stats property async 2021-11-13 13:13:04 -05:00
cloudwithax fc71bdda1f woops forgot to add https to rest uri if the node is securre 2021-11-13 13:10:27 -05:00
cloudwithax 2e77a7d7df Added secure kwarg to create_node() and fixed some bugs 2021-11-12 21:07:50 -05:00
vveeps 3115989f42 fix Track.original and make some Playlist attrs consistent 2021-11-12 18:01:40 +02:00
cloudwithax 360889e048 Fixed attribute semantics 2021-11-11 17:31:44 -05:00
cloudwithax 11e4a34a96 Added original spotify track to pomice.Track as an attribute 2021-11-11 17:13:34 -05:00
vveeps 267fd32898 ignore playlist items with track: null 2021-11-11 16:52:31 +02:00
vveeps c13181beae don't set _current to None if track was replaced 2021-11-07 14:46:43 +02:00
vveeps d396ac44f1 use current.original in internal logic 2021-11-07 14:34:09 +02:00
vveeps 81bf0b9756 fix spotify url issue and handle local tracks properly 2021-11-06 23:07:11 +02:00
vveeps ec02cbe146 make spotify tracks seekable and change Playlist.selected_track 2021-11-06 23:05:51 +02:00
cloudwithax 02621cf669 Update README to show docs 2021-11-06 15:14:56 -04:00
cloudwithax 99766f58db Update README to show docs 2021-11-06 15:13:26 -04:00
cloudwithax 3d20c8cf5e Fixed some bugs and cleaned up some code 2021-11-04 21:06:29 -04:00
cloudwithax 43edfacda1 Revert property changes and fix bugs 2021-11-04 08:07:27 -04:00
cloudwithax a8fb8529c6 probably would be smart to specify reqs before installing the main package in rtd 2021-11-02 21:45:42 -04:00
cloudwithax 5526dcbb9c dammit readthedocs 2021-11-02 21:43:01 -04:00
cloudwithax 463e6f06f6 one more thing before i build docs 2021-11-02 21:37:04 -04:00
cloudwithax c05880080c readthedocs makes me want to kashoot myself 2021-11-02 21:33:18 -04:00
cloudwithax 752155c8bd apparently i have to add the repo to the rtd requirements too fml 2021-11-02 21:27:33 -04:00
cloudwithax 221fc4fcee had to add absolute path so it shows up right 2021-11-02 21:16:44 -04:00
cloudwithax 66f5f3620a fuck readthedocs man uggghhh 2021-11-02 21:00:47 -04:00
cloudwithax 468fc3511c Please for the love of god remove the build folder 2021-11-02 20:54:07 -04:00
cloudwithax f368adddcd added requirements so read the docs doesnt freak out 2021-11-02 20:52:44 -04:00
cloudwithax 6926ca3009 Removed /_build/ from showing up in repo 2021-11-02 20:46:15 -04:00
cloudwithax a13f9d7a0d Merge branch 'main' of https://github.com/cloudwithax/pomice 2021-11-02 20:44:07 -04:00
cloudwithax d9d958266e FINALLY ADDED DOCS 2021-11-02 20:43:21 -04:00
vveeps 046f5413bb make example a bit cleaner 2021-10-31 20:09:14 +02:00
vveeps 3e09556e11 return -> raise 2021-10-31 19:57:43 +02:00
Clxud 93653f33ec
forgot to await some funcs 2021-10-31 13:22:01 -04:00
cloudwithax c83e59e91f Merge branch 'main' of https://github.com/cloudwithax/pomice 2021-10-30 12:03:08 -04:00
cloudwithax 36bddc1de6 Added 100% coverage of all filters and exception field for TrackExceptionEvent for Lavalink 3.4 release 2021-10-30 12:02:45 -04:00
vveeps 0cac9594e4 add player.is_dead 2021-10-29 16:14:01 +03:00
cloudwithax 10506a1fbe Some code cleanup before release 2021-10-28 17:55:48 -04:00
vveeps 2e0daa4840 T 2021-10-28 18:10:01 +03:00
vveeps 8c9e1114da bump version to 1.1.0 because changes are majorish 2021-10-28 18:04:45 +03:00
vveeps b72258cb60 make player.destroy useful 2021-10-28 17:58:59 +03:00
vveeps f4ffbcd5b8 make player.destroy work even when already disconnected 2021-10-28 17:38:32 +03:00
vveeps 7fda302a31 code cleanup 2021-10-28 17:23:48 +03:00
vveeps df9ae54e6c spotify code cleanup 2021-10-28 16:48:25 +03:00
vveeps 5bc27bad58 don't stop playback on disconnect (+ change some docstrings) 2021-10-28 16:13:36 +03:00
cloudwithax 89b18e7a2a Changed versioning for PyPI release 2021-10-27 17:56:24 -04:00
vveeps d344f82325 player.current should be None after a track ends 2021-10-26 21:22:06 +03:00
vveeps fd2d5645fe improve disconnect logic a bit 2021-10-26 18:07:22 +03:00
vveeps 762160e84b dont error on imageless spotify tracks 2021-10-26 17:57:42 +03:00
vveeps 1b54aae3b3 small fix 2021-10-26 15:39:21 +03:00
vveeps 12544691eb improve play command payloads a bit (+ run autoformatting) 2021-10-26 15:32:08 +03:00
cloudwithax 2aca3a8d3a Improved Spotify playlist querying to be a little less janky 2021-10-20 19:54:39 -04:00
VP 38b40b676b cleanup (i hate single quotes) 2021-10-20 22:50:24 +03:00
VP db9f1cfba3 add param for play noReplace 2021-10-20 22:44:39 +03:00
VP 5d6c50f11e remove unused line 2021-10-20 22:31:24 +03:00
VP c782b718cb Merge branch 'main' of https://github.com/cloudwithax/pomice 2021-10-20 21:18:27 +03:00
VP 2afff11a5e handle LOAD_FAILED loadtype without erroring xd 2021-10-20 21:17:30 +03:00
VP 70c06fd12f
PR #10 (Crussader/main) / Player.__call__ 2021-10-20 21:16:41 +03:00
Crussader d7bc7a8958
updated player.py
updated with `__call__` dunder.
2021-10-20 22:12:07 +04:00
VP 7b83a9fc69 cleanup / type stuff 2021-10-20 14:36:12 +03:00
VP 584f6e5286 event rewrite 2 2021-10-20 14:36:01 +03:00
cloudwithax 7d53934697 Major Spotify impl rewrite + some other goodies 2021-10-19 20:29:24 -04:00
cloudwithax ea6c2baf3c Fix examples to better reflect changes in library 2021-10-17 17:21:24 -04:00
cloudwithax fde5f9711a Fix banner lol 2021-10-17 13:24:16 -04:00
cloudwithax ccdc1141a8 Add banner to README 2021-10-17 13:20:22 -04:00
cloudwithax 8466ab9816 Merge branch 'main' of https://github.com/cloudwithax/pomice 2021-10-17 13:19:09 -04:00
cloudwithax 849b710e35 Added banner 2021-10-17 13:18:53 -04:00
Clxud dc8af24385
Update README.md 2021-10-17 13:01:15 -04:00
cloudwithax be0a1eb699 Updated versioning for PyPI release 2021-10-17 12:18:17 -04:00
cloudwithax abfcfd04a0 Added examples and updated README 2021-10-17 12:08:37 -04:00
cloudwithax a6ffce0215 Added examples and updated README 2021-10-17 12:07:29 -04:00
vveeps b8c263166f fix websocket event class names
https://github.com/freyacodes/Lavalink/blob/master/IMPLEMENTATION.md#incoming-messages
2021-10-11 11:25:54 +03:00
cloudwithax e4ee754d30 Fixed SearchType parsing + version bump 2021-10-09 18:26:39 -04:00
vveeps a20209d5c8 make search prefix detection a bit stricter for good practice 2021-10-10 01:03:12 +03:00
vveeps e0f38e50f8 dont prepend search type if it's already there 2021-10-10 00:59:12 +03:00
cloudwithax fb07aca29e Version bump 2021-10-09 17:31:45 -04:00
cloudwithax a900f77424 Added better query parsing to get_tracks() to make it easier to search up keywords rather than having the end user implementing it themself 2021-10-09 17:30:16 -04:00
vveeps cb46150ce2 add missing context to Track.original 2021-10-10 00:26:49 +03:00
vveeps 7d2600ed7f cleanup + make some params kw only 2021-10-10 00:13:48 +03:00
vveeps 553bf9ce63 remove unused code 2021-10-09 23:58:24 +03:00
vveeps 5d53b468e3 fix track search_type bs for good 2021-10-09 23:58:15 +03:00
vveeps d9f5f202fd fix search type issue 2021-10-09 23:49:35 +03:00
vveeps 2c5e8b12b0 fix circular import issue 2021-10-09 23:14:02 +03:00
vveeps e66440e9b3 we do a little cleaning up 2021-10-09 23:03:12 +03:00
vveeps a72afa8031 Merge branch 'main' of https://github.com/cloudwithax/pomice 2021-10-09 22:44:12 +03:00
cloudwithax f6a3752298 Added Discord audio attachment URL regex to better parse audio tracks. Take that, Groovy... 2021-10-08 21:12:40 -04:00
cloudwithax e138091fcf are you fucking kidding me 2021-10-08 18:59:11 -04:00
cloudwithax d0a149793f Fixed versioning 2021-10-08 18:50:26 -04:00
cloudwithax e51e26cbf7 Fixed bug where self.current would not set to none after stop 2021-10-08 18:49:00 -04:00
cloudwithax 7343d14225 Finally fixed SearchType and cleaned up some code 2021-10-08 18:34:27 -04:00
vveeps a4c211dd82 remove unused import 2021-10-08 17:18:48 +03:00
vveeps 543e2d9b86 cleanup + searchtype stuff 2021-10-08 17:15:18 +03:00
cloudwithax c1ff8d77c0 Remove partially implemented queue from utils.py 2021-10-07 20:36:28 -04:00
cloudwithax b53fe52331 Fixed some bugs and added new SearchType enum 2021-10-07 20:34:32 -04:00
vveeps bdf0a75055 remove _ from non-internal attributes for now 2021-10-07 18:37:41 +03:00
vveeps fc9f22e5ff change all Player.node to new Player._node 2021-10-07 18:28:34 +03:00
vveeps 01af4e9417 change all Player.guild to new Player._guild 2021-10-07 18:27:17 +03:00
vveeps 980e9b5085 remove redundant assignment because node._pool is not a thing anymore 2021-10-07 18:24:22 +03:00
vveeps be592f3f2e change self to cls in classmethods 2021-10-07 18:23:58 +03:00
vveeps d753322f05 fix circular import problem 2021-10-07 18:20:43 +03:00
vveeps 77bd640d6a clean imports 2021-10-07 18:03:54 +03:00
vveeps 7588c5d5e9 move NodePool to node.py 2021-10-07 18:01:03 +03:00
vveeps 7c9dec116d bump version 2021-10-07 17:46:41 +03:00
vveeps a85dc41e68 change spotify track youtube query to "artist - title" 2021-10-07 17:46:09 +03:00
cloudwithax ec9e6929b4 Cleaned up some code and added some types 2021-10-06 19:20:46 -04:00
Clxud 134398dcbd
Merge pull request #7 from vveeps/spotify-playlist-info 2021-10-06 17:14:40 -04:00
vveeps 4d4e5bfb51 bugfix 2021-10-07 00:06:21 +03:00
vveeps cbaa67d90f add playlist thumbnail and uri 2021-10-06 23:57:34 +03:00
Clxud fe1e49182b
Merge pull request #6 from vveeps/track-eq 2021-10-06 16:08:18 -04:00
Clxud abb22b0316
Merge pull request #5 from vveeps/main 2021-10-06 16:08:10 -04:00
vveeps ece289b051 add Track.__eq__ 2021-10-06 23:01:22 +03:00
vveeps ec7368481a actually nvm let's not bother with merge conflicts 2021-10-06 21:09:56 +03:00
vveeps e4de4e9740 bump version 2021-10-06 21:08:59 +03:00
vveeps 8434d65b3c change voice event handlers to support dpy master 2021-10-06 21:04:53 +03:00
cloudwithax adbec06613 Removed redudant search term for searching up partial tracks 2021-10-03 17:05:31 -04:00
cloudwithax 423060f675 Removed redudant search term for searching up partial tracks 2021-10-03 17:04:44 -04:00
cloudwithax dbf49e6c21 Update README 2021-10-03 16:44:30 -04:00
cloudwithax 64295ed0c8 Update README 2021-10-03 16:44:07 -04:00
cloudwithax 5ff83360f2 Update README 2021-10-03 16:39:36 -04:00
Clxud 95d01338eb
Merge pull request #4 from vveeps/main
Style changes and typo fixes
2021-10-03 16:35:53 -04:00
vveeps 1a6cc5a90b fix typo 2021-10-03 23:30:59 +03:00
vveeps 032dc0dc65 style changes 2021-10-03 23:27:26 +03:00
cloudwithax 3e18be528e Update README 2021-10-03 15:48:19 -04:00
Clxud c12d65a890
Merge pull request #3 from cloudwithax/dpy2.0
merging into main
2021-10-03 15:25:50 -04:00
Clxud f6c2267e9f
Merge pull request #2 from vveeps/main
add discord.py 2.0 support
2021-10-03 15:13:46 -04:00
vveeps c0b4e1a446 add discord.py 2.0 support 2021-10-03 22:04:07 +03:00
cloudwithax afc3109b27 Updated readme 2021-10-03 13:59:10 -04:00
cloudwithax 19682c4938 Updated readme 2021-10-03 13:58:35 -04:00
cloudwithax 4638eecc67 Fixed bugs related to Spotify track queueing and searching 2021-10-03 12:05:34 -04:00
cloudwithax ad53028ac7 Fixed bugs related to Spotify track queueing and searching 2021-10-03 12:05:20 -04:00
cloudwithax 1319af5b5b Forgot to add Spotify credential arguments in create_node 2021-10-03 10:59:11 -04:00
cloudwithax 53a4576025 Updated version 1.0.2 -> 1.0.3 2021-10-03 10:54:51 -04:00
cloudwithax f83e9e9cca Added Spotify functionality and added docs to every function and property that needed it 2021-10-03 10:53:49 -04:00
cloudwithax eb7c529c14 Added spotify track queueing support 2021-10-02 20:51:05 -04:00
cloudwithax 34dcc1ec10 Merge branch 'main' of https://github.com/cloudwithax/pomice 2021-09-27 16:07:27 -04:00
cloudwithax a8314031b0 Updated player state fetching 2021-09-27 16:07:13 -04:00
57 changed files with 7055 additions and 546 deletions

12
.gitignore vendored
View File

@ -3,3 +3,15 @@
__pycache/
dist/
pomice.egg-info/
docs/_build/
build/
.gitpod.yml
.python-version
Pipfile.lock
.mypy_cache/
.vscode/
.idea/
.venv/
*.code-workspace
*.ini
.pypirc

33
.pre-commit-config.yaml Normal file
View File

@ -0,0 +1,33 @@
# See https://pre-commit.com for more information
# See https://pre-commit.com/hooks.html for more hooks
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.5.0
hooks:
- id: check-ast
- id: check-builtin-literals
- id: debug-statements
- id: end-of-file-fixer
- id: requirements-txt-fixer
- id: trailing-whitespace
- repo: https://github.com/psf/black
rev: 23.10.1
hooks:
- id: black
language_version: python3.13
- repo: https://github.com/asottile/pyupgrade
rev: v3.15.0
hooks:
- id: pyupgrade
args: [--py37-plus, --keep-runtime-typing]
- repo: https://github.com/asottile/reorder-python-imports
rev: v3.12.0
hooks:
- id: reorder-python-imports
- repo: https://github.com/asottile/add-trailing-comma
rev: v3.1.0
hooks:
- id: add-trailing-comma
default_language_version:
python: python3.13

15
.readthedocs.yaml Normal file
View File

@ -0,0 +1,15 @@
version: 2
build:
os: ubuntu-22.04
tools:
python: "3.8"
sphinx:
configuration: docs/conf.py
python:
install:
- requirements: docs/requirements_rtd.txt

18
Makefile Normal file
View File

@ -0,0 +1,18 @@
prepare:
pipenv install --dev
pipenv run pre-commit install
shell:
pipenv shell
lint:
pipenv run pre-commit run --all-files
test:
pipenv run mypy
serve-docs:
@cd docs;\
make html;\
cd _build/html;\
python -m http.server;\

21
Pipfile Normal file
View File

@ -0,0 +1,21 @@
[[source]]
url = "https://pypi.org/simple"
verify_ssl = true
name = "pypi"
[packages]
orjson = "*"
"discord.py" = {extras = ["voice"], version = "*"}
websockets = "*"
[dev-packages]
mypy = "*"
pre-commit = "*"
furo = "*"
sphinx = "*"
myst-parser = "*"
black = "*"
typing-extensions = "*"
[requires]
python_version = "3.8"

View File

@ -1,7 +1,18 @@
# Pomice
The modern [Lavalink](https://github.com/freyacodes/Lavalink) wrapper designed for [discord.py](https://github.com/Rapptz/discord.py)
![](https://img.shields.io/badge/license-GPL-2f2f2f) ![](https://img.shields.io/badge/python-3.8-2f2f2f)
![](https://raw.githubusercontent.com/cloudwithax/pomice/main/banner.jpg)
[![GPL](https://img.shields.io/github/license/cloudwithax/pomice?color=2f2f2f)](https://github.com/cloudwithax/pomice/blob/main/LICENSE) ![](https://img.shields.io/pypi/pyversions/pomice?color=2f2f2f) [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)
[![Discord](https://img.shields.io/discord/899324069235810315?color=%237289DA&label=Pomice%20Support&logo=discord&logoColor=white)](https://discord.gg/r64qjTSHG8) [![Read the Docs](https://readthedocs.org/projects/pomice/badge/?version=latest)](https://pomice.readthedocs.io/en/latest/)
Pomice is a fully asynchronous Python library designed for communicating with [Lavalink](https://github.com/freyacodes/Lavalink) seamlessly within the [discord.py](https://github.com/Rapptz/discord.py) library. It features 100% coverage of the [Lavalink](https://github.com/freyacodes/Lavalink) spec that can be accessed with easy-to-understand functions along with Spotify and Apple Music querying capabilities using built-in custom clients, making it easier to develop your next big music bot.
## Quick Links
- [Discord Server](https://discord.gg/r64qjTSHG8)
- [Read the Docs](https://pomice.readthedocs.io/en/latest/)
- [PyPI Homepage](https://pypi.org/project/pomice/)
# Install
@ -17,9 +28,15 @@ pip install pomice
pip install git+https://github.com/cloudwithax/pomice
```
# Support And Documentation
The official documentation is [here](https://pomice.readthedocs.io/en/latest/)
You can join our support server [here](https://discord.gg/r64qjTSHG8)
# Examples
In-depth examples are located in the examples folder
In-depth examples are located in the [examples folder](https://github.com/cloudwithax/pomice/tree/main/examples)
Here's a quick example:
@ -49,7 +66,7 @@ class Music(commands.Cog):
def __init__(self, bot) -> None:
self.bot = bot
self.obsidian = pomice.NodePool()
self.pomice = pomice.NodePool()
async def start_nodes(self):
await self.pomice.create_node(bot=self.bot, host='127.0.0.1', port='3030',
@ -79,7 +96,7 @@ class Music(commands.Cog):
player = ctx.voice_client
results = await player.get_tracks(query=f'ytsearch:{search}')
results = await player.get_tracks(query=f'{search}')
if not results:
raise commands.CommandError('No results were found for that search term.')
@ -97,7 +114,7 @@ bot.run("token here")
# FAQ
Why is it saying "Cannot connect to host"?
- You need to have a Lavalink node setup before you can use this library. Download it [here](https://github.com/freyacodes/Lavalink/releases/tag/3.3.2.5)
- You need to have a Lavalink node setup before you can use this library. Download it [here](https://github.com/freyacodes/Lavalink/releases/latest)
What experience do I need?
@ -106,3 +123,7 @@ What experience do I need?
Why is it saying "No module named pomice found"?
- You need to [install](#Install) the package before you can use it
# Contributors
- Thanks to [vveeps](https://github.com/vveeps) for implementing some features I wasn't able to do myself

BIN
banner.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

20
docs/Makefile Normal file
View File

@ -0,0 +1,20 @@
# Minimal makefile for Sphinx documentation
#
# You can set these variables from the command line, and also
# from the environment for the first two.
SPHINXOPTS ?=
SPHINXBUILD ?= sphinx-build
SOURCEDIR = .
BUILDDIR = _build
# Put it first so that "make" without argument is like "make help".
help:
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
.PHONY: help Makefile
# Catch-all target: route all unknown targets to Sphinx using the new
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
%: Makefile
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)

9
docs/api/enums.md Normal file
View File

@ -0,0 +1,9 @@
```{eval-rst}
Enums
-------------------
.. automodule:: pomice.enums
:members:
:undoc-members:
:show-inheritance:
```

9
docs/api/events.md Normal file
View File

@ -0,0 +1,9 @@
```{eval-rst}
Events
--------------------
.. automodule:: pomice.events
:members:
:undoc-members:
:show-inheritance:
```

9
docs/api/exceptions.md Normal file
View File

@ -0,0 +1,9 @@
```{eval-rst}
Exceptions
------------------------
.. automodule:: pomice.exceptions
:members:
:undoc-members:
:show-inheritance:
```

9
docs/api/filters.md Normal file
View File

@ -0,0 +1,9 @@
```{eval-rst}
Filters
---------------------
.. automodule:: pomice.filters
:members:
:undoc-members:
:show-inheritance:
```

18
docs/api/index.md Normal file
View File

@ -0,0 +1,18 @@
# API Reference
Here, you will find the different classes and methods used within Pomice.
```{toctree}
:maxdepth: 1
enums.md
events.md
exceptions.md
filters.md
objects.md
player.md
pool.md
queue.md
utils.md
```

9
docs/api/objects.md Normal file
View File

@ -0,0 +1,9 @@
```{eval-rst}
Objects
---------------------
.. automodule:: pomice.objects
:members:
:undoc-members:
:show-inheritance:
```

9
docs/api/player.md Normal file
View File

@ -0,0 +1,9 @@
```{eval-rst}
Player
--------------------
.. automodule:: pomice.player
:members:
:undoc-members:
:show-inheritance:
```

9
docs/api/pool.md Normal file
View File

@ -0,0 +1,9 @@
```{eval-rst}
Pool
------------------
.. automodule:: pomice.pool
:members:
:undoc-members:
:show-inheritance:
```

9
docs/api/queue.md Normal file
View File

@ -0,0 +1,9 @@
```{eval-rst}
Queue
------------------
.. automodule:: pomice.queue
:members:
:undoc-members:
:show-inheritance:
```

9
docs/api/utils.md Normal file
View File

@ -0,0 +1,9 @@
```{eval-rst}
Utils
-------------------
.. automodule:: pomice.utils
:members:
:undoc-members:
:show-inheritance:
```

121
docs/conf.py Normal file
View File

@ -0,0 +1,121 @@
# type: ignore
import importlib
import inspect
import os
import sys
from typing import Any
from typing import Dict
sys.path.insert(0, os.path.abspath("."))
sys.path.insert(0, os.path.abspath(".."))
project = "Pomice"
copyright = "2023, cloudwithax"
author = "cloudwithax"
release = "2.2"
extensions = [
"sphinx.ext.autodoc",
"sphinx.ext.autosummary",
"sphinx.ext.linkcode",
"myst_parser",
]
myst_enable_extensions = [
"amsmath",
"colon_fence",
"deflist",
"dollarmath",
"fieldlist",
"html_admonition",
"html_image",
"replacements",
"smartquotes",
"strikethrough",
"substitution",
"tasklist",
]
myst_heading_anchors = 3
templates_path = ["_templates"]
exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
# We need to include this because discord.py has special tags
# they inlcude within their docstrings that dont parse
# right within our docs
rst_prolog = """
.. |coro| replace:: This function is a |coroutine_link|_.
.. |maybecoro| replace:: This function *could be a* |coroutine_link|_.
.. |coroutine_link| replace:: *coroutine*
.. _coroutine_link: https://docs.python.org/3/library/asyncio-task.html#coroutine
"""
html_theme = "furo"
html_static_path = ["_static"]
html_title = "Pomice"
language = "en"
html_theme_options: Dict[str, Any] = {
"footer_icons": [
{
"name": "GitHub",
"url": "https://github.com/cloudwithax/pomice",
"html": """
<svg stroke="currentColor" fill="currentColor" stroke-width="0" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8z"></path>
</svg>
""",
"class": "",
},
],
"source_repository": "https://github.com/cloudwithax/pomice",
"source_branch": "main",
"source_directory": "docs/",
}
# Grab lines from source files and embed into the docs
# so theres a point of reference
def linkcode_resolve(domain, info):
# i absolutely MUST add this here or else
# the docs will not build. fuck sphinx
try:
if domain != "py":
return None
if not info["module"]:
return None
mod = importlib.import_module(info["module"])
if "." in info["fullname"]:
objname, attrname = info["fullname"].split(".")
obj = getattr(mod, objname)
try:
obj = getattr(obj, attrname)
except AttributeError:
return None
else:
obj = getattr(mod, info["fullname"])
try:
file = inspect.getsourcefile(obj)
lines = inspect.getsourcelines(obj)
except TypeError:
# e.g. object is a typing.Union
return None
file = os.path.relpath(file, os.path.abspath(".."))
start, end = lines[1], lines[1] + len(lines[0]) - 1
return f"https://github.com/cloudwithax/pomice/blob/main/{file}#L{start}-L{end}"
except:
pass

27
docs/faq.md Normal file
View File

@ -0,0 +1,27 @@
# Frequently Asked Questions
> Why is it saying "Cannot connect to host"?
Here are some common issues:
- You don't have a Lavalink node installed
- You have a Lavalink node, but it's not configured properly
- You have a Lavalink node and it's configured properly, but is unreachable due to firewall rules or a malformed network configuration.
If you are experiencing the first issue, you can download Lavalink [here.](https://github.com/freyacodes/Lavalink/releases/latest)
As for the other listed issues, either consult the Lavalink docs or go through the proper support channels for your specfic issue at hand.
For any other issues not listed here, please consult your preferred resource for more information.
> What experience do I need?
This library assumes that you have some experience with Python, asynchronous programming and the discord.py library.
> How do I install Pomice?
Refer to the [Installation](installation.md) section.
> How do I use Pomice?
If you are interested in learning how Pomice works, refer to the [API Reference](api/index.md) section.
If you want a quick example, refer to the [Quickstart](quickstart.md) section.

47
docs/hdi/events.md Normal file
View File

@ -0,0 +1,47 @@
# Use the Events class
Pomice has different events that are triggered depending on events that Lavalink emits:
- `Event.TrackEndEvent()`
- `Event.TrackExceptionEvent()`
- `Event.TrackStartEvent()`
- `Event.TrackStuckEvent()`
- `Event.WebsoocketClosedEvent()`
- `Event.WebsocketOpenEvent()`
The classes listed here are as they appear in Pomice. When you use them within your application,
the way you use them will be different. Here's an example on how you would use the `TrackStartEvent` within an event listener in a cog:
```py
@commands.Cog.listener
async def on_pomice_track_start(self, player: Player, track: Track):
...
```
## Event definitions
Each event within Pomice has an event definition you can use to listen for said event within
your application. Here are all the definitions:
- `Event.TrackEndEvent()` -> `on_pomice_track_end`
- `Event.TrackExceptionEvent()` -> `on_pomice_track_exception`
- `Event.TrackStartEvent()` -> `on_pomice_track_start`
- `Event.TrackStuckEvent()` -> `on_pomice_track_stuck`
- `Event.WebsocketClosedEvent()` -> `on_pomice_websocket_closed`
- `Event.WebsocketOpenEvent()` -> `on_pomice_websocket_open`
All events related to tracks carry a `Player` object so you can access player-specific functions
and properties for further evaluation. They also carry a `Track` object so you can access track-specific functions and properties for further evaluation as well.
`Event.TrackEndEvent()` carries the reason for the track ending. If the track ends suddenly, you can use the reason provided to determine a solution.
`Event.TrackExceptionEvent()` carries the exception, or reason why the track failed to play. The format for the exception is `REASON: [SEVERITY]`.
`Event.TrackStuckEvent()` carries the threshold, or amount of time Lavalink will wait before it discards the stuck track and stops it from playing.
`Event.WebsocketClosedEvent()` carries a payload object that contains a `Guild` object, the code number, the reason for disconnect and whether or not it was by the
remote, or the node.
`Event.WebsocketOpenEvent()` carries a target, which is usually the node IP, and the SSRC, a 32-bit integer uniquely identifying the source of the RTP packets sent from
Lavalink.

186
docs/hdi/filters.md Normal file
View File

@ -0,0 +1,186 @@
# Use the Filter class
Pomice takes full advantage of the Lavalink filter system by using a unique system to apply filters on top of one another. We call this system "filter stacking". With this system, we can stack any filter on top of one another to produce one-of-a-kind audio effects on playback while still being able to easily manage each filters.
## Types of filters
Lavalink, and by extension, Pomice, has different types of filters you can use.
Here are the different types and what they do:
:::{list-table}
:header-rows: 1
* - Type
- Class
- Description
* - Channel Mix
- `pomice.ChannelMix()`
- Adjusts stereo panning of a track.
* - Distortion
- `pomice.Distortion()`
- Generates a distortion effect on a track.
* - Equalizer
- `pomice.Equalizer()`
- Represents a 15 band equalizer. You can adjust the dynamic of the sound using this filter.
* - Karaoke
- `pomice.Karaoke()`
- Filters the vocals from the track.
* - Low Pass
- `pomice.LowPass()`
- Filters out high frequencies and only lets low frequencies pass through.
* - Rotation
- `pomice.Rotation()`
- Produces a stereo-like panning effect, which sounds like the audio is being rotated around the listeners head
* - Timescale
- `pomice.Timescale()`
- Adjusts the speed and pitch of a track.
* - Tremolo
- `pomice.Tremolo()`
- Rapidly changes the volume of the track, producing a wavering tone.
* - Vibrato
- `pomice.Vibrato()`
- Rapidly changes the pitch of the track.
:::
Each filter has individual values you can adjust to fine-tune the sound of the filter. If you want to see what values each filter has, refer to [](../api/filters.md).
If you are stuck on what values adjust what, some filters include presets that you can apply to get a certain sound, i.e: `pomice.Timescale` has the `vaporwave()` and `nightcore()` and so on. You can also play around with the values and generate your own unique sound if you'd like.
## Adding a filter
:::{important}
You must have the `Player` class initialized first before using this. Refer to [](player.md)
:::
To add a filter, we need to use `Player.add_filter()`
```py
await Player.add_filter(...)
```
After you have initialized your function, we need to fill in the proper parameters:
:::{list-table}
:header-rows: 1
* - Name
- Type
- Description
* - `filter`
- `Filter`
- The filter to apply
* - `fast_apply`
- `bool`
- If set to `True`, the specified filter will apply (almost) instantly if a song is playing. Default value is `False`.
:::
After you set those parameters, your function should look something like this:
```py
await Player.add_filter(
filter=<your filter object here>,
fast_apply=<True/False>
)
```
After running this function, you should see your currently playing track sound different depending on the filter you chose.
## Removing a filter
:::{important}
You must have the `Player` class initialized first before using this. Refer to [](player.md)
:::
To remove a filter, we need to use `Player.remove_filter()`
```py
await Player.remove_filter(...)
```
After you have initialized your function, we need to fill in the proper parameters:
:::{list-table}
:header-rows: 1
* - Name
- Type
- Description
* - `filter`
- `Filter`
- The filter to remove
* - `fast_apply`
- `bool`
- If set to `True`, the specified filter will be removed (almost) instantly if a song is playing. Default value is `False`.
:::
After you set those parameters, your function should look something like this:
```py
await Player.remove_filter(
filter=<your filter object here>,
fast_apply=<True/False>
)
```
After running this function, you should see your currently playing track sound different depending on the filter you chose to remove.
## Resetting all filters
:::{important}
You must have the `Player` class initialized first before using this. Refer to [](player.md)
:::
To reset all filters, we need to use `Player.reset_filters()`
```py
await Player.reset_filters()
```
After you have initialized your function, you can optionally include the `fast_apply` parameter, which is a boolean. If this is set to `True`, it'll remove all filters (almost) instantly if theres a track playing.
```py
await Player.reset_filters(fast_apply=<True/False>)
```

16
docs/hdi/index.md Normal file
View File

@ -0,0 +1,16 @@
# How Do I?
This section covers all the basic functions of Pomice and how to use them.
If you find the [API Reference](../api/index.md) section too confusing or would
rather have a straightforward explanation as to how to use a certain function,
this is for you.
```{toctree}
pool.md
node.md
player.md
filters.md
queue.md
events.md
```

182
docs/hdi/node.md Normal file
View File

@ -0,0 +1,182 @@
# Use the Node class
The `Node` class is one of the main classes you will be interacting with when using Pomice.
The `Node` class has a couple functions you will be using frequently:
- `Node.get_player()`
- `Node.get_tracks()`
- `Node.get_recommendations()`
There are also properties the `Node` class has to access certain values:
:::{list-table}
:header-rows: 1
* - Property
- Type
- Description
* - `Node.bot`
- `Client`
- Returns the discord.py client linked to this node.
* - `Node.is_connected`
- `bool`
- Returns whether this node is connected or not.
* - `Node.latency` `Node.ping`
- `float`
- Returns the latency of the node.
* - `Node.player_count`
- `int`
- Returns how many players are connected to this node.
* - `Node.players`
- `Dict[int, Player]`
- Returns a dict containing the guild ID and the player object.
* - `Node.pool`
- `NodePool`
- Returns the pool this node is apart of.
* - `Node.stats`
- `NodeStats`
- Returns the nodes stats.
:::
## Getting a player
To get a player from the nodes list of players, we need to use `Node.get_player()`
```py
await Node.get_player(...)
```
After you have initialized your function, you need to specify the `guild_id` of the player.
```py
await Node.get_player(guild_id=<your guild ID here>)
```
If the node finds a player with the guild ID you provided, it'll return the [](../api/player.md) object associated with the guild ID.
## Getting tracks
To get tracks using Lavalink, we need to use `Node.get_tracks()`
You can also use `Player.get_tracks()` to do the same thing, but this can be used to fetch tracks regardless if a player exists.
```py
await Node.get_tracks(...)
```
After you have initialized your function, we need to fill in the proper parameters:
:::{list-table}
:header-rows: 1
* - Name
- Type
- Description
* - `query`
- `str`
- The string you want to search up
* - `ctx`
- `Optional[commands.Context]`
- Optional value which sets a `Context` object on the tracks you search.
* - `search_type`
- `SearchType`
- Enum which sets the provider to search from. Default value is `SearchType.ytsearch`
* - `filters`
- `Optional[List[Filter]]`
- Optional value which sets the filters that should apply when the track is played on the tracks you search.
:::
After you set those parameters, your function should look something like this:
```py
await Node.get_tracks(
query="<your query here>",
ctx=<optional ctx object here>,
search_type=<optional search type here>,
filters=[<optional filters here>]
)
```
:::{important}
All querying of Spotify and Apple Music tracks or playlists is handled in this function if you enabled that functionality when creating your node.
If you want to enable it, refer to [](pool.md#adding-a-node)
:::
You should get a list of `Track` in return after running this function for you to then do whatever you want with it.
Ideally, you should be putting all tracks into some sort of a queue. If you would like to learn about how to use
our queue implementation, you can refer to [](queue.md)
## Getting recommendations
To get recommadations using Lavalink, we need to use `Node.get_recommendations()`
You can also use `Player.get_recommendations()` to do the same thing, but this can be used to fetch recommendations regardless if a player exists.
```py
await Node.get_recommendations(...)
```
After you have initialized your function, we need to fill in the proper parameters:
:::{list-table}
:header-rows: 1
* - Name
- Type
- Description
* - `track`
- `Track`
- The track to fetch recommendations for
* - `ctx`
- `Optional[commands.Context]`
- Optional value which sets a `Context` object on the recommendations you fetch.
:::
After you set those parameters, your function should look something like this:
```py
await Node.get_recommendations(
track=<your track object here>,
ctx=<optional ctx object here>,
)
```
You should get a list of `Track` in return after running this function for you to then do whatever you want with it.
Ideally, you should be putting all tracks into some sort of a queue. If you would like to learn about how to use
our queue implementation, you can refer to [](queue.md)

501
docs/hdi/player.md Normal file
View File

@ -0,0 +1,501 @@
# Use the Player class
The `Player` class is the class you will be interacting with the most within Pomice.
The `Player` class has a couple functions you will be using frequently:
- `Player.add_filter()`
- `Player.destroy()`
- `Player.get_recommendations()`
- `Player.get_tracks()`
- `Player.play()`
- `Player.remove_filter()`
- `Player.reset_filters()`
- `Player.seek()`
- `Player.set_pause()`
- `Player.set_volume()`
- `Player.stop()`
There are also properties the `Player` class has to access certain values:
:::{list-table}
:header-rows: 1
* - Property
- Type
- Description
* - `Player.bot`
- `Client`
- Returns the bot associated with this player instance.
* - `Player.current`
- `Track`
- Returns the currently playing track.
* - `Player.filters`
- `Filters`
- Returns the helper class for interacting with filters.
* - `Player.guild`
- `Guild`
- Returns the guild associated with the player.
* - `Player.is_connected`
- `bool`
- Returns whether or not the player is connected.
* - `Player.is_dead`
- `bool`
- Returns whether the player is dead or not. A player is considered dead if it has been destroyed and removed from stored players.
* - `Player.is_paused`
- `bool`
- Returns whether or not the player has a track which is paused or not.
* - `Player.is_playing`
- `bool`
- Returns whether or not the player is actively playing a track.
* - `Player.node`
- `Node`
- Returns the node the player is connected to.
* - `Player.position`
- `float`
- Returns the players position in a track in milliseconds.
* - `Player.adjusted_position`
- `float`
- Returns the players position in a track in milliseconds, adjusted for rate if affected.
* - `Player.adjusted_length`
- `float`
- Returns the current track length in milliseconds, adjusted for rate if affected.
* - `Player.rate`
- `float`
- Returns the players current rate, which represents the speed of the currently playing track. This rate is affected by the `Timescale` filter.
* - `Player.volume`
- `int`
- Returns the players current volume.
:::
## Getting tracks
To get tracks using Lavalink, we need to use `Player.get_tracks()`
You can also use `Node.get_tracks()` to do the same thing but without having a player.
```py
await Player.get_tracks(...)
```
After you have initialized your function, we need to fill in the proper parameters:
:::{list-table}
:header-rows: 1
* - Name
- Type
- Description
* - `query`
- `str`
- The string you want to search up
* - `ctx`
- `Optional[commands.Context]`
- Optional value which sets a `Context` object on the tracks you search.
* - `search_type`
- `SearchType`
- Enum which sets the provider to search from. Default value is `SearchType.ytsearch`
* - `filters`
- `Optional[List[Filter]]`
- Optional value which sets the filters that should apply when the track is played on the tracks you search.
:::
After you set those parameters, your function should look something like this:
```py
await Player.get_tracks(
query="<your query here>",
ctx=<optional ctx object here>,
search_type=<optional search type here>,
filters=[<optional filters here>]
)
```
:::{important}
All querying of Spotify and Apple Music tracks or playlists is handled in this function if you enabled that functionality when creating your node.
If you want to enable it, refer to [](pool.md#adding-a-node)
:::
You should get a list of `Track` in return after running this function for you to then do whatever you want with it.
Ideally, you should be putting all tracks into some sort of a queue. If you would like to learn about how to use
our queue implementation, you can refer to [](queue.md)
## Getting recommendations
To get recommendations using Lavalink, we need to use `Player.get_recommendations()`
You can also use `Node.get_recommendations()` to do the same thing without having a player.
```py
await Player.get_recommendations(...)
```
After you have initialized your function, we need to fill in the proper parameters:
:::{list-table}
:header-rows: 1
* - Name
- Type
- Description
* - `track`
- `Track`
- The track to fetch recommendations for
* - `ctx`
- `Optional[commands.Context]`
- Optional value which sets a `Context` object on the recommendations you fetch.
:::
After you set those parameters, your function should look something like this:
```py
await Player.get_recommendations(
track=<your track object here>,
ctx=<optional ctx object here>,
)
```
You should get a list of `Track` in return after running this function for you to then do whatever you want with it.
Ideally, you should be putting all tracks into some sort of a queue. If you would like to learn about how to use
our queue implementation, you can refer to [](queue.md)
## Connecting a player
To connect a player to a channel you need to pass the `Player` class into your `channel.connect()` function:
```py
await voice_channel.connect(cls=Player)
```
This will instance the player and make it available to your guild. If you want to access your player after instancing it,
you must use either `Guild.voice_client` or `Context.voice_client`.
## Controlling the player
There are a few functions to control the player:
- `Player.destroy()`
- `Player.play()`
- `Player.seek()`
- `Player.set_pause()`
- `Player.set_volume()`
- `Player.stop()`
### Destroying a player
To destroy a player, we need to use `Player.destroy()`
```py
await Player.destroy()
```
### Playing a track
To play a track, we need to use `Player.play()`
```py
await Player.play(...)
```
After you have initialized your function, we need to fill in the proper parameters:
:::{list-table}
:header-rows: 1
* - Name
- Type
- Description
* - `track`
- `Track`
- The track to play
* - `start`
- `int`
- The time (in milliseconds) to start the track at. Default value is `0`
* - `end`
- `int`
- The time (in milliseconds) to end the track at. Default value is `0`
* - `ignore_if_playing`
- `bool`
- If set, ignores the current track playing and replaces it with this track. Default value is `False`
:::
After you set those parameters, your function should look something like this:
```py
await Player.play(
track=<your track object here>,
start=<your optional start time here>,
end=<your optional end time here>,
ignore_if_playing=<your optional boolean here>
)
```
After running this function, it should return the `Track` you specified when running the function. This means the track is now playing.
### Seeking to a position
To seek to a position, we need to use `Player.seek()`
```py
await Player.seek(...)
```
After you have initialized your function, we need to include the `position` parameter, which is an amount in milliseconds:
```py
await Player.seek(position=<your pos here>)
```
After running this function, your currently playing track should seek to your specified position
### Pausing/unpausing the player
To pause/unpause the player, we need to use `Player.set_pause()`
```py
await Player.set_pause(...)
```
After you have initialized your function, we need to include the `pause` parameter, which is a boolean:
```py
await Player.set_pause(pause=<True/False>)
```
After running this function, your currently playing track should either pause or unpause depending on what you set.
### Setting the player volume
To set the volume the player, we need to use `Player.set_volume()`
```py
await Player.set_volume(...)
```
:::{important}
Lavalink accept ranges from 0 to 500 for this parameter. Inputting a value either higher or lower
than this amount will **not work.**
:::
After you have initialized your function, we need to include the `amount` parameter, which is an integer:
```py
await Player.set_volume(amount=<int>)
```
After running this function, your currently playing track should adjust in volume depending on the amount you set.
### Stopping the player
To stop the player, we need to use `Player.stop()`
```py
await Player.stop()
```
### Moving the player to another channel
To move the player to another channel, we need to use `Player.move_to()`
```py
await Player.move_to(...)
```
After you have initialized your function, we need to include the `channel` parameter, which is a `VoiceChannel`:
```py
await Player.move_to(channel)
```
After running this function, your player should be in the new voice channel. All voice state updates should also be handled.
## Controlling filters
Pomice has an extensive suite of filter management tools to help you make the most of Lavalink and it's filters.
Here are some of the functions you will be using to control filters:
- `Player.add_filter()`
- `Player.remove_filter()`
- `Player.reset_filters()`
### Adding a filter
To add a filter, we need to use `Player.add_filter()`
```py
await Player.add_filter(...)
```
After you have initialized your function, we need to fill in the proper parameters:
:::{list-table}
:header-rows: 1
* - Name
- Type
- Description
* - `filter`
- `Filter`
- The filter to apply
* - `fast_apply`
- `bool`
- If set to `True`, the specified filter will apply (almost) instantly if a song is playing. Default value is `False`.
:::
After you set those parameters, your function should look something like this:
```py
await Player.add_filter(
filter=<your filter object here>,
fast_apply=<True/False>
)
```
After running this function, you should see your currently playing track sound different depending on the filter you chose.
### Removing a filter
To remove a filter, we need to use `Player.remove_filter()`
```py
await Player.remove_filter(...)
```
After you have initialized your function, we need to fill in the proper parameters:
:::{list-table}
:header-rows: 1
* - Name
- Type
- Description
* - `filter`
- `Filter`
- The filter to remove
* - `fast_apply`
- `bool`
- If set to `True`, the specified filter will be removed (almost) instantly if a song is playing. Default value is `False`.
:::
After you set those parameters, your function should look something like this:
```py
await Player.remove_filter(
filter=<your filter object here>,
fast_apply=<True/False>
)
```
After running this function, you should see your currently playing track sound different depending on the filter you chose to remove.
### Resetting all filters
To reset all filters, we need to use `Player.reset_filters()`
```py
await Player.reset_filters()
```
After you have initialized your function, you can optionally include the `fast_apply` parameter, which is a boolean. If this is set to `True`, it'll remove all filters (almost) instantly if theres a track playing.
```py
await Player.reset_filters(fast_apply=<True/False>)
```

156
docs/hdi/pool.md Normal file
View File

@ -0,0 +1,156 @@
# Use the NodePool class
The `NodePool` class is the first class you will use when using Pomice.
The `NodePool` Class has three main functions you can use:
- `NodePool.create_node()`
- `NodePool.get_node()`
- `NodePool.get_best_node()`
## Adding a node
To add a node to our `NodePool`, we need to run `NodePool.create_node()`.
```py
await NodePool.create_node(...)
```
After you have initialized your function, we need to fill in the proper parameters:
:::{list-table}
:header-rows: 1
* - Name
- Type
- Description
* - `bot`
- `Client`
- A discord.py `Client` object (can be either a `Client` or a `commands.Bot`)
* - `host`
- `str`
- The IP/URL of your Lavalink node. Remember not to include the port in this field
* - `port`
- `int`
- The port your Lavalink node uses. By default, Lavalink uses `2333`.
* - `identifier`
- `str`
- The identifier your `Node` object uses to distinguish itself.
* - `password`
- `str`
- The password used to connect to your node.
* - `spotify_client_id`
- `Optional[str]`
- Your Spotify client ID goes here. You need this along with the client secret if you want to use Spotify functionality within Pomice.
* - `spotify_client_secret`
- `Optional[str]`
- Your Spotify client secret goes here. You need this along with the client ID if you want to use Spotify functionality within Pomice.
* - `apple_music`
- `bool`
- Set this value to `True` if you want to use Apple Music functionality within Pomice. Apple Music will **not work** if you don't enable this.
* - `fallback`
- `bool`
- Set this value to `True` if you want Pomice to automatically switch all players to another available node if one disconnects.
You must have two or more nodes to be able to do this.
* - `logger`
- `Optional[logging.Logger]`
- If you would like to receive logging information from Pomice, set this to your logger class
:::
All the other parameters not listed here have default values that are either set within the function or set later in the initialization of the node. If you would like to set these parameters to a different value, you are free to do so.
After you set those parameters, your function should look something like this:
```py
await NodePool.create_node(
bot=bot,
host="<your ip here>",
port=<your port here>,
identifier="<your id here>",
password="<your password here>",
spotify_client_id="<your spotify client id here>",
spotify_client_secret="<your spotify client secret here>"
apple_music=<True/False>,
fallback=<True/False>,
logger=<your logger here>
)
```
:::{important}
For features like Spotify and Apple Music, you are **not required** to fill in anything for them if you do not want to use them. If you do end up queuing a Spotify or Apple Music track, it is **up to you** on how you decide to handle it, whether it be through your own methods or a Lavalink plugin.
:::
Now that you have your Node object created, move on to [Using a node](node.md) to see what you can do with your `Node` object.
## Getting a node
To get a node from the node pool, we need to use `NodePool.get_node()`
```py
await NodePool.get_node(...)
```
After you have initialized your function, you can specify a identifier if you want to grab a specified node:
```py
await NodePool.get_node(identifier="<your id here>")
```
If you do not set a identifier, it'll return a random node from the pool.
## Getting the best node
To get a node from the node pool based on certain requirements, we need to use `NodePool.get_best_node()`
```py
await NodePool.get_best_node(...)
```
After you have initialized your function, you need to specify a `NodeAlgorithm` to use to grab your node from the pool.
The available algorithms are `by_ping` and `by_players`.
If you want to view what they do, refer to the `NodeAlgorithm` enum in the [](../api/enums.md) section.
```py
await NodePool.get_best_node(algorithm=NodeAlgorithm.xyz)
```
## Disconnecting all nodes from the pool
To disconnect all nodes from the pool, we need to use `NodePool.disconnect()`
```py
await NodePool.disconnect()
```
After running this function, all nodes in the pool should disconnect and no longer be available to use.

224
docs/hdi/queue.md Normal file
View File

@ -0,0 +1,224 @@
# Use the Queue class
Pomice has an optional queue system that works seamlessly with the library. This queue system introduce quality-of-life features that every music application should ideally have like queue shuffling, queue jumping, and looping.
To use the queue system with Pomice, you must first subclass the `Player` class within your application like so:
```py
from pomice import Player
class CustomPlayer(Player):
...
```
After you have initialized your subclass, you can add a `queue` variable to your class so you can access your queue when you initialize your player:
```py
from pomice import Player, Queue
class CustomPlayer(Player):
...
self.queue = Queue()
```
## Adding a song to the queue
To add a song to the queue, we must use `Queue.put()`
```py
Queue.put()
```
After you have initialized your function, we need to include the `item` parameter, which is a `Track`:
```py
Queue.put(item=<your Track here>)
```
After running the function, your track should be in the queue.
## Getting a track from the queue
To get a track from the queue, we need to do a few things.
To get a track using its position within the queue, you first need to get the position as a number, also known as its index. If you dont have the index and instead want to search for its index using keywords, you have to implement a fuzzy searching algorithm to find said track using a search query as an input and have it compare that query against the titles of the tracks in the queue. After that, you can get the `Track` object by [getting it with its index](queue.md#getting-track-with-its-index)
### Getting index of track
If you have the `Track` object and want to get its index within the queue, we can use `Queue.find_position()`
```py
Queue.find_position()
```
After you have initialized your function, we need to include the `item` parameter, which is a `Track`:
```py
Queue.find_position(item=<your Track here>)
```
After running the function, it should return the position of the track as an integer.
### Getting track with its index
If you have the index of the track and want to get the `Track` object, you first need to get the raw queue list:
```py
queue = Queue.get_queue()
```
After you have your queue, you can use basic list splicing to get the track object:
```py
track = queue[<index>]
```
## Getting the next track in the queue
To get the next track in the queue, we need to use `Queue.get()`
```py
Queue.get()
```
After running this function, it'll return the first track from the queue and remove it.
:::{note}
If you have a queue loop mode set, this behavior will be overridden since the queue is not allowed to remove tracks from the queue if its looping.
:::
## Removing a track from the queue
To remove a track from the queue, we must use `Queue.remove()`
```py
Queue.remove()
```
After you have initialized your function, we need to include the `item` parameter, which is a `Track`:
```py
Queue.remove(item=<your Track here>)
```
:::{important}
Your `Track` object must be in the queue if you want to remove it. Make sure you follow [](queue.md#getting-a-track-from-the-queue) before running this function.
:::
After running this function, your track should be removed from the queue.
## Shuffling the queue
To shuffle the queue, we must use `Queue.shuffle()`
```py
Queue.shuffle()
```
After running this function, your queue should be in a different order than it was originally.
:::{tip}
This function works best if theres atleast **3** tracks in the queue. The more tracks, the more variation the shuffle has.
:::
## Looping the queue
To loop the queue, we must use `Queue.set_loop_mode()`
```py
Queue.set_loop_mode(...)
```
After you have initialized your function, we need to include the `mode` parameter, which is a `LoopMode` enum:
```py
Queue.set_loop_mode(mode=LoopMode.<mode>)
```
The two types of `LoopMode` enums are `LoopMode.QUEUE` and `LoopMode.TRACK`. `QUEUE` loops the entire queue and `TRACK` loops the current track.
After running the function, your queue will now loop using the mode you specify.
### Resetting the loop mode
To reset the loop mode, we must use `Queue.disable_loop()`
```py
Queue.disable_loop()
```
:::{important}
You must have a loop mode set before using this function. It will **not work** if you do not a loop mode set
:::
After running the function, your queue should return to its normal functionality.
## Jumping to a track in the queue
To jump to a track in the queue, we must use `Queue.jump()`
```py
Queue.jump(...)
```
After you have initialized your function, we need to include the `item` parameter, which is a `Track`:
```py
Queue.jump(item=<your Track here>)
```
:::{important}
Your `Track` object must be in the queue if you want to jump to it. Make sure you follow [](queue.md#getting-a-track-from-the-queue) before running this function.
:::
After running this function, any items before the specified item will be removed, effectively "jumping" to the specified item in the queue. The next item obtained using `Queue.get()` will be your specified track.

40
docs/index.md Normal file
View File

@ -0,0 +1,40 @@
---
hide-toc: true
---
# Pomice
![](https://raw.githubusercontent.com/cloudwithax/pomice/main/banner.jpg)
Pomice is a fully asynchronous Python library designed for communicating with [Lavalink](https://github.com/freyacodes/Lavalink) seamlessly within the [discord.py](https://github.com/Rapptz/discord.py) library. It features 100% API coverage of the entire [Lavalink](https://github.com/freyacodes/Lavalink) spec that can be accessed with easy-to-understand functions. We also include Spotify and Apple Music querying capabilites using built-in custom clients, making it easier to develop your next big music bot.
## Quick Links:
- [Installation](installation.md)
- [Quickstart](quickstart.md)
- [Frequently Asked Questions](faq.md)
- [How Do I?](hdi/index.md)
- [API Reference](api/index.md)
```{toctree}
:caption: Before You Start
:hidden:
installation
quickstart
faq
```
```{toctree}
:caption: How Do I?
:hidden:
hdi/index.md
```
```{toctree}
:caption: API Reference
:hidden:
api/index.md
```

30
docs/installation.md Normal file
View File

@ -0,0 +1,30 @@
# Installation
This library is designed to work with the Lavalink audio delivery system,
which directly interfaces with Discord to provide buttery smooth audio without
wasting your precious system resources.
Pomice is made with convenience to the user, in that everything is easy to use
and is out of your way, while also being customizable.
In order to start using this library, please download a Lavalink node to start,
you can get it [here](https://github.com/freyacodes/Lavalink/releases/latest)
After you have your Lavalink node set up, you can install Pomice:
```
pip install pomice
```
Pomice will handle installing all required dependencies for you so you can
start coding with it without a hitch.
After you installed Pomice, get familiar with how it works by starting out with [an example.](quickstart.md)
If you need more than just a quick example, get our drop-in [advanced cog](https://github.com/cloudwithax/pomice/blob/main/examples/advanced.py)
to take advantage of all of Pomice's features.
You are free to use this as a base to add on to for any music features you want to implement within your application.
If you want to jump into the library and learn how to do everything you need, refer to the [How Do I?](hdi/index.md) section.
If you want a deeper look into how the library works beyond the [How Do I?](hdi/index.md) guide, refer to the [API Reference](api/index.md) section.

35
docs/make.bat Normal file
View File

@ -0,0 +1,35 @@
@ECHO OFF
pushd %~dp0
REM Command file for Sphinx documentation
if "%SPHINXBUILD%" == "" (
set SPHINXBUILD=sphinx-build
)
set SOURCEDIR=.
set BUILDDIR=_build
if "%1" == "" goto help
%SPHINXBUILD% >NUL 2>NUL
if errorlevel 9009 (
echo.
echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
echo.installed, then set the SPHINXBUILD environment variable to point
echo.to the full path of the 'sphinx-build' executable. Alternatively you
echo.may add the Sphinx directory to PATH.
echo.
echo.If you don't have Sphinx installed, grab it from
echo.https://www.sphinx-doc.org/
exit /b 1
)
%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
goto end
:help
%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
:end
popd

83
docs/quickstart.md Normal file
View File

@ -0,0 +1,83 @@
# Quick Jumpstart
If you want a quick example as to how to start with Pomice, look below:
```py
import pomice
import discord
import re
from discord.ext import commands
URL_REG = re.compile(r"https?://(?:www\.)?.+")
class MyBot(commands.Bot):
def __init__(self) -> None:
super().__init__(
command_prefix="!",
activity=discord.Activity(
type=discord.ActivityType.listening, name="to music!"
),
)
self.add_cog(Music(self))
async def on_ready(self) -> None:
print("I'm online!")
await self.cogs["Music"].start_nodes()
class Music(commands.Cog):
def __init__(self, bot) -> None:
self.bot = bot
self.pomice = pomice.NodePool()
async def start_nodes(self):
await self.pomice.create_node(
bot=self.bot,
host="127.0.0.1",
port="3030",
password="youshallnotpass",
identifier="MAIN",
)
print(f"Node is ready!")
@commands.command(name="join", aliases=["connect"])
async def join(
self, ctx: commands.Context, *, channel: discord.TextChannel = None
) -> None:
if not channel:
channel = getattr(ctx.author.voice, "channel", None)
if not channel:
raise commands.CheckFailure(
"You must be in a voice channel to use this command"
"without specifying the channel argument."
)
await ctx.author.voice.channel.connect(cls=pomice.Player)
await ctx.send(f"Joined the voice channel `{channel}`")
@commands.command(name="play")
async def play(self, ctx, *, search: str) -> None:
if not ctx.voice_client:
await ctx.invoke(self.join)
player = ctx.voice_client
results = await player.get_tracks(query=f"{search}")
if not results:
raise commands.CommandError("No results were found for that search term.")
if isinstance(results, pomice.Playlist):
await player.play(track=results.tracks[0])
else:
await player.play(track=results[0])
bot = MyBot()
bot.run("token here")
```

View File

@ -0,0 +1,6 @@
aiohttp
discord.py[voice]
furo
myst_parser
orjson
websockets

391
examples/advanced.py Normal file
View File

@ -0,0 +1,391 @@
# type: ignore
"""
This example aims to show the full capabilities of the library.
This is in the form of a drop-in cog you can use and modify to your liking.
This example aims to include everything you would need to make a fully functioning music bot,
from a queue system, advanced queue control and more.
"""
import math
from contextlib import suppress
import discord
from discord.ext import commands
import pomice
class Player(pomice.Player):
"""Custom pomice Player class."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.queue = pomice.Queue()
self.controller: discord.Message = None
# Set context here so we can send a now playing embed
self.context: commands.Context = None
self.dj: discord.Member = None
self.pause_votes = set()
self.resume_votes = set()
self.skip_votes = set()
self.shuffle_votes = set()
self.stop_votes = set()
async def do_next(self) -> None:
# Clear the votes for a new song
self.pause_votes.clear()
self.resume_votes.clear()
self.skip_votes.clear()
self.shuffle_votes.clear()
self.stop_votes.clear()
# Check if theres a controller still active and deletes it
if self.controller:
with suppress(discord.HTTPException):
await self.controller.delete()
# Queue up the next track, else teardown the player
try:
track: pomice.Track = self.queue.get()
except pomice.QueueEmpty:
return await self.teardown()
await self.play(track)
# Call the controller (a.k.a: The "Now Playing" embed) and check if one exists
if track.is_stream:
embed = discord.Embed(
title="Now playing",
description=f":red_circle: **LIVE** [{track.title}]({track.uri}) [{track.requester.mention}]",
)
self.controller = await self.context.send(embed=embed)
else:
embed = discord.Embed(
title=f"Now playing",
description=f"[{track.title}]({track.uri}) [{track.requester.mention}]",
)
self.controller = await self.context.send(embed=embed)
async def teardown(self):
"""Clear internal states, remove player controller and disconnect."""
with suppress((discord.HTTPException), (KeyError)):
await self.destroy()
if self.controller:
await self.controller.delete()
async def set_context(self, ctx: commands.Context):
"""Set context for the player"""
self.context = ctx
self.dj = ctx.author
class Music(commands.Cog):
def __init__(self, bot: commands.Bot) -> None:
self.bot = bot
# In order to initialize a node, or really do anything in this library,
# you need to make a node pool
self.pomice = pomice.NodePool()
# Start the node
bot.loop.create_task(self.start_nodes())
async def start_nodes(self):
# Waiting for the bot to get ready before connecting to nodes.
await self.bot.wait_until_ready()
# You can pass in Spotify credentials to enable Spotify querying.
# If you do not pass in valid Spotify credentials, Spotify querying will not work
await self.pomice.create_node(
bot=self.bot,
host="127.0.0.1",
port=3030,
password="youshallnotpass",
identifier="MAIN",
)
print(f"Node is ready!")
def required(self, ctx: commands.Context):
"""Method which returns required votes based on amount of members in a channel."""
player: Player = ctx.voice_client
channel = self.bot.get_channel(int(player.channel.id))
required = math.ceil((len(channel.members) - 1) / 2.5)
if ctx.command.name == "stop":
if len(channel.members) == 3:
required = 2
return required
def is_privileged(self, ctx: commands.Context):
"""Check whether the user is an Admin or DJ."""
player: Player = ctx.voice_client
return player.dj == ctx.author or ctx.author.guild_permissions.kick_members
# The following are events from pomice.events
# We are using these so that if the track either stops or errors,
# we can just skip to the next track
# Of course, you can modify this to do whatever you like
@commands.Cog.listener()
async def on_pomice_track_end(self, player: Player, track, _):
await player.do_next()
@commands.Cog.listener()
async def on_pomice_track_stuck(self, player: Player, track, _):
await player.do_next()
@commands.Cog.listener()
async def on_pomice_track_exception(self, player: Player, track, _):
await player.do_next()
@commands.command(aliases=["joi", "j", "summon", "su", "con", "connect"])
async def join(self, ctx: commands.Context, *, channel: discord.VoiceChannel = None) -> None:
if not channel:
channel = getattr(ctx.author.voice, "channel", None)
if not channel:
return await ctx.send(
"You must be in a voice channel in order to use this command!",
)
# With the release of discord.py 1.7, you can now add a compatible
# VoiceProtocol class as an argument in VoiceChannel.connect().
# This library takes advantage of that and is how you initialize a player.
await ctx.author.voice.channel.connect(cls=Player)
player: Player = ctx.voice_client
# Set the player context so we can use it so send messages
await player.set_context(ctx=ctx)
await ctx.send(f"Joined the voice channel `{channel.name}`")
@commands.command(aliases=["disconnect", "dc", "disc", "lv", "fuckoff"])
async def leave(self, ctx: commands.Context):
if not (player := ctx.voice_client):
return await ctx.send(
"You must have the bot in a channel in order to use this command",
delete_after=7,
)
await player.destroy()
await ctx.send("Player has left the channel.")
@commands.command(aliases=["pla", "p"])
async def play(self, ctx: commands.Context, *, search: str) -> None:
# Checks if the player is in the channel before we play anything
if not (player := ctx.voice_client):
await ctx.author.voice.channel.connect(cls=Player)
player: Player = ctx.voice_client
await player.set_context(ctx=ctx)
# If you search a keyword, Pomice will automagically search the result using YouTube
# You can pass in "search_type=" as an argument to change the search type
# i.e: player.get_tracks("query", search_type=SearchType.ytmsearch)
# will search up any keyword results on YouTube Music
# We will also set the context here to get special features, like a track.requester object
results = await player.get_tracks(search, ctx=ctx)
if not results:
return await ctx.send("No results were found for that search term", delete_after=7)
if isinstance(results, pomice.Playlist):
for track in results.tracks:
player.queue.put(track)
else:
track = results[0]
player.queue.put(track)
if not player.is_playing:
await player.do_next()
@commands.command(aliases=["pau", "pa"])
async def pause(self, ctx: commands.Context):
"""Pause the currently playing song."""
if not (player := ctx.voice_client):
return await ctx.send(
"You must have the bot in a channel in order to use this command",
delete_after=7,
)
if player.is_paused or not player.is_connected:
return
if self.is_privileged(ctx):
await ctx.send("An admin or DJ has paused the player.", delete_after=10)
player.pause_votes.clear()
return await player.set_pause(True)
required = self.required(ctx)
player.pause_votes.add(ctx.author)
if len(player.pause_votes) >= required:
await ctx.send("Vote to pause passed. Pausing player.", delete_after=10)
player.pause_votes.clear()
await player.set_pause(True)
else:
await ctx.send(
f"{ctx.author.mention} has voted to pause the player. Votes: {len(player.pause_votes)}/{required}",
delete_after=15,
)
@commands.command(aliases=["res", "r"])
async def resume(self, ctx: commands.Context):
"""Resume a currently paused player."""
if not (player := ctx.voice_client):
return await ctx.send(
"You must have the bot in a channel in order to use this command",
delete_after=7,
)
if not player.is_paused or not player.is_connected:
return
if self.is_privileged(ctx):
await ctx.send("An admin or DJ has resumed the player.", delete_after=10)
player.resume_votes.clear()
return await player.set_pause(False)
required = self.required(ctx)
player.resume_votes.add(ctx.author)
if len(player.resume_votes) >= required:
await ctx.send("Vote to resume passed. Resuming player.", delete_after=10)
player.resume_votes.clear()
await player.set_pause(False)
else:
await ctx.send(
f"{ctx.author.mention} has voted to resume the player. Votes: {len(player.resume_votes)}/{required}",
delete_after=15,
)
@commands.command(aliases=["n", "nex", "next", "sk"])
async def skip(self, ctx: commands.Context):
"""Skip the currently playing song."""
if not (player := ctx.voice_client):
return await ctx.send(
"You must have the bot in a channel in order to use this command",
delete_after=7,
)
if not player.is_connected:
return
if self.is_privileged(ctx):
await ctx.send("An admin or DJ has skipped the song.", delete_after=10)
player.skip_votes.clear()
return await player.stop()
if ctx.author == player.current.requester:
await ctx.send("The song requester has skipped the song.", delete_after=10)
player.skip_votes.clear()
return await player.stop()
required = self.required(ctx)
player.skip_votes.add(ctx.author)
if len(player.skip_votes) >= required:
await ctx.send("Vote to skip passed. Skipping song.", delete_after=10)
player.skip_votes.clear()
await player.stop()
else:
await ctx.send(
f"{ctx.author.mention} has voted to skip the song. Votes: {len(player.skip_votes)}/{required} ",
delete_after=15,
)
@commands.command()
async def stop(self, ctx: commands.Context):
"""Stop the player and clear all internal states."""
if not (player := ctx.voice_client):
return await ctx.send(
"You must have the bot in a channel in order to use this command",
delete_after=7,
)
if not player.is_connected:
return
if self.is_privileged(ctx):
await ctx.send("An admin or DJ has stopped the player.", delete_after=10)
return await player.teardown()
required = self.required(ctx)
player.stop_votes.add(ctx.author)
if len(player.stop_votes) >= required:
await ctx.send("Vote to stop passed. Stopping the player.", delete_after=10)
await player.teardown()
else:
await ctx.send(
f"{ctx.author.mention} has voted to stop the player. Votes: {len(player.stop_votes)}/{required}",
delete_after=15,
)
@commands.command(aliases=["mix", "shuf"])
async def shuffle(self, ctx: commands.Context):
"""Shuffle the players queue."""
if not (player := ctx.voice_client):
return await ctx.send(
"You must have the bot in a channel in order to use this command",
delete_after=7,
)
if not player.is_connected:
return
if player.queue.qsize() < 3:
return await ctx.send(
"The queue is empty. Add some songs to shuffle the queue.",
delete_after=15,
)
if self.is_privileged(ctx):
await ctx.send("An admin or DJ has shuffled the queue.", delete_after=10)
player.shuffle_votes.clear()
return player.queue.shuffle()
required = self.required(ctx)
player.shuffle_votes.add(ctx.author)
if len(player.shuffle_votes) >= required:
await ctx.send("Vote to shuffle passed. Shuffling the queue.", delete_after=10)
player.shuffle_votes.clear()
player.queue.shuffle()
else:
await ctx.send(
f"{ctx.author.mention} has voted to shuffle the queue. Votes: {len(player.shuffle_votes)}/{required}",
delete_after=15,
)
@commands.command(aliases=["v", "vol"])
async def volume(self, ctx: commands.Context, *, vol: int):
"""Change the players volume, between 1 and 100."""
if not (player := ctx.voice_client):
return await ctx.send(
"You must have the bot in a channel in order to use this command",
delete_after=7,
)
if not player.is_connected:
return
if not self.is_privileged(ctx):
return await ctx.send("Only the DJ or admins may change the volume.")
if not 0 < vol < 101:
return await ctx.send("Please enter a value between 1 and 100.")
await player.set_volume(vol)
await ctx.send(f"Set the volume to **{vol}**%", delete_after=7)
async def setup(bot: commands.Bot):
await bot.add_cog(Music(bot))

136
examples/basic.py Normal file
View File

@ -0,0 +1,136 @@
# type: ignore
import discord
from discord.ext import commands
import pomice
class MyBot(commands.Bot):
def __init__(self) -> None:
super().__init__(
command_prefix="!",
activity=discord.Activity(
type=discord.ActivityType.listening,
name="to music!",
),
)
self.add_cog(Music(self))
self.loop.create_task(self.cogs["Music"].start_nodes())
async def on_ready(self) -> None:
print("I'm online!")
class Music(commands.Cog):
def __init__(self, bot: commands.Bot) -> None:
self.bot = bot
# In order to initialize a node, or really do anything in this library,
# you need to make a node pool
self.pomice = pomice.NodePool()
async def start_nodes(self):
# You can pass in Spotify credentials to enable Spotify querying.
# If you do not pass in valid Spotify credentials, Spotify querying will not work
await self.pomice.create_node(
bot=self.bot,
host="127.0.0.1",
port=3030,
password="youshallnotpass",
identifier="MAIN",
)
print(f"Node is ready!")
@commands.command(aliases=["connect"])
async def join(self, ctx: commands.Context, *, channel: discord.VoiceChannel = None) -> None:
if not channel:
channel = getattr(ctx.author.voice, "channel", None)
if not channel:
raise commands.CheckFailure(
"You must be in a voice channel to use this command "
"without specifying the channel argument.",
)
# With the release of discord.py 1.7, you can now add a compatible
# VoiceProtocol class as an argument in VoiceChannel.connect().
# This library takes advantage of that and is how you initialize a player.
await ctx.author.voice.channel.connect(cls=pomice.Player)
await ctx.send(f"Joined the voice channel `{channel}`")
@commands.command(aliases=["dc", "disconnect"])
async def leave(self, ctx: commands.Context):
if not ctx.voice_client:
raise commands.CommandError("No player detected")
player: pomice.Player = ctx.voice_client
await player.destroy()
await ctx.send("Player has left the channel.")
@commands.command(aliases=["p"])
async def play(self, ctx: commands.Context, *, search: str) -> None:
# Checks if the player is in the channel before we play anything
if not ctx.voice_client:
await ctx.invoke(self.join)
player: pomice.Player = ctx.voice_client
# If you search a keyword, Pomice will automagically search the result using YouTube
# You can pass in "search_type=" as an argument to change the search type
# i.e: player.get_tracks("query", search_type=SearchType.ytmsearch)
# will search up any keyword results on YouTube Music
results = await player.get_tracks(search)
if not results:
raise commands.CommandError(
"No results were found for that search term.",
)
if isinstance(results, pomice.Playlist):
await player.play(track=results.tracks[0])
else:
await player.play(track=results[0])
@commands.command()
async def pause(self, ctx: commands.Context):
if not ctx.voice_client:
raise commands.CommandError("No player detected")
player: pomice.Player = ctx.voice_client
if player.is_paused:
return await ctx.send("Player is already paused!")
await player.set_pause(pause=True)
await ctx.send("Player has been paused")
@commands.command()
async def resume(self, ctx: commands.Context):
if not ctx.voice_client:
raise commands.CommandError("No player detected")
player: pomice.Player = ctx.voice_client
if not player.is_paused:
return await ctx.send("Player is already playing!")
await player.set_pause(pause=False)
await ctx.send("Player has been resumed")
@commands.command()
async def stop(self, ctx: commands.Context):
if not ctx.voice_client:
raise commands.CommandError("No player detected")
player: pomice.Player = ctx.voice_client
if not player.is_playing:
return await ctx.send("Player is already stopped!")
await player.stop()
await ctx.send("Player has been stopped")
bot = MyBot()
bot.run("token")

View File

@ -1,14 +1,37 @@
"""Pomice wrapper for Lavalink, made possible by cloudwithax 2021"""
"""
Pomice
~~~~~~
The modern Lavalink wrapper designed for discord.py.
__version__ = "1.0.1"
Copyright (c) 2024, cloudwithax
Licensed under GPL-3.0
"""
import discord
if not discord.version_info.major >= 2:
class DiscordPyOutdated(Exception):
pass
raise DiscordPyOutdated(
"You must have discord.py (v2.0 or greater) to use this library. "
"Uninstall your current version and install discord.py 2.0 "
"using 'pip install discord.py'",
)
__version__ = "2.10.0"
__title__ = "pomice"
__author__ = "cloudwithax"
__license__ = "GPL-3.0"
__copyright__ = "Copyright (c) 2023, cloudwithax"
from .exceptions import *
from .enums import *
from .events import *
from .exceptions import *
from .filters import *
from .objects import *
from .pool import NodePool
from .node import Node
from .player import Player
from .queue import *
from .player import *
from .pool import *
from .routeplanner import *

View File

@ -0,0 +1,4 @@
"""Apple Music module for Pomice, made possible by cloudwithax 2023"""
from .client import *
from .exceptions import *
from .objects import *

298
pomice/applemusic/client.py Normal file
View File

@ -0,0 +1,298 @@
from __future__ import annotations
import asyncio
import base64
import logging
import re
from datetime import datetime
from typing import AsyncGenerator
from typing import Dict
from typing import List
from typing import Optional
from typing import Union
import aiohttp
import orjson as json
from .exceptions import *
from .objects import *
__all__ = ("Client",)
AM_URL_REGEX = re.compile(
r"https?://music\.apple\.com/(?P<country>[a-zA-Z]{2})/(?P<type>album|playlist|song|artist)/(?P<name>.+?)/(?P<id>[^/?]+?)(?:/)?(?:\?.*)?$",
)
AM_SINGLE_IN_ALBUM_REGEX = re.compile(
r"https?://music\.apple\.com/(?P<country>[a-zA-Z]{2})/(?P<type>album|playlist|song|artist)/(?P<name>.+)/(?P<id>[^/?]+)(\?i=)(?P<id2>[^&]+)(?:&.*)?$",
)
AM_SCRIPT_REGEX = re.compile(r'<script.*?src="(/assets/index-.*?)"')
AM_REQ_URL = "https://api.music.apple.com/v1/catalog/{country}/{type}s/{id}"
AM_BASE_URL = "https://api.music.apple.com"
class Client:
"""The base Apple Music client for Pomice.
This will do all the heavy lifting of getting tracks from Apple Music
and translating it to a valid Lavalink track. No client auth is required here.
"""
def __init__(self, *, playlist_concurrency: int = 6) -> None:
self.expiry: datetime = datetime(1970, 1, 1)
self.token: str = ""
self.headers: Dict[str, str] = {}
self.session: aiohttp.ClientSession = None # type: ignore
self._log = logging.getLogger(__name__)
# Concurrency knob for parallel playlist page retrieval
self._playlist_concurrency = max(1, playlist_concurrency)
async def _set_session(self, session: aiohttp.ClientSession) -> None:
self.session = session
async def request_token(self) -> None:
# First lets get the raw response from the main page
resp = await self.session.get("https://music.apple.com")
if resp.status != 200:
raise AppleMusicRequestException(
f"Error while fetching results: {resp.status} {resp.reason}",
)
# Looking for script tag that fits criteria
text = await resp.text()
match = re.search(AM_SCRIPT_REGEX, text)
if not match:
raise AppleMusicRequestException(
"Could not find valid script URL in response.",
)
# Found the script file, lets grab our token
result = match.group(1)
asset_url = result
resp = await self.session.get("https://music.apple.com" + asset_url)
if resp.status != 200:
raise AppleMusicRequestException(
f"Error while fetching results: {resp.status} {resp.reason}",
)
text = await resp.text()
match = re.search('"(eyJ.+?)"', text)
if not match:
raise AppleMusicRequestException(
"Could not find token in response.",
)
result = match.group(1)
self.token = result
self.headers = {
"Authorization": f"Bearer {result}",
"Origin": "https://apple.com",
}
token_split = self.token.split(".")[1]
token_json = base64.b64decode(
token_split + "=" * (-len(token_split) % 4),
).decode()
token_data = json.loads(token_json)
self.expiry = datetime.fromtimestamp(token_data["exp"])
if self._log:
self._log.debug(f"Fetched Apple Music bearer token successfully")
async def search(self, query: str) -> Union[Album, Playlist, Song, Artist]:
if not self.token or datetime.utcnow() > self.expiry:
await self.request_token()
result = AM_URL_REGEX.match(query)
if not result:
raise InvalidAppleMusicURL(
"The Apple Music link provided is not valid.",
)
country = result.group("country")
type = result.group("type")
id = result.group("id")
if type == "album" and (sia_result := AM_SINGLE_IN_ALBUM_REGEX.match(query)):
# apple music likes to generate links for singles off an album
# by adding a param at the end of the url
# so we're gonna scan for that and correct it
id = sia_result.group("id2")
type = "song"
request_url = AM_REQ_URL.format(country=country, type=type, id=id)
else:
request_url = AM_REQ_URL.format(country=country, type=type, id=id)
resp = await self.session.get(request_url, headers=self.headers)
if resp.status != 200:
raise AppleMusicRequestException(
f"Error while fetching results: {resp.status} {resp.reason}",
)
data: dict = await resp.json(loads=json.loads)
if self._log:
self._log.debug(
f"Made request to Apple Music API with status {resp.status} and response {data}",
)
data = data["data"][0]
if type == "song":
return Song(data)
elif type == "album":
return Album(data)
elif type == "artist":
resp = await self.session.get(
f"{request_url}/view/top-songs",
headers=self.headers,
)
if resp.status != 200:
raise AppleMusicRequestException(
f"Error while fetching results: {resp.status} {resp.reason}",
)
top_tracks: dict = await resp.json(loads=json.loads)
artist_tracks: dict = top_tracks["data"]
return Artist(data, tracks=artist_tracks)
else:
track_data: dict = data["relationships"]["tracks"]
album_tracks: List[Song] = [Song(track) for track in track_data["data"]]
if not len(album_tracks):
raise AppleMusicRequestException(
"This playlist is empty and therefore cannot be queued.",
)
# Apple Music uses cursor pagination with 'next'. We'll fetch subsequent pages
# concurrently by first collecting cursors in rolling waves.
next_cursor = track_data.get("next")
semaphore = asyncio.Semaphore(self._playlist_concurrency)
async def fetch_page(url: str) -> List[Song]:
async with semaphore:
resp = await self.session.get(url, headers=self.headers)
if resp.status != 200:
if self._log:
self._log.warning(
f"Apple Music page fetch failed {resp.status} {resp.reason} for {url}",
)
return []
pj: dict = await resp.json(loads=json.loads)
songs = [Song(track) for track in pj.get("data", [])]
# Return songs; we will look for pj.get('next') in streaming iterator variant
return songs, pj.get("next") # type: ignore
# We'll implement a wave-based approach similar to Spotify but need to follow cursors.
# Because we cannot know all cursors upfront, we'll iteratively fetch waves.
waves: List[List[Song]] = []
cursors: List[str] = []
if next_cursor:
cursors.append(next_cursor)
# Limit total waves to avoid infinite loops in malformed responses
max_waves = 50
wave_size = self._playlist_concurrency * 2
wave_counter = 0
while cursors and wave_counter < max_waves:
current = cursors[:wave_size]
cursors = cursors[wave_size:]
tasks = [
fetch_page(AM_BASE_URL + cursor) for cursor in current # type: ignore[arg-type]
]
results = await asyncio.gather(*tasks, return_exceptions=True)
for res in results:
if isinstance(res, tuple): # (songs, next)
songs, nxt = res
if songs:
waves.append(songs)
if nxt:
cursors.append(nxt)
wave_counter += 1
for w in waves:
album_tracks.extend(w)
return Playlist(data, album_tracks)
async def iter_playlist_tracks(
self,
*,
query: str,
batch_size: int = 100,
) -> AsyncGenerator[List[Song], None]:
"""Stream Apple Music playlist tracks in batches.
Parameters
----------
query: str
Apple Music playlist URL.
batch_size: int
Logical grouping size for yielded batches.
"""
if not self.token or datetime.utcnow() > self.expiry:
await self.request_token()
result = AM_URL_REGEX.match(query)
if not result or result.group("type") != "playlist":
raise InvalidAppleMusicURL("Provided query is not a valid Apple Music playlist URL.")
country = result.group("country")
playlist_id = result.group("id")
request_url = AM_REQ_URL.format(country=country, type="playlist", id=playlist_id)
resp = await self.session.get(request_url, headers=self.headers)
if resp.status != 200:
raise AppleMusicRequestException(
f"Error while fetching results: {resp.status} {resp.reason}",
)
data: dict = await resp.json(loads=json.loads)
playlist_data = data["data"][0]
track_data: dict = playlist_data["relationships"]["tracks"]
first_page_tracks = [Song(track) for track in track_data["data"]]
for i in range(0, len(first_page_tracks), batch_size):
yield first_page_tracks[i : i + batch_size]
next_cursor = track_data.get("next")
semaphore = asyncio.Semaphore(self._playlist_concurrency)
async def fetch(cursor: str) -> tuple[List[Song], Optional[str]]:
url = AM_BASE_URL + cursor
async with semaphore:
r = await self.session.get(url, headers=self.headers)
if r.status != 200:
if self._log:
self._log.warning(
f"Skipping Apple Music page due to {r.status} {r.reason}",
)
return [], None
pj: dict = await r.json(loads=json.loads)
songs = [Song(track) for track in pj.get("data", [])]
return songs, pj.get("next")
# Rolling waves of fetches following cursor chain
max_waves = 50
wave_size = self._playlist_concurrency * 2
waves = 0
cursors: List[str] = []
if next_cursor:
cursors.append(next_cursor)
while cursors and waves < max_waves:
current = cursors[:wave_size]
cursors = cursors[wave_size:]
results = await asyncio.gather(*[fetch(c) for c in current])
for songs, nxt in results:
if songs:
for j in range(0, len(songs), batch_size):
yield songs[j : j + batch_size]
if nxt:
cursors.append(nxt)
waves += 1

View File

@ -0,0 +1,16 @@
__all__ = (
"AppleMusicRequestException",
"InvalidAppleMusicURL",
)
class AppleMusicRequestException(Exception):
"""An error occurred when making a request to the Apple Music API"""
pass
class InvalidAppleMusicURL(Exception):
"""An invalid Apple Music URL was passed"""
pass

View File

@ -0,0 +1,92 @@
"""Module for managing Apple Music objects"""
from typing import List
__all__ = (
"Song",
"Playlist",
"Album",
"Artist",
)
class Song:
"""The base class for an Apple Music song"""
def __init__(self, data: dict) -> None:
self.name: str = data["attributes"]["name"]
self.url: str = data["attributes"]["url"]
self.isrc: str = data["attributes"]["isrc"]
self.length: float = data["attributes"]["durationInMillis"]
self.id: str = data["id"]
self.artists: str = data["attributes"]["artistName"]
self.image: str = data["attributes"]["artwork"]["url"].replace(
"{w}x{h}",
f'{data["attributes"]["artwork"]["width"]}x{data["attributes"]["artwork"]["height"]}',
)
def __repr__(self) -> str:
return (
f"<Pomice.applemusic.Song name={self.name} artists={self.artists} "
f"length={self.length} id={self.id} isrc={self.isrc}>"
)
class Playlist:
"""The base class for an Apple Music playlist"""
def __init__(self, data: dict, tracks: List[Song]) -> None:
self.name: str = data["attributes"]["name"]
self.owner: str = data["attributes"]["curatorName"]
self.id: str = data["id"]
self.tracks: List[Song] = tracks
self.total_tracks: int = len(tracks)
self.url: str = data["attributes"]["url"]
# we'll use the first song's image as the image for the playlist
# because apple dynamically generates playlist covers client-side
self.image = self.tracks[0].image
def __repr__(self) -> str:
return (
f"<Pomice.applemusic.Playlist name={self.name} owner={self.owner} id={self.id} "
f"total_tracks={self.total_tracks} tracks={self.tracks}>"
)
class Album:
"""The base class for an Apple Music album"""
def __init__(self, data: dict) -> None:
self.name: str = data["attributes"]["name"]
self.url: str = data["attributes"]["url"]
self.id: str = data["id"]
self.artists: str = data["attributes"]["artistName"]
self.total_tracks: int = data["attributes"]["trackCount"]
self.tracks: List[Song] = [Song(track) for track in data["relationships"]["tracks"]["data"]]
self.image: str = data["attributes"]["artwork"]["url"].replace(
"{w}x{h}",
f'{data["attributes"]["artwork"]["width"]}x{data["attributes"]["artwork"]["height"]}',
)
def __repr__(self) -> str:
return (
f"<Pomice.applemusic.Album name={self.name} artists={self.artists} id={self.id} "
f"total_tracks={self.total_tracks} tracks={self.tracks}>"
)
class Artist:
"""The base class for an Apple Music artist"""
def __init__(self, data: dict, tracks: dict) -> None:
self.name: str = f'Top tracks for {data["attributes"]["name"]}'
self.url: str = data["attributes"]["url"]
self.id: str = data["id"]
self.genres: str = ", ".join(genre for genre in data["attributes"]["genreNames"])
self.tracks: List[Song] = [Song(track) for track in tracks]
self.image: str = data["attributes"]["artwork"]["url"].replace(
"{w}x{h}",
f'{data["attributes"]["artwork"]["width"]}x{data["attributes"]["artwork"]["height"]}',
)
def __repr__(self) -> str:
return f"<Pomice.applemusic.Artist name={self.name} id={self.id} " f"tracks={self.tracks}>"

308
pomice/enums.py Normal file
View File

@ -0,0 +1,308 @@
import re
from enum import Enum
from enum import IntEnum
__all__ = (
"SearchType",
"TrackType",
"PlaylistType",
"NodeAlgorithm",
"LoopMode",
"RouteStrategy",
"RouteIPType",
"URLRegex",
"LogLevel",
)
class SearchType(Enum):
"""
The enum for the different search types for Pomice.
This feature is exclusively for the Spotify search feature of Pomice.
If you are not using this feature, this class is not necessary.
SearchType.ytsearch searches using regular Youtube,
which is best for all scenarios.
SearchType.ytmsearch searches using YouTube Music,
which is best for getting audio-only results.
SearchType.scsearch searches using SoundCloud,
which is an alternative to YouTube or YouTube Music.
"""
ytsearch = "ytsearch"
ytmsearch = "ytmsearch"
scsearch = "scsearch"
other = "other"
@classmethod
def _missing_(cls, value: object) -> "SearchType": # type: ignore[override]
return cls.other
def __str__(self) -> str:
return self.value
class TrackType(Enum):
"""
The enum for the different track types for Pomice.
TrackType.YOUTUBE defines that the track is from YouTube
TrackType.SOUNDCLOUD defines that the track is from SoundCloud.
TrackType.SPOTIFY defines that the track is from Spotify
TrackType.APPLE_MUSIC defines that the track is from Apple Music.
TrackType.HTTP defines that the track is from an HTTP source.
TrackType.LOCAL defines that the track is from a local source.
TrackType.OTHER defines that the track is from an unknown source (possible from 3rd-party plugins).
"""
# We don't have to define anything special for these, since these just serve as flags
YOUTUBE = "youtube"
SOUNDCLOUD = "soundcloud"
SPOTIFY = "spotify"
APPLE_MUSIC = "apple_music"
HTTP = "http"
LOCAL = "local"
OTHER = "other"
@classmethod
def _missing_(cls, value: object) -> "TrackType": # type: ignore[override]
return cls.OTHER
def __str__(self) -> str:
return self.value
class PlaylistType(Enum):
"""
The enum for the different playlist types for Pomice.
PlaylistType.YOUTUBE defines that the playlist is from YouTube
PlaylistType.SOUNDCLOUD defines that the playlist is from SoundCloud.
PlaylistType.SPOTIFY defines that the playlist is from Spotify
PlaylistType.APPLE_MUSIC defines that the playlist is from Apple Music.
PlaylistType.OTHER defines that the playlist is from an unknown source (possible from 3rd-party plugins).
"""
# We don't have to define anything special for these, since these just serve as flags
YOUTUBE = "youtube"
SOUNDCLOUD = "soundcloud"
SPOTIFY = "spotify"
APPLE_MUSIC = "apple_music"
OTHER = "other"
@classmethod
def _missing_(cls, value: object) -> "PlaylistType": # type: ignore[override]
return cls.OTHER
def __str__(self) -> str:
return self.value
class NodeAlgorithm(Enum):
"""
The enum for the different node algorithms in Pomice.
The enums in this class are to only differentiate different
methods, since the actual method is handled in the
get_best_node() method.
NodeAlgorithm.by_ping returns a node based on it's latency,
preferring a node with the lowest response time
NodeAlgorithm.by_players return a nodes based on how many players it has.
This algorithm prefers nodes with the least amount of players.
"""
# We don't have to define anything special for these, since these just serve as flags
by_ping = "BY_PING"
by_players = "BY_PLAYERS"
def __str__(self) -> str:
return self.value
class LoopMode(Enum):
"""
The enum for the different loop modes.
This feature is exclusively for the queue utility of pomice.
If you are not using this feature, this class is not necessary.
LoopMode.TRACK sets the queue loop to the current track.
LoopMode.QUEUE sets the queue loop to the whole queue.
"""
# We don't have to define anything special for these, since these just serve as flags
TRACK = "track"
QUEUE = "queue"
def __str__(self) -> str:
return self.value
class RouteStrategy(Enum):
"""
The enum for specifying the route planner strategy for Lavalink.
This feature is exclusively for the RoutePlanner class.
If you are not using this feature, this class is not necessary.
RouteStrategy.ROTATE_ON_BAN specifies that the node is rotating IPs
whenever they get banned by Youtube.
RouteStrategy.LOAD_BALANCE specifies that the node is selecting
random IPs to balance out requests between them.
RouteStrategy.NANO_SWITCH specifies that the node is switching
between IPs every CPU clock cycle.
RouteStrategy.ROTATING_NANO_SWITCH specifies that the node is switching
between IPs every CPU clock cycle and is rotating between IP blocks on
ban.
"""
ROTATE_ON_BAN = "RotatingIpRoutePlanner"
LOAD_BALANCE = "BalancingIpRoutePlanner"
NANO_SWITCH = "NanoIpRoutePlanner"
ROTATING_NANO_SWITCH = "RotatingNanoIpRoutePlanner"
class RouteIPType(Enum):
"""
The enum for specifying the route planner IP block type for Lavalink.
This feature is exclusively for the RoutePlanner class.
If you are not using this feature, this class is not necessary.
RouteIPType.IPV4 specifies that the IP block type is IPV4
RouteIPType.IPV6 specifies that the IP block type is IPV6
"""
IPV4 = "Inet4Address"
IPV6 = "Inet6Address"
class URLRegex:
"""
The enum for all the URL Regexes in use by Pomice.
URLRegex.SPOTIFY_URL returns the Spotify URL Regex.
URLRegex.DISCORD_MP3_URL returns the Discord MP3 URL Regex.
URLRegex.YOUTUBE_URL returns the Youtube URL Regex.
URLRegex.YOUTUBE_PLAYLIST returns the Youtube Playlist Regex.
URLRegex.YOUTUBE_TIMESTAMP returns the Youtube Timestamp Regex.
URLRegex.AM_URL returns the Apple Music URL Regex.
URLRegex.SOUNDCLOUD_URL returns the SoundCloud URL Regex.
URLRegex.BASE_URL returns the standard URL Regex.
"""
# Spotify share links can include query parameters like ?si=XXXX, a trailing slash,
# or an intl locale segment (e.g. /intl-en/). Broaden the regex so we still capture
# the type and id while ignoring extra parameters. This prevents the URL from being
# treated as a generic Lavalink identifier and ensures internal Spotify handling runs.
SPOTIFY_URL = re.compile(
r"https?://open\.spotify\.com/(?:intl-[a-zA-Z-]+/)?(?P<type>album|playlist|track|artist)/(?P<id>[a-zA-Z0-9]+)(?:/)?(?:\?.*)?$",
)
DISCORD_MP3_URL = re.compile(
r"https?://cdn.discordapp.com/attachments/(?P<channel_id>[0-9]+)/"
r"(?P<message_id>[0-9]+)/(?P<file>[a-zA-Z0-9_.]+)+",
)
YOUTUBE_URL = re.compile(
r"^((?:https?:)?\/\/)?((?:www|m)\.)?((?:youtube\.com|youtu.be))"
r"(\/(?:[\w\-]+\?v=|embed\/|v\/)?)([\w\-]+)(\S+)?$",
)
YOUTUBE_PLAYLIST_URL = re.compile(
r"^((?:https?:)?\/\/)?((?:www|m)\.)?((?:youtube\.com|youtu.be))/playlist\?list=.*",
)
YOUTUBE_TIMESTAMP = re.compile(
r"(?P<video>^.*?)(\?t|&start)=(?P<time>\d+)?.*",
)
# Apple Music links sometimes append additional query parameters (e.g. &l=en, &uo=4).
# Allow arbitrary query parameters so valid links are captured and parsed.
AM_URL = re.compile(
r"https?://music\.apple\.com/(?P<country>[a-zA-Z]{2})/"
r"(?P<type>album|playlist|song|artist)/(?P<name>.+?)/(?P<id>[^/?]+?)(?:/)?(?:\?.*)?$",
)
# Single-in-album links may also carry extra query params beyond the ?i=<trackid> token.
AM_SINGLE_IN_ALBUM_REGEX = re.compile(
r"https?://music\.apple\.com/(?P<country>[a-zA-Z]{2})/(?P<type>album|playlist|song|artist)/"
r"(?P<name>.+)/(?P<id>[^/?]+)(\?i=)(?P<id2>[^&]+)(?:&.*)?$",
)
SOUNDCLOUD_URL = re.compile(
r"((?:https?:)?\/\/)?((?:www|m)\.)?soundcloud.com\/.*/.*",
)
SOUNDCLOUD_PLAYLIST_URL = re.compile(
r"^(https?:\/\/)?(www.)?(m\.)?soundcloud\.com\/.*/sets/.*",
)
SOUNDCLOUD_TRACK_IN_SET_URL = re.compile(
r"^(https?:\/\/)?(www.)?(m\.)?soundcloud\.com/[a-zA-Z0-9-._]+/[a-zA-Z0-9-._]+(\?in)",
)
LAVALINK_SEARCH = re.compile(r"(?P<type>ytm?|sc)search:")
BASE_URL = re.compile(r"https?://(?:www\.)?.+")
class LogLevel(IntEnum):
"""
The enum for specifying the logging level within Pomice.
This class serves as shorthand for logging.<level>
This enum is exclusively for the logging feature in Pomice.
If you are not using this feature, this class is not necessary.
LogLevel.DEBUG sets the logging level to "debug".
LogLevel.INFO sets the logging level to "info".
LogLevel.WARN sets the logging level to "warn".
LogLevel.ERROR sets the logging level to "error".
LogLevel.CRITICAL sets the logging level to "CRITICAL".
"""
DEBUG = 10
INFO = 20
WARN = 30
ERROR = 40
CRITICAL = 50
@classmethod
def from_str(cls, level_str):
try:
return cls[level_str.upper()]
except KeyError:
raise ValueError(f"No such log level: {level_str}")

View File

@ -1,85 +1,197 @@
from __future__ import annotations
from abc import ABC
from typing import Any
from typing import Optional
from typing import Tuple
from typing import TYPE_CHECKING
from discord import Client
from discord import Guild
from discord.ext import commands
from .objects import Track
from .pool import NodePool
if TYPE_CHECKING:
from .player import Player
class PomiceEvent:
def __init__(self):
pass
__all__ = (
"PomiceEvent",
"TrackStartEvent",
"TrackEndEvent",
"TrackStuckEvent",
"TrackExceptionEvent",
"WebSocketClosedPayload",
"WebSocketClosedEvent",
"WebSocketOpenEvent",
)
class PomiceEvent(ABC):
"""The base class for all events dispatched by a node.
Every event must be formatted within your bot's code as a listener.
i.e: If you want to listen for when a track starts, the event would be:
```py
@bot.listen
async def on_pomice_track_start(self, event):
```
"""
name = "event"
handler_args: Tuple
def dispatch(self, bot: Client) -> None:
bot.dispatch(f"pomice_{self.name}", *self.handler_args)
name = 'event'
class TrackStartEvent(PomiceEvent):
def __init__(self, data):
super().__init__()
"""Fired when a track has successfully started.
Returns the player associated with the event and the pomice.Track object.
"""
self.name = "track_start"
self.player = NodePool.get_node().get_player(int(data["guildId"]))
self.track_id = data['track']
name = "track_start"
__slots__ = (
"player",
"track",
)
def __init__(self, data: dict, player: Player):
self.player: Player = player
self.track: Optional[Track] = self.player._current
# on_pomice_track_start(player, track)
self.handler_args = self.player, self.track
def __repr__(self) -> str:
return f"<Pomice.TrackStartEvent track_id={self.track_id}>"
return f"<Pomice.TrackStartEvent player={self.player!r} track={self.track!r}>"
class TrackEndEvent(PomiceEvent):
def __init__(self, data):
super().__init__()
"""Fired when a track has successfully ended.
Returns the player associated with the event along with the pomice.Track object and reason.
"""
self.name = "track_end"
self.player = NodePool.get_node().get_player(int(data["guildId"]))
self.track_id = data['track']
self.reason = data['reason']
name = "track_end"
__slots__ = ("player", "track", "reason")
def __init__(self, data: dict, player: Player):
self.player: Player = player
self.track: Optional[Track] = self.player._ending_track
self.reason: str = data["reason"]
# on_pomice_track_end(player, track, reason)
self.handler_args = self.player, self.track, self.reason
def __repr__(self) -> str:
return f"<Pomice.TrackEndEvent track_id={self.track_id} reason={self.reason}>"
return (
f"<Pomice.TrackEndEvent player={self.player!r} track_id={self.track!r} "
f"reason={self.reason!r}>"
)
class TrackStuckEvent(PomiceEvent):
def __init__(self, data):
super().__init__()
"""Fired when a track is stuck and cannot be played. Returns the player
associated with the event along with the pomice.Track object
to be further parsed by the end user.
"""
self.name = "track_stuck"
self.player = NodePool.get_node().get_player(int(data["guildId"]))
name = "track_stuck"
self.track_id = data["track"]
self.threshold = data["thresholdMs"]
__slots__ = ("player", "track", "threshold")
def __init__(self, data: dict, player: Player):
self.player: Player = player
self.track: Optional[Track] = self.player._ending_track
self.threshold: float = data["thresholdMs"]
# on_pomice_track_stuck(player, track, threshold)
self.handler_args = self.player, self.track, self.threshold
def __repr__(self) -> str:
return f"<Pomice.TrackStuckEvent track_id={self.track_id} threshold={self.threshold}>"
return (
f"<Pomice.TrackStuckEvent player={self.player!r} track={self.track!r} "
f"threshold={self.threshold!r}>"
)
class TrackExceptionEvent(PomiceEvent):
def __init__(self, data):
super().__init__()
"""Fired when a track error has occured.
Returns the player associated with the event along with the error code and exception.
"""
self.name = "track_exception"
self.player = NodePool.get_node().get_player(int(data["guildId"]))
name = "track_exception"
self.error = data["error"]
self.exception = data["exception"]
__slots__ = ("player", "track", "exception")
def __init__(self, data: dict, player: Player):
self.player: Player = player
self.track: Optional[Track] = self.player._ending_track
# Error is for Lavalink <= 3.3
self.exception: str = data.get(
"error",
"",
) or data.get("exception", "")
# on_pomice_track_exception(player, track, error)
self.handler_args = self.player, self.track, self.exception
def __repr__(self) -> str:
return f"<Pomice.TrackExceptionEvent> error={self.error} exeception={self.exception}"
return f"<Pomice.TrackExceptionEvent player={self.player!r} exception={self.exception!r}>"
class WebsocketClosedEvent(PomiceEvent):
def __init__(self, data):
super().__init__()
self.name = "websocket_closed"
self.player = NodePool.get_node().get_player(int(data["guildId"]))
class WebSocketClosedPayload:
__slots__ = ("guild", "code", "reason", "by_remote")
self.reason = data["reason"]
self.code = data["code"]
def __init__(self, data: dict):
self.guild: Optional[Guild] = NodePool.get_node().bot.get_guild(int(data["guildId"]))
self.code: int = data["code"]
self.reason: str = data["code"]
self.by_remote: bool = data["byRemote"]
def __repr__(self) -> str:
return f"<Pomice.WebsocketClosedEvent reason={self.reason} code={self.code}>"
return (
f"<Pomice.WebSocketClosedPayload guild={self.guild!r} code={self.code!r} "
f"reason={self.reason!r} by_remote={self.by_remote!r}>"
)
class WebsocketOpenEvent(PomiceEvent):
def __init__(self, data):
super().__init__()
self.name = "websocket_open"
self.player = NodePool.get_node().get_player(int(data["guildId"]))
class WebSocketClosedEvent(PomiceEvent):
"""Fired when a websocket connection to a node has been closed.
Returns the reason and the error code.
"""
self.target: str = data['target']
self.ssrc: int = data['ssrc']
name = "websocket_closed"
__slots__ = ("payload",)
def __init__(self, data: dict, _: Any) -> None:
self.payload: WebSocketClosedPayload = WebSocketClosedPayload(data)
# on_pomice_websocket_closed(payload)
self.handler_args = (self.payload,)
def __repr__(self) -> str:
return f"<Pomice.WebsocketOpenEvent target={self.target} ssrc={self.ssrc}>"
return f"<Pomice.WebsocketClosedEvent payload={self.payload!r}>"
class WebSocketOpenEvent(PomiceEvent):
"""Fired when a websocket connection to a node has been initiated.
Returns the target and the session SSRC.
"""
name = "websocket_open"
__slots__ = ("target", "ssrc")
def __init__(self, data: dict, _: Any) -> None:
self.target: str = data["target"]
self.ssrc: int = data["ssrc"]
# on_pomice_websocket_open(target, ssrc)
self.handler_args = self.target, self.ssrc
def __repr__(self) -> str:
return f"<Pomice.WebsocketOpenEvent target={self.target!r} ssrc={self.ssrc!r}>"

View File

@ -1,3 +1,26 @@
__all__ = (
"PomiceException",
"NodeException",
"NodeCreationError",
"NodeConnectionFailure",
"NodeConnectionClosed",
"NodeRestException",
"NodeNotAvailable",
"NoNodesAvailable",
"TrackInvalidPosition",
"TrackLoadError",
"FilterInvalidArgument",
"FilterTagInvalid",
"FilterTagAlreadyInUse",
"InvalidSpotifyClientAuthorization",
"AppleMusicNotEnabled",
"QueueException",
"QueueFull",
"QueueEmpty",
"LavalinkVersionIncompatible",
)
class PomiceException(Exception):
"""Base of all Pomice exceptions."""
@ -15,30 +38,90 @@ class NodeConnectionFailure(NodeException):
class NodeConnectionClosed(NodeException):
"""The nodes connection is closed."""
"""The node's connection is closed."""
pass
class NodeRestException(NodeException):
"""A request made using the node's REST uri failed"""
pass
class NodeNotAvailable(PomiceException):
"""The node is not currently available."""
"""The node is currently unavailable."""
pass
class NoNodesAvailable(PomiceException):
"""There are no nodes currently available."""
pass
class TrackInvalidPosition(PomiceException):
"""An invalid position was chosen for a track."""
pass
class TrackLoadError(PomiceException):
"""There was an error while loading a track."""
pass
class FilterInvalidArgument(PomiceException):
"""An invalid argument was passed to a filter."""
pass
class FilterTagInvalid(PomiceException):
"""An invalid tag was passed or Pomice was unable to find a filter tag"""
pass
class FilterTagAlreadyInUse(PomiceException):
"""A filter with a tag is already in use by another filter"""
pass
class InvalidSpotifyClientAuthorization(PomiceException):
"""No Spotify client authorization was provided for track searching."""
pass
class AppleMusicNotEnabled(PomiceException):
"""An Apple Music Link was passed in when Apple Music functionality was not enabled."""
pass
class QueueException(Exception):
"""Base Pomice queue exception."""
pass
class QueueFull(QueueException):
"""Exception raised when attempting to add to a full Queue."""
pass
class QueueEmpty(QueueException):
"""Exception raised when attempting to retrieve from an empty Queue."""
pass
class LavalinkVersionIncompatible(PomiceException):
"""Lavalink version is incompatible. Must be using Lavalink > 3.7.0 to avoid this error."""
pass

View File

@ -1,90 +1,574 @@
from . import exceptions
import collections
from typing import Any
from typing import Dict
from typing import List
from typing import Optional
from typing import Tuple
from .exceptions import FilterInvalidArgument
__all__ = (
"Filter",
"Equalizer",
"Timescale",
"Karaoke",
"Tremolo",
"Vibrato",
"Rotation",
"Distortion",
"ChannelMix",
"LowPass",
)
class Filter:
"""
The base class for all filters.
You can use these filters if you have the latest Lavalink version
installed. If you do not have the latest Lavalink version,
these filters will not work.
def __init__(self):
self.payload = None
You must specify a tag for each filter you put on.
This is necessary for the removal of filters.
"""
__slots__ = ("payload", "tag", "preload")
def __init__(self, *, tag: str):
self.payload: Optional[Dict] = None
self.tag: str = tag
self.preload: bool = False
def set_preload(self) -> bool:
"""Internal method to set whether or not the filter was preloaded."""
self.preload = True
return self.preload
class Equalizer(Filter):
"""
Filter which represents a 15 band equalizer.
You can adjust the dynamic of the sound using this filter.
i.e: Applying a bass boost filter to emphasize the bass in a song.
The format for the levels is: List[Tuple[int, float]]
"""
__slots__ = (
"eq",
"raw",
)
def __init__(self, *, tag: str, levels: list):
super().__init__(tag=tag)
self.eq = self._factory(levels)
self.raw = levels
self.payload = {"equalizer": self.eq}
def _factory(self, levels: List[Tuple[Any, Any]]) -> List[Dict]:
_dict: Dict = collections.defaultdict(int)
_dict.update(levels)
data = [{"band": i, "gain": _dict[i]} for i in range(15)]
return data
def __repr__(self) -> str:
return f"<Pomice.EqualizerFilter tag={self.tag} eq={self.eq} raw={self.raw}>"
def __eq__(self, __value: object) -> bool:
if not isinstance(__value, Equalizer):
return False
return self.raw == __value.raw
@classmethod
def flat(cls) -> "Equalizer":
"""Equalizer preset which represents a flat EQ board,
with all levels set to their default values.
"""
levels = [
(0, 0.0),
(1, 0.0),
(2, 0.0),
(3, 0.0),
(4, 0.0),
(5, 0.0),
(6, 0.0),
(7, 0.0),
(8, 0.0),
(9, 0.0),
(10, 0.0),
(11, 0.0),
(12, 0.0),
(13, 0.0),
(14, 0.0),
]
return cls(tag="flat", levels=levels)
@classmethod
def boost(cls) -> "Equalizer":
"""Equalizer preset which boosts the sound of a track,
making it sound fun and energetic by increasing the bass
and the highs.
"""
levels = [
(0, -0.075),
(1, 0.125),
(2, 0.125),
(3, 0.1),
(4, 0.1),
(5, 0.05),
(6, 0.075),
(7, 0.0),
(8, 0.0),
(9, 0.0),
(10, 0.0),
(11, 0.0),
(12, 0.125),
(13, 0.15),
(14, 0.05),
]
return cls(tag="boost", levels=levels)
@classmethod
def metal(cls) -> "Equalizer":
"""Equalizer preset which increases the mids of a track,
preferably one of the metal genre, to make it sound
more full and concert-like.
"""
levels = [
(0, 0.0),
(1, 0.1),
(2, 0.1),
(3, 0.15),
(4, 0.13),
(5, 0.1),
(6, 0.0),
(7, 0.125),
(8, 0.175),
(9, 0.175),
(10, 0.125),
(11, 0.125),
(12, 0.1),
(13, 0.075),
(14, 0.0),
]
return cls(tag="metal", levels=levels)
@classmethod
def piano(cls) -> "Equalizer":
"""Equalizer preset which increases the mids and highs
of a track, preferably a piano based one, to make it
stand out.
"""
levels = [
(0, -0.25),
(1, -0.25),
(2, -0.125),
(3, 0.0),
(4, 0.25),
(5, 0.25),
(6, 0.0),
(7, -0.25),
(8, -0.25),
(9, 0.0),
(10, 0.0),
(11, 0.5),
(12, 0.25),
(13, -0.025),
]
return cls(tag="piano", levels=levels)
class Timescale(Filter):
"""Filter which changes the speed and pitch of a track.
You can make some very nice effects with this filter,
i.e: a vaporwave-esque filter which slows the track down
a certain amount to produce said effect.
"""
def __init__(self, *, speed: float = 1.0, pitch: float = 1.0, rate: float = 1.0):
super().__init__()
__slots__ = ("speed", "pitch", "rate")
def __init__(self, *, tag: str, speed: float = 1.0, pitch: float = 1.0, rate: float = 1.0):
super().__init__(tag=tag)
if speed < 0:
raise exceptions.FilterInvalidArgument("Timescale speed must be more than 0.")
raise FilterInvalidArgument("Timescale speed must be more than 0.")
if pitch < 0:
raise exceptions.FilterInvalidArgument("Timescale pitch must be more than 0.")
raise FilterInvalidArgument("Timescale pitch must be more than 0.")
if rate < 0:
raise exceptions.FilterInvalidArgument("Timescale rate must be more than 0.")
raise FilterInvalidArgument("Timescale rate must be more than 0.")
self.speed = speed
self.pitch = pitch
self.rate = rate
self.speed: float = speed
self.pitch: float = pitch
self.rate: float = rate
self.payload = {"timescale": {"speed": self.speed,
"pitch": self.pitch,
"rate": self.rate}}
self.payload: dict = {
"timescale": {"speed": self.speed, "pitch": self.pitch, "rate": self.rate},
}
def __repr__(self):
return f"<Pomice.TimescaleFilter speed={self.speed} pitch={self.pitch} rate={self.rate}>"
@classmethod
def vaporwave(cls) -> "Timescale":
"""Timescale preset which slows down the currently playing track,
giving it the effect of a half-speed record/casette playing.
This preset will assign the tag 'vaporwave'.
"""
return cls(tag="vaporwave", speed=0.8, pitch=0.8)
@classmethod
def nightcore(cls) -> "Timescale":
"""Timescale preset which speeds up the currently playing track,
which matches up to nightcore, a genre of sped-up music
This preset will assign the tag 'nightcore'.
"""
return cls(tag="nightcore", speed=1.25, pitch=1.3)
def __repr__(self) -> str:
return f"<Pomice.TimescaleFilter tag={self.tag} speed={self.speed} pitch={self.pitch} rate={self.rate}>"
def __eq__(self, __value: object) -> bool:
if not isinstance(__value, Timescale):
return False
return (
self.speed == __value.speed
and self.pitch == __value.pitch
and self.rate == __value.rate
)
class Karaoke(Filter):
"""Filter which filters the vocal track from any song and leaves the instrumental.
Best for karaoke as the filter implies.
"""
def __init__(self, *, level: float, mono_level: float, filter_band: float, filter_width: float):
super().__init__()
__slots__ = ("level", "mono_level", "filter_band", "filter_width")
self.level = level
self.mono_level = mono_level
self.filter_band = filter_band
self.filter_width = filter_width
def __init__(
self,
*,
tag: str,
level: float = 1.0,
mono_level: float = 1.0,
filter_band: float = 220.0,
filter_width: float = 100.0,
):
super().__init__(tag=tag)
self.payload = {"karaoke": {"level": self.level,
self.level: float = level
self.mono_level: float = mono_level
self.filter_band: float = filter_band
self.filter_width: float = filter_width
self.payload: dict = {
"karaoke": {
"level": self.level,
"monoLevel": self.mono_level,
"filterBand": self.filter_band,
"filterWidth": self.filter_width}}
"filterWidth": self.filter_width,
},
}
def __repr__(self):
return f"<Pomice.KaraokeFilter level={self.level} mono_level={self.mono_level} filter_band={self.filter_band} filter_width={self.filter_width}>"
def __repr__(self) -> str:
return (
f"<Pomice.KaraokeFilter tag={self.tag} level={self.level} mono_level={self.mono_level} "
f"filter_band={self.filter_band} filter_width={self.filter_width}>"
)
def __eq__(self, __value: object) -> bool:
if not isinstance(__value, Karaoke):
return False
return (
self.level == __value.level
and self.mono_level == __value.mono_level
and self.filter_band == __value.filter_band
and self.filter_width == __value.filter_width
)
class Tremolo(Filter):
"""Filter which produces a wavering tone in the music,
causing it to sound like the music is changing in volume rapidly.
"""
def __init__(self, *, frequency: float, depth: float):
super().__init__()
__slots__ = ("frequency", "depth")
def __init__(self, *, tag: str, frequency: float = 2.0, depth: float = 0.5):
super().__init__(tag=tag)
if frequency < 0:
raise exceptions.FilterInvalidArgument("Tremolo frequency must be more than 0.")
raise FilterInvalidArgument(
"Tremolo frequency must be more than 0.",
)
if depth < 0 or depth > 1:
raise exceptions.FilterInvalidArgument("Tremolo depth must be between 0 and 1.")
raise FilterInvalidArgument(
"Tremolo depth must be between 0 and 1.",
)
self.frequency = frequency
self.depth = depth
self.frequency: float = frequency
self.depth: float = depth
self.payload = {"tremolo": {"frequency": self.frequency,
"depth": self.depth}}
self.payload: dict = {
"tremolo": {
"frequency": self.frequency,
"depth": self.depth,
},
}
def __repr__(self):
return f"<Pomice.TremoloFilter frequency={self.frequency} depth={self.depth}>"
def __repr__(self) -> str:
return (
f"<Pomice.TremoloFilter tag={self.tag} frequency={self.frequency} depth={self.depth}>"
)
def __eq__(self, __value: object) -> bool:
if not isinstance(__value, Tremolo):
return False
return self.frequency == __value.frequency and self.depth == __value.depth
class Vibrato(Filter):
"""Filter which produces a wavering tone in the music, similar to the Tremolo filter,
but changes in pitch rather than volume.
"""
def __init__(self, *, frequency: float, depth: float):
__slots__ = ("frequency", "depth")
def __init__(self, *, tag: str, frequency: float = 2.0, depth: float = 0.5):
super().__init__(tag=tag)
super().__init__()
if frequency < 0 or frequency > 14:
raise exceptions.FilterInvalidArgument("Vibrato frequency must be between 0 and 14.")
raise FilterInvalidArgument(
"Vibrato frequency must be between 0 and 14.",
)
if depth < 0 or depth > 1:
raise exceptions.FilterInvalidArgument("Vibrato depth must be between 0 and 1.")
raise FilterInvalidArgument(
"Vibrato depth must be between 0 and 1.",
)
self.frequency = frequency
self.depth = depth
self.frequency: float = frequency
self.depth: float = depth
self.payload = {"vibrato": {"frequency": self.frequency,
"depth": self.depth}}
self.payload: dict = {
"vibrato": {
"frequency": self.frequency,
"depth": self.depth,
},
}
def __repr__(self):
return f"<Pomice.VibratoFilter frequency={self.frequency} depth={self.depth}>"
def __repr__(self) -> str:
return (
f"<Pomice.VibratoFilter tag={self.tag} frequency={self.frequency} depth={self.depth}>"
)
def __eq__(self, __value: object) -> bool:
if not isinstance(__value, Vibrato):
return False
return self.frequency == __value.frequency and self.depth == __value.depth
class Rotation(Filter):
"""Filter which produces a stereo-like panning effect, which sounds like
the audio is being rotated around the listener's head
"""
__slots__ = ("rotation_hertz",)
def __init__(self, *, tag: str, rotation_hertz: float = 5):
super().__init__(tag=tag)
self.rotation_hertz: float = rotation_hertz
self.payload: dict = {"rotation": {"rotationHz": self.rotation_hertz}}
def __repr__(self) -> str:
return f"<Pomice.RotationFilter tag={self.tag} rotation_hertz={self.rotation_hertz}>"
def __eq__(self, __value: object) -> bool:
if not isinstance(__value, Rotation):
return False
return self.rotation_hertz == __value.rotation_hertz
class ChannelMix(Filter):
"""Filter which manually adjusts the panning of the audio, which can make
for some cool effects when done correctly.
"""
__slots__ = (
"left_to_left",
"right_to_right",
"left_to_right",
"right_to_left",
)
def __init__(
self,
*,
tag: str,
left_to_left: float = 1,
right_to_right: float = 1,
left_to_right: float = 0,
right_to_left: float = 0,
):
super().__init__(tag=tag)
if 0 > left_to_left > 1:
raise ValueError(
"'left_to_left' value must be more than or equal to 0 or less than or equal to 1.",
)
if 0 > right_to_right > 1:
raise ValueError(
"'right_to_right' value must be more than or equal to 0 or less than or equal to 1.",
)
if 0 > left_to_right > 1:
raise ValueError(
"'left_to_right' value must be more than or equal to 0 or less than or equal to 1.",
)
if 0 > right_to_left > 1:
raise ValueError(
"'right_to_left' value must be more than or equal to 0 or less than or equal to 1.",
)
self.left_to_left: float = left_to_left
self.left_to_right: float = left_to_right
self.right_to_left: float = right_to_left
self.right_to_right: float = right_to_right
self.payload: dict = {
"channelMix": {
"leftToLeft": self.left_to_left,
"leftToRight": self.left_to_right,
"rightToLeft": self.right_to_left,
"rightToRight": self.right_to_right,
},
}
def __repr__(self) -> str:
return (
f"<Pomice.ChannelMix tag={self.tag} left_to_left={self.left_to_left} left_to_right={self.left_to_right} "
f"right_to_left={self.right_to_left} right_to_right={self.right_to_right}>"
)
def __eq__(self, __value: object) -> bool:
if not isinstance(__value, ChannelMix):
return False
return (
self.left_to_left == __value.left_to_left
and self.left_to_right == __value.left_to_right
and self.right_to_left == __value.right_to_left
and self.right_to_right == __value.right_to_right
)
class Distortion(Filter):
"""Filter which generates a distortion effect. Useful for certain filter implementations where
distortion is needed.
"""
__slots__ = (
"sin_offset",
"sin_scale",
"cos_offset",
"cos_scale",
"tan_offset",
"tan_scale",
"offset",
"scale",
)
def __init__(
self,
*,
tag: str,
sin_offset: float = 0,
sin_scale: float = 1,
cos_offset: float = 0,
cos_scale: float = 1,
tan_offset: float = 0,
tan_scale: float = 1,
offset: float = 0,
scale: float = 1,
):
super().__init__(tag=tag)
self.sin_offset: float = sin_offset
self.sin_scale: float = sin_scale
self.cos_offset: float = cos_offset
self.cos_scale: float = cos_scale
self.tan_offset: float = tan_offset
self.tan_scale: float = tan_scale
self.offset: float = offset
self.scale: float = scale
self.payload: dict = {
"distortion": {
"sinOffset": self.sin_offset,
"sinScale": self.sin_scale,
"cosOffset": self.cos_offset,
"cosScale": self.cos_scale,
"tanOffset": self.tan_offset,
"tanScale": self.tan_scale,
"offset": self.offset,
"scale": self.scale,
},
}
def __repr__(self) -> str:
return (
f"<Pomice.Distortion tag={self.tag} sin_offset={self.sin_offset} sin_scale={self.sin_scale}> "
f"cos_offset={self.cos_offset} cos_scale={self.cos_scale} tan_offset={self.tan_offset} "
f"tan_scale={self.tan_scale} offset={self.offset} scale={self.scale}"
)
def __eq__(self, __value: object) -> bool:
if not isinstance(__value, Distortion):
return False
return (
self.sin_offset == __value.sin_offset
and self.sin_scale == __value.sin_scale
and self.cos_offset == __value.cos_offset
and self.cos_scale == __value.cos_scale
and self.tan_offset == __value.tan_offset
and self.tan_scale == __value.tan_scale
and self.offset == __value.offset
and self.scale == __value.scale
)
class LowPass(Filter):
"""Filter which supresses higher frequencies and allows lower frequencies to pass.
You can also do this with the Equalizer filter, but this is an easier way to do it.
"""
__slots__ = ("smoothing", "payload")
def __init__(self, *, tag: str, smoothing: float = 20):
super().__init__(tag=tag)
self.smoothing: float = smoothing
self.payload: dict = {"lowPass": {"smoothing": self.smoothing}}
def __repr__(self) -> str:
return f"<Pomice.LowPass tag={self.tag} smoothing={self.smoothing}>"
def __eq__(self, __value: object) -> bool:
if not isinstance(__value, LowPass):
return False
return self.smoothing == __value.smoothing

View File

@ -1,205 +0,0 @@
import aiohttp
import discord
import asyncio
import typing
import json
import socket
import time
from discord.ext import commands
from typing import Optional, Union
from urllib.parse import quote
from . import events
from . import exceptions
from . import objects
from . import __version__
from .utils import ExponentialBackoff, NodeStats
class Node:
def __init__(self, pool, bot: Union[commands.Bot, discord.Client, commands.AutoShardedBot, discord.AutoShardedClient], host: str, port: int, password: str, identifier: str, **kwargs):
self._bot = bot
self._host = host
self._port = port
self._password = password
self._identifier = identifier
self._pool = pool
self._websocket_uri = f"ws://{self._host}:{self._port}"
self._rest_uri = f"http://{self._host}:{self._port}"
self._session = aiohttp.ClientSession()
self._websocket: aiohttp.ClientWebSocketResponse = None
self._task: asyncio.Task = None
self._connection_id = None
self._metadata = None
self._available = None
self._headers = {
"Authorization": self._password,
"User-Id": str(self._bot.user.id),
"Client-Name": f"Pomice/{__version__}"
}
self._players = {}
self._bot.add_listener(self._update_handler, "on_socket_response")
def __repr__(self):
return f"<Pomice.node ws_uri={self._websocket_uri} rest_uri={self._rest_uri} player_count={len(self._players)}>"
@property
def is_connected(self) -> bool:
return self._websocket is not None and not self._websocket.closed
@property
async def latency(self):
start_time = time.time()
await self.send(op="ping")
end_time = await self._bot.wait_for(f"node_ping")
return (end_time - start_time) * 1000
@property
async def stats(self):
await self.send(op="get-stats")
node_stats = await self._bot.wait_for(f"node_stats")
return node_stats
@property
def players(self):
return self._players
@property
def bot(self):
return self._bot
@property
def pool(self):
return self._pool
async def _update_handler(self, data: dict):
await self._bot.wait_until_ready()
if not data:
return
if data["t"] == "VOICE_SERVER_UPDATE":
guild_id = int(data["d"]["guild_id"])
try:
player = self._players[guild_id]
await player._voice_server_update(data["d"])
except KeyError:
return
elif data["t"] == "VOICE_STATE_UPDATE":
if int(data["d"]["user_id"]) != self._bot.user.id:
return
guild_id = int(data["d"]["guild_id"])
try:
player = self._players[guild_id]
await player._voice_state_update(data["d"])
except KeyError:
return
else:
return
async def _listen(self):
backoff = ExponentialBackoff(base=7)
while True:
msg = await self._websocket.receive()
if msg.type == aiohttp.WSMsgType.CLOSED:
retry = backoff.delay()
await asyncio.sleep(retry)
if not self.is_connected:
self._bot.loop.create_task(self.connect())
else:
self._bot.loop.create_task(self._handle_payload(msg.json()))
async def _handle_payload(self, data: dict) -> None:
op = data.get('op', None)
if not op:
return
if op == 'stats':
self._stats = NodeStats(data)
return
if not (player := self._players.get(int(data['guildId']))):
return
if op == 'event':
await player._dispatch_event(data)
elif op == 'playerUpdate':
await player._update_state(data)
async def send(self, **data):
if not self.available:
raise exceptions.NodeNotAvailable(f"The node '{self.identifier}' is not currently available.")
await self._websocket.send_str(json.dumps(data))
def get_player(self, guild_id: int):
return self._players.get(guild_id, None)
async def connect(self):
await self._bot.wait_until_ready()
try:
self._websocket = await self._session.ws_connect(self._websocket_uri, headers=self._headers, heartbeat=60)
self._task = self._bot.loop.create_task(self._listen())
self._pool._nodes[self._identifier] = self
self.available = True
return self
except aiohttp.WSServerHandshakeError:
raise exceptions.NodeConnectionFailure(f"The password for node '{self.identifier}' is invalid.")
except aiohttp.InvalidURL:
raise exceptions.NodeConnectionFailure(f"The URI for node '{self.identifier}' is invalid.")
except socket.gaierror:
raise exceptions.NodeConnectionFailure(f"The node '{self.identifier}' failed to connect.")
async def disconnect(self):
for player in self.players.copy().values():
await player.destroy()
await self._websocket.close()
del self._pool.nodes[self._identifier]
self.available = False
self._task.cancel()
async def get_tracks(self, query: str, ctx: commands.Context = None):
async with self._session.get(url=f"{self._rest_uri}/loadtracks?identifier={quote(query)}", headers={"Authorization": self._password}) as response:
data = await response.json()
load_type = data.get("loadType")
if not load_type:
raise exceptions.TrackLoadError("There was an error while trying to load this track.")
elif load_type == "LOAD_FAILED":
raise exceptions.TrackLoadError(f"There was an error of severity '{data['severity']}' while loading tracks.\n\n{data['cause']}")
elif load_type == "NO_MATCHES":
return None
elif load_type == "PLAYLIST_LOADED":
if ctx:
return objects.Playlist(playlist_info=data["playlistInfo"], tracks=data["tracks"], ctx=ctx)
else:
return objects.Playlist(playlist_info=data["playlistInfo"], tracks=data["tracks"])
elif load_type == "SEARCH_RESULT" or load_type == "TRACK_LOADED":
if ctx:
return [objects.Track(track_id=track["track"], info=track["info"], ctx=ctx) for track in data["tracks"]]
else:
return [objects.Track(track_id=track["track"], info=track["info"]) for track in data["tracks"]]

View File

@ -1,48 +1,167 @@
from __future__ import annotations
from typing import List
from typing import Optional
from typing import Union
from discord import ClientUser
from discord import Member
from discord import User
from discord.ext import commands
from .enums import PlaylistType
from .enums import SearchType
from .enums import TrackType
from .filters import Filter
__all__ = (
"Track",
"Playlist",
)
class Track:
"""The base track object. Returns critical track information needed for parsing by Lavalink.
You can also pass in commands.Context to get a discord.py Context object in your track.
"""
def __init__(self, track_id: str, info: dict, ctx: commands.Context = None):
__slots__ = (
"track_id",
"info",
"track_type",
"filters",
"timestamp",
"original",
"_search_type",
"playlist",
"title",
"author",
"uri",
"identifier",
"isrc",
"thumbnail",
"length",
"ctx",
"requester",
"is_stream",
"is_seekable",
"position",
)
self.track_id = track_id
self.info = info
def __init__(
self,
*,
track_id: str,
info: dict,
ctx: Optional[commands.Context] = None,
track_type: TrackType,
search_type: SearchType = SearchType.ytsearch,
filters: Optional[List[Filter]] = None,
timestamp: Optional[float] = None,
requester: Optional[Union[Member, User, ClientUser]] = None,
):
self.track_id: str = track_id
self.info: dict = info
self.track_type: TrackType = track_type
self.filters: Optional[List[Filter]] = filters
self.timestamp: Optional[float] = timestamp
self.title = info.get("title")
self.author = info.get("author")
self.length = info.get("length")
if ctx:
self.ctx: commands.Context = ctx
if self.track_type == TrackType.SPOTIFY or self.track_type == TrackType.APPLE_MUSIC:
self.original: Optional[Track] = None
else:
self.original = self
self._search_type: SearchType = search_type
self.playlist: Optional[Playlist] = None
self.title: str = info.get("title", "Unknown Title")
self.author: str = info.get("author", "Unknown Author")
self.uri: str = info.get("uri", "")
self.identifier: str = info.get("identifier", "")
self.isrc: Optional[str] = info.get("isrc", None)
self.thumbnail: Optional[str] = info.get("thumbnail")
if self.uri and self.track_type is TrackType.YOUTUBE:
self.thumbnail = f"https://img.youtube.com/vi/{self.identifier}/mqdefault.jpg"
self.length: int = info.get("length", 0)
self.is_stream: bool = info.get("isStream", False)
self.is_seekable: bool = info.get("isSeekable", False)
self.position: int = info.get("position", 0)
self.ctx: Optional[commands.Context] = ctx
self.requester: Optional[Union[Member, User, ClientUser]] = requester
if not self.requester and self.ctx:
self.requester = self.ctx.author
self.identifier = info.get("identifier")
self.uri = info.get("uri")
self.is_stream = info.get("isStream")
self.is_seekable = info.get("isSeekable")
self.position = info.get("position")
def __str__(self):
def __eq__(self, other: object) -> bool:
if not isinstance(other, Track):
return False
return other.track_id == self.track_id
def __str__(self) -> str:
return self.title
def __repr__(self):
def __repr__(self) -> str:
return f"<Pomice.track title={self.title!r} uri=<{self.uri!r}> length={self.length}>"
class Playlist:
"""The base playlist object.
Returns critical playlist information needed for parsing by Lavalink.
You can also pass in commands.Context to get a discord.py Context object in your tracks.
"""
def __init__(self, playlist_info: dict, tracks: list, ctx: commands.Context = None):
__slots__ = (
"playlist_info",
"tracks",
"name",
"playlist_type",
"_thumbnail",
"_uri",
"selected_track",
"track_count",
)
self.playlist_info = playlist_info
self.tracks_raw = tracks
def __init__(
self,
*,
playlist_info: dict,
tracks: list,
playlist_type: PlaylistType,
thumbnail: Optional[str] = None,
uri: Optional[str] = None,
):
self.playlist_info: dict = playlist_info
self.tracks: List[Track] = tracks
self.name: str = playlist_info.get("name", "Unknown Playlist")
self.playlist_type: PlaylistType = playlist_type
self.name = playlist_info.get("name")
self.selected_track = playlist_info.get("selectedTrack")
self._thumbnail: Optional[str] = thumbnail
self._uri: Optional[str] = uri
if ctx:
self.tracks = [Track(track_id=track["track"], info=track["info"], ctx=ctx) for track in self.tracks_raw]
else:
self.tracks = [Track(track_id=track["track"], info=track["info"]) for track in self.tracks_raw]
for track in self.tracks:
track.playlist = self
def __str__(self):
self.selected_track: Optional[Track] = None
if (index := playlist_info.get("selectedTrack", -1)) != -1:
self.selected_track = self.tracks[index]
self.track_count: int = len(self.tracks)
def __str__(self) -> str:
return self.name
def __repr__(self):
def __repr__(self) -> str:
return f"<Pomice.playlist name={self.name!r} track_count={len(self.tracks)}>"
@property
def uri(self) -> Optional[str]:
"""Returns either an Apple Music/Spotify URL/URI, or None if its neither of those."""
return self._uri
@property
def thumbnail(self) -> Optional[str]:
"""Returns either an Apple Music/Spotify album/playlist thumbnail, or None if its neither of those."""
return self._thumbnail

View File

@ -1,181 +1,765 @@
from __future__ import annotations
import time
from typing import Any
from typing import Dict
from typing import List
from typing import Optional
from typing import TYPE_CHECKING
from typing import Union
import discord
from . import exceptions
from . import filters
from . import objects
from .node import Node
from .pool import NodePool
from . import events
import discord
from discord import VoiceProtocol, VoiceChannel
from discord import Client
from discord import Guild
from discord import VoiceChannel
from discord import VoiceProtocol
from discord.ext import commands
from typing import Optional, Any, Union
from . import events
from .enums import SearchType
from .events import PomiceEvent
from .events import TrackEndEvent
from .events import TrackStartEvent
from .exceptions import FilterInvalidArgument
from .exceptions import FilterTagAlreadyInUse
from .exceptions import FilterTagInvalid
from .exceptions import TrackInvalidPosition
from .exceptions import TrackLoadError
from .filters import Filter
from .filters import Timescale
from .objects import Playlist
from .objects import Track
from .pool import Node
from .pool import NodePool
from pomice.utils import LavalinkVersion
if TYPE_CHECKING:
from discord.types.voice import VoiceServerUpdate
from discord.types.voice import GuildVoiceState
__all__ = ("Filters", "Player")
class Filters:
"""Helper class for filters"""
__slots__ = ("_filters",)
def __init__(self) -> None:
self._filters: List[Filter] = []
@property
def has_preload(self) -> bool:
"""Property which checks if any applied filters were preloaded"""
return any(f for f in self._filters if f.preload == True)
@property
def has_global(self) -> bool:
"""Property which checks if any applied filters are global"""
return any(f for f in self._filters if f.preload == False)
@property
def empty(self) -> bool:
"""Property which checks if the filter list is empty"""
return len(self._filters) == 0
def add_filter(self, *, filter: Filter) -> None:
"""Adds a filter to the list of filters applied"""
if any(f for f in self._filters if f.tag == filter.tag):
raise FilterTagAlreadyInUse(
"A filter with that tag is already in use.",
)
self._filters.append(filter)
def remove_filter(self, *, filter_tag: str) -> None:
"""Removes a filter from the list of filters applied using its filter tag"""
if not any(f for f in self._filters if f.tag == filter_tag):
raise FilterTagInvalid("A filter with that tag was not found.")
for index, filter in enumerate(self._filters):
if filter.tag == filter_tag:
del self._filters[index]
def edit_filter(self, *, filter_tag: str, to_apply: Filter) -> None:
"""Edits a filter in the list of filters applied using its filter tag and replaces it with the new filter."""
if not any(f for f in self._filters if f.tag == filter_tag):
raise FilterTagInvalid("A filter with that tag was not found.")
for index, filter in enumerate(self._filters):
if filter.tag == filter_tag:
if not type(filter) == type(to_apply):
raise FilterInvalidArgument(
"Edited filter is not the same type as the current filter.",
)
if self._filters[index] == to_apply:
raise FilterInvalidArgument("Edited filter is the same as the current filter.")
if to_apply.tag != filter_tag:
raise FilterInvalidArgument(
"Edited filter tag is not the same as the current filter tag.",
)
self._filters[index] = to_apply
def has_filter(self, *, filter_tag: str) -> bool:
"""Checks if a filter exists in the list of filters using its filter tag"""
return any(f for f in self._filters if f.tag == filter_tag)
def has_filter_type(self, *, filter_type: Filter) -> bool:
"""Checks if any filters applied match the specified filter type."""
return any(f for f in self._filters if type(f) == type(filter_type))
def reset_filters(self) -> None:
"""Removes all filters from the list"""
self._filters = []
def get_preload_filters(self) -> List[Filter]:
"""Get all preloaded filters"""
return [f for f in self._filters if f.preload == True]
def get_all_payloads(self) -> Dict[str, Any]:
"""Returns a formatted dict of all the filter payloads"""
payload: Dict[str, Any] = {}
for _filter in self._filters:
if _filter.payload:
payload.update(_filter.payload)
return payload
def get_filters(self) -> List[Filter]:
"""Returns the current list of applied filters"""
return self._filters
class Player(VoiceProtocol):
"""The base player class for Pomice.
In order to initiate a player, you must pass it in as a cls when you connect to a channel.
i.e: ```py
await ctx.author.voice.channel.connect(cls=pomice.Player)
```
"""
def __init__(self, client: Union[commands.Bot, discord.Client, commands.AutoShardedBot, discord.AutoShardedClient], channel: VoiceChannel):
super().__init__(client=client, channel=channel)
__slots__ = (
"client",
"channel",
"_bot",
"_guild",
"_node",
"_current",
"_filters",
"_volume",
"_paused",
"_is_connected",
"_last_position",
"_last_update",
"_ending_track",
"_log",
"_voice_state",
"_player_endpoint_uri",
)
self.client: Union[commands.Bot, discord.Client, commands.AutoShardedBot, discord.AutoShardedClient] = client
self._bot: Union[commands.Bot, discord.Client, commands.AutoShardedBot, discord.AutoShardedClient] = client
def __call__(self, client: Client, channel: VoiceChannel) -> Player:
self.client = client
self.channel = channel
self._guild = channel.guild
return self
def __init__(
self,
client: Client,
channel: VoiceChannel,
*,
node: Optional[Node] = None,
) -> None:
self.client: Client = client
self.channel: VoiceChannel = channel
self._guild: discord.Guild = channel.guild
self._dj: discord.Member = None
self._guild = channel.guild
self._node: Node = NodePool.get_node()
self._current: objects.Track = None
self._filter: filters.Filter = None
self._bot: Client = client
self._node: Node = node if node else NodePool.get_node()
self._current: Optional[Track] = None
self._filters: Filters = Filters()
self._volume: int = 100
self._paused: bool = False
self._is_connected: bool = False
self._position: int = 0
self._last_update: int = 0
self._current_track_id = None
self._last_position: int = 0
self._last_update: float = 0
self._ending_track: Optional[Track] = None
self._log = self._node._log
self._voice_state: dict = {}
self._session_id: Optional[str] = None
self._voice_server_update_data: Optional[dict] = None
self._player_endpoint_uri: str = f"sessions/{self._node._session_id}/players"
def __repr__(self):
return f"<Pomice.player bot={self._bot} guildId={self._guild.id} is_connected={self.is_connected} is_playing={self.is_playing}>"
def __repr__(self) -> str:
return (
f"<Pomice.player bot={self.bot} guildId={self.guild.id} "
f"is_connected={self.is_connected} is_playing={self.is_playing}>"
)
@property
def position(self):
def position(self) -> float:
"""Property which returns the player's position in a track in milliseconds"""
if not self.is_playing:
return 0
if self._paused:
return min(self._position, self._current.length)
current: Track = self._current # type: ignore
if current.original:
current = current.original
position = round(self._position + ((time.time() * 1000) - self._last_update))
if self.is_paused:
return min(self._last_position, current.length)
if position > self._current.length:
difference = (time.time() * 1000) - self._last_update
position = self._last_position + difference
return round(min(position, current.length))
@property
def rate(self) -> float:
"""Property which returns the player's current rate"""
if _filter := next((f for f in self._filters._filters if isinstance(f, Timescale)), None):
return _filter.speed or _filter.rate
return 1.0
@property
def adjusted_position(self) -> float:
"""Property which returns the player's position in a track in milliseconds adjusted for rate"""
return self.position / self.rate
@property
def adjusted_length(self) -> float:
"""Property which returns the player's track length in milliseconds adjusted for rate"""
if not self.is_playing:
return 0
return position
return self.current.length / self.rate # type: ignore
@property
def is_connected(self):
return self._is_connected
@property
def is_playing(self):
def is_playing(self) -> bool:
"""Property which returns whether or not the player is actively playing a track."""
return self._is_connected and self._current is not None
@property
def is_paused(self):
return self._is_connected and self._paused is True
def is_connected(self) -> bool:
"""Property which returns whether or not the player is connected"""
return self._is_connected
@property
def node(self):
def is_paused(self) -> bool:
"""Property which returns whether or not the player has a track which is paused or not."""
return self._is_connected and self._paused
@property
def current(self) -> Optional[Track]:
"""Property which returns the currently playing track"""
return self._current
@property
def node(self) -> Node:
"""Property which returns the node the player is connected to"""
return self._node
@property
def current(self):
return self._current
def guild(self) -> Guild:
"""Property which returns the guild associated with the player"""
return self._guild
@property
def volume(self):
def volume(self) -> int:
"""Property which returns the players current volume"""
return self._volume
@property
def filters(self) -> Filters:
"""Property which returns the helper class for interacting with filters"""
return self._filters
async def _update_state(self, data: dict):
@property
def bot(self) -> Client:
"""Property which returns the bot associated with this player instance"""
return self._bot
state = data.get('state')
self._last_update = state.get('time')
self._is_connected = state.get('connected')
self._position = state.get('position')
@property
def is_dead(self) -> bool:
"""Returns a bool representing whether the player is dead or not.
A player is considered dead if it has been destroyed and removed from stored players.
"""
return self.guild.id not in self._node._players
async def _dispatch_voice_update(self) -> None:
def _adjust_end_time(self) -> Optional[str]:
if self._node._version >= LavalinkVersion(3, 7, 5):
return None
if not self._session_id or not self._voice_server_update_data:
return "0"
async def _update_state(self, data: dict) -> None:
state: dict = data.get("state", {})
self._last_update = int(state.get("time", 0))
self._is_connected = bool(state.get("connected"))
self._last_position = int(state.get("position", 0))
if self._log:
self._log.debug(f"Got player update state with data {state}")
async def _dispatch_voice_update(self, voice_data: Optional[Dict[str, Any]] = None) -> None:
if {"sessionId", "event"} != self._voice_state.keys():
return
await self._node.send(op='voiceUpdate', sessionId=self._session_id, guildId=str(self._guild.id), event={**self._voice_server_update_data})
state = voice_data or self._voice_state
async def _voice_server_update(self, data: dict):
data = {
"token": state["event"]["token"],
"endpoint": state["event"]["endpoint"],
"sessionId": state["sessionId"],
}
self._voice_server_update_data = data
await self._dispatch_voice_update()
await self._node.send(
method="PATCH",
path=self._player_endpoint_uri,
guild_id=self._guild.id,
data={"voice": data},
)
if self._log:
self._log.debug(
f"Dispatched voice update to {state['event']['endpoint']} with data {data}",
)
async def _voice_state_update(self, data: dict):
async def on_voice_server_update(self, data: VoiceServerUpdate) -> None:
self._voice_state.update({"event": data})
await self._dispatch_voice_update(self._voice_state)
if not (channel_id := data.get('channel_id')):
self.channel, self._session_id, self._voice_server_update_data = None
async def on_voice_state_update(self, data: GuildVoiceState) -> None:
self._voice_state.update({"sessionId": data.get("session_id")})
channel_id = data.get("channel_id")
if not channel_id:
await self.disconnect()
self._voice_state.clear()
return
self.channel = self._guild.get_channel(int(channel_id))
self._session_id = data.get('session_id')
await self._dispatch_voice_update()
channel = self.guild.get_channel(int(channel_id))
async def _dispatch_event(self, data: dict):
event_type = data.get('type')
event = getattr(events, event_type, None)
event = event(data)
self._bot.dispatch(f"pomice_{event.name}", event)
if self.channel != channel:
self.channel = channel
async def get_tracks(self, query: str, ctx: commands.Context = None):
return await self._node.get_tracks(query, ctx)
if not channel:
await self.disconnect()
self._voice_state.clear()
return
async def connect(self, *, timeout: float, reconnect: bool):
await self._guild.change_voice_state(channel=self.channel)
if not data.get("token"):
return
await self._dispatch_voice_update({**self._voice_state, "event": data})
async def _dispatch_event(self, data: dict) -> None:
event_type: str = data["type"]
event: PomiceEvent = getattr(events, event_type)(data, self)
if isinstance(event, TrackEndEvent) and event.reason not in ("REPLACED", "replaced"):
self._current = None
event.dispatch(self._bot)
if isinstance(event, TrackStartEvent):
self._ending_track = self._current
if self._log:
self._log.debug(f"Dispatched event {data['type']} to player.")
async def _refresh_endpoint_uri(self, session_id: Optional[str]) -> None:
self._player_endpoint_uri = f"sessions/{session_id}/players"
async def _swap_node(self, *, new_node: Node) -> None:
if self.current:
data: dict = {"position": self.position, "encodedTrack": self.current.track_id}
del self._node._players[self._guild.id]
self._node = new_node
self._node._players[self._guild.id] = self
# reassign uri to update session id
await self._refresh_endpoint_uri(new_node._session_id)
await self._dispatch_voice_update()
await self._node.send(
method="PATCH",
path=self._player_endpoint_uri,
guild_id=self._guild.id,
data=data or None,
)
if self._log:
self._log.debug(f"Swapped all players to new node {new_node._identifier}.")
async def get_tracks(
self,
query: str,
*,
ctx: Optional[commands.Context] = None,
search_type: SearchType | None = SearchType.ytsearch,
filters: Optional[List[Filter]] = None,
) -> Optional[Union[List[Track], Playlist]]:
"""Fetches tracks from the node's REST api to parse into Lavalink.
If you passed in Spotify API credentials when you created the node,
you can also pass in a Spotify URL of a playlist, album or track and it will be parsed
accordingly.
You can pass in a discord.py Context object to get a
Context object on any track you search.
You may also pass in a List of filters
to be applied to your track once it plays.
"""
return await self._node.get_tracks(query, ctx=ctx, search_type=search_type, filters=filters)
async def build_track(self, identifier: str, ctx: Optional[commands.Context] = None) -> Track:
"""
Builds a track using a valid track identifier
You can also pass in a discord.py Context object to get a
Context object on the track it builds.
"""
return await self._node.build_track(identifier, ctx=ctx)
async def get_recommendations(
self,
*,
track: Track,
ctx: Optional[commands.Context] = None,
) -> Optional[Union[List[Track], Playlist]]:
"""
Gets recommendations from either YouTube or Spotify.
You can pass in a discord.py Context object to get a
Context object on all tracks that get recommended.
"""
return await self._node.get_recommendations(track=track, ctx=ctx)
async def connect(
self,
*,
timeout: float,
reconnect: bool,
self_deaf: bool = False,
self_mute: bool = False,
) -> None:
await self.guild.change_voice_state(
channel=self.channel,
self_deaf=self_deaf,
self_mute=self_mute,
)
self._node._players[self.guild.id] = self
self._is_connected = True
async def stop(self):
async def stop(self) -> None:
"""Stops the currently playing track."""
self._current = None
await self._node.send(op='stop', guildId=str(self._guild.id))
await self._node.send(
method="PATCH",
path=self._player_endpoint_uri,
guild_id=self._guild.id,
data={"encodedTrack": None},
)
async def disconnect(self, *, force: bool = False):
await self.stop()
await self._guild.change_voice_state(channel=None)
if self._log:
self._log.debug(f"Player has been stopped.")
async def disconnect(self, *, force: bool = False) -> None:
"""Disconnects the player from voice."""
try:
await self.guild.change_voice_state(channel=None)
finally:
self.cleanup()
self.channel = None
self._is_connected = False
del self._node._players[self._guild.id]
self.channel = None # type: ignore
async def destroy(self):
async def destroy(self) -> None:
"""Disconnects and destroys the player, and runs internal cleanup."""
try:
await self.disconnect()
await self._node.send(op='destroy', guildId=str(self._guild.id))
except AttributeError:
# 'NoneType' has no attribute '_get_voice_client_key' raised by self.cleanup() ->
# assume we're already disconnected and cleaned up
assert self.channel is None and not self.is_connected
self._node._players.pop(self.guild.id)
if self.node.is_connected:
await self._node.send(
method="DELETE",
path=self._player_endpoint_uri,
guild_id=self._guild.id,
)
if self._log:
self._log.debug("Player has been destroyed.")
async def play(
self,
track: Track,
*,
start: int = 0,
end: int = 0,
ignore_if_playing: bool = False,
) -> Track:
"""Plays a track. If a Spotify track is passed in, it will be handled accordingly."""
if not track._search_type:
track.original = track
# Make sure we've never searched the track before
if track._search_type and track.original is None:
# First lets try using the tracks ISRC, every track has one (hopefully)
try:
if not track.isrc:
# We have to bare raise here because theres no other way to skip this block feasibly
raise
search = (
await self._node.get_tracks(f"{track._search_type}:{track.isrc}", ctx=track.ctx)
)[
0
] # type: ignore
except Exception:
# First method didn't work, lets try just searching it up
try:
search = (
await self._node.get_tracks(
f"{track._search_type}:{track.title} - {track.author}",
ctx=track.ctx,
)
)[
0
] # type: ignore
except:
# The song wasn't able to be found, raise error
raise TrackLoadError(
"No equivalent track was able to be found.",
)
data = {
"encodedTrack": search.track_id,
"position": str(start),
"endTime": self._adjust_end_time(),
}
track.original = search
track.track_id = search.track_id
# Set track_id for later lavalink searches
else:
data = {
"encodedTrack": track.track_id,
"position": str(start),
"endTime": self._adjust_end_time(),
}
# Lets set the current track before we play it so any
# corresponding events can capture it correctly
async def play(self, track: objects.Track, start_position: int = 0):
await self._node.send(op='play', guildId=str(self._guild.id), track=track.track_id, startTime=start_position, endTime=track.length, noReplace=False)
self._current = track
# Remove preloaded filters if last track had any
if self.filters.has_preload:
for filter in self.filters.get_preload_filters():
await self.remove_filter(filter_tag=filter.tag)
# Global filters take precedence over track filters
# So if no global filters are detected, lets apply any
# necessary track filters
# Check if theres no global filters and if the track has any filters
# that need to be applied
if track.filters and not self.filters.has_global:
# Now apply all filters
for filter in track.filters:
await self.add_filter(_filter=filter)
# Lavalink v3.7.5 changed the way the end time parameter works
# so now the end time cannot be zero.
# If it isnt zero, it'll be set to None.
# Otherwise, it'll be set here:
if end > 0:
data["endTime"] = str(end)
await self._node.send(
method="PATCH",
path=self._player_endpoint_uri,
guild_id=self._guild.id,
data=data,
query=f"noReplace={ignore_if_playing}",
)
if self._log:
self._log.debug(
f"Playing {track.title} from uri {track.uri} with a length of {track.length}",
)
return self._current
async def seek(self, position: int):
async def seek(self, position: float) -> float:
"""Seeks to a position in the currently playing track milliseconds"""
if not self._current or not self._current.original:
return 0.0
if position < 0 or position > self.current.length:
raise exceptions.TrackInvalidPosition(f"Seek position must be between 0 and the track length")
if position < 0 or position > self._current.original.length:
raise TrackInvalidPosition(
"Seek position must be between 0 and the track length",
)
await self._node.send(op='seek', guildId=str(self._guild.id), position=position)
await self._node.send(
method="PATCH",
path=self._player_endpoint_uri,
guild_id=self._guild.id,
data={"position": position},
)
if self._log:
self._log.debug(f"Seeking to {position}.")
return self.position
async def set_pause(self, pause: bool):
await self._node.send(op='pause', guildId=str(self._guild.id), pause=pause)
async def set_pause(self, pause: bool) -> bool:
"""Sets the pause state of the currently playing track."""
await self._node.send(
method="PATCH",
path=self._player_endpoint_uri,
guild_id=self._guild.id,
data={"paused": pause},
)
self._paused = pause
if self._log:
self._log.debug(f"Player has been {'paused' if pause else 'resumed'}.")
return self._paused
async def set_volume(self, volume: int):
await self._node.send(op='volume', guildId=str(self._guild.id), volume=volume)
async def set_volume(self, volume: int) -> int:
"""Sets the volume of the player as an integer. Lavalink accepts values from 0 to 500."""
await self._node.send(
method="PATCH",
path=self._player_endpoint_uri,
guild_id=self._guild.id,
data={"volume": volume},
)
self._volume = volume
if self._log:
self._log.debug(f"Player volume has been adjusted to {volume}")
return self._volume
async def set_filter(self, filter: filters.Filter):
await self._node.send(op='filters', guildId=str(self._guild.id), **filter.payload)
async def move_to(self, channel: VoiceChannel) -> None:
"""Moves the player to a new voice channel."""
await self.guild.change_voice_state(channel=channel)
self.channel = channel
await self._dispatch_voice_update()
async def add_filter(self, _filter: Filter, fast_apply: bool = False) -> Filters:
"""Adds a filter to the player. Takes a pomice.Filter object.
This will only work if you are using a version of Lavalink that supports filters.
If you would like for the filter to apply instantly, set the `fast_apply` arg to `True`.
(You must have a song playing in order for `fast_apply` to work.)
"""
self._filters.add_filter(filter=_filter)
payload = self._filters.get_all_payloads()
await self._node.send(
method="PATCH",
path=self._player_endpoint_uri,
guild_id=self._guild.id,
data={"filters": payload},
)
if self._log:
self._log.debug(f"Filter has been applied to player with tag {_filter.tag}")
if fast_apply:
if self._log:
self._log.debug(f"Fast apply passed, now applying filter instantly.")
await self.seek(self.position)
self._filter = filter
return filter
return self._filters
async def remove_filter(self, filter_tag: str, fast_apply: bool = False) -> Filters:
"""Removes a filter from the player. Takes a filter tag.
This will only work if you are using a version of Lavalink that supports filters.
If you would like for the filter to apply instantly, set the `fast_apply` arg to `True`.
(You must have a song playing in order for `fast_apply` to work.)
"""
self._filters.remove_filter(filter_tag=filter_tag)
payload = self._filters.get_all_payloads()
await self._node.send(
method="PATCH",
path=self._player_endpoint_uri,
guild_id=self._guild.id,
data={"filters": payload},
)
if self._log:
self._log.debug(f"Filter has been removed from player with tag {filter_tag}")
if fast_apply:
if self._log:
self._log.debug(f"Fast apply passed, now removing filter instantly.")
await self.seek(self.position)
return self._filters
async def edit_filter(
self,
*,
filter_tag: str,
edited_filter: Filter,
fast_apply: bool = False,
) -> Filters:
"""Edits a filter from the player using its filter tag and a new filter of the same type.
The filter to be replaced must have the same tag as the one you are replacing it with.
This will only work if you are using a version of Lavalink that supports filters.
If you would like for the filter to apply instantly, set the `fast_apply` arg to `True`.
(You must have a song playing in order for `fast_apply` to work.)
"""
self._filters.edit_filter(filter_tag=filter_tag, to_apply=edited_filter)
payload = self._filters.get_all_payloads()
await self._node.send(
method="PATCH",
path=self._player_endpoint_uri,
guild_id=self._guild.id,
data={"filters": payload},
)
if self._log:
self._log.debug(f"Filter with tag {filter_tag} has been edited to {edited_filter!r}")
if fast_apply:
if self._log:
self._log.debug(f"Fast apply passed, now editing filter instantly.")
await self.seek(self.position)
return self._filters
async def reset_filters(self, *, fast_apply: bool = False) -> None:
"""Resets all currently applied filters to their default parameters.
You must have filters applied in order for this to work.
If you would like the filters to be removed instantly, set the `fast_apply` arg to `True`.
(You must have a song playing in order for `fast_apply` to work.)
"""
if not self._filters:
raise FilterInvalidArgument(
"You must have filters applied first in order to use this method.",
)
self._filters.reset_filters()
await self._node.send(
method="PATCH",
path=self._player_endpoint_uri,
guild_id=self._guild.id,
data={"filters": {}},
)
if self._log:
self._log.debug(f"All filters have been removed from player.")
if fast_apply:
if self._log:
self._log.debug(f"Fast apply passed, now removing all filters instantly.")
await self.seek(self.position)

File diff suppressed because it is too large Load Diff

0
pomice/py.typed Normal file
View File

374
pomice/queue.py Normal file
View File

@ -0,0 +1,374 @@
from __future__ import annotations
import random
from copy import copy
from typing import Iterable
from typing import Iterator
from typing import List
from typing import Optional
from typing import Union
from .enums import LoopMode
from .exceptions import QueueEmpty
from .exceptions import QueueException
from .exceptions import QueueFull
from .objects import Track
__all__ = ("Queue",)
class Queue(Iterable[Track]):
"""Queue for Pomice. This queue takes pomice.Track as an input and includes looping and shuffling."""
__slots__ = (
"max_size",
"_queue",
"_overflow",
"_loop_mode",
"_current_item",
)
def __init__(
self,
max_size: Optional[int] = None,
*,
overflow: bool = True,
):
self.max_size: Optional[int] = max_size
self._current_item: Track
self._queue: List[Track] = []
self._overflow: bool = overflow
self._loop_mode: Optional[LoopMode] = None
def __str__(self) -> str:
"""String showing all Track objects appearing as a list."""
return str(list(f"'{t}'" for t in self))
def __repr__(self) -> str:
"""Official representation with max_size and member count."""
return f"<{self.__class__.__name__} max_size={self.max_size} members={self.count}>"
def __bool__(self) -> bool:
"""Treats the queue as a bool, with it evaluating True when it contains members."""
return bool(self.count)
def __call__(self, item: Track) -> None:
"""Allows the queue instance to be called directly in order to add a member."""
self.put(item)
def __len__(self) -> int:
"""Return the number of members in the queue."""
return self.count
def __getitem__(self, index: int) -> Track:
"""Returns a member at the given position.
Does not remove item from queue.
"""
if not isinstance(index, int):
raise ValueError("'int' type required.'")
return self._queue[index]
def __setitem__(self, index: int, item: Track) -> None:
"""Inserts an item at given position."""
if not isinstance(index, int):
raise ValueError("'int' type required.'")
self.put_at_index(index, item)
def __delitem__(self, index: int) -> None:
"""Delete item at given position."""
self._queue.__delitem__(index)
def __iter__(self) -> Iterator[Track]:
"""Iterate over members in the queue.
Does not remove items when iterating.
"""
return self._queue.__iter__()
def __reversed__(self) -> Iterator[Track]:
"""Iterate over members in reverse order."""
return self._queue.__reversed__()
def __contains__(self, item: Track) -> bool:
"""Check if an item is a member of the queue."""
return item in self._queue
def __add__(self, other: Iterable[Track]) -> Queue:
"""Return a new queue containing all members.
The new queue will have the same max_size as the original.
"""
if not isinstance(other, Iterable):
raise TypeError(
f"Adding with the '{type(other)}' type is not supported.",
)
new_queue = self.copy()
new_queue.extend(other)
return new_queue
def __iadd__(self, other: Union[Iterable[Track], Track]) -> Queue:
"""Add items to queue."""
if isinstance(other, Track):
self.put(other)
return self
if isinstance(other, Iterable):
self.extend(other)
return self
raise TypeError(
f"Adding '{type(other)}' type to the queue is not supported.",
)
def _get(self) -> Track:
return self._queue.pop(0)
def _drop(self) -> Track:
return self._queue.pop()
def _index(self, item: Track) -> int:
return self._queue.index(item)
def _put(self, item: Track) -> None:
self._queue.append(item)
def _insert(self, index: int, item: Track) -> None:
self._queue.insert(index, item)
def _remove(self, item: Track) -> None:
self._queue.remove(item)
def _get_random_float(self) -> float:
return random.random()
@staticmethod
def _check_track(item: Track) -> Track:
if not isinstance(item, Track):
raise TypeError("Only pomice.Track objects are supported.")
return item
@classmethod
def _check_track_container(cls, iterable: Iterable) -> List[Track]:
iterable = list(iterable)
for item in iterable:
cls._check_track(item)
return iterable
@property
def count(self) -> int:
"""Returns queue member count."""
return len(self._queue)
@property
def is_empty(self) -> bool:
"""Returns True if queue has no members."""
return not bool(self.count)
@property
def is_full(self) -> bool:
"""Returns True if queue item count has reached max_size."""
return False if self.max_size is None else self.count >= self.max_size
@property
def is_looping(self) -> bool:
"""Returns True if the queue is looping either a track or the queue"""
return bool(self._loop_mode)
@property
def loop_mode(self) -> Optional[LoopMode]:
"""Returns the LoopMode enum set in the queue object"""
return self._loop_mode
@property
def size(self) -> int:
"""Returns the amount of items in the queue"""
return len(self._queue)
def get_queue(self) -> List:
"""Returns the queue as a List"""
return self._queue
def get(self) -> Track:
"""Return next immediately available item in queue if any.
Raises QueueEmpty if no items in queue.
"""
if self._loop_mode == LoopMode.TRACK:
return self._current_item
if self.is_empty:
raise QueueEmpty("No items in the queue.")
if self._loop_mode == LoopMode.QUEUE:
# set current item to first track in queue if not set already
# otherwise exception will be raised
if not self._current_item or self._current_item not in self._queue:
if self._queue:
item = self._queue[0]
else:
raise QueueEmpty("No items in the queue.")
# set current item to first track in queue if not set already
if not self._current_item:
self._current_item = self._queue[0]
item = self._current_item
# we reached the end of the queue, go back to first track
if self._index(self._current_item) == len(self._queue) - 1:
item = self._queue[0]
# we are in the middle of the queue, go the next item
else:
index = self._index(self._current_item) + 1
item = self._queue[index]
else:
item = self._get()
self._current_item = item
return item
def pop(self) -> Track:
"""Return item from the right end side of the queue.
Raises QueueEmpty if no items in queue.
"""
if self.is_empty:
raise QueueEmpty("No items in the queue.")
return self._queue.pop()
def remove(self, item: Track) -> None:
"""
Removes a item within the queue.
Raises ValueError if item is not in queue.
"""
return self._remove(self._check_track(item))
def find_position(self, item: Track) -> int:
"""Find the position a given item within the queue.
Raises ValueError if item is not in queue.
"""
return self._index(self._check_track(item))
def put(self, item: Track) -> None:
"""Put the given item into the back of the queue."""
if self.is_full:
if not self._overflow:
raise QueueFull(
f"Queue max_size of {self.max_size} has been reached.",
)
self._drop()
return self._put(self._check_track(item))
def put_at_index(self, index: int, item: Track) -> None:
"""Put the given item into the queue at the specified index."""
if self.is_full:
if not self._overflow:
raise QueueFull(
f"Queue max_size of {self.max_size} has been reached.",
)
self._drop()
return self._insert(index, self._check_track(item))
def put_at_front(self, item: Track) -> None:
"""Put the given item into the front of the queue."""
return self.put_at_index(0, item)
def extend(self, iterable: Iterable[Track], *, atomic: bool = True) -> None:
"""
Add the members of the given iterable to the end of the queue.
If atomic is set to True, no tracks will be added upon any exceptions.
If atomic is set to False, as many tracks will be added as possible.
When overflow is enabled for the queue, `atomic=True` won't prevent dropped items.
"""
if atomic:
iterable = self._check_track_container(iterable)
if not self._overflow and self.max_size is not None:
new_len = len(iterable)
if (new_len + self.count) > self.max_size:
raise QueueFull(
f"Queue has {self.count}/{self.max_size} items, "
f"cannot add {new_len} more.",
)
for item in iterable:
self.put(item)
def copy(self) -> Queue:
"""Create a copy of the current queue including it's members."""
new_queue = self.__class__(max_size=self.max_size)
new_queue._queue = copy(self._queue)
return new_queue
def clear(self) -> None:
"""Remove all items from the queue."""
self._queue.clear()
def set_loop_mode(self, mode: LoopMode) -> None:
"""
Sets the loop mode of the queue.
Takes the LoopMode enum as an argument.
"""
self._loop_mode = mode
if self._loop_mode == LoopMode.QUEUE:
try:
index = self._index(self._current_item)
except ValueError:
index = 0
if self._current_item not in self._queue:
self._queue.insert(index, self._current_item)
self._current_item = self._queue[index]
def disable_loop(self) -> None:
"""
Disables loop mode if set.
Raises QueueException if loop mode is already None.
"""
if not self._loop_mode:
raise QueueException("Queue loop is already disabled.")
if self._loop_mode == LoopMode.QUEUE:
index = self.find_position(self._current_item) + 1
self._queue = self._queue[index:]
self._loop_mode = None
def shuffle(self) -> None:
"""Shuffles the queue."""
return random.shuffle(self._queue)
def clear_track_filters(self) -> None:
"""Clears all filters applied to tracks"""
for track in self._queue:
track.filters = None
def jump(self, item: Track) -> None:
"""
Jumps to the item specified in the queue.
If the queue is not looping, the queue will be mutated.
Otherwise, the current item will be adjusted to the item
before the specified track.
The queue is adjusted so that the next item that is retrieved
is the track that is specified, effectively 'jumping' the queue.
"""
if self._loop_mode == LoopMode.TRACK:
raise QueueException("Jumping the queue whilst looping a track is not allowed.")
index = self.find_position(item)
if self._loop_mode == LoopMode.QUEUE:
self._current_item = self._queue[index - 1]
else:
new_queue = self._queue[index : self.size]
self._queue = new_queue

33
pomice/routeplanner.py Normal file
View File

@ -0,0 +1,33 @@
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from .pool import Node
from .utils import RouteStats
__all__ = ("RoutePlanner",)
class RoutePlanner:
"""
The base route planner class for Pomice.
Handles all requests made to the route planner API for Lavalink.
"""
def __init__(self, node: Node) -> None:
self.node: Node = node
async def get_status(self) -> RouteStats:
"""Gets the status of the route planner API."""
data: dict = await self.node.send(method="GET", path="routeplanner/status")
return RouteStats(data)
async def free_address(self, ip: str) -> None:
"""Frees an address using the route planner API"""
await self.node.send(method="POST", path="routeplanner/free/address", data={"address": ip})
async def free_all_addresses(self) -> None:
"""Frees all available addresses using the route planner api"""
await self.node.send(method="POST", path="routeplanner/free/address/all")

View File

@ -0,0 +1,4 @@
"""Spotify module for Pomice, made possible by cloudwithax 2023"""
from .client import Client
from .exceptions import *
from .objects import *

360
pomice/spotify/client.py Normal file
View File

@ -0,0 +1,360 @@
from __future__ import annotations
import asyncio
import logging
import re
import time
from base64 import b64encode
from typing import AsyncGenerator
from typing import Dict
from typing import List
from typing import Optional
from typing import Union
from urllib.parse import quote
import aiohttp
import orjson as json
from .exceptions import InvalidSpotifyURL
from .exceptions import SpotifyRequestException
from .objects import *
__all__ = ("Client",)
GRANT_URL = "https://accounts.spotify.com/api/token"
REQUEST_URL = "https://api.spotify.com/v1/{type}s/{id}"
# Keep this in sync with URLRegex.SPOTIFY_URL (enums.py). Accept intl locale segment,
# optional trailing slash, and query parameters.
SPOTIFY_URL_REGEX = re.compile(
r"https?://open\.spotify\.com/(?:intl-[a-zA-Z-]+/)?(?P<type>album|playlist|track|artist)/(?P<id>[a-zA-Z0-9]+)(?:/)?(?:\?.*)?$",
)
class Client:
"""The base client for the Spotify module of Pomice.
This class will do all the heavy lifting of getting all the metadata
for any Spotify URL you throw at it.
"""
def __init__(
self,
client_id: str,
client_secret: str,
*,
playlist_concurrency: int = 10,
playlist_page_limit: Optional[int] = None,
) -> None:
self._client_id = client_id
self._client_secret = client_secret
# HTTP session will be injected by Node
self.session: Optional[aiohttp.ClientSession] = None
self._bearer_token: Optional[str] = None
self._expiry: float = 0.0
self._auth_token = b64encode(f"{self._client_id}:{self._client_secret}".encode())
self._grant_headers = {"Authorization": f"Basic {self._auth_token.decode()}"}
self._bearer_headers: Optional[Dict] = None
self._log = logging.getLogger(__name__)
# Performance tuning knobs
self._playlist_concurrency = max(1, playlist_concurrency)
self._playlist_page_limit = playlist_page_limit
async def _set_session(self, session: aiohttp.ClientSession) -> None:
self.session = session
async def _fetch_bearer_token(self) -> None:
_data = {"grant_type": "client_credentials"}
if not self.session:
raise SpotifyRequestException("HTTP session not initialized for Spotify client.")
resp = await self.session.post(GRANT_URL, data=_data, headers=self._grant_headers)
if resp.status != 200:
raise SpotifyRequestException(
f"Error fetching bearer token: {resp.status} {resp.reason}",
)
data: dict = await resp.json(loads=json.loads)
if self._log:
self._log.debug(f"Fetched Spotify bearer token successfully")
self._bearer_token = data["access_token"]
self._expiry = time.time() + (int(data["expires_in"]) - 10)
self._bearer_headers = {
"Authorization": f"Bearer {self._bearer_token}",
}
async def search(self, *, query: str) -> Union[Track, Album, Artist, Playlist]:
if not self._bearer_token or time.time() >= self._expiry:
await self._fetch_bearer_token()
result = SPOTIFY_URL_REGEX.match(query)
if not result:
raise InvalidSpotifyURL("The Spotify link provided is not valid.")
spotify_type = result.group("type")
spotify_id = result.group("id")
request_url = REQUEST_URL.format(type=spotify_type, id=spotify_id)
if not self.session:
raise SpotifyRequestException("HTTP session not initialized for Spotify client.")
resp = await self.session.get(request_url, headers=self._bearer_headers)
if resp.status != 200:
raise SpotifyRequestException(
f"Error while fetching results: {resp.status} {resp.reason}",
)
data: dict = await resp.json(loads=json.loads)
if self._log:
self._log.debug(
f"Made request to Spotify API with status {resp.status} and response {data}",
)
if spotify_type == "track":
return Track(data)
elif spotify_type == "album":
return Album(data)
elif spotify_type == "artist":
if not self.session:
raise SpotifyRequestException("HTTP session not initialized for Spotify client.")
resp = await self.session.get(
f"{request_url}/top-tracks?market=US",
headers=self._bearer_headers,
)
if resp.status != 200:
raise SpotifyRequestException(
f"Error while fetching results: {resp.status} {resp.reason}",
)
track_data: dict = await resp.json(loads=json.loads)
tracks = track_data["tracks"]
return Artist(data, tracks)
else:
# For playlists we optionally use a reduced fields payload to shrink response sizes.
# NB: We cannot apply fields filter to initial request because original metadata is needed.
tracks = [
Track(track["track"])
for track in data["tracks"]["items"]
if track["track"] is not None
]
if not tracks:
raise SpotifyRequestException(
"This playlist is empty and therefore cannot be queued.",
)
total_tracks = data["tracks"]["total"]
limit = data["tracks"]["limit"]
# Shortcircuit small playlists (single page)
if total_tracks <= limit:
return Playlist(data, tracks)
# Build remaining page URLs; Spotify supports offset-based pagination.
remaining_offsets = range(limit, total_tracks, limit)
page_urls: List[str] = []
fields_filter = (
"items(track(name,duration_ms,id,is_local,external_urls,external_ids,artists(name),album(images)))"
",next"
)
for idx, offset in enumerate(remaining_offsets):
if self._playlist_page_limit is not None and idx >= self._playlist_page_limit:
break
page_urls.append(
f"{request_url}/tracks?offset={offset}&limit={limit}&fields={quote(fields_filter)}",
)
if page_urls:
semaphore = asyncio.Semaphore(self._playlist_concurrency)
async def fetch_page(url: str) -> Optional[List[Track]]:
async with semaphore:
if not self.session:
raise SpotifyRequestException(
"HTTP session not initialized for Spotify client.",
)
resp = await self.session.get(url, headers=self._bearer_headers)
if resp.status != 200:
if self._log:
self._log.warning(
f"Page fetch failed {resp.status} {resp.reason} for {url}",
)
return None
page_json: dict = await resp.json(loads=json.loads)
return [
Track(item["track"])
for item in page_json.get("items", [])
if item.get("track") is not None
]
# Chunk gather in waves to avoid creating thousands of tasks at once
aggregated: List[Track] = []
wave_size = self._playlist_concurrency * 2
for i in range(0, len(page_urls), wave_size):
wave = page_urls[i : i + wave_size]
results = await asyncio.gather(
*[fetch_page(url) for url in wave],
return_exceptions=False,
)
for result in results:
if result:
aggregated.extend(result)
tracks.extend(aggregated)
return Playlist(data, tracks)
async def iter_playlist_tracks(
self,
*,
query: str,
batch_size: int = 100,
) -> AsyncGenerator[List[Track], None]:
"""Stream playlist tracks in batches without waiting for full materialization.
Parameters
----------
query: str
Spotify playlist URL.
batch_size: int
Number of tracks yielded per batch (logical grouping after fetch). Does not alter API page size.
"""
if not self._bearer_token or time.time() >= self._expiry:
await self._fetch_bearer_token()
match = SPOTIFY_URL_REGEX.match(query)
if not match or match.group("type") != "playlist":
raise InvalidSpotifyURL("Provided query is not a valid Spotify playlist URL.")
playlist_id = match.group("id")
request_url = REQUEST_URL.format(type="playlist", id=playlist_id)
if not self.session:
raise SpotifyRequestException("HTTP session not initialized for Spotify client.")
resp = await self.session.get(request_url, headers=self._bearer_headers)
if resp.status != 200:
raise SpotifyRequestException(
f"Error while fetching results: {resp.status} {resp.reason}",
)
data: dict = await resp.json(loads=json.loads)
# Yield first page immediately
first_page_tracks = [
Track(item["track"])
for item in data["tracks"]["items"]
if item.get("track") is not None
]
# Batch yield
for i in range(0, len(first_page_tracks), batch_size):
yield first_page_tracks[i : i + batch_size]
total = data["tracks"]["total"]
limit = data["tracks"]["limit"]
remaining_offsets = range(limit, total, limit)
fields_filter = (
"items(track(name,duration_ms,id,is_local,external_urls,external_ids,artists(name),album(images)))"
",next"
)
semaphore = asyncio.Semaphore(self._playlist_concurrency)
async def fetch(offset: int) -> List[Track]:
url = (
f"{request_url}/tracks?offset={offset}&limit={limit}&fields={quote(fields_filter)}"
)
async with semaphore:
if not self.session:
raise SpotifyRequestException(
"HTTP session not initialized for Spotify client.",
)
r = await self.session.get(url, headers=self._bearer_headers)
if r.status != 200:
if self._log:
self._log.warning(
f"Skipping page offset={offset} due to {r.status} {r.reason}",
)
return []
pj: dict = await r.json(loads=json.loads)
return [
Track(item["track"])
for item in pj.get("items", [])
if item.get("track") is not None
]
# Fetch pages in rolling waves; yield promptly as soon as a wave completes.
wave_size = self._playlist_concurrency * 2
for i, offset in enumerate(remaining_offsets):
# Build wave
if i % wave_size == 0:
wave_offsets = list(
o for o in remaining_offsets if o >= offset and o < offset + wave_size
)
results = await asyncio.gather(*[fetch(o) for o in wave_offsets])
for page_tracks in results:
if not page_tracks:
continue
for j in range(0, len(page_tracks), batch_size):
yield page_tracks[j : j + batch_size]
# Skip ahead in iterator by adjusting enumerate drive (consume extras)
# Fast-forward the generator manually
for _ in range(len(wave_offsets) - 1):
try:
next(remaining_offsets) # type: ignore
except StopIteration:
break
async def get_recommendations(self, *, query: str) -> List[Track]:
if not self._bearer_token or time.time() >= self._expiry:
await self._fetch_bearer_token()
result = SPOTIFY_URL_REGEX.match(query)
if not result:
raise InvalidSpotifyURL("The Spotify link provided is not valid.")
spotify_type = result.group("type")
spotify_id = result.group("id")
if not spotify_type == "track":
raise InvalidSpotifyURL(
"The provided query is not a Spotify track.",
)
request_url = REQUEST_URL.format(
type="recommendation",
id=f"?seed_tracks={spotify_id}",
)
if not self.session:
raise SpotifyRequestException("HTTP session not initialized for Spotify client.")
resp = await self.session.get(request_url, headers=self._bearer_headers)
if resp.status != 200:
raise SpotifyRequestException(
f"Error while fetching results: {resp.status} {resp.reason}",
)
data: dict = await resp.json(loads=json.loads)
tracks = [Track(track) for track in data["tracks"]]
return tracks
async def track_search(self, *, query: str) -> List[Track]:
if not self._bearer_token or time.time() >= self._expiry:
await self._fetch_bearer_token()
request_url = f"https://api.spotify.com/v1/search?q={quote(query)}&type=track"
if not self.session:
raise SpotifyRequestException("HTTP session not initialized for Spotify client.")
resp = await self.session.get(request_url, headers=self._bearer_headers)
if resp.status != 200:
raise SpotifyRequestException(
f"Error while fetching results: {resp.status} {resp.reason}",
)
data: dict = await resp.json(loads=json.loads)
tracks = [Track(track) for track in data["tracks"]["items"]]
return tracks

View File

@ -0,0 +1,16 @@
__all__ = (
"SpotifyRequestException",
"InvalidSpotifyURL",
)
class SpotifyRequestException(Exception):
"""An error occurred when making a request to the Spotify API"""
pass
class InvalidSpotifyURL(Exception):
"""An invalid Spotify URL was passed"""
pass

97
pomice/spotify/objects.py Normal file
View File

@ -0,0 +1,97 @@
from typing import List
from typing import Optional
__all__ = (
"Track",
"Playlist",
"Album",
"Artist",
)
class Track:
"""The base class for a Spotify Track"""
def __init__(self, data: dict, image: Optional[str] = None) -> None:
self.name: str = data["name"]
self.artists: str = ", ".join(artist["name"] for artist in data["artists"])
self.length: float = data["duration_ms"]
self.id: str = data["id"]
self.isrc: Optional[str] = None
if data.get("external_ids"):
self.isrc = data["external_ids"]["isrc"]
self.image: Optional[str] = image
if data.get("album") and data["album"].get("images"):
self.image = data["album"]["images"][0]["url"]
self.uri: Optional[str] = None
if not data["is_local"]:
self.uri = data["external_urls"]["spotify"]
def __repr__(self) -> str:
return (
f"<Pomice.spotify.Track name={self.name} artists={self.artists} "
f"length={self.length} id={self.id} isrc={self.isrc}>"
)
class Playlist:
"""The base class for a Spotify playlist"""
def __init__(self, data: dict, tracks: List[Track]) -> None:
self.name: str = data["name"]
self.tracks = tracks
self.owner: str = data["owner"]["display_name"]
self.total_tracks: int = data["tracks"]["total"]
self.id: str = data["id"]
if data.get("images") and len(data["images"]):
self.image = data["images"][0]["url"]
else:
self.image = self.tracks[0].image
self.uri = data["external_urls"]["spotify"]
def __repr__(self) -> str:
return (
f"<Pomice.spotify.Playlist name={self.name} owner={self.owner} id={self.id} "
f"total_tracks={self.total_tracks} tracks={self.tracks}>"
)
class Album:
"""The base class for a Spotify album"""
def __init__(self, data: dict) -> None:
self.name: str = data["name"]
self.artists: str = ", ".join(artist["name"] for artist in data["artists"])
self.image: str = data["images"][0]["url"]
self.tracks = [Track(track, image=self.image) for track in data["tracks"]["items"]]
self.total_tracks: int = data["total_tracks"]
self.id: str = data["id"]
self.uri: str = data["external_urls"]["spotify"]
def __repr__(self) -> str:
return (
f"<Pomice.spotify.Album name={self.name} artists={self.artists} id={self.id} "
f"total_tracks={self.total_tracks} tracks={self.tracks}>"
)
class Artist:
"""The base class for a Spotify artist"""
def __init__(self, data: dict, tracks: dict) -> None:
self.name: str = (
# Setting that because its only playing top tracks
f"Top tracks for {data['name']}"
)
self.genres: str = ", ".join(genre for genre in data["genres"])
self.followers: int = data["followers"]["total"]
self.image: str = data["images"][0]["url"]
self.tracks = [Track(track, image=self.image) for track in tracks]
self.id: str = data["id"]
self.uri: str = data["external_urls"]["spotify"]
def __repr__(self) -> str:
return f"<Pomice.spotify.Artist name={self.name} id={self.id} " f"tracks={self.tracks}>"

View File

@ -1,42 +1,56 @@
"""
The MIT License (MIT)
Copyright (c) 2015-present Rapptz
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
"""
import random
import socket
import time
from datetime import datetime
from itertools import zip_longest
from timeit import default_timer as timer
from typing import Any
from typing import Callable
from typing import Dict
from typing import Iterable
from typing import NamedTuple
from typing import Optional
from .enums import RouteIPType
from .enums import RouteStrategy
__all__ = [
'ExponentialBackoff',
'PomiceStats'
]
__all__ = (
"ExponentialBackoff",
"NodeStats",
"FailingIPBlock",
"RouteStats",
"Ping",
"LavalinkVersion",
)
class ExponentialBackoff:
"""
The MIT License (MIT)
Copyright (c) 2015-present Rapptz
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
"""
def __init__(self, base: int = 1, *, integral: bool = False) -> None:
self._base = base
self._exp = 0
self._max = 10
self._reset_time = base * 2 ** 11
self._reset_time = base * 2**11
self._last_invocation = time.monotonic()
rand = random.Random()
@ -45,7 +59,6 @@ class ExponentialBackoff:
self._randfunc = rand.randrange if integral else rand.uniform
def delay(self) -> float:
invocation = time.monotonic()
interval = invocation - self._last_invocation
self._last_invocation = invocation
@ -54,26 +67,212 @@ class ExponentialBackoff:
self._exp = 0
self._exp = min(self._exp + 1, self._max)
return self._randfunc(0, self._base * 2 ** self._exp)
return self._randfunc(0, self._base * 2**self._exp) # type: ignore
class NodeStats:
"""The base class for the node stats object.
Gives critical information on the node, which is updated every minute.
"""
def __init__(self, data: dict) -> None:
__slots__ = (
"used",
"free",
"reservable",
"allocated",
"cpu_cores",
"cpu_system_load",
"cpu_process_load",
"players_active",
"players_total",
"uptime",
)
memory = data.get('memory')
self.used = memory.get('used')
self.free = memory.get('free')
self.reservable = memory.get('reservable')
self.allocated = memory.get('allocated')
def __init__(self, data: Dict[str, Any]) -> None:
memory: dict = data.get("memory", {})
self.used = memory.get("used")
self.free = memory.get("free")
self.reservable = memory.get("reservable")
self.allocated = memory.get("allocated")
cpu = data.get('cpu')
self.cpu_cores = cpu.get('cores')
self.cpu_system_load = cpu.get('systemLoad')
self.cpu_process_load = cpu.get('lavalinkLoad')
cpu: dict = data.get("cpu", {})
self.cpu_cores = cpu.get("cores")
self.cpu_system_load = cpu.get("systemLoad")
self.cpu_process_load = cpu.get("lavalinkLoad")
self.players_active = data.get('playingPlayers')
self.players_total = data.get('players')
self.uptime = data.get('uptime')
self.players_active = data.get("playingPlayers")
self.players_total = data.get("players")
self.uptime = data.get("uptime")
def __repr__(self) -> str:
return f'<Pomice.NodeStats total_players={self.players_total} playing_active={self.players_active}>'
return f"<Pomice.NodeStats total_players={self.players_total!r} playing_active={self.players_active!r}>"
class FailingIPBlock:
"""
The base class for the failing IP block object from the route planner stats.
Gives critical information about any failing addresses on the block
and the time they failed.
"""
__slots__ = ("address", "failing_time")
def __init__(self, data: dict) -> None:
self.address = data.get("address")
self.failing_time = datetime.fromtimestamp(
float(data.get("failingTimestamp", 0)),
)
def __repr__(self) -> str:
return f"<Pomice.FailingIPBlock address={self.address} failing_time={self.failing_time}>"
class RouteStats:
"""
The base class for the route planner stats object.
Gives critical information about the route planner strategy on the node.
"""
__slots__ = (
"strategy",
"ip_block_type",
"ip_block_size",
"failing_addresses",
"block_index",
"address_index",
)
def __init__(self, data: Dict[str, Any]) -> None:
self.strategy = RouteStrategy(data.get("class"))
details: dict = data.get("details", {})
ip_block: dict = details.get("ipBlock", {})
self.ip_block_type = RouteIPType(ip_block.get("type"))
self.ip_block_size = ip_block.get("size")
self.failing_addresses = [
FailingIPBlock(
data,
)
for data in details.get("failingAddresses", [])
]
self.block_index = details.get("blockIndex")
self.address_index = details.get("currentAddressIndex")
def __repr__(self) -> str:
return f"<Pomice.RouteStats route_strategy={self.strategy!r} failing_addresses={len(self.failing_addresses)}>"
class Ping:
# Thanks to https://github.com/zhengxiaowai/tcping for the nice ping impl
def __init__(self, host: str, port: int, timeout: int = 5) -> None:
self.timer = self.Timer()
self._successed = 0
self._failed = 0
self._conn_time = None
self._host = host
self._port = port
self._timeout = timeout
class Socket:
def __init__(self, family: int, type_: int, timeout: Optional[float]) -> None:
s = socket.socket(family, type_)
s.settimeout(timeout)
self._s = s
def connect(self, host: str, port: int) -> None:
self._s.connect((host, port))
def shutdown(self) -> None:
self._s.shutdown(socket.SHUT_RD)
def close(self) -> None:
self._s.close()
class Timer:
def __init__(self) -> None:
self._start: float = 0.0
self._stop: float = 0.0
def start(self) -> None:
self._start = timer()
def stop(self) -> None:
self._stop = timer()
def cost(self, funcs: Iterable[Callable], args: Any) -> float:
self.start()
for func, arg in zip_longest(funcs, args):
if arg:
func(*arg)
else:
func()
self.stop()
return self._stop - self._start
def _create_socket(self, family: int, type_: int) -> Socket:
return self.Socket(family, type_, self._timeout)
def get_ping(self) -> float:
s = self._create_socket(socket.AF_INET, socket.SOCK_STREAM)
cost_time = self.timer.cost(
(s.connect, s.shutdown),
((self._host, self._port), None),
)
s_runtime = 1000 * (cost_time)
return s_runtime
class LavalinkVersion(NamedTuple):
major: int
minor: int
fix: int
def __eq__(self, other: object) -> bool:
if not isinstance(other, LavalinkVersion):
return False
return (
(self.major == other.major) and (self.minor == other.minor) and (self.fix == other.fix)
)
def __ne__(self, other: object) -> bool:
if not isinstance(other, LavalinkVersion):
return False
return not (self == other)
def __lt__(self, other: object) -> bool:
if not isinstance(other, LavalinkVersion):
return False
if self.major > other.major:
return False
if self.minor > other.minor:
return False
if self.fix > other.fix:
return False
return True
def __gt__(self, other: object) -> bool:
if not isinstance(other, LavalinkVersion):
return False
return not (self < other)
def __le__(self, other: object) -> bool:
if not isinstance(other, LavalinkVersion):
return False
return (self < other) or (self == other)
def __ge__(self, other: object) -> bool:
if not isinstance(other, LavalinkVersion):
return False
return (self > other) or (self == other)

View File

@ -4,3 +4,16 @@ requires = [
"wheel"
]
build-backend = "setuptools.build_meta"
[tool.black]
line-length = 100
[tool.mypy]
mypy_path = "./"
files = ["pomice"]
disallow_untyped_defs = true
disallow_any_unimported = true
no_implicit_optional = true
check_untyped_defs = true
warn_unused_ignores = true
show_error_codes = true

View File

@ -1,33 +1,73 @@
# type: ignore
import re
import setuptools
version = ""
requirements = ["aiohttp>=3.7.4,<4", "orjson", "websockets"]
with open("pomice/__init__.py") as f:
version = re.search(
r'^__version__\s*=\s*[\'"]([^\'"]*)[\'"]',
f.read(),
re.MULTILINE,
).group(1)
if not version:
raise RuntimeError("version is not set")
if version.endswith(("a", "b", "rc")):
# append version identifier based on commit count
try:
import subprocess
p = subprocess.Popen(
["git", "rev-list", "--count", "HEAD"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
out, err = p.communicate()
if out:
version += out.decode("utf-8").strip()
p = subprocess.Popen(
["git", "rev-parse", "--short", "HEAD"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
out, err = p.communicate()
if out:
version += "+g" + out.decode("utf-8").strip()
except Exception:
pass
with open("README.md") as f:
readme = f.read()
setuptools.setup(
name="pomice",
author="cloudwithax",
version="1.0.1",
version=version,
url="https://github.com/cloudwithax/pomice",
packages=setuptools.find_packages(),
license="GPL",
description="The modern Lavalink wrapper designed for Discord.py",
long_description=readme,
long_description_content_type="text/markdown",
package_data={"pomice": ["py.typed"]},
include_package_data=True,
install_requires=['discord.py>=1.7.1'],
install_requires=requirements,
extra_require=None,
classifiers=[
"Framework :: AsyncIO",
'Operating System :: OS Independent',
'Natural Language :: English',
'Intended Audience :: Developers',
"Operating System :: OS Independent",
"Natural Language :: English",
"Intended Audience :: Developers",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.8",
'Topic :: Software Development :: Libraries :: Python Modules',
'Topic :: Software Development :: Libraries',
"Topic :: Internet"
"Topic :: Software Development :: Libraries :: Python Modules",
"Topic :: Software Development :: Libraries",
"Topic :: Internet",
],
python_requires='>=3.8',
keywords=['pomice', 'lavalink', "discord.py"],
python_requires=">=3.8",
keywords=["pomice", "lavalink", "discord.py"],
)