Initial commit

This commit is contained in:
cloudwithax 2021-09-25 13:40:41 -04:00
commit 07b158988a
13 changed files with 944 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
*.pyc
.git/
__pycache/
dist/
pomice.egg-info/

108
README.md Normal file
View File

@ -0,0 +1,108 @@
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)
# Install
To install the library, you need the lastest version of pip and minimum Python 3.8
> Stable version
```
pip install pomice
```
> Unstable version (this one gets more frequent changes)
```
pip install git+https://github.com/cloudwithax/pomice
```
# Examples
In-depth examples are located in the examples folder
Here's a quick example:
```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.obsidian = 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'ytsearch:{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")
```
# 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)
What experience do I need?
- This library requires that you have some experience with Python, asynchronous programming and the discord.py library.
Why is it saying "No module named pomice found"?
- You need to [install](#Install) the package before you can use it

14
pomice/__init__.py Normal file
View File

@ -0,0 +1,14 @@
"""Big poopoo peepee moment"""
__version__ = "1.0.6.2"
__title__ = "pomice"
__author__ = "cloudwithax"
from .exceptions import *
from .events import *
from .filters import *
from .objects import *
from .pool import NodePool
from .node import Node
from .player import Player

85
pomice/events.py Normal file
View File

@ -0,0 +1,85 @@
from .pool import NodePool
class PomiceEvent:
def __init__(self):
pass
name = 'event'
class TrackStartEvent(PomiceEvent):
def __init__(self, data):
super().__init__()
self.name = "track_start"
self.player = NodePool.get_node().get_player(int(data["guildId"]))
self.track_id = data['track']
def __repr__(self) -> str:
return f"<Pomice.TrackStartEvent track_id={self.track_id}>"
class TrackEndEvent(PomiceEvent):
def __init__(self, data):
super().__init__()
self.name = "track_end"
self.player = NodePool.get_node().get_player(int(data["guildId"]))
self.track_id = data['track']
self.reason = data['reason']
def __repr__(self) -> str:
return f"<Pomice.TrackEndEvent track_id={self.track_id} reason={self.reason}>"
class TrackStuckEvent(PomiceEvent):
def __init__(self, data):
super().__init__()
self.name = "track_stuck"
self.player = NodePool.get_node().get_player(int(data["guildId"]))
self.track_id = data["track"]
self.threshold = data["thresholdMs"]
def __repr__(self) -> str:
return f"<Pomice.TrackStuckEvent track_id={self.track_id} threshold={self.threshold}>"
class TrackExceptionEvent(PomiceEvent):
def __init__(self, data):
super().__init__()
self.name = "track_exception"
self.player = NodePool.get_node().get_player(int(data["guildId"]))
self.error = data["error"]
self.exception = data["exception"]
def __repr__(self) -> str:
return f"<Pomice.TrackExceptionEvent> error={self.error} exeception={self.exception}"
class WebsocketClosedEvent(PomiceEvent):
def __init__(self, data):
super().__init__()
self.name = "websocket_closed"
self.player = NodePool.get_node().get_player(int(data["guildId"]))
self.reason = data["reason"]
self.code = data["code"]
def __repr__(self) -> str:
return f"<Pomice.WebsocketClosedEvent reason={self.reason} code={self.code}>"
class WebsocketOpenEvent(PomiceEvent):
def __init__(self, data):
super().__init__()
self.name = "websocket_open"
self.player = NodePool.get_node().get_player(int(data["guildId"]))
self.target: str = data['target']
self.ssrc: int = data['ssrc']
def __repr__(self) -> str:
return f"<Pomice.WebsocketOpenEvent target={self.target} ssrc={self.ssrc}>"

44
pomice/exceptions.py Normal file
View File

@ -0,0 +1,44 @@
class PomiceException(Exception):
"""Base of all Pomice exceptions."""
class NodeException(Exception):
"""Base exception for nodes."""
class NodeCreationError(NodeException):
"""There was a problem while creating the node."""
class NodeConnectionFailure(NodeException):
"""There was a problem while connecting to the node."""
class NodeConnectionClosed(NodeException):
"""The nodes connection is closed."""
pass
class NodeNotAvailable(PomiceException):
"""The node is not currently available."""
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

90
pomice/filters.py Normal file
View File

@ -0,0 +1,90 @@
from . import exceptions
class Filter:
def __init__(self):
self.payload = None
class Timescale(Filter):
def __init__(self, *, speed: float = 1.0, pitch: float = 1.0, rate: float = 1.0):
super().__init__()
if speed < 0:
raise exceptions.FilterInvalidArgument("Timescale speed must be more than 0.")
if pitch < 0:
raise exceptions.FilterInvalidArgument("Timescale pitch must be more than 0.")
if rate < 0:
raise exceptions.FilterInvalidArgument("Timescale rate must be more than 0.")
self.speed = speed
self.pitch = pitch
self.rate = rate
self.payload = {"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}>"
class Karaoke(Filter):
def __init__(self, *, level: float, mono_level: float, filter_band: float, filter_width: float):
super().__init__()
self.level = level
self.mono_level = mono_level
self.filter_band = filter_band
self.filter_width = filter_width
self.payload = {"karaoke": {"level": self.level,
"monoLevel": self.mono_level,
"filterBand": self.filter_band,
"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}>"
class Tremolo(Filter):
def __init__(self, *, frequency: float, depth: float):
super().__init__()
if frequency < 0:
raise exceptions.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.")
self.frequency = frequency
self.depth = depth
self.payload = {"tremolo": {"frequency": self.frequency,
"depth": self.depth}}
def __repr__(self):
return f"<Pomice.TremoloFilter frequency={self.frequency} depth={self.depth}>"
class Vibrato(Filter):
def __init__(self, *, frequency: float, depth: float):
super().__init__()
if frequency < 0 or frequency > 14:
raise exceptions.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.")
self.frequency = frequency
self.depth = depth
self.payload = {"vibrato": {"frequency": self.frequency,
"depth": self.depth}}
def __repr__(self):
return f"<Pomice.VibratoFilter frequency={self.frequency} depth={self.depth}>"

205
pomice/node.py Normal file
View File

@ -0,0 +1,205 @@
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"]]

48
pomice/objects.py Normal file
View File

@ -0,0 +1,48 @@
from discord.ext import commands
class Track:
def __init__(self, track_id: str, info: dict, ctx: commands.Context = None):
self.track_id = track_id
self.info = info
self.title = info.get("title")
self.author = info.get("author")
self.length = info.get("length")
if ctx:
self.ctx: commands.Context = 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):
return self.title
def __repr__(self):
return f"<Pomice.track title={self.title!r} uri=<{self.uri!r}> length={self.length}>"
class Playlist:
def __init__(self, playlist_info: dict, tracks: list, ctx: commands.Context = None):
self.playlist_info = playlist_info
self.tracks_raw = tracks
self.name = playlist_info.get("name")
self.selected_track = playlist_info.get("selectedTrack")
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]
def __str__(self):
return self.name
def __repr__(self):
return f"<Pomice.playlist name={self.name!r} track_count={len(self.tracks)}>"

181
pomice/player.py Normal file
View File

@ -0,0 +1,181 @@
import time
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.ext import commands
from typing import Optional, Any, Union
class Player(VoiceProtocol):
def __init__(self, client: Union[commands.Bot, discord.Client, commands.AutoShardedBot, discord.AutoShardedClient], channel: VoiceChannel):
super().__init__(client=client, channel=channel)
self.client: Union[commands.Bot, discord.Client, commands.AutoShardedBot, discord.AutoShardedClient] = client
self._bot: Union[commands.Bot, discord.Client, commands.AutoShardedBot, discord.AutoShardedClient] = client
self.channel: VoiceChannel = channel
self._guild: discord.Guild = channel.guild
self._dj: discord.Member = None
self._node: Node = NodePool.get_node()
self._current: objects.Track = None
self._filter: filters.Filter = None
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._session_id: Optional[str] = None
self._voice_server_update_data: Optional[dict] = None
def __repr__(self):
return f"<Pomice.player bot={self._bot} guildId={self._guild.id} is_connected={self.is_connected} is_playing={self.is_playing}>"
@property
def position(self):
if not self.is_playing:
return 0
if self._paused:
return min(self._position, self._current.length)
position = round(self._position + ((time.time() * 1000) - self._last_update))
if position > self._current.length:
return 0
return position
@property
def is_connected(self):
return self._is_connected
@property
def is_playing(self):
return self._is_connected and self._current is not None
@property
def is_paused(self):
return self._is_connected and self._paused is True
@property
def node(self):
return self._node
@property
def current(self):
return self._current
@property
def volume(self):
return self._volume
async def _update_state(self, data: dict):
state = data.get('state')
self._last_update = state.get('time')
self._is_connected = state.get('connected')
self._position = state.get('position')
async def _dispatch_voice_update(self) -> None:
if not self._session_id or not self._voice_server_update_data:
return
await self._node.send(op='voiceUpdate', sessionId=self._session_id, guildId=str(self._guild.id), event={**self._voice_server_update_data})
async def _voice_server_update(self, data: dict):
self._voice_server_update_data = data
await self._dispatch_voice_update()
async def _voice_state_update(self, data: dict):
if not (channel_id := data.get('channel_id')):
self.channel, self._session_id, self._voice_server_update_data = None
return
self.channel = self._guild.get_channel(int(channel_id))
self._session_id = data.get('session_id')
await self._dispatch_voice_update()
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)
async def get_tracks(self, query: str, ctx: commands.Context = None):
return await self._node.get_tracks(query, ctx)
async def connect(self, *, timeout: float, reconnect: bool):
await self._guild.change_voice_state(channel=self.channel)
self._node._players[self._guild.id] = self
self._is_connected = True
async def stop(self):
self._current = None
await self._node.send(op='stop', guildId=str(self._guild.id))
async def disconnect(self, *, force: bool = False):
await self.stop()
await self._guild.change_voice_state(channel=None)
self.cleanup()
self.channel = None
self._is_connected = False
del self._node._players[self._guild.id]
async def destroy(self):
await self.disconnect()
await self._node.send(op='destroy', guildId=str(self._guild.id))
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
return self._current
async def seek(self, position: int):
if position < 0 or position > self.current.length:
raise exceptions.TrackInvalidPosition(f"Seek position must be between 0 and the track length")
await self._node.send(op='seek', guildId=str(self._guild.id), position=position)
return self.position
async def set_pause(self, pause: bool):
await self._node.send(op='pause', guildId=str(self._guild.id), pause=pause)
self._paused = pause
return self._paused
async def set_volume(self, volume: int):
await self._node.send(op='volume', guildId=str(self._guild.id), volume=volume)
self._volume = 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)
await self.seek(self.position)
self._filter = filter
return filter

46
pomice/pool.py Normal file
View File

@ -0,0 +1,46 @@
import discord
import typing
import random
from . import exceptions
from .node import Node
from discord.ext import commands
class NodePool:
_nodes: dict = {}
def __repr__(self):
return f"<Pomice.NodePool node_count={len(self._nodes.values())}>"
@property
def nodes(self):
return self._nodes
@classmethod
def get_node(self, *, identifier: str = None) -> Node:
available_nodes = {identifier: node for identifier, node in self._nodes.items()}
if not available_nodes:
raise exceptions.NoNodesAvailable('There are no nodes available.')
if identifier is None:
return random.choice(list(available_nodes.values()))
return available_nodes.get(identifier, None)
@classmethod
async def create_node(self, bot: typing.Union[commands.Bot, discord.Client, commands.AutoShardedBot, discord.AutoShardedClient], host: str, port: str, password: str, identifier: str) -> Node:
if identifier in self._nodes.keys():
raise exceptions.NodeCreationError(f"A node with identifier '{identifier}' already exists.")
node = Node(pool=self, bot=bot, host=host, port=port, password=password, identifier=identifier)
await node.connect()
self._nodes[node._identifier] = node
return node

79
pomice/utils.py Normal file
View File

@ -0,0 +1,79 @@
"""
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 time
__all__ = [
'ExponentialBackoff',
'PomiceStats'
]
class ExponentialBackoff:
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._last_invocation = time.monotonic()
rand = random.Random()
rand.seed()
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
if interval > self._reset_time:
self._exp = 0
self._exp = min(self._exp + 1, self._max)
return self._randfunc(0, self._base * 2 ** self._exp)
class NodeStats:
def __init__(self, data: dict) -> None:
memory = 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')
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}>'

6
pyproject.toml Normal file
View File

@ -0,0 +1,6 @@
[build-system]
requires = [
"setuptools>=42",
"wheel"
]
build-backend = "setuptools.build_meta"

33
setup.py Normal file
View File

@ -0,0 +1,33 @@
import setuptools
with open("README.md") as f:
readme = f.read()
setuptools.setup(
name="pomice",
author="cloudwithax",
version="1.0.0",
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",
include_package_data=True,
install_requires=['discord.py>=1.7.1'],
extra_require=None,
classifiers=[
"Framework :: AsyncIO",
'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"
],
python_requires='>=3.8',
keywords=['pomice', 'lavalink', "discord.py"],
)