diff --git a/.github/workflows/build_and_publish.yml b/.github/workflows/build_and_publish.yml new file mode 100644 index 00000000..f6a49e29 --- /dev/null +++ b/.github/workflows/build_and_publish.yml @@ -0,0 +1,50 @@ +name: Publish to PyPi @ Release + +on: + release: + types: [published] + +jobs: + build: + name: Build distribution + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: "3.x" + - name: Install pypa/build + run: >- + python3 -m + pip install + build + --user + - name: Build source and wheels + run: python3 -m build + - name: Store the distribution packages + uses: actions/upload-artifact@v3 + with: + name: python-package-distributions + path: dist/ + + publish-to-pypi: + name: Upload to PyPi + needs: + - build + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/Wavelink + permissions: + id-token: write + + steps: + - name: Download all the dists + uses: actions/download-artifact@v3 + with: + name: python-package-distributions + path: dist/ + - name: Publish distribution to PyPi + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.github/workflows/coverage_and_lint.yml b/.github/workflows/coverage_and_lint.yml new file mode 100644 index 00000000..4ba8f883 --- /dev/null +++ b/.github/workflows/coverage_and_lint.yml @@ -0,0 +1,55 @@ +name: Type Coverage and Linting + +on: + push: + branches: + - main + pull_request: + branches: + - main + types: [opened, reopened, synchronize] + +jobs: + check: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.11", "3.x"] + + name: "Type Coverage and Linting @ ${{ matrix.python-version }}" + steps: + - name: "Checkout Repository" + uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: "Setup Python @ ${{ matrix.python-version }}" + id: setup-python + uses: actions/setup-python@v4 + with: + python-version: "${{ matrix.python-version }}" + cache: "pip" + + - name: "Install Python deps @ ${{ matrix.python-version }}" + id: install-deps + run: | + pip install -U -r requirements.txt + - name: "Run Pyright @ ${{ matrix.python-version }}" + uses: jakebailey/pyright-action@v1 + with: + no-comments: ${{ matrix.python-version != '3.x' }} + warnings: false + + - name: Lint + if: ${{ always() && steps.install-deps.outcome == 'success' }} + uses: github/super-linter/slim@v4 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + DEFAULT_BRANCH: main + VALIDATE_ALL_CODEBASE: false + VALIDATE_PYTHON_BLACK: true + VALIDATE_PYTHON_ISORT: true + LINTER_RULES_PATH: / + PYTHON_ISORT_CONFIG_FILE: pyproject.toml + PYTHON_BLACK_CONFIG_FILE: pyproject.toml \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..5e8f6be6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,162 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +.vscode/ \ No newline at end of file diff --git a/.readthedocs.yml b/.readthedocs.yml index d6629d49..b4b329db 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -11,5 +11,4 @@ python: - requirements: requirements.txt - requirements: docs/requirements.txt - method: pip - path: . - system_packages: true \ No newline at end of file + path: . \ No newline at end of file diff --git a/README.rst b/README.rst index c7c560dd..2c39b902 100644 --- a/README.rst +++ b/README.rst @@ -5,7 +5,7 @@ :target: https://www.python.org -.. image:: https://img.shields.io/github/license/EvieePy/Wavelink.svg +.. image:: https://img.shields.io/github/license/PythonistaGuild/Wavelink.svg :target: LICENSE @@ -25,123 +25,79 @@ Wavelink is a robust and powerful Lavalink wrapper for `Discord.py `_. -Wavelink features a fully asynchronous API that's intuitive and easy to use with built in Spotify Support and Node Pool Balancing. +Wavelink features a fully asynchronous API that's intuitive and easy to use. + + +Migrating from Version 2 to Version 3: +###################################### + +`Migrating Guide `_ **Features:** -- Fully Asynchronous -- Auto-Play and Looping (With the inbuilt Queue system) -- Spotify Support -- Node Balancing and Fail-over -- Supports Lavalink 3.7+ +- Full asynchronous design. +- Lavalink v4+ Supported with REST API. +- discord.py v2.0.0+ Support. +- Advanced AutoPlay and track recommendations for continuous play. +- Object orientated design with stateful objects and payloads. +- Fully annotated and complies with Pyright strict typing. Documentation ---------------------------- -`Official Documentation `_ +------------- +`Official Documentation `_ Support ---------------------------- +------- For support using WaveLink, please join the official `support server -`_ on `Discord `_. +`_ on `Discord `_. .. image:: https://discordapp.com/api/guilds/490948346773635102/widget.png?style=banner2 :target: https://discord.gg/RAKc3HF Installation ---------------------------- -The following commands are currently the valid ways of installing WaveLink. - -**WaveLink 2 requires Python 3.10+** +------------ +**WaveLink 3 requires Python 3.10+** **Windows** .. code:: sh - py -3.10 -m pip install -U Wavelink + py -3.10 -m pip install -U wavelink **Linux** .. code:: sh - python3.10 -m pip install -U Wavelink - -Getting Started ----------------------------- - -**See also:** `Examples `_ - -.. code:: py - - import discord - import wavelink - from discord.ext import commands - + python3.10 -m pip install -U wavelink - class Bot(commands.Bot): +**Virtual Environments** - def __init__(self) -> None: - intents = discord.Intents.default() - intents.message_content = True - - super().__init__(intents=intents, command_prefix='?') - - async def on_ready(self) -> None: - print(f'Logged in {self.user} | {self.user.id}') - - async def setup_hook(self) -> None: - # Wavelink 2.0 has made connecting Nodes easier... Simply create each Node - # and pass it to NodePool.connect with the client/bot. - node: wavelink.Node = wavelink.Node(uri='http://localhost:2333', password='youshallnotpass') - await wavelink.NodePool.connect(client=self, nodes=[node]) - - - bot = Bot() - - - @bot.command() - async def play(ctx: commands.Context, *, search: str) -> None: - """Simple play command.""" +.. code:: sh - if not ctx.voice_client: - vc: wavelink.Player = await ctx.author.voice.channel.connect(cls=wavelink.Player) - else: - vc: wavelink.Player = ctx.voice_client + pip install -U wavelink - tracks = await wavelink.YouTubeTrack.search(search) - if not tracks: - await ctx.send(f'No tracks found with query: `{search}`') - return - track = tracks[0] - await vc.play(track) +Getting Started +--------------- +**See Examples:** `Examples `_ - @bot.command() - async def disconnect(ctx: commands.Context) -> None: - """Simple disconnect command. - This command assumes there is a currently connected Player. - """ - vc: wavelink.Player = ctx.voice_client - await vc.disconnect() +Lavalink +-------- +Wavelink **3** requires **Lavalink v4**. +See: `Lavalink `_ -Lavalink Installation ---------------------- +For spotify support, simply install and use `LavaSrc `_ with your `wavelink.Playable` -Head to the official `Lavalink repo `_ and give it a star! -- Create a folder for storing Lavalink.jar and related files/folders. -- Copy and paste the example `application.yml `_ to ``application.yml`` in the folder we created earlier. You can open the yml in Notepad or any simple text editor. -- Change your password in the ``application.yml`` and store it in a config for your bot. -- Set local to true in the ``application.yml`` if you wish to use ``wavelink.LocalTrack`` for local machine search options... Otherwise ignore. -- Save and exit. -- Install `Java 17(Windows) `_ or **Java 13+** on the machine you are running. -- Download `Lavalink.jar `_ and place it in the folder created earlier. -- Open a cmd prompt or terminal and change directory ``cd`` into the folder we made earlier. -- Run: ``java -jar Lavalink.jar`` +Notes +----- -If you are having any problems installing Lavalink, please join the official Discord Server listed above for help. +- Wavelink **3** is compatible with Lavalink **v4+**. +- Wavelink has built in support for Lavalink Plugins including LavaSrc and SponsorBlock. +- Wavelink is fully typed in compliance with Pyright Strict, though some nuances remain between discord.py and wavelink. diff --git a/docs/_static/js/custom.js b/docs/_static/js/custom.js index 1250f16c..e3e002a3 100644 --- a/docs/_static/js/custom.js +++ b/docs/_static/js/custom.js @@ -21,4 +21,4 @@ function dynamicallyLoadScript(url) { } -dynamicallyLoadScript('https://kit.fontawesome.com/12146b1c3e.js'); +dynamicallyLoadScript('https://kit.fontawesome.com/12146b1c3e.js'); \ No newline at end of file diff --git a/docs/_static/styles/furo.css b/docs/_static/styles/furo.css index 18a84ea2..26332d03 100644 --- a/docs/_static/styles/furo.css +++ b/docs/_static/styles/furo.css @@ -2363,4 +2363,4 @@ ul.search li { content: "Supported Operations:"; font-weight: bold; padding: 1rem; -} +} \ No newline at end of file diff --git a/docs/conf.py b/docs/conf.py index 57622a6a..3c0484cc 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -15,9 +15,10 @@ # sys.path.insert(0, os.path.abspath('.')) +import os + # -- Project information ----------------------------------------------------- import re -import os import sys sys.path.insert(0, os.path.abspath(".")) @@ -30,8 +31,8 @@ author = "PythonistaGuild, EvieePy" # The full version, including alpha/beta/rc tags -release = '' -with open('../wavelink/__init__.py') as f: +release = "" +with open("../wavelink/__init__.py") as f: release = re.search(r'^__version__\s*=\s*[\'"]([^\'"]*)[\'"]', f.read(), re.MULTILINE).group(1) # type: ignore version = release @@ -41,32 +42,18 @@ # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ - 'prettyversion', + "prettyversion", "sphinx.ext.autodoc", "sphinx.ext.extlinks", "sphinx.ext.napoleon", "sphinx.ext.intersphinx", - 'details', - 'exception_hierarchy', - 'attributetable', - "sphinxext.opengraph", - 'hoverxref.extension', - 'sphinxcontrib_trio', + "details", + "exception_hierarchy", + "attributetable", + "hoverxref.extension", + "sphinxcontrib_trio", ] -# OpenGraph Meta Tags -ogp_site_name = "Wavelink Documentation" -ogp_image = "https://raw.githubusercontent.com/PythonistaGuild/Wavelink/master/logo.png" -ogp_description = "Documentation for Wavelink, the Powerful Lavalink wrapper for discord.py." -ogp_site_url = "https://wavelink.dev/" -ogp_custom_meta_tags = [ - '', - '' -] -ogp_enable_meta_description = True - # Add any paths that contain templates here, relative to this directory. # templates_path = ["_templates"] @@ -83,21 +70,17 @@ html_theme = "furo" # html_logo = "logo.png" -html_theme_options = { - "sidebar_hide_name": True, - "light_logo": "logo.png", - "dark_logo": "wl_dark.png" -} +html_theme_options = {"sidebar_hide_name": True, "light_logo": "logo.png", "dark_logo": "wl_dark.png"} # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, -# so a file named "default.css" will overwrite the builtin "default.css". +# so a file named "default.styles" will overwrite the builtin "default.styles". # These folders are copied to the documentation's HTML output html_static_path = ["_static"] # These paths are either relative to html_static_path # or fully qualified paths (eg. https://...) -html_css_files = ["styles/furo.css"] +html_css_files = ["styles/furo.styles"] html_js_files = ["js/custom.js"] napoleon_google_docstring = False @@ -122,35 +105,35 @@ intersphinx_mapping = { "py": ("https://docs.python.org/3", None), - "dpy": ("https://discordpy.readthedocs.io/en/stable/", None) + "dpy": ("https://discordpy.readthedocs.io/en/stable/", None), } extlinks = { - 'wlissue': ('https://github.com/PythonistaGuild/Wavelink/issues/%s', 'GH-%s'), - 'ddocs': ('https://discord.com/developers/docs/%s', None), + "wlissue": ("https://github.com/PythonistaGuild/Wavelink/issues/%s", "GH-%s"), + "ddocs": ("https://discord.com/developers/docs/%s", None), } # Hoverxref Settings... hoverxref_auto_ref = True -hoverxref_intersphinx = ['py', 'dpy'] +hoverxref_intersphinx = ["py", "dpy"] hoverxref_role_types = { - 'hoverxref': 'modal', - 'ref': 'modal', - 'confval': 'tooltip', - 'mod': 'tooltip', - 'class': 'tooltip', - 'attr': 'tooltip', - 'func': 'tooltip', - 'meth': 'tooltip', - 'exc': 'tooltip' + "hoverxref": "modal", + "ref": "modal", + "confval": "tooltip", + "mod": "tooltip", + "class": "tooltip", + "attr": "tooltip", + "func": "tooltip", + "meth": "tooltip", + "exc": "tooltip", } hoverxref_roles = list(hoverxref_role_types.keys()) -hoverxref_domains = ['py'] -hoverxref_default_type = 'tooltip' -hoverxref_tooltip_theme = ['tooltipster-punk', 'tooltipster-shadow', 'tooltipster-shadow-custom'] +hoverxref_domains = ["py"] +hoverxref_default_type = "tooltip" +hoverxref_tooltip_theme = ["tooltipster-punk", "tooltipster-shadow", "tooltipster-shadow-custom"] pygments_style = "sphinx" @@ -161,11 +144,11 @@ def autodoc_skip_member(app, what, name, obj, skip, options): - exclusions = ('__weakref__', '__doc__', '__module__', '__dict__', '__init__') + exclusions = ("__weakref__", "__doc__", "__module__", "__dict__", "__init__") exclude = name in exclusions return True if exclude else None def setup(app): - app.connect('autodoc-skip-member', autodoc_skip_member) + app.connect("autodoc-skip-member", autodoc_skip_member) diff --git a/docs/ext/spotify.rst b/docs/ext/spotify.rst deleted file mode 100644 index 23e5d347..00000000 --- a/docs/ext/spotify.rst +++ /dev/null @@ -1,106 +0,0 @@ -.. currentmodule:: wavelink.ext.spotify - - -Intro ------ -The Spotify extension is a QoL extension that helps in searching for and queueing tracks from Spotify URLs. To get started create a :class:`~SpotifyClient` and pass in your credentials. You then pass this to your :class:`wavelink.Node`'s. - -An example: - -.. code-block:: python3 - - import discord - import wavelink - from discord.ext import commands - from wavelink.ext import spotify - - - class Bot(commands.Bot): - - def __init__(self) -> None: - intents = discord.Intents.default() - intents.message_content = True - - super().__init__(intents=intents, command_prefix='?') - - async def on_ready(self) -> None: - print(f'Logged in {self.user} | {self.user.id}') - - async def setup_hook(self) -> None: - sc = spotify.SpotifyClient( - client_id='CLIENT_ID', - client_secret='CLIENT_SECRET' - ) - - node: wavelink.Node = wavelink.Node(uri='http://127.0.0.1:2333', password='youshallnotpass') - await wavelink.NodePool.connect(client=self, nodes=[node], spotify=sc) - - - bot = Bot() - - - @bot.command() - @commands.is_owner() - async def play(ctx: commands.Context, *, search: str) -> None: - - try: - vc: wavelink.Player = await ctx.author.voice.channel.connect(cls=wavelink.Player) - except discord.ClientException: - vc: wavelink.Player = ctx.voice_client - - vc.autoplay = True - - track: spotify.SpotifyTrack = await spotify.SpotifyTrack.search(search) - - if not vc.is_playing(): - await vc.play(track, populate=True) - else: - await vc.queue.put_wait(track) - - -Helpers -------- -.. autofunction:: decode_url - - -Payloads --------- -.. attributetable:: SpotifyDecodePayload - -.. autoclass:: SpotifyDecodePayload - - -Client ------- -.. attributetable:: SpotifyClient - -.. autoclass:: SpotifyClient - :members: - - -Enums ------ -.. attributetable:: SpotifySearchType - -.. autoclass:: SpotifySearchType - :members: - - -Spotify Tracks --------------- -.. attributetable:: SpotifyTrack - -.. autoclass:: SpotifyTrack - :members: - - -Exceptions ----------- -.. py:exception:: SpotifyRequestError - - Base error for Spotify requests. - - status: :class:`int` - The status code returned from the request. - reason: Optional[:class:`str`] - The reason the request failed. Could be ``None``. diff --git a/docs/extensions/attributetable.py b/docs/extensions/attributetable.py index 01dc0881..5b6f41be 100644 --- a/docs/extensions/attributetable.py +++ b/docs/extensions/attributetable.py @@ -1,9 +1,10 @@ from __future__ import annotations + import importlib import inspect import re from enum import Enum -from typing import Dict, List, NamedTuple, Optional, Tuple, Sequence, TYPE_CHECKING +from typing import TYPE_CHECKING, Dict, List, NamedTuple, Optional, Sequence, Tuple from docutils import nodes from sphinx import addnodes @@ -42,51 +43,51 @@ class attributetable_item(nodes.Part, nodes.Element): def visit_attributetable_node(self: DPYHTML5Translator, node: attributetable) -> None: - class_ = node['python-class'] + class_ = node["python-class"] self.body.append(f'
') def visit_attributetablecolumn_node(self: DPYHTML5Translator, node: attributetablecolumn) -> None: - self.body.append(self.starttag(node, 'div', CLASS='py-attribute-table-column')) + self.body.append(self.starttag(node, "div", CLASS="py-attribute-table-column")) def visit_attributetabletitle_node(self: DPYHTML5Translator, node: attributetabletitle) -> None: - self.body.append(self.starttag(node, 'span')) + self.body.append(self.starttag(node, "span")) def visit_attributetablebadge_node(self: DPYHTML5Translator, node: attributetablebadge) -> None: attributes = { - 'class': 'py-attribute-table-badge', - 'title': node['badge-type'], + "class": "py-attribute-table-badge", + "title": node["badge-type"], } - self.body.append(self.starttag(node, 'span', **attributes)) + self.body.append(self.starttag(node, "span", **attributes)) def visit_attributetable_item_node(self: DPYHTML5Translator, node: attributetable_item) -> None: - self.body.append(self.starttag(node, 'li', CLASS='py-attribute-table-entry')) + self.body.append(self.starttag(node, "li", CLASS="py-attribute-table-entry")) def depart_attributetable_node(self: DPYHTML5Translator, node: attributetable) -> None: - self.body.append('
') + self.body.append("") def depart_attributetablecolumn_node(self: DPYHTML5Translator, node: attributetablecolumn) -> None: - self.body.append('') + self.body.append("") def depart_attributetabletitle_node(self: DPYHTML5Translator, node: attributetabletitle) -> None: - self.body.append('') + self.body.append("") def depart_attributetablebadge_node(self: DPYHTML5Translator, node: attributetablebadge) -> None: - self.body.append('') + self.body.append("") def depart_attributetable_item_node(self: DPYHTML5Translator, node: attributetable_item) -> None: - self.body.append('') + self.body.append("") -_name_parser_regex = re.compile(r'(?P[\w.]+\.)?(?P\w+)') +_name_parser_regex = re.compile(r"(?P[\w.]+\.)?(?P\w+)") class PyAttributeTable(SphinxDirective): @@ -102,13 +103,13 @@ def parse_name(self, content: str) -> Tuple[str, str]: raise RuntimeError(f"content {content} somehow doesn't match regex in {self.env.docname}.") path, name = match.groups() if path: - modulename = path.rstrip('.') + modulename = path.rstrip(".") else: - modulename = self.env.temp_data.get('autodoc:module') + modulename = self.env.temp_data.get("autodoc:module") if not modulename: - modulename = self.env.ref_context.get('py:module') + modulename = self.env.ref_context.get("py:module") if modulename is None: - raise RuntimeError(f'modulename somehow None for {content} in {self.env.docname}.') + raise RuntimeError(f"modulename somehow None for {content} in {self.env.docname}.") return modulename, name @@ -140,12 +141,12 @@ def run(self) -> List[attributetableplaceholder]: replaced. """ content = self.arguments[0].strip() - node = attributetableplaceholder('') + node = attributetableplaceholder("") modulename, name = self.parse_name(content) - node['python-doc'] = self.env.docname - node['python-module'] = modulename - node['python-class'] = name - node['python-full-name'] = f'{modulename}.{name}' + node["python-doc"] = self.env.docname + node["python-module"] = modulename + node["python-class"] = name + node["python-full-name"] = f"{modulename}.{name}" return [node] @@ -153,20 +154,20 @@ def build_lookup_table(env: BuildEnvironment) -> Dict[str, List[str]]: # Given an environment, load up a lookup table of # full-class-name: objects result = {} - domain = env.domains['py'] + domain = env.domains["py"] ignored = { - 'data', - 'exception', - 'module', - 'class', + "data", + "exception", + "module", + "class", } for fullname, _, objtype, docname, _, _ in domain.get_objects(): if objtype in ignored: continue - classname, _, child = fullname.rpartition('.') + classname, _, child = fullname.rpartition(".") try: result[classname].append(child) except KeyError: @@ -186,15 +187,15 @@ def process_attributetable(app: Sphinx, doctree: nodes.Node, fromdocname: str) - lookup = build_lookup_table(env) for node in doctree.traverse(attributetableplaceholder): - modulename, classname, fullname = node['python-module'], node['python-class'], node['python-full-name'] + modulename, classname, fullname = node["python-module"], node["python-class"], node["python-full-name"] groups = get_class_results(lookup, modulename, classname, fullname) - table = attributetable('') + table = attributetable("") for label, subitems in groups.items(): if not subitems: continue table.append(class_results_to_node(label, sorted(subitems, key=lambda c: c.label))) - table['python-class'] = fullname + table["python-class"] = fullname if not table: node.replace_self([]) @@ -209,8 +210,8 @@ def get_class_results( cls = getattr(module, name) groups: Dict[str, List[TableElement]] = { - _('Attributes'): [], - _('Methods'): [], + _("Attributes"): [], + _("Methods"): [], } try: @@ -219,11 +220,11 @@ def get_class_results( return groups for attr in members: - if attr.startswith('__') and attr.endswith('__'): + if attr.startswith("__") and attr.endswith("__"): continue - attrlookup = f'{fullname}.{attr}' - key = _('Attributes') + attrlookup = f"{fullname}.{attr}" + key = _("Attributes") badge = None label = attr value = None @@ -234,31 +235,31 @@ def get_class_results( break if value is not None: - doc = value.__doc__ or '' + doc = value.__doc__ or "" - if inspect.iscoroutinefunction(value) or doc.startswith('|coro|'): - key = _('Methods') - badge = attributetablebadge('async', 'async') - badge['badge-type'] = _('coroutine') + if inspect.iscoroutinefunction(value) or doc.startswith("|coro|"): + key = _("Methods") + badge = attributetablebadge("async", "async") + badge["badge-type"] = _("coroutine") elif isinstance(value, classmethod): - key = _('Methods') - label = f'{name}.{attr}' - badge = attributetablebadge('cls', 'cls') - badge['badge-type'] = _('classmethod') + key = _("Methods") + label = f"{name}.{attr}" + badge = attributetablebadge("cls", "cls") + badge["badge-type"] = _("classmethod") elif inspect.isfunction(value): - if doc.startswith(('A decorator', 'A shortcut decorator')): + if doc.startswith(("A decorator", "A shortcut decorator")): # finicky but surprisingly consistent - key = _('Methods') - badge = attributetablebadge('@', '@') - badge['badge-type'] = _('decorator') + key = _("Methods") + badge = attributetablebadge("@", "@") + badge["badge-type"] = _("decorator") elif inspect.isasyncgenfunction(value): - key = _('Methods') - badge = attributetablebadge('async for', 'async for') - badge['badge-type'] = _('async iterable') + key = _("Methods") + badge = attributetablebadge("async for", "async for") + badge["badge-type"] = _("async iterable") else: - key = _('Methods') - badge = attributetablebadge('def', 'def') - badge['badge-type'] = _('method') + key = _("Methods") + badge = attributetablebadge("def", "def") + badge["badge-type"] = _("method") groups[key].append(TableElement(fullname=attrlookup, label=label, badge=badge)) @@ -267,27 +268,27 @@ def get_class_results( def class_results_to_node(key: str, elements: Sequence[TableElement]) -> attributetablecolumn: title = attributetabletitle(key, key) - ul = nodes.bullet_list('') + ul = nodes.bullet_list("") for element in elements: ref = nodes.reference( - '', '', internal=True, refuri=f'#{element.fullname}', anchorname='', *[nodes.Text(element.label)] + "", "", internal=True, refuri=f"#{element.fullname}", anchorname="", *[nodes.Text(element.label)] ) - para = addnodes.compact_paragraph('', '', ref) + para = addnodes.compact_paragraph("", "", ref) if element.badge is not None: - ul.append(attributetable_item('', element.badge, para)) + ul.append(attributetable_item("", element.badge, para)) else: - ul.append(attributetable_item('', para)) + ul.append(attributetable_item("", para)) - return attributetablecolumn('', title, ul) + return attributetablecolumn("", title, ul) def setup(app: Sphinx): - app.add_directive('attributetable', PyAttributeTable) + app.add_directive("attributetable", PyAttributeTable) app.add_node(attributetable, html=(visit_attributetable_node, depart_attributetable_node)) app.add_node(attributetablecolumn, html=(visit_attributetablecolumn_node, depart_attributetablecolumn_node)) app.add_node(attributetabletitle, html=(visit_attributetabletitle_node, depart_attributetabletitle_node)) app.add_node(attributetablebadge, html=(visit_attributetablebadge_node, depart_attributetablebadge_node)) app.add_node(attributetable_item, html=(visit_attributetable_item_node, depart_attributetable_item_node)) app.add_node(attributetableplaceholder) - app.connect('doctree-resolved', process_attributetable) - return {'parallel_read_safe': True} \ No newline at end of file + app.connect("doctree-resolved", process_attributetable) + return {"parallel_read_safe": True} diff --git a/docs/extensions/details.py b/docs/extensions/details.py index b1a56096..18a79193 100644 --- a/docs/extensions/details.py +++ b/docs/extensions/details.py @@ -1,34 +1,40 @@ -from docutils.parsers.rst import Directive -from docutils.parsers.rst import states, directives -from docutils.parsers.rst.roles import set_classes from docutils import nodes +from docutils.parsers.rst import Directive, directives, states +from docutils.parsers.rst.roles import set_classes + class details(nodes.General, nodes.Element): pass + class summary(nodes.General, nodes.Element): pass + def visit_details_node(self, node): - self.body.append(self.starttag(node, 'details', CLASS=node.attributes.get('class', ''))) + self.body.append(self.starttag(node, "details", CLASS=node.attributes.get("class", ""))) + def visit_summary_node(self, node): - self.body.append(self.starttag(node, 'summary', CLASS=node.attributes.get('summary-class', ''))) + self.body.append(self.starttag(node, "summary", CLASS=node.attributes.get("summary-class", ""))) self.body.append(node.rawsource) + def depart_details_node(self, node): - self.body.append('\n') + self.body.append("\n") + def depart_summary_node(self, node): - self.body.append('') + self.body.append("") + class DetailsDirective(Directive): final_argument_whitespace = True optional_arguments = 1 option_spec = { - 'class': directives.class_option, - 'summary-class': directives.class_option, + "class": directives.class_option, + "summary-class": directives.class_option, } has_content = True @@ -37,7 +43,7 @@ def run(self): set_classes(self.options) self.assert_has_content() - text = '\n'.join(self.content) + text = "\n".join(self.content) node = details(text, **self.options) if self.arguments: @@ -48,8 +54,9 @@ def run(self): self.state.nested_parse(self.content, self.content_offset, node) return [node] + def setup(app): app.add_node(details, html=(visit_details_node, depart_details_node)) app.add_node(summary, html=(visit_summary_node, depart_summary_node)) - app.add_directive('details', DetailsDirective) - return {'parallel_read_safe': True} \ No newline at end of file + app.add_directive("details", DetailsDirective) + return {"parallel_read_safe": True} diff --git a/docs/extensions/exception_hierarchy.py b/docs/extensions/exception_hierarchy.py index 974de2df..988eeb7d 100644 --- a/docs/extensions/exception_hierarchy.py +++ b/docs/extensions/exception_hierarchy.py @@ -1,28 +1,32 @@ -from docutils.parsers.rst import Directive -from docutils.parsers.rst import states, directives -from docutils.parsers.rst.roles import set_classes from docutils import nodes +from docutils.parsers.rst import Directive, directives, states +from docutils.parsers.rst.roles import set_classes from sphinx.locale import _ + class exception_hierarchy(nodes.General, nodes.Element): pass + def visit_exception_hierarchy_node(self, node): - self.body.append(self.starttag(node, 'div', CLASS='exception-hierarchy-content')) + self.body.append(self.starttag(node, "div", CLASS="exception-hierarchy-content")) + def depart_exception_hierarchy_node(self, node): - self.body.append('\n') + self.body.append("\n") + class ExceptionHierarchyDirective(Directive): has_content = True def run(self): self.assert_has_content() - node = exception_hierarchy('\n'.join(self.content)) + node = exception_hierarchy("\n".join(self.content)) self.state.nested_parse(self.content, self.content_offset, node) return [node] + def setup(app): app.add_node(exception_hierarchy, html=(visit_exception_hierarchy_node, depart_exception_hierarchy_node)) - app.add_directive('exception_hierarchy', ExceptionHierarchyDirective) - return {'parallel_read_safe': True} \ No newline at end of file + app.add_directive("exception_hierarchy", ExceptionHierarchyDirective) + return {"parallel_read_safe": True} diff --git a/docs/extensions/prettyversion.py b/docs/extensions/prettyversion.py index fd3b4763..4ffb195b 100644 --- a/docs/extensions/prettyversion.py +++ b/docs/extensions/prettyversion.py @@ -1,8 +1,7 @@ -from docutils.statemachine import StringList -from docutils.parsers.rst import Directive -from docutils.parsers.rst import states, directives -from docutils.parsers.rst.roles import set_classes from docutils import nodes +from docutils.parsers.rst import Directive, directives, states +from docutils.parsers.rst.roles import set_classes +from docutils.statemachine import StringList from sphinx.locale import _ @@ -19,25 +18,25 @@ class pretty_version_removed(nodes.General, nodes.Element): def visit_pretty_version_added_node(self, node): - self.body.append(self.starttag(node, 'div', CLASS='pretty-version pv-added')) - self.body.append(self.starttag(node, 'i', CLASS='fa-solid fa-circle-plus')) - self.body.append('') + self.body.append(self.starttag(node, "div", CLASS="pretty-version pv-added")) + self.body.append(self.starttag(node, "i", CLASS="fa-solid fa-circle-plus")) + self.body.append("") def visit_pretty_version_changed_node(self, node): - self.body.append(self.starttag(node, 'div', CLASS='pretty-version pv-changed')) - self.body.append(self.starttag(node, 'i', CLASS='fa-solid fa-wrench')) - self.body.append('') + self.body.append(self.starttag(node, "div", CLASS="pretty-version pv-changed")) + self.body.append(self.starttag(node, "i", CLASS="fa-solid fa-wrench")) + self.body.append("") def visit_pretty_version_removed_node(self, node): - self.body.append(self.starttag(node, 'div', CLASS='pretty-version pv-removed')) - self.body.append(self.starttag(node, 'i', CLASS='fa-solid fa-trash')) - self.body.append('') + self.body.append(self.starttag(node, "div", CLASS="pretty-version pv-removed")) + self.body.append(self.starttag(node, "i", CLASS="fa-solid fa-trash")) + self.body.append("") def depart_pretty_version_node(self, node): - self.body.append('\n') + self.body.append("\n") class PrettyVersionAddedDirective(Directive): @@ -47,10 +46,10 @@ class PrettyVersionAddedDirective(Directive): def run(self): version = self.arguments[0] - joined = '\n'.join(self.content) if self.content else '' + joined = "\n".join(self.content) if self.content else "" content = [f'**New in version:** *{version}*{" - " if joined else ""}', joined] - node = pretty_version_added('\n'.join(content)) + node = pretty_version_added("\n".join(content)) self.state.nested_parse(StringList(content), self.content_offset, node) return [node] @@ -63,10 +62,10 @@ class PrettyVersionChangedDirective(Directive): def run(self): version = self.arguments[0] - joined = '\n'.join(self.content) if self.content else '' + joined = "\n".join(self.content) if self.content else "" content = [f'**Version changed:** *{version}*{" - " if joined else ""}', joined] - node = pretty_version_changed('\n'.join(content)) + node = pretty_version_changed("\n".join(content)) self.state.nested_parse(StringList(content), self.content_offset, node) return [node] @@ -79,10 +78,10 @@ class PrettyVersionRemovedDirective(Directive): def run(self): version = self.arguments[0] - joined = '\n'.join(self.content) + joined = "\n".join(self.content) content = [f'**Removed in version:** *{version}*{" - " if joined else ""}', joined] - node = pretty_version_removed('\n'.join(content)) + node = pretty_version_removed("\n".join(content)) self.state.nested_parse(StringList(content), self.content_offset, node) return [node] @@ -93,8 +92,8 @@ def setup(app): app.add_node(pretty_version_changed, html=(visit_pretty_version_changed_node, depart_pretty_version_node)) app.add_node(pretty_version_removed, html=(visit_pretty_version_removed_node, depart_pretty_version_node)) - app.add_directive('versionadded', PrettyVersionAddedDirective, override=True) - app.add_directive('versionchanged', PrettyVersionChangedDirective, override=True) - app.add_directive('versionremoved', PrettyVersionRemovedDirective, override=True) + app.add_directive("versionadded", PrettyVersionAddedDirective, override=True) + app.add_directive("versionchanged", PrettyVersionChangedDirective, override=True) + app.add_directive("versionremoved", PrettyVersionRemovedDirective, override=True) - return {'parallel_read_safe': True} + return {"parallel_read_safe": True} diff --git a/docs/index.rst b/docs/index.rst index 25b656cf..b3c26f62 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,15 +1,3 @@ -:og:description: Wavelink is a robust and powerful Lavalink wrapper for Discord.py. Featuring, a fully asynchronous API that's intuitive and easy to use with built in Spotify Support, Node Pool Balancing, advanced Queues, autoplay feature and looping features built in. -:og:title: Wavelink Documentation - - -.. meta:: - :title: Wavelink Documentation - :description: Wavelink is a robust and powerful Lavalink wrapper for Discord.py. Featuring, a fully asynchronous API that's intuitive and easy to use with built in Spotify Support, Node Pool Balancing, advanced Queues, autoplay feature and looping features built in. - :language: en-US - :keywords: wavelink, lavalink, python api - :copyright: PythonistaGuild. 2019 - Present - - .. raw:: html @@ -23,10 +11,16 @@

Featuring:

  • Full asynchronous design.
  • -
  • Lavalink v3.7+ Supported with REST API.
  • +
  • Lavalink v4+ Supported with REST API.
  • discord.py v2.0.0+ Support.
  • -
  • Spotify and YouTube AutoPlay supported.
  • -
  • Object orientated design with stateful objects.
  • +
  • Advanced AutoPlay and track recommendations for continuous play.
  • +
  • Object orientated design with stateful objects and payloads.
  • +
  • Fully annotated and complies with Pyright strict typing.
  • +
+ +

Migrating from version 2 to 3:

+

@@ -36,8 +30,6 @@
  • For help with installing visit: Installing -
  • Your first steps with the library: Quickstart -
  • Frequently asked questions: FAQ

API Reference

@@ -51,13 +43,6 @@
  • API Reference
  • - -
    - Wavelink Extension API's: - -

    Getting Help

    @@ -74,6 +59,7 @@ :caption: Getting Started: installing + migrating recipes @@ -85,15 +71,6 @@ wavelink -.. rst-class:: index-display-none -.. toctree:: - :maxdepth: 1 - :caption: Spotify Extension - - ext/spotify - - - .. raw:: html

    Table of Contents

    diff --git a/docs/installing.rst b/docs/installing.rst index 3ab7f711..2ba70b45 100644 --- a/docs/installing.rst +++ b/docs/installing.rst @@ -1,6 +1,6 @@ Installing ============ -WaveLink requires Python 3.10+. +WaveLink **3** requires Python 3.10+. You can download the latest version of Python `here `_. **Windows:** @@ -20,5 +20,5 @@ Debugging --------- Make sure you have the latest version of Python installed, or if you prefer, a Python version of 3.10 or greater. -If you have have any other issues feel free to search for duplicates and then create a new issue on GitHub with as much detail as +If you have any other issues feel free to search for duplicates and then create a new issue on GitHub with as much detail as possible. Including providing the output of pip, your OS details and Python version. \ No newline at end of file diff --git a/docs/migrating.rst b/docs/migrating.rst new file mode 100644 index 00000000..e2c7a09b --- /dev/null +++ b/docs/migrating.rst @@ -0,0 +1,303 @@ +Migrating +--------- + +Version **3** of wavelink has brought about many changes. This short guide should help you get started when moving +from version **2**. + + +**Some things may be missing from this page. If you see anything wrong or missing please make an issue on GitHub.** + + +Key Notes +========= + +- Version **3** is now fully typed and compliant with pyright strict. +- Version **3** only works on Lavalink **v4+**. +- Version **3** now uses black and isort formatting, for consistency. +- Version **3** now better uses positional-only and keyword-only arguments in many places. +- Version **3** has better error handling with requests to your Lavalink nodes. +- Version **3** has Lavalink websocket completeness. All events have been implemented, with corresponding payloads. +- Version **3** has an experimental LFU request cache, saving you requests to YouTube and Spotify etc. + + +Removed +******* + +- Spotify Extension + - Wavelink no longer has it's own Spotify Extension. Instead it has native support for LavaSrc and other source plugins. + - Using LavaSrc with Wavelink **3** is just as simple as using the built-in Lavalink search types. +- Removed all Track Types E.g. (YouTubeTrack, SoundCloudTrack) + - Wavelink **3** uses one class for all tracks. :class:`wavelink.Playable`. + - :class:`wavelink.Playable` is a much easier and simple to use track that provides a powerful interface. +- :class:`wavelink.TrackSource` removed ``Local`` and ``Unknown`` + + +Changed +******* + +- All events have unique payloads. +- Playlists can not be used to search. +- :class:`wavelink.Playable` was changed significantly. Please see the docs for more info. +- :meth:`wavelink.Playable.search` was changed significantly. Please see the docs for more info. +- ``Node.id`` is now ``Node.identifier``. +- ``wavelink.NodePool`` is now ``wavelink.Pool``. +- :meth:`wavelink.Pool.connect` no longer requires the ``client`` keyword argument. +- :meth:`wavelink.Pool.get_node` the ``id`` parameter is now known as ``identifier`` and is positional-only. This parameter is also optional. +- :meth:`wavelink.Pool.fetch_tracks` was previously known as both ``.get_tracks`` and ``.get_playlist``. This method now returns either the appropriate :class:`wavelink.Playlist` or list[:class:`wavelink.Playable`]. If there is an error when searching, this method raises either a ``LavalinkException`` (The request failed somehow) or ``LavalinkLoadException`` there was an error loading the search (Request didn't fail). +- :meth:`wavelink.Queue.put_wait` now has an option to atomically add tracks from a :class:`wavelink.Playlist` or list[:class:`wavelink.Playable`]. This defaults to True. This currently checks if the track in the Playlist is Playable and if any errors occur will not add any tracks from the Playlist to the queue. IF set to ``False``, Playable tracks will be added to the Queue up until an error occurs or every track was successfully added. +- :meth:`wavelink.Queue.put_wait` and :meth:`wavelink.Queue.put` now return an int of the amount of tracks added. +- :meth:`wavelink.Player.stop` is now known as :meth:`wavelink.Player.skip`, though they both exist as aliases. +- ``Player.current_node`` is now known as :attr:`wavelink.Player.node`. +- ``Player.is_connected()`` is now known as :attr:`wavelink.Player.connected`. +- ``Player.is_paused()`` is now known as :attr:`wavelink.Player.paused`. +- ``Player.is_playing()`` is now known as :attr:`wavelink.Player.playing`. +- :meth:`wavelink.Player.connect` now accepts a timeout argument as a float in seconds. +- :meth:`wavelink.Player.play` has had additional arguments added. See the docs. +- ``Player.resume()`` logic was moved to :meth:`wavelink.Player.pause`. +- :meth:`wavelink.Player.seek` the ``position`` parameter is now positional-only, and has a default of ``0`` which restarts the track from the beginning. +- :meth:`wavelink.Player.set_volume` the ``value`` parameter is now positional-only, and has a default of ``100``. +- :attr:`wavelink.Player.autoplay` accepts a :class:`wavelink.AutoPlayMode` instead of a bool. AutoPlay has been changed to be more effecient and better with recomendations. +- :class:`wavelink.Queue` accepts a :class:`wavelink.QueueMode` in :attr:`wavelink.Queue.mode` for looping. +- Filters have been completely reworked. See: :class:`wavelink.Filters` +- ``Player.set_filter`` is now known as :meth:`wavelink.Player.set_filters` +- ``Player.filter`` is now known as :attr:`wavelink.Player.filters` + + +Added +***** + +- :class:`wavelink.PlaylistInfo` +- :meth:`wavelink.Playlist.track_extras` +- :attr:`wavelink.Node.client` property was added. This is the Bot/Client associated with the node. +- :attr:`wavelink.Node.password` property was added. This is the password used to connect and make requests with this node. +- :attr:`wavelink.Node.heartbeat` property was added. This is the seconds as a float that aiohttp will send a heartbeat over websocket. +- :attr:`wavelink.Node.session_id` property was added. This is the Lavalink session ID associated with this node. +- :class:`wavelink.AutoPlayMode` +- :class:`wavelink.QueueMode` +- :meth:`wavelink.Node.close` +- :meth:`wavelink.Pool.close` +- :func:`wavelink.on_wavelink_node_closed` +- :meth:`wavelink.Node.send` +- :class:`wavelink.Search` +- LFU (Least Frequently Used) Cache for request caching. + + +Connecting +========== +Connecting in version **3** is similar to version **2**. +It is recommended to use discord.py ``setup_hook`` to connect your nodes. + + +.. code:: python3 + + async def setup_hook(self) -> None: + nodes = [wavelink.Node(uri="...", password="...")] + + # cache_capacity is EXPERIMENTAL. Turn it off by passing None + await wavelink.Pool.connect(nodes=nodes, client=self, cache_capacity=100) + +When your node connects you will recieve the :class:`wavelink.NodeReadyEventPayload` via :func:`wavelink.on_wavelink_node_ready`. + + +Searching and Playing +===================== +Searching and playing tracks in version **3** is different, though should feel quite similar but easier. + + +.. code:: python3 + + # Search for tracks, with the default "ytsearch:" prefix. + tracks: wavelink.Search = await wavelink.Playable.search("Ocean Drive") + if not tracks: + # No tracks were found... + ... + + # Search for tracks, with a URL. + tracks: wavelink.Search = await wavelink.Playable.search("https://www.youtube.com/watch?v=KDxJlW6cxRk") + + # Search for tracks, using Spotify and the LavaSrc Plugin. + tracks: wavelink.Search = await wavelink.Playable.search("4b93D55xv3YCH5mT4p6HPn", source="spsearch") + + # Search for tracks, using Spotify and the LavaSrc Plugin, with a URL. + # Notice we don't need to pass a source argument with URL based searches... + tracks: wavelink.Search = await wavelink.Playable.search("https://open.spotify.com/track/4b93D55xv3YCH5mT4p6HPn") + + # Search for a playlist, using Spotify and the LavaSrc Plugin. + # or alternatively any other playlist URL from another source like YouTube. + tracks: wavelink.Search = await wavelink.Playable.search("https://open.spotify.com/playlist/37i9dQZF1DWXRqgorJj26U") + + +:class:`wavelink.Search` should be used to annotate your variables. +`.search` always returns a list[:class:`wavelink.Playable`] or :class:`wavelink.Playlist`, if no tracks were found +this method will return an empty ``list`` which should be checked, E.g: + +.. code:: python3 + + tracks: wavelink.Search = await wavelink.Playable.search(query) + if not tracks: + # No tracks were found... + return + + if isinstance(tracks, wavelink.Playlist): + # tracks is a playlist... + added: int = await player.queue.put_wait(tracks) + await ctx.send(f"Added the playlist **`{tracks.name}`** ({added} songs) to the queue.") + else: + track: wavelink.Playable = tracks[0] + await player.queue.put_wait(track) + await ctx.send(f"Added **`{track}`** to the queue.") + + +when playing a song from a command it is advised to check whether the Player is currently playing anything first, with +:attr:`wavelink.Player.playing` + +.. code:: python3 + + if not player.playing: + await player.play(track) + + +You can skip adding any track to your history queue in version **3** by passing ``add_history=False`` to ``.play``. + +Wavelink **does not** advise using the ``on_wavelink_track_end`` event in most cases. Use this event only when you plan to +not use ``AutoPlay`` at all. Since version **3** implements ``AutPlayMode.partial``, a setting which skips fetching and recommending tracks, +using this event is no longer recommended in most use cases. + +To send track updates or do player updates, consider using :func:`wavelink.on_wavelink_track_start` instead. + +.. code:: python3 + + async def on_wavelink_track_start(self, payload: wavelink.TrackStartEventPayload) -> None: + player: wavelink.Player | None = payload.player + if not player: + return + + original: wavelink.Playable | None = payload.original + track: wavelink.Playable = payload.track + + embed: discord.Embed = discord.Embed(title="Now Playing") + embed.description = f"**{track.title}** by `{track.author}`" + + if track.artwork: + embed.set_image(url=track.artwork) + + if original and original.recommended: + embed.description += f"\n\n`This track was recommended via {track.source}`" + + if track.album.name: + embed.add_field(name="Album", value=track.album.name) + + # Send this embed to a channel... + # See: simple.py example on GitHub. + + +.. note:: + + Please read the AutoPlay section for advice on how to properly use version **3** with AutoPlay. + + +AutoPlay +======== +Version **3** optimized AutoPlay and how it recommends tracks. + +Available are currently **3** different AutoPlay modes. +See: :class:`wavelink.AutoPlayMode` + +Setting :attr:`wavelink.Player.autoplay` to :attr:`wavelink.AutoPlayMode.enabled` will allow the player to fetch and recommend tracks +based on your current listening history. This currently works with Spotify, YouTube and YouTube Music. This mode handles everything including looping, and prioritizes the Queue +over the AutoQueue. + +Setting :attr:`wavelink.Player.autoplay` to :attr:`wavelink.AutoPlayMode.partial` will allow the player to handle the automatic playing of the next track +but **will NOT** recommend or fetch recommendations for playing in the future. This mode handles everything including looping. + +Setting :attr:`wavelink.Player.autoplay` to :attr:`wavelink.AutoPlayMode.disabled` will stop the player from automatically playing tracks. You will need +to use :func:`wavelink.on_wavelink_track_end` in this case. + +AutoPlay also implements error safety. In the case of too many consecutive errors trying to play a track, AutoPlay will stop attempting until manually restarted +by playing a track E.g. with :meth:`wavelink.Player.play`. + + +Pausing and Resuming +==================== +Version **3** slightly changes pausing behaviour. + +All logic is done in :meth:`wavelink.Player.pause` and you simply pass a bool (``True`` to pause and ``False`` to resume). + +.. code:: python3 + + await player.pause(not player.paused) + + +Queue +===== +Version **3** made some internal changes to :class:`wavelink.Queue`. + +The most noticeable is :attr:`wavelink.Queue.mode` which allows you to turn the Queue to either, +:attr:`wavelink.QueueMode.loop`, :attr:`wavelink.QueueMode.loop_all` or :attr:`wavelink.QueueMode.normal`. + +- :attr:`wavelink.QueueMode.normal` means the queue will not loop at all. +- :attr:`wavelink.QueueMode.loop_all` will loop every song in the history when the queue has been exhausted. +- :attr:`wavelink.QueueMode.loop` will loop the current track continuously until turned off or skipped via :meth:`wavelink.Player.skip` with ``force=True``. + + +Filters +======= +Version **3** has reworked the filters to hopefully be easier to use and feel more intuitive. + +See: :class:`~wavelink.Filters`. +See: :attr:`~wavelink.Player.filters` +See: :meth:`~wavelink.Player.set_filters` +See: :meth:`~wavelink.Player.play` + +**Some common recipes:** + +.. code:: python3 + + # Create a brand new Filters and apply it... + # You can use player.set_filters() for an easier way to reset. + filters: wavelink.Filters = wavelink.Filters() + await player.set_filters(filters) + + + # Retrieve the payload of any Filters instance... + filters: wavelink.Filters = player.filters + print(filters()) + + + # Set some filters... + # You can set and reset individual filters at the same time... + filters: wavelink.Filters = player.filters + filters.timescale.set(pitch=1.2, speed=1.1, rate=1) + filters.rotation.set(rotation_hz=0.2) + filters.equalizer.reset() + + await player.set_filters(filters) + + + # Reset a filter... + filters: wavelink.Filters = player.filters + filters.timescale.reset() + + await player.set_filters(filters) + + + # Reset all filters... + filters: wavelink.Filters = player.filters + filters.reset() + + await player.set_filters(filters) + + + # Reset and apply filters easier method... + await player.set_filters() + + +Lavalink Plugins +================ +Version **3** supports plugins in most cases without the need for any extra steps. + +In some cases though you may need to send additional data. +You can use :meth:`wavelink.Node.send` for this purpose. + +See the docs for more info. + diff --git a/docs/recipes.rst b/docs/recipes.rst index 69f2aa97..ac33c18b 100644 --- a/docs/recipes.rst +++ b/docs/recipes.rst @@ -1,279 +1,4 @@ Recipes and Examples -============================= -Below are common short examples and recipes for use with WaveLink 2. -This is not an exhaustive list, for more detailed examples, see: `GitHub Examples `_ +==================== - -Listening to Events -------------------- -WaveLink 2 makes use of the built in event dispatcher of Discord.py. -This means you can listen to WaveLink events the same way you listen to discord.py events. - -All WaveLink events are prefixed with ``on_wavelink_`` - - -**Outside of a Cog:** - -.. code:: python3 - - @bot.event - async def on_wavelink_node_ready(node: Node) -> None: - print(f"Node {node.id} is ready!") - - -**Inside a Cog:** - -.. code:: python3 - - @commands.Cog.listener() - async def on_wavelink_node_ready(self, node: Node) -> None: - print(f"Node {node.id} is ready!") - - - -Creating and using Nodes ------------------------- -Wavelink 2 has a more intuitive way of creating and storing Nodes. -Nodes are now stored at class level in a `NodePool`. Once a node has been created, you can access that node anywhere that -wavelink can be imported. - - -**Creating a Node:** - -.. code:: python3 - - # Creating a node is as simple as this... - # The node will be automatically stored to the global NodePool... - # You can create as many nodes as you like, most people only need 1... - node: wavelink.Node = wavelink.Node(uri='http://localhost:2333', password='youshallnotpass') - await wavelink.NodePool.connect(client=bot, nodes=[node]) - - -**Accessing the best Node from the NodePool:** - -.. code:: python3 - - # Accessing a Node is easy... - node = wavelink.NodePool.get_node() - - -**Accessing a node by identifier from the NodePool:** - -.. code:: python3 - - node = wavelink.NodePool.get_node(id="MY_NODE_ID") - - -**Accessing a list of Players a Node contains:** - -.. code:: python3 - - # A mapping of Guild ID to Player. - node = wavelink.NodePool.get_node() - print(node.players) - - -**Attaching Spotify support to a Node:** - -.. code:: python3 - - from wavelink.ext import spotify - - - sc = spotify.SpotifyClient( - client_id='CLIENT_ID', - client_secret='SECRET' - ) - node: wavelink.Node = wavelink.Node(uri='http://localhost:2333', password='youshallnotpass') - await wavelink.NodePool.connect(client=self, nodes=[node], spotify=sc) - - -Searching Tracks ----------------- -Below are some common recipes for searching tracks. - - -**A Simple YouTube search:** - -.. code:: python3 - - tracks = await wavelink.YouTubeTrack.search("Ocean Drive - Duke Dumont") - if not tracks: - # No tracks were found, do something here... - return - - track = tracks[0] - - -**As a Discord.py converter:** - -.. code:: python3 - - @commands.command() - async def play(self, ctx: commands.Context, *, track: wavelink.YouTubeTrack): - # The track will be the first result from what you searched when invoking the command... - ... - - -Creating Players and VoiceProtocol ----------------------------------- -Below are some common examples of how to use the new VoiceProtocol with WaveLink. - - -**A Simple Player:** - -.. code:: python3 - - import discord - import wavelink - - from discord.ext import commands - - - @commands.command() - async def connect(self, ctx: commands.Context, *, channel: discord.VoiceChannel | None = None): - try: - channel = channel or ctx.author.channel.voice - except AttributeError: - return await ctx.send('No voice channel to connect to. Please either provide one or join one.') - - # vc is short for voice client... - # Our "vc" will be our wavelink.Player as type-hinted below... - # wavelink.Player is also a VoiceProtocol... - - vc: wavelink.Player = await channel.connect(cls=wavelink.Player) - return vc - - -**A custom Player setup:** - -.. code:: python3 - - import discord - import wavelink - from typing import Any - - from discord.ext import commands - - - class Player(wavelink.Player): - """A Player with a DJ attribute.""" - - def __init__(self, dj: discord.Member, *args: Any, **kwargs: Any): - super().__init__(*args, **kwargs) - self.dj = dj - - - @commands.command() - async def connect(self, ctx: commands.Context, *, channel: discord.VoiceChannel | None = None): - try: - channel = channel or ctx.author.channel.voice - except AttributeError: - return await ctx.send('No voice channel to connect to. Please either provide one or join one.') - - # vc is short for voice client... - # Our "vc" will be our Player as type hinted below... - # Player is also a VoiceProtocol... - - player = Player(dj=ctx.author) - vc: Player = await channel.connect(cls=player) - - return vc - - -**Accessing the Player(VoiceProtocol) (with ctx or guild):** - -.. code:: python3 - - @commands.command() - async def play(self, ctx: commands.Context, *, track: wavelink.YouTubeTrack): - vc: wavelink.Player = ctx.voice_client - - if not vc: - # Call a connect command or similar that returns a vc... - vc = ... - - # You can also access player from anywhere you have guild... - vc = ctx.guild.voice_client - - -**Accessing a Player from your Node:** - -.. code:: python3 - - # Could return None, if the Player was not found... - - node = wavelink.NodePool.get_node() - player = node.get_player(ctx.guild.id) - - -Common Operations ------------------ -Below are some common operations used with WaveLink. -See the documentation for more info. - -.. code:: python3 - - # Play a track... - await player.play(track) - - # Turn AutoPlay on... - player.autoplay = True - - # Similarly turn AutoPlay off... - player.autoplay = False - - # Pause the current song... - await player.pause() - - # Resume the current song from pause state... - await player.resume() - - # Stop the current song from playing... - await player.stop() - - # Stop the current song from playing and disconnect and cleanup the player... - await player.disconnect() - - # Move the player to another channel... - await player.move_to(channel) - - # Set the player volume... - await player.set_volume(30) - - # Seek the currently playing song (position is an integer of milliseconds)... - await player.seek(position) - - # Check if the player is playing... - player.is_playing() - - # Check if the player is paused... - player.is_paused() - - # Check of the player is connected... - player.is_connected() - - # Get the best connected node... - node = wavelink.NodePool.get_connected_node() - - # Shuffle the player queue... - player.queue.shuffle() - - # Turn on singular track looping... - player.queue.loop = True - - # Turn on multi track looping... - player.queue.loop_all = True - - # Common node properties... - node.uri - node.id - node.players - node.status - - # Common player properties... - player.queue # The players inbuilt queue... - player.guild # The guild associated with the player... - player.current # The currently playing song... - player.position # The currently playing songs position in milliseconds... - player.ping # The ping of this current player... \ No newline at end of file +Coming soon... \ No newline at end of file diff --git a/docs/requirements.txt b/docs/requirements.txt index 9138c6ac..a5321f9e 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -6,6 +6,5 @@ sphinxcontrib-websupport Pygments furo discord.py>=2.0.1 -sphinxext-opengraph sphinx-hoverxref sphinxcontrib_trio \ No newline at end of file diff --git a/docs/wavelink.rst b/docs/wavelink.rst index 608904af..caca9279 100644 --- a/docs/wavelink.rst +++ b/docs/wavelink.rst @@ -3,13 +3,9 @@ API Reference ------------- -The wavelink API Reference. - +The wavelink 3 API Reference. This section outlines the API and all it's components within wavelink. -Wavelink is a robust and powerful Lavalink wrapper for Discord.py. Featuring, -a fully asynchronous API that's intuitive and easy to use with built in Spotify Support, Node Pool Balancing, -advanced Queues, autoplay feature and looping features built in. Event Reference --------------- @@ -17,74 +13,154 @@ Event Reference WaveLink Events are events dispatched when certain events happen in Lavalink and Wavelink. All events must be coroutines. -Events are dispatched via discord.py and as such can be used with listener syntax. -All Track Events receive the :class:`payloads.TrackEventPayload` payload. +Events are dispatched via discord.py and as such can be used with discord.py listener syntax. +All Track Events receive the appropriate payload. + **For example:** -An event listener in a cog... +An event listener in a cog. .. code-block:: python3 @commands.Cog.listener() - async def on_wavelink_node_ready(node: Node) -> None: - print(f"Node {node.id} is ready!") + async def on_wavelink_node_ready(self, payload: wavelink.NodeReadyEventPayload) -> None: + print(f"Node {payload.node!r} is ready!") -.. function:: on_wavelink_node_ready(node: Node) +.. function:: on_wavelink_node_ready(payload: wavelink.NodeReadyEventPayload) Called when the Node you are connecting to has initialised and successfully connected to Lavalink. + This event can be called many times throughout your bots lifetime, as it will be called when Wavelink successfully + reconnects to your node in the event of a disconnect. + +.. function:: on_wavelink_stats_update(payload: wavelink.StatsEventPayload) -.. function:: on_wavelink_track_event(payload: TrackEventPayload) + Called when the ``stats`` OP is received by Lavalink. - Called when any Track Event occurs. +.. function:: on_wavelink_player_update(payload: wavelink.PlayerUpdateEventPayload) -.. function:: on_wavelink_track_start(payload: TrackEventPayload) + Called when the ``playerUpdate`` OP is received from Lavalink. + This event contains information about a specific connected player on the node. + +.. function:: on_wavelink_track_start(payload: wavelink.TrackStartEventPayload) Called when a track starts playing. -.. function:: on_wavelink_track_end(payload: TrackEventPayload) + .. note:: + + It is preferred to use this method when sending feedback about the now playing track etc. + +.. function:: on_wavelink_track_end(payload: wavelink.TrackEndEventPayload) Called when the current track has finished playing. -.. function:: on_wavelink_websocket_closed(payload: WebsocketClosedPayload) + .. warning:: + + If you are using AutoPlay, please make sure you take this into consideration when using this event. + See: :func:`on_wavelink_track_start` for an event for performing logic when a new track starts playing. + +.. function:: on_wavelink_track_exception(payload: wavelink.TrackExceptionEventPayload) + + Called when an exception occurs while playing a track. + +.. function:: on_wavelink_track_stuck(payload: wavelink.TrackStuckEventPayload) + + Called when a track gets stuck while playing. + +.. function:: on_wavelink_websocket_closed(payload: wavelink.WebsocketClosedEventPayload) Called when the websocket to the voice server is closed. +.. function:: on_wavelink_node_closed(node: wavelink.Node, disconnected: list[wavelink.Player]) + + Called when a node has been closed and cleaned-up. The second parameter ``disconnected`` is a list of + :class:`wavelink.Player` that were connected on this Node and are now disconnected. + + +Types +----- +.. attributetable:: Search + +.. py:class:: Search + + A type hint used when searching tracks. Used in :meth:`Playable.search` and :meth:`Pool.fetch_tracks` + + **Example:** + + .. code:: python3 + + tracks: wavelink.Search = await wavelink.Playable.search("Ocean Drive") + Payloads --------- -.. attributetable:: TrackEventPayload +.. attributetable:: NodeReadyEventPayload -.. autoclass:: TrackEventPayload +.. autoclass:: NodeReadyEventPayload :members: -.. attributetable:: WebsocketClosedPayload +.. attributetable:: TrackStartEventPayload -.. autoclass:: WebsocketClosedPayload +.. autoclass:: TrackStartEventPayload :members: +.. attributetable:: TrackEndEventPayload -Enums ------ -.. attributetable:: NodeStatus +.. autoclass:: TrackEndEventPayload + :members: -.. autoclass:: NodeStatus +.. attributetable:: TrackExceptionEventPayload + +.. autoclass:: TrackExceptionEventPayload :members: -.. attributetable:: TrackSource +.. attributetable:: TrackStuckEventPayload -.. autoclass:: TrackSource +.. autoclass:: TrackStuckEventPayload + :members: + +.. attributetable:: WebsocketClosedEventPayload + +.. autoclass:: WebsocketClosedEventPayload + :members: + +.. attributetable:: PlayerUpdateEventPayload + +.. autoclass:: PlayerUpdateEventPayload + :members: + +.. attributetable:: StatsEventPayload + +.. autoclass:: StatsEventPayload :members: -.. attributetable:: LoadType +.. attributetable:: StatsEventMemory -.. autoclass:: LoadType +.. autoclass:: StatsEventMemory :members: -.. attributetable:: TrackEventType +.. attributetable:: StatsEventCPU -.. autoclass:: TrackEventType +.. autoclass:: StatsEventCPU + :members: + +.. attributetable:: StatsEventFrames + +.. autoclass:: StatsEventFrames + :members: + + +Enums +----- +.. attributetable:: NodeStatus + +.. autoclass:: NodeStatus + :members: + +.. attributetable:: TrackSource + +.. autoclass:: TrackSource :members: .. attributetable:: DiscordVoiceCloseType @@ -92,25 +168,22 @@ Enums .. autoclass:: DiscordVoiceCloseType :members: +.. attributetable:: AutoPlayMode -Abstract Base Classes ---------------------- -.. attributetable:: wavelink.tracks.Playable - -.. autoclass:: wavelink.tracks.Playable +.. autoclass:: AutoPlayMode :members: -.. attributetable:: wavelink.tracks.Playlist +.. attributetable:: QueueMode -.. autoclass:: wavelink.tracks.Playlist +.. autoclass:: QueueMode :members: -NodePool +Pool -------- -.. attributetable:: NodePool +.. attributetable:: Pool -.. autoclass:: NodePool +.. autoclass:: Pool :members: Node @@ -124,52 +197,40 @@ Node Tracks ------ -Tracks inherit from :class:`Playable`. Not all fields will be available for each track type. +Tracks in wavelink 3 have been simplified. Please read the docs for :class:`Playable`. +Additionally the following data classes are provided on every :class:`Playable`. -GenericTrack -~~~~~~~~~~~~ +.. attributetable:: Artist -.. attributetable:: GenericTrack - -.. autoclass:: GenericTrack +.. autoclass:: Artist :members: - :inherited-members: -YouTubeTrack -~~~~~~~~~~~~ - -.. attributetable:: YouTubeTrack +.. attributetable:: Album -.. autoclass:: YouTubeTrack +.. autoclass:: Album :members: - :inherited-members: -YouTubeMusicTrack -~~~~~~~~~~~~~~~~~ -.. attributetable:: YouTubeMusicTrack +Playable +~~~~~~~~~~~~ + +.. attributetable:: Playable -.. autoclass:: YouTubeMusicTrack +.. autoclass:: Playable :members: - :inherited-members: -SoundCloudTrack +Playlists ~~~~~~~~~~~~~~~ -.. attributetable:: SoundCloudTrack +.. attributetable:: Playlist -.. autoclass:: SoundCloudTrack +.. autoclass:: Playlist :members: - :inherited-members: -YouTubePlaylist -~~~~~~~~~~~~~~~ +.. attributetable:: PlaylistInfo -.. attributetable:: YouTubePlaylist - -.. autoclass:: YouTubePlaylist +.. autoclass:: PlaylistInfo :members: - :inherited-members: Player @@ -179,27 +240,25 @@ Player .. autoclass:: Player :members: + :exclude-members: on_voice_state_update, on_voice_server_update -Queues +Queue ------ -.. attributetable:: BaseQueue - -.. autoclass:: BaseQueue - :members: - .. attributetable:: Queue .. autoclass:: Queue :members: + :inherited-members: + Filters ------- -.. attributetable:: Filter +.. attributetable:: Filters -.. autoclass:: Filter +.. autoclass:: Filters :members: .. attributetable:: Equalizer @@ -254,51 +313,64 @@ Exceptions .. exception_hierarchy:: - :exc:`~WavelinkException` - - :exc:`~AuthorizationFailed` - - :exc:`~InvalidNode` - - :exc:`~InvalidLavalinkVersion` - - :exc:`~InvalidLavalinkResponse` - - :exc:`~NoTracksError` + - :exc:`~NodeException` + - :exc:`~InvalidClientException` + - :exc:`~AuthorizationFailedException` + - :exc:`~InvalidNodeException` + - :exc:`~LavalinkException` + - :exc:`~LavalinkLoadException` + - :exc:`~InvalidChannelStateException` + - :exc:`~ChannelTimeoutException` - :exc:`~QueueEmpty` - - :exc:`~InvalidChannelStateError` - - :exc:`~InvalidChannelPermissions` .. py:exception:: WavelinkException - Base wavelink exception. + Base wavelink Exception class. + All wavelink exceptions derive from this exception. -.. py:exception:: AuthorizationFailed +.. py:exception:: NodeException - Exception raised when password authorization failed for this Lavalink node. + Error raised when an Unknown or Generic error occurs on a Node. -.. py:exception:: InvalidNode +.. py:exception:: InvalidClientException -.. py:exception:: InvalidLavalinkVersion + Exception raised when an invalid :class:`discord.Client` + is provided while connecting a :class:`wavelink.Node`. - Exception raised when you try to use wavelink 2 with a Lavalink version under 3.7. +.. py:exception:: AuthorizationFailedException -.. py:exception:: InvalidLavalinkResponse + Exception raised when Lavalink fails to authenticate a :class:`~wavelink.Node`, with the provided password. - Exception raised when wavelink receives an invalid response from Lavalink. +.. py:exception:: InvalidNodeException - status: :class:`int` | :class:`None` - The status code. Could be :class:`None`. + Exception raised when a :class:`Node` is tried to be retrieved from the + :class:`Pool` without existing, or the ``Pool`` is empty. -.. py:exception:: NoTracksError +.. py:exception:: LavalinkException - Exception raised when no tracks could be found. + Exception raised when Lavalink returns an invalid response. -.. py:exception:: QueueEmpty + Attributes + ---------- + status: int + The response status code. + reason: str | None + The response reason. Could be ``None`` if no reason was provided. + +.. py:exception:: LavalinkLoadException - Exception raised when you try to retrieve from an empty queue. + Exception raised when loading tracks failed via Lavalink. -.. py:exception:: InvalidChannelStateError +.. py:exception:: InvalidChannelStateException - Base exception raised when an error occurs trying to connect to a :class:`discord.VoiceChannel`. + Exception raised when a :class:`~wavelink.Player` tries to connect to an invalid channel or + has invalid permissions to use this channel. -.. py:exception:: InvalidChannelPermissions +.. py:exception:: ChannelTimeoutException - Exception raised when the client does not have correct permissions to join the channel. + Exception raised when connecting to a voice channel times out. + +.. py:exception:: QueueEmpty - Could also be raised when there are too many users already in a user limited channel. + Exception raised when you try to retrieve from an empty queue via ``.get()``. \ No newline at end of file diff --git a/examples/simple.py b/examples/simple.py index 237e248b..197949b3 100644 --- a/examples/simple.py +++ b/examples/simple.py @@ -1,7 +1,7 @@ """ MIT License -Copyright (c) 2019-Present PythonistaGuild +Copyright (c) 2019-Current PythonistaGuild, EvieePy Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -21,55 +21,185 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ +import asyncio +import logging +from typing import cast + import discord -import wavelink from discord.ext import commands +import wavelink + class Bot(commands.Bot): - def __init__(self) -> None: - intents = discord.Intents.default() + intents: discord.Intents = discord.Intents.default() intents.message_content = True - super().__init__(intents=intents, command_prefix='?') + discord.utils.setup_logging(level=logging.INFO) + super().__init__(command_prefix="?", intents=intents) + + async def setup_hook(self) -> None: + nodes = [wavelink.Node(uri="...", password="...")] + + # cache_capacity is EXPERIMENTAL. Turn it off by passing None + await wavelink.Pool.connect(nodes=nodes, client=self, cache_capacity=100) async def on_ready(self) -> None: - print(f'Logged in {self.user} | {self.user.id}') + logging.info(f"Logged in: {self.user} | {self.user.id}") - async def setup_hook(self) -> None: - # Wavelink 2.0 has made connecting Nodes easier... Simply create each Node - # and pass it to NodePool.connect with the client/bot. - node: wavelink.Node = wavelink.Node(uri='http://localhost:2333', password='youshallnotpass') - await wavelink.NodePool.connect(client=self, nodes=[node]) + async def on_wavelink_node_ready(self, payload: wavelink.NodeReadyEventPayload) -> None: + logging.info(f"Wavelink Node connected: {payload.node!r} | Resumed: {payload.resumed}") + + async def on_wavelink_track_start(self, payload: wavelink.TrackStartEventPayload) -> None: + player: wavelink.Player | None = payload.player + if not player: + # Handle edge cases... + return + + original: wavelink.Playable | None = payload.original + track: wavelink.Playable = payload.track + + embed: discord.Embed = discord.Embed(title="Now Playing") + embed.description = f"**{track.title}** by `{track.author}`" + if track.artwork: + embed.set_image(url=track.artwork) -bot = Bot() + if original and original.recommended: + embed.description += f"\n\n`This track was recommended via {track.source}`" + + if track.album.name: + embed.add_field(name="Album", value=track.album.name) + + await player.home.send(embed=embed) + + +bot: Bot = Bot() @bot.command() -async def play(ctx: commands.Context, *, search: str) -> None: - """Simple play command.""" +async def play(ctx: commands.Context, *, query: str) -> None: + """Play a song with the given query.""" + if not ctx.guild: + return - if not ctx.voice_client: - vc: wavelink.Player = await ctx.author.voice.channel.connect(cls=wavelink.Player) - else: - vc: wavelink.Player = ctx.voice_client + player: wavelink.Player + player = cast(wavelink.Player, ctx.voice_client) # type: ignore + + if not player: + try: + player = await ctx.author.voice.channel.connect(cls=wavelink.Player) # type: ignore + except AttributeError: + await ctx.send("Please join a voice channel first before using this command.") + return + except discord.ClientException: + await ctx.send("I was unable to join this voice channel. Please try again.") + return + + # Turn on AutoPlay to enabled mode. + # enabled = AutoPlay will play songs for us and fetch recommendations... + # partial = AutoPlay will play songs for us, but WILL NOT fetch recommendations... + # disabled = AutoPlay will do nothing... + player.autoplay = wavelink.AutoPlayMode.enabled + + # Lock the player to this channel... + if not hasattr(player, "home"): + player.home = ctx.channel + elif player.home != ctx.channel: + await ctx.send(f"You can only play songs in {player.home.mention}, as the player has already started there.") + return - tracks: list[wavelink.YouTubeTrack] = await wavelink.YouTubeTrack.search(search) + # This will handle fetching Tracks and Playlists... + # Seed the doc strings for more information on this method... + # If spotify is enabled via LavaSrc, this will automatically fetch Spotify tracks if you pass a URL... + # Defaults to YouTube for non URL based queries... + tracks: wavelink.Search = await wavelink.Playable.search(query) if not tracks: - await ctx.send(f'Sorry I could not find any songs with search: `{search}`') + await ctx.send(f"{ctx.author.mention} - Could not find any tracks with that query. Please try again.") + return + + if isinstance(tracks, wavelink.Playlist): + # tracks is a playlist... + added: int = await player.queue.put_wait(tracks) + await ctx.send(f"Added the playlist **`{tracks.name}`** ({added} songs) to the queue.") + else: + track: wavelink.Playable = tracks[0] + await player.queue.put_wait(track) + await ctx.send(f"Added **`{track}`** to the queue.") + + if not player.playing: + # Play now since we aren't playing anything... + await player.play(player.queue.get(), volume=30) + + # Optionally delete the invokers message... + try: + await ctx.message.delete() + except discord.HTTPException: + pass + + +@bot.command() +async def skip(ctx: commands.Context) -> None: + """Skip the current song.""" + player: wavelink.Player = cast(wavelink.Player, ctx.voice_client) + if not player: return - track: wavelink.YouTubeTrack = tracks[0] - await vc.play(track) + await player.skip(force=True) + await ctx.message.add_reaction("\u2705") @bot.command() +async def nightcore(ctx: commands.Context) -> None: + """Set the filter to a nightcore style.""" + player: wavelink.Player = cast(wavelink.Player, ctx.voice_client) + if not player: + return + + filters: wavelink.Filters = player.filters + filters.timescale.set(pitch=1.2, speed=1.2, rate=1) + await player.set_filters(filters) + + await ctx.message.add_reaction("\u2705") + + +@bot.command(name="toggle", aliases=["pause", "resume"]) +async def pause_resume(ctx: commands.Context) -> None: + """Pause or Resume the Player depending on its current state.""" + player: wavelink.Player = cast(wavelink.Player, ctx.voice_client) + if not player: + return + + await player.pause(not player.paused) + await ctx.message.add_reaction("\u2705") + + +@bot.command() +async def volume(ctx: commands.Context, value: int) -> None: + """Change the volume of the player.""" + player: wavelink.Player = cast(wavelink.Player, ctx.voice_client) + if not player: + return + + await player.set_volume(value) + await ctx.message.add_reaction("\u2705") + + +@bot.command(aliases=["dc"]) async def disconnect(ctx: commands.Context) -> None: - """Simple disconnect command. + """Disconnect the Player.""" + player: wavelink.Player = cast(wavelink.Player, ctx.voice_client) + if not player: + return + + await player.disconnect() + await ctx.message.add_reaction("\u2705") + + +async def main() -> None: + async with bot: + await bot.start("BOT_TOKEN_HERE") + - This command assumes there is a currently connected Player. - """ - vc: wavelink.Player = ctx.voice_client - await vc.disconnect() +asyncio.run(main()) diff --git a/examples/spotify_autoplay.py b/examples/spotify_autoplay.py deleted file mode 100644 index f2fa569b..00000000 --- a/examples/spotify_autoplay.py +++ /dev/null @@ -1,90 +0,0 @@ -""" -MIT License - -Copyright (c) 2019-Present PythonistaGuild - -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 discord -import wavelink -from discord.ext import commands -from wavelink.ext import spotify - - -class Bot(commands.Bot): - - def __init__(self) -> None: - intents = discord.Intents.default() - intents.message_content = True - - super().__init__(intents=intents, command_prefix='?') - - async def on_ready(self) -> None: - print(f'Logged in {self.user} | {self.user.id}') - - async def setup_hook(self) -> None: - # Wavelink 2.0 has made connecting Nodes easier... Simply create each Node - # and pass it to NodePool.connect with the client/bot. - # Fill your Spotify API details and pass it to connect. - sc = spotify.SpotifyClient( - client_id='CLIENT_ID', - client_secret='SECRET' - ) - node: wavelink.Node = wavelink.Node(uri='http://localhost:2333', password='youshallnotpass') - await wavelink.NodePool.connect(client=self, nodes=[node], spotify=sc) - - -bot = Bot() - - -@bot.command() -async def play(ctx: commands.Context, *, search: str) -> None: - """Simple play command that accepts a Spotify song URL. - - This command enables AutoPlay. AutoPlay finds songs automatically when used with Spotify. - Tracks added to the Queue will be played in front (Before) of those added by AutoPlay. - """ - - if not ctx.voice_client: - vc: wavelink.Player = await ctx.author.voice.channel.connect(cls=wavelink.Player) - else: - vc: wavelink.Player = ctx.voice_client - - # Check the search to see if it matches a valid Spotify URL... - decoded = spotify.decode_url(search) - if not decoded or decoded['type'] is not spotify.SpotifySearchType.track: - await ctx.send('Only Spotify Track URLs are valid.') - return - - # Set autoplay to True. This can be disabled at anytime... - vc.autoplay = True - - tracks: list[spotify.SpotifyTrack] = await spotify.SpotifyTrack.search(search) - if not tracks: - await ctx.send('This does not appear to be a valid Spotify URL.') - return - - track: spotify.SpotifyTrack = tracks[0] - - # IF the player is not playing immediately play the song... - # otherwise put it in the queue... - if not vc.is_playing(): - await vc.play(track, populate=True) - else: - await vc.queue.put_wait(track) diff --git a/pyproject.toml b/pyproject.toml index 4527dde9..93c48007 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta" [project] name = "wavelink" -version = "2.6.5" +version = "3.0.0" authors = [ - { name="EvieePy", email="evieepy@gmail.com" }, + { name="PythonistaGuild, EvieePy", email="evieepy@gmail.com" }, ] dynamic = ["dependencies"] -description = "A robust and powerful Lavalink wrapper for discord.py" +description = "A robust and powerful, fully asynchronous Lavalink wrapper built for discord.py in Python." readme = "README.rst" requires-python = ">=3.10" classifiers = [ @@ -28,5 +28,23 @@ classifiers = [ [project.urls] "Homepage" = "https://github.com/PythonistaGuild/Wavelink" +[tool.setuptools] +packages = ["wavelink", "wavelink.types"] + [tool.setuptools.dynamic] dependencies = {file = ["requirements.txt"]} + +[tool.setuptools.package-data] +wavelink = ["py.typed"] + +[tool.black] +line-length = 120 + +[tool.isort] +profile = "black" + +[tool.pyright] +ignore = ["test*.py", "examples/*.py", "docs/*"] +pythonVersion = "3.10" +typeCheckingMode = "strict" +reportPrivateUsage = false diff --git a/requirements.txt b/requirements.txt index b880f057..b5d12bd7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ aiohttp>=3.7.4,<4 discord.py>=2.0.1 -yarl~=1.8.2 \ No newline at end of file +yarl==1.9.2 +async_timeout \ No newline at end of file diff --git a/wavelink/__init__.py b/wavelink/__init__.py index 2c546357..a92a252e 100644 --- a/wavelink/__init__.py +++ b/wavelink/__init__.py @@ -25,13 +25,16 @@ __author__ = "PythonistaGuild, EvieePy" __license__ = "MIT" __copyright__ = "Copyright 2019-Present (c) PythonistaGuild, EvieePy" -__version__ = "2.6.5" +__version__ = "3.0.0" + from .enums import * from .exceptions import * +from .filters import * +from .lfu import CapacityZero as CapacityZero +from .lfu import LFUCache as LFUCache from .node import * from .payloads import * from .player import Player as Player -from .tracks import * from .queue import * -from .filters import * +from .tracks import * diff --git a/wavelink/__main__.py b/wavelink/__main__.py index 4e1d4717..d42cbf7d 100644 --- a/wavelink/__main__.py +++ b/wavelink/__main__.py @@ -3,35 +3,29 @@ import subprocess import sys -import aiohttp -import discord - import wavelink - -parser = argparse.ArgumentParser(prog='wavelink') -parser.add_argument('--version', action='store_true', help='Get version and debug information for wavelink.') +parser = argparse.ArgumentParser(prog="wavelink") +parser.add_argument("--version", action="store_true", help="Get version and debug information for wavelink.") args = parser.parse_args() def get_debug_info() -> None: - python_info = '\n'.join(sys.version.split('\n')) - java_version = subprocess.check_output(['java', '-version'], stderr=subprocess.STDOUT) - java_version = f'\n{" " * 8}- '.join(v for v in java_version.decode().split('\r\n') if v) + python_info = "\n".join(sys.version.split("\n")) + java_version = subprocess.check_output(["java", "-version"], stderr=subprocess.STDOUT) + java_version = f'\n{" " * 8}- '.join(v for v in java_version.decode().split("\r\n") if v) info: str = f""" + wavelink: {wavelink.__version__} + Python: - {python_info} System: - {platform.platform()} Java: - {java_version or "Version Not Found"} - Libraries: - - wavelink : v{wavelink.__version__} - - discord.py : v{discord.__version__} - - aiohttp : v{aiohttp.__version__} """ print(info) @@ -39,4 +33,3 @@ def get_debug_info() -> None: if args.version: get_debug_info() - diff --git a/wavelink/backoff.py b/wavelink/backoff.py index 606719a9..df0ccf2f 100644 --- a/wavelink/backoff.py +++ b/wavelink/backoff.py @@ -1,14 +1,18 @@ """ The MIT License (MIT) + Copyright (c) 2021-Present PythonistaGuild, EvieePy, 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 @@ -25,6 +29,7 @@ class Backoff: """An implementation of an Exponential Backoff. + Parameters ---------- base: int @@ -49,7 +54,7 @@ def __init__(self, *, base: int = 1, maximum_time: float = 30.0, maximum_tries: self._last_wait: float = 0 def calculate(self) -> float: - exponent = min((self._retries ** 2), self._maximum_time) + exponent = min((self._retries**2), self._maximum_time) wait = self._rand(0, (self._base * 2) * exponent) if wait <= self._last_wait: diff --git a/wavelink/enums.py b/wavelink/enums.py index 4d750b4a..7f2f0dac 100644 --- a/wavelink/enums.py +++ b/wavelink/enums.py @@ -1,7 +1,7 @@ """ MIT License -Copyright (c) 2019-Present PythonistaGuild +Copyright (c) 2019-Current PythonistaGuild, EvieePy Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -21,22 +21,22 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ -from discord.enums import Enum +import enum -__all__ = ('NodeStatus', 'TrackSource', 'LoadType', 'TrackEventType', 'DiscordVoiceCloseType') +__all__ = ("NodeStatus", "TrackSource", "DiscordVoiceCloseType", "AutoPlayMode", "QueueMode") -class NodeStatus(Enum): - """Enum representing the current status of a Node. +class NodeStatus(enum.Enum): + """Enum representing the connection status of a Node. Attributes ---------- DISCONNECTED - 0 + The Node has been disconnected or has never been connected previously. CONNECTING - 1 + The Node is currently attempting to connect. CONNECTED - 2 + The Node is currently connected. """ DISCONNECTED = 0 @@ -44,69 +44,25 @@ class NodeStatus(Enum): CONNECTED = 2 -class TrackSource(Enum): - """Enum representing the Track Source Type. +class TrackSource(enum.Enum): + """Enum representing a :class:`Playable` source. Attributes ---------- YouTube - 0 + A source representing a track that comes from YouTube. YouTubeMusic - 1 + A source representing a track that comes from YouTube Music. SoundCloud - 2 - Local - 3 - Unknown - 4 + A source representing a track that comes from SoundCloud. """ YouTube = 0 YouTubeMusic = 1 SoundCloud = 2 - Local = 3 - Unknown = 4 -class LoadType(Enum): - """Enum representing the Tracks Load Type. - - Attributes - ---------- - track_loaded - "TRACK_LOADED" - playlist_loaded - "PLAYLIST_LOADED" - search_result - "SEARCH_RESULT" - no_matches - "NO_MATCHES" - load_failed - "LOAD_FAILED" - """ - track_loaded = "TRACK_LOADED" - playlist_loaded = "PLAYLIST_LOADED" - search_result = "SEARCH_RESULT" - no_matches = "NO_MATCHES" - load_failed = "LOAD_FAILED" - - -class TrackEventType(Enum): - """Enum representing the TrackEvent types. - - Attributes - ---------- - START - "TrackStartEvent" - END - "TrackEndEvent" - """ - - START = 'TrackStartEvent' - END = 'TrackEndEvent' - - -class DiscordVoiceCloseType(Enum): +class DiscordVoiceCloseType(enum.Enum): """Enum representing the various Discord Voice Websocket Close Codes. Attributes @@ -138,6 +94,7 @@ class DiscordVoiceCloseType(Enum): UNKNOWN_ENCRYPTION_MODE 4016 """ + CLOSE_NORMAL = 1000 # Not Discord but standard websocket UNKNOWN_OPCODE = 4001 FAILED_DECODE_PAYLOAD = 4002 @@ -151,3 +108,41 @@ class DiscordVoiceCloseType(Enum): DISCONNECTED = 4014 VOICE_SERVER_CRASHED = 4015 UNKNOWN_ENCRYPTION_MODE = 4016 + + +class AutoPlayMode(enum.Enum): + """Enum representing the various AutoPlay modes. + + Attributes + ---------- + enabled + When enabled, AutoPlay will work fully autonomously and fill the auto_queue with recommended tracks. + If a song is put into a players standard queue, AutoPlay will use it as a priority. + partial + When partial, AutoPlay will work fully autonomously but **will not** fill the auto_queue with + recommended tracks. + disabled + When disabled, AutoPlay will not do anything automatically. + """ + + enabled = 0 + partial = 1 + disabled = 2 + + +class QueueMode(enum.Enum): + """Enum representing the various modes on :class:`wavelink.Queue` + + Attributes + ---------- + normal + When set, the queue will not loop either track or history. This is the default. + loop + When set, the track will continuously loop. + loop_all + When set, the queue will continuously loop through all tracks. + """ + + normal = 0 + loop = 1 + loop_all = 2 diff --git a/wavelink/exceptions.py b/wavelink/exceptions.py index c615b1e4..ad9830b4 100644 --- a/wavelink/exceptions.py +++ b/wavelink/exceptions.py @@ -1,7 +1,7 @@ """ MIT License -Copyright (c) 2019-Present PythonistaGuild +Copyright (c) 2019-Current PythonistaGuild, EvieePy Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -23,72 +23,123 @@ """ from __future__ import annotations -from typing import Any +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .types.response import ErrorResponse, LoadedErrorPayload + __all__ = ( - 'WavelinkException', - 'AuthorizationFailed', - 'InvalidNode', - 'InvalidLavalinkVersion', - 'InvalidLavalinkResponse', - 'NoTracksError', - 'QueueEmpty', - 'InvalidChannelStateError', - 'InvalidChannelPermissions', + "WavelinkException", + "NodeException", + "InvalidClientException", + "AuthorizationFailedException", + "InvalidNodeException", + "LavalinkException", + "LavalinkLoadException", + "InvalidChannelStateException", + "ChannelTimeoutException", + "QueueEmpty", ) class WavelinkException(Exception): - """Base wavelink exception.""" + """Base wavelink Exception class. + + All wavelink exceptions derive from this exception. + """ - def __init__(self, *args: Any, **kwargs: Any) -> None: - super().__init__(*args) +class NodeException(WavelinkException): + """Error raised when an Unknown or Generic error occurs on a Node. -class AuthorizationFailed(WavelinkException): - """Exception raised when password authorization failed for this Lavalink node.""" - pass + This exception may be raised when an error occurs reaching your Node. + Attributes + ---------- + status: int | None + The status code received when making a request. Could be None. + """ -class InvalidNode(WavelinkException): - pass + def __init__(self, msg: str | None = None, status: int | None = None) -> None: + super().__init__(msg) + self.status = status -class InvalidLavalinkVersion(WavelinkException): - """Exception raised when you try to use wavelink 2 with a Lavalink version under 3.7.""" - pass + +class InvalidClientException(WavelinkException): + """Exception raised when an invalid :class:`discord.Client` + is provided while connecting a :class:`wavelink.Node`. + """ + + +class AuthorizationFailedException(WavelinkException): + """Exception raised when Lavalink fails to authenticate a :class:`~wavelink.Node`, with the provided password.""" + + +class InvalidNodeException(WavelinkException): + """Exception raised when a :class:`Node` is tried to be retrieved from the + :class:`Pool` without existing, or the ``Pool`` is empty. + """ -class InvalidLavalinkResponse(WavelinkException): - """Exception raised when wavelink receives an invalid response from Lavalink. +class LavalinkException(WavelinkException): + """Exception raised when Lavalink returns an invalid response. Attributes ---------- - status: int | None - The status code. Could be None. + status: int + The response status code. + reason: str | None + The response reason. Could be ``None`` if no reason was provided. """ - def __init__(self, *args: Any, **kwargs: Any) -> None: - super().__init__(*args) - self.status: int | None = kwargs.get('status') + def __init__(self, msg: str | None = None, /, *, data: ErrorResponse) -> None: + self.timestamp: int = data["timestamp"] + self.status: int = data["status"] + self.error: str = data["error"] + self.trace: str | None = data.get("trace") + self.path: str = data["path"] + if not msg: + msg = f"Failed to fulfill request to Lavalink: status={self.status}, reason={self.error}, path={self.path}" -class NoTracksError(WavelinkException): - """Exception raised when no tracks could be found.""" - pass + super().__init__(msg) -class QueueEmpty(WavelinkException): - """Exception raised when you try to retrieve from an empty queue.""" - pass +class LavalinkLoadException(WavelinkException): + """Exception raised when an error occurred loading tracks via Lavalink. + + Attributes + ---------- + error: str + The error message from Lavalink. + severity: str + The severity of this error sent via Lavalink. + cause: str + The cause of this error sent via Lavalink. + """ + def __init__(self, msg: str | None = None, /, *, data: LoadedErrorPayload) -> None: + self.error: str = data["message"] + self.severity: str = data["severity"] + self.cause: str = data["cause"] -class InvalidChannelStateError(WavelinkException): - """Base exception raised when an error occurs trying to connect to a :class:`discord.VoiceChannel`.""" + if not msg: + msg = f"Failed to Load Tracks: error={self.error}, severity={self.severity}, cause={self.cause}" + super().__init__(msg) -class InvalidChannelPermissions(InvalidChannelStateError): - """Exception raised when the client does not have correct permissions to join the channel. - Could also be raised when there are too many users already in a user limited channel. - """ \ No newline at end of file +class InvalidChannelStateException(WavelinkException): + """Exception raised when a :class:`~wavelink.Player` tries to connect to an invalid channel or + has invalid permissions to use this channel. + """ + + +class ChannelTimeoutException(WavelinkException): + """Exception raised when connecting to a voice channel times out.""" + + +class QueueEmpty(WavelinkException): + """Exception raised when you try to retrieve from an empty queue.""" diff --git a/wavelink/ext/spotify/__init__.py b/wavelink/ext/spotify/__init__.py deleted file mode 100644 index 1ac21fc0..00000000 --- a/wavelink/ext/spotify/__init__.py +++ /dev/null @@ -1,514 +0,0 @@ -""" -MIT License - -Copyright (c) 2019-Present PythonistaGuild - -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. -""" -from __future__ import annotations - -import asyncio -import base64 -import logging -import time -from typing import Any, List, Optional, Type, TypeVar, Union, TYPE_CHECKING - -import aiohttp -from discord.ext import commands - -import wavelink -from wavelink import Node, NodePool - -from .utils import * - - -if TYPE_CHECKING: - from wavelink import Player, Playable - - -__all__ = ( - 'SpotifySearchType', - 'SpotifyClient', - 'SpotifyTrack', - 'SpotifyRequestError', - 'decode_url', - 'SpotifyDecodePayload' -) - - -logger: logging.Logger = logging.getLogger(__name__) - - -ST = TypeVar("ST", bound="Playable") - - -class SpotifyAsyncIterator: - - def __init__(self, *, query: str, limit: int, type: SpotifySearchType, node: Node): - self._query = query - self._limit = limit - self._type = type - self._node = node - - self._first = True - self._count = 0 - self._queue = asyncio.Queue() - - def __aiter__(self): - return self - - async def fill_queue(self): - tracks = await self._node._spotify._search(query=self._query, iterator=True, type=self._type) - - for track in tracks: - await self._queue.put(track) - - async def __anext__(self): - if self._first: - await self.fill_queue() - self._first = False - - if self._limit is not None and self._count == self._limit: - raise StopAsyncIteration - - try: - track = self._queue.get_nowait() - except asyncio.QueueEmpty as e: - raise StopAsyncIteration from e - - if track is None: - return await self.__anext__() - - track = SpotifyTrack(track) - - self._count += 1 - return track - - -class SpotifyRequestError(Exception): - """Base error for Spotify requests. - - Attributes - ---------- - status: int - The status code returned from the request. - reason: Optional[str] - The reason the request failed. Could be None. - """ - - def __init__(self, status: int, reason: Optional[str] = None): - self.status = status - self.reason = reason - - -class SpotifyTrack: - """A track retrieved via Spotify. - - - .. container:: operations - - .. describe:: str(track) - - Returns a string representing this SpotifyTrack's name and artists. - - .. describe:: repr(track) - - Returns an official string representation of this SpotifyTrack. - - .. describe:: track == other_track - - Check whether a track is equal to another. Tracks are equal if they both have the same Spotify ID. - - - Attributes - ---------- - raw: dict[str, Any] - The raw payload from Spotify for this track. - album: str - The album name this track belongs to. - images: list[str] - A list of URLs to images associated with this track. - artists: list[str] - A list of artists for this track. - genres: list[str] - A list of genres associated with this tracks artist. - name: str - The track name. - title: str - An alias to name. - uri: str - The URI for this spotify track. - id: str - The spotify ID for this track. - isrc: str | None - The International Standard Recording Code associated with this track. - length: int - The track length in milliseconds. - duration: int - Alias to length. - explicit: bool - Whether this track is explicit or not. - """ - - __slots__ = ( - 'raw', - 'album', - 'images', - 'artists', - 'name', - 'title', - 'uri', - 'id', - 'length', - 'duration', - 'explicit', - 'isrc', - '__dict__' - ) - - def __init__(self, data: dict[str, Any]) -> None: - self.raw: dict[str, Any] = data - - album = data['album'] - self.album: str = album['name'] - self.images: list[str] = [i['url'] for i in album['images']] - - artists = data['artists'] - self.artists: list[str] = [a['name'] for a in artists] - # self.genres: list[str] = [a['genres'] for a in artists] - - self.name: str = data['name'] - self.title: str = self.name - self.uri: str = data['uri'] - self.id: str = data['id'] - self.length: int = data['duration_ms'] - self.duration: int = self.length - self.isrc: str | None = data.get("external_ids", {}).get('irsc') - self.explicit: bool = data.get('explicit', False) in {"true", True} - - def __str__(self) -> str: - return f'{self.name} - {self.artists[0]}' - - def __repr__(self) -> str: - return f'SpotifyTrack(id={self.id}, isrc={self.isrc}, name={self.name}, duration={self.duration})' - - def __eq__(self, other) -> bool: - if isinstance(other, SpotifyTrack): - return self.id == other.id - return NotImplemented - - def __hash__(self) -> int: - return hash(self.id) - - @classmethod - async def search( - cls, - query: str, - *, - node: Node | None = None, - ) -> list['SpotifyTrack']: - """|coro| - - Search for tracks with the given query. - - Parameters - ---------- - query: str - The Spotify URL to query for. - node: Optional[:class:`wavelink.Node`] - An optional Node to use to make the search with. - - Returns - ------- - List[:class:`SpotifyTrack`] - - Examples - -------- - Searching for a singular tack to play... - - .. code:: python3 - - tracks: list[spotify.SpotifyTrack] = await spotify.SpotifyTrack.search(query=SPOTIFY_URL) - if not tracks: - # No tracks found, do something? - return - - track: spotify.SpotifyTrack = tracks[0] - - - Searching for all tracks in an album... - - .. code:: python3 - - tracks: list[spotify.SpotifyTrack] = await spotify.SpotifyTrack.search(query=SPOTIFY_URL) - if not tracks: - # No tracks found, do something? - return - - - .. versionchanged:: 2.6.0 - - This method no longer takes in the ``type`` parameter. The query provided will be automatically decoded, - if the ``type`` returned by :func:`decode_url` is unusable, this method will return an empty :class:`list` - """ - if node is None: - node: Node = NodePool.get_connected_node() - - decoded: SpotifyDecodePayload = decode_url(query) - - if not decoded or decoded.type is SpotifySearchType.unusable: - logger.debug(f'Spotify search handled an unusable search type for query: "{query}".') - return [] - - return await node._spotify._search(query=query, type=decoded.type) - - @classmethod - def iterator(cls, - *, - query: str, - limit: int | None = None, - node: Node | None = None, - ): - """An async iterator version of search. - - This can be useful when searching for large playlists or albums with Spotify. - - Parameters - ---------- - query: str - The Spotify URL to search for. Must be of type Playlist or Album. - limit: Optional[int] - Limit the amount of tracks returned. - node: Optional[:class:`wavelink.Node`] - An optional node to use when querying for tracks. Defaults to the best available. - - Examples - -------- - - .. code:: python3 - - async for track in spotify.SpotifyTrack.iterator(query=...): - ... - - - .. versionchanged:: 2.6.0 - - This method no longer takes in the ``type`` parameter. The query provided will be automatically decoded, - if the ``type`` returned by :func:`decode_url` is not either ``album`` or ``playlist`` this method will - raise a :exc:`TypeError`. - """ - decoded: SpotifyDecodePayload = decode_url(query) - - if not decoded or decoded.type is not SpotifySearchType.album and decoded.type is not SpotifySearchType.playlist: - raise TypeError('Spotify iterator query must be either a valid Spotify album or playlist URL.') - - if node is None: - node = NodePool.get_connected_node() - - return SpotifyAsyncIterator(query=query, limit=limit, node=node, type=decoded.type) - - @classmethod - async def convert(cls: Type[ST], ctx: commands.Context, argument: str) -> ST: - """Converter which searches for and returns the first track. - - Used as a type hint in a - `discord.py command `_. - """ - results = await cls.search(argument) - - if not results: - raise commands.BadArgument(f"Could not find any songs matching query: {argument}") - - return results[0] - - async def fulfill(self, *, player: Player, cls: Playable, populate: bool) -> Playable: - """Used to fulfill the :class:`wavelink.Player` Auto Play Queue. - - .. warning:: - - Usually you would not call this directly. Instead you would set :attr:`wavelink.Player.autoplay` to true, - and allow the player to fulfill this request automatically. - - - Parameters - ---------- - player: :class:`wavelink.player.Player` - If :attr:`wavelink.Player.autoplay` is enabled, this search will fill the AutoPlay Queue with more tracks. - cls - The class to convert this Spotify Track to. - """ - - if not self.isrc: - tracks: list[cls] = await cls.search(f'{self.name} - {self.artists[0]}') - else: - tracks: list[cls] = await cls.search(f'"{self.isrc}"') - if not tracks: - tracks: list[cls] = await cls.search(f'{self.name} - {self.artists[0]}') - - if not player.autoplay or not populate: - return tracks[0] - - node: Node = player.current_node - sc: SpotifyClient | None = node._spotify - - if not sc: - raise RuntimeError(f"There is no spotify client associated with <{node:!r}>") - - if sc.is_token_expired(): - await sc._get_bearer_token() - - if len(player._track_seeds) == 5: - player._track_seeds.pop(0) - - player._track_seeds.append(self.id) - - url: str = RECURL.format(tracks=','.join(player._track_seeds)) - async with node._session.get(url=url, headers=sc.bearer_headers) as resp: - if resp.status != 200: - raise SpotifyRequestError(resp.status, resp.reason) - - data = await resp.json() - - recos = [SpotifyTrack(t) for t in data['tracks']] - for reco in recos: - if reco in player.auto_queue or reco in player.auto_queue.history: - continue - - await player.auto_queue.put_wait(reco) - - return tracks[0] - - -class SpotifyClient: - """Spotify client passed to :class:`wavelink.Node` for searching via Spotify. - - Parameters - ---------- - client_id: str - Your spotify application client ID. - client_secret: str - Your spotify application secret. - """ - - def __init__(self, *, client_id: str, client_secret: str): - self._client_id = client_id - self._client_secret = client_secret - - self.session = aiohttp.ClientSession() - - self._bearer_token: str | None = None - self._expiry: int = 0 - - @property - def grant_headers(self) -> dict: - authbytes = f'{self._client_id}:{self._client_secret}'.encode() - return {'Authorization': f'Basic {base64.b64encode(authbytes).decode()}', - 'Content-Type': 'application/x-www-form-urlencoded'} - - @property - def bearer_headers(self) -> dict: - return {'Authorization': f'Bearer {self._bearer_token}'} - - async def _get_bearer_token(self) -> None: - async with self.session.post(GRANTURL, headers=self.grant_headers) as resp: - if resp.status != 200: - raise SpotifyRequestError(resp.status, resp.reason) - - data = await resp.json() - self._bearer_token = data['access_token'] - self._expiry = time.time() + (int(data['expires_in']) - 10) - - def is_token_expired(self) -> bool: - return time.time() >= self._expiry - - async def _search(self, - query: str, - type: SpotifySearchType = SpotifySearchType.track, - iterator: bool = False, - ) -> list[SpotifyTrack]: - - if self.is_token_expired(): - await self._get_bearer_token() - - regex_result = URLREGEX.match(query) - - url = ( - BASEURL.format( - entity=regex_result['type'], identifier=regex_result['id'] - ) - if regex_result - else BASEURL.format(entity=type.name, identifier=query) - ) - - async with self.session.get(url, headers=self.bearer_headers) as resp: - if resp.status == 400: - return [] - - elif resp.status != 200: - raise SpotifyRequestError(resp.status, resp.reason) - - data = await resp.json() - - if data['type'] == 'track': - return [SpotifyTrack(data)] - - elif data['type'] == 'album': - album_data: dict[str, Any] = { - 'album_type': data['album_type'], - 'artists': data['artists'], - 'available_markets': data['available_markets'], - 'external_urls': data['external_urls'], - 'href': data['href'], - 'id': data['id'], - 'images': data['images'], - 'name': data['name'], - 'release_date': data['release_date'], - 'release_date_precision': data['release_date_precision'], - 'total_tracks': data['total_tracks'], - 'type': data['type'], - 'uri': data['uri'], - } - tracks = [] - for track in data['tracks']['items']: - track['album'] = album_data - if iterator: - tracks.append(track) - else: - tracks.append(SpotifyTrack(track)) - - return tracks - - elif data['type'] == 'playlist': - if not iterator: - return [SpotifyTrack(t['track']) for t in data['tracks']['items'] if t['track'] is not None] - if not data['tracks']['next']: - return [t['track'] for t in data['tracks']['items'] if t['track'] is not None] - - url = data['tracks']['next'] - - items = [t['track'] for t in data['tracks']['items'] if t['track'] is not None] - while True: - async with self.session.get(url, headers=self.bearer_headers) as resp: - data = await resp.json() - - items.extend([t['track'] for t in data['items'] if t['track'] is not None]) - if not data['next']: - return items - - url = data['next'] diff --git a/wavelink/ext/spotify/utils.py b/wavelink/ext/spotify/utils.py deleted file mode 100644 index 9576f285..00000000 --- a/wavelink/ext/spotify/utils.py +++ /dev/null @@ -1,171 +0,0 @@ -""" -MIT License - -Copyright (c) 2019-Present PythonistaGuild - -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 enum -import re -from typing import Any, Optional - - -__all__ = ( - 'GRANTURL', - 'URLREGEX', - 'BASEURL', - 'RECURL', - 'SpotifyDecodePayload', - 'decode_url', - 'SpotifySearchType' -) - - -GRANTURL = 'https://accounts.spotify.com/api/token?grant_type=client_credentials' -URLREGEX = re.compile(r'(https?://open.)?(spotify)(.com/|:)(.*[/:])?' - r'(?Palbum|playlist|track|artist|show|episode)([/:])' - r'(?P[a-zA-Z0-9]+)(\?si=[a-zA-Z0-9]+)?(&dl_branch=[0-9]+)?') -BASEURL = 'https://api.spotify.com/v1/{entity}s/{identifier}' -RECURL = 'https://api.spotify.com/v1/recommendations?seed_tracks={tracks}' - - -class SpotifySearchType(enum.Enum): - """An enum specifying which type to search for with a given Spotify ID. - - Attributes - ---------- - track - Track search type. - album - Album search type. - playlist - Playlist search type. - unusable - Unusable type. This type is assigned when Wavelink can not be used to play this track. - """ - track = 0 - album = 1 - playlist = 2 - unusable = 3 - - -class SpotifyDecodePayload: - """The SpotifyDecodePayload received when using :func:`decode_url`. - - .. container:: operations - - .. describe:: repr(payload) - - Returns an official string representation of this payload. - - .. describe:: payload['attr'] - - Allows this object to be accessed like a dictionary. - - - Attributes - ---------- - type: :class:`SpotifySearchType` - Either track, album or playlist. - If type is not track, album or playlist, a special unusable type is assigned. - id: str - The Spotify ID associated with the decoded URL. - - - .. note:: - - To keep backwards compatibility with previous versions of :func:`decode_url`, you can access this object like - a dictionary. - - - .. warning:: - - You do not instantiate this object manually. Instead you receive it via :func:`decode_url`. - - - .. versionadded:: 2.6.0 - """ - - def __init__(self, *, type_: SpotifySearchType, id_: str) -> None: - self.__type = type_ - self.__id = id_ - - def __repr__(self) -> str: - return f'SpotifyDecodePayload(type={self.type}, id={self.id})' - - @property - def type(self) -> SpotifySearchType: - return self.__type - - @property - def id(self) -> str: - return self.__id - - def __getitem__(self, item: Any) -> SpotifySearchType | str: - valid: list[str] = ['type', 'id'] - - if item not in valid: - raise KeyError(f'SpotifyDecodePayload object has no key {item}. Valid keys are "{valid}".') - - return getattr(self, item) - - -def decode_url(url: str) -> SpotifyDecodePayload | None: - """Check whether the given URL is a valid Spotify URL and return a :class:`SpotifyDecodePayload` if this URL - is valid, or ``None`` if this URL is invalid. - - Parameters - ---------- - url: str - The URL to check. - - Returns - ------- - Optional[:class:`SpotifyDecodePayload`] - The decode payload containing the Spotify :class:`SpotifySearchType` and Spotify ID. - - Could return ``None`` if the URL is invalid. - - Examples - -------- - - .. code:: python3 - - from wavelink.ext import spotify - - - decoded = spotify.decode_url("https://open.spotify.com/track/6BDLcvvtyJD2vnXRDi1IjQ?si=e2e5bd7aaf3d4a2a") - - - .. versionchanged:: 2.6.0 - - This function now returns :class:`SpotifyDecodePayload`. For backwards compatibility you can access this - payload like a dictionary. - """ - match = URLREGEX.match(url) - if match: - try: - type_ = SpotifySearchType[match['type']] - except KeyError: - type_ = SpotifySearchType.unusable - - return SpotifyDecodePayload(type_=type_, id_=match['id']) - - return None - diff --git a/wavelink/filters.py b/wavelink/filters.py index d0e9a50e..17871a74 100644 --- a/wavelink/filters.py +++ b/wavelink/filters.py @@ -1,35 +1,48 @@ -"""MIT License +""" +The MIT License (MIT) -Copyright (c) 2019-Present PythonistaGuild +Copyright (c) 2021-Present PythonistaGuild, EvieePy -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: +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 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, +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. +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. """ - from __future__ import annotations -import abc -import collections -from typing import Any +from typing import TYPE_CHECKING, TypedDict + +if TYPE_CHECKING: + from typing_extensions import Self, Unpack + + from .types.filters import ChannelMix as ChannelMixPayload + from .types.filters import Distortion as DistortionPayload + from .types.filters import Equalizer as EqualizerPayload + from .types.filters import FilterPayload + from .types.filters import Karaoke as KaraokePayload + from .types.filters import LowPass as LowPassPayload + from .types.filters import Rotation as RotationPayload + from .types.filters import Timescale as TimescalePayload + from .types.filters import Tremolo as TremoloPayload + from .types.filters import Vibrato as VibratoPayload __all__ = ( - "BaseFilter", + "FiltersOptions", + "Filters", "Equalizer", "Karaoke", "Timescale", @@ -39,593 +52,765 @@ "Distortion", "ChannelMix", "LowPass", - "Filter", ) -class BaseFilter(abc.ABC): - """ - .. container:: operations +class FiltersOptions(TypedDict, total=False): + volume: float + equalizer: Equalizer + karaoke: Karaoke + timescale: Timescale + tremolo: Tremolo + vibrato: Vibrato + rotation: Rotation + distortion: Distortion + channel_mix: ChannelMix + low_pass: LowPass + reset: bool - .. describe:: repr(filter) - Returns an official string representation of this filter. - """ +class EqualizerOptions(TypedDict): + bands: list[EqualizerPayload] | None - def __init__(self, name: str | None = None) -> None: - self.name: str = name or "Unknown" - def __repr__(self) -> str: - return f"" +class KaraokeOptions(TypedDict): + level: float | None + mono_level: float | None + filter_band: float | None + filter_width: float | None - @property - @abc.abstractmethod - def _payload(self) -> Any: - raise NotImplementedError + +class RotationOptions(TypedDict): + rotation_hz: float | None -class Equalizer(BaseFilter): - """An equalizer filter. +class DistortionOptions(TypedDict): + sin_offset: float | None + sin_scale: float | None + cos_offset: float | None + cos_scale: float | None + tan_offset: float | None + tan_scale: float | None + offset: float | None + scale: float | None - Parameters - ---------- - name: str - The name of this filter. Can be used to differentiate between equalizer filters. - bands: List[Dict[str, int]] - A list of equalizer bands, each item is a dictionary with "band" and "gain" keys. + +class ChannelMixOptions(TypedDict): + left_to_left: float | None + left_to_right: float | None + right_to_left: float | None + right_to_right: float | None + + +class Equalizer: + """Equalizer Filter Class. + + There are 15 bands ``0`` to ``14`` that can be changed. + Each band has a ``gain`` which is the multiplier for the given band. ``gain`` defaults to ``0``. + + Valid ``gain`` values range from ``-0.25`` to ``1.0``, where ``-0.25`` means the given band is completely muted, + and ``0.25`` means it will be doubled. + + Modifying the ``gain`` could also change the volume of the output. """ - def __init__( - self, - name: str = "CustomEqualizer", - *, - bands: list[tuple[int, float]] - ) -> None: - super().__init__(name=name) + def __init__(self, payload: list[EqualizerPayload] | None = None) -> None: + if payload and len(payload) == 15: + self._payload = self._set(payload) - if any((band, gain) for band, gain in bands if band < 0 or band > 15 or gain < -0.25 or gain > 1.0): - raise ValueError("Equalizer bands must be between 0 and 15 and gains between -0.25 and 1.0") + else: + payload_: dict[int, EqualizerPayload] = {n: {"band": n, "gain": 0.0} for n in range(15)} + self._payload = payload_ - _dict = collections.defaultdict(float) - _dict.update(bands) + def _set(self, payload: list[EqualizerPayload]) -> dict[int, EqualizerPayload]: + default: dict[int, EqualizerPayload] = {n: {"band": n, "gain": 0.0} for n in range(15)} - self.bands = [{"band": band, "gain": _dict[band]} for band in range(15)] + for eq in payload: + band: int = eq["band"] + if band > 14 or band < 0: + continue - def __repr__(self) -> str: - return f"" + default[band] = eq - @property - def _payload(self) -> list[dict[str, float]]: - return self.bands + return default - @classmethod - def flat(cls) -> Equalizer: - """A flat equalizer.""" + def set(self, **options: Unpack[EqualizerOptions]) -> Self: + """Set the bands of the Equalizer class. - bands = [ - (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(name="Flat EQ", bands=bands) + Accepts a keyword argument ``bands`` which is a ``list`` of ``dict`` containing the keys ``band`` and ``gain``. - @classmethod - def boost(cls) -> Equalizer: - """A boost equalizer.""" + ``band`` can be an ``int`` beteween ``0`` and ``14``. + ``gain`` can be a float between ``-0.25`` and ``1.0``, where ``-0.25`` means the given band is completely muted, + and ``0.25`` means it will be doubled. - bands = [ - (0, -0.075), (1, 0.125), (2, 0.125), (3, 0.1), (4, 0.1), - (5, .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(name="Boost EQ", bands=bands) + Using this method changes **all** bands, resetting any bands not provided. + To change specific bands, consider accessing :attr:`~wavelink.Equalizer.payload` first. + """ + default: dict[int, EqualizerPayload] = {n: {"band": n, "gain": 0.0} for n in range(15)} + payload: list[EqualizerPayload] | None = options.get("bands", None) - @classmethod - def metal(cls) -> Equalizer: - """A metal equalizer.""" + if payload is None: + self._payload = default + return self - bands = [ - (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) - ] + self._payload = self._set(payload) + return self - return cls(name="Metal EQ", bands=bands) + def reset(self) -> Self: + """Reset this filter to its defaults.""" + self._payload: dict[int, EqualizerPayload] = {n: {"band": n, "gain": 0.0} for n in range(15)} + return self - @classmethod - def piano(cls) -> Equalizer: - """A piano equalizer.""" + @property + def payload(self) -> dict[int, EqualizerPayload]: + """The raw payload associated with this filter. - bands = [ - (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(name="Piano EQ", bands=bands) + This property returns a copy. + """ + return self._payload.copy() + def __str__(self) -> str: + return "Equalizer" -class Karaoke(BaseFilter): - """ - A Karaoke filter. - - The default values provided for all the parameters will play the track normally. - - Parameters - ---------- - level: float - How much of an effect this filter should have. - mono_level: float - How much of an effect this filter should have on mono tracks. - filter_band: float - The band this filter should target. - filter_width: float - The width of the band this filter should target. + def __repr__(self) -> str: + return f"" + + +class Karaoke: + """Karaoke Filter class. + + Uses equalization to eliminate part of a band, usually targeting vocals. """ - def __init__( - self, - *, - level: float = 1.0, - mono_level: float = 1.0, - filter_band: float = 220.0, - filter_width: float = 100.0 - ) -> None: - super().__init__(name="Karaoke") - - self.level: float = level - self.mono_level: float = mono_level - self.filter_band: float = filter_band - self.filter_width: float = filter_width + def __init__(self, payload: KaraokePayload) -> None: + self._payload = payload + + def set(self, **options: Unpack[KaraokeOptions]) -> Self: + """Set the properties of the this filter. + + This method accepts keyword argument pairs. + This method does not override existing settings if they are not provided. + + Parameters + ---------- + level: Optional[float] + The level ``0`` to ``1.0`` where ``0.0`` is no effect and ``1.0`` is full effect. + mono_level: Optional[float] + The mono level ``0`` to ``1.0`` where ``0.0`` is no effect and ``1.0`` is full effect. + filter_band: Optional[float] + The filter band in Hz. + filter_width: Optional[float] + The filter width. + """ + self._payload: KaraokePayload = { + "level": options.get("level", self._payload.get("level")), + "monoLevel": options.get("mono_level", self._payload.get("monoLevel")), + "filterBand": options.get("filter_band", self._payload.get("filterBand")), + "filterWidth": options.get("filter_width", self._payload.get("filterWidth")), + } + return self - def __repr__(self) -> str: - return f"" + def reset(self) -> Self: + """Reset this filter to its defaults.""" + self._payload: KaraokePayload = {} + return self @property - def _payload(self) -> dict[str, float]: - return { - "level": self.level, - "monoLevel": self.mono_level, - "filterBand": self.filter_band, - "filterWidth": self.filter_width - } + def payload(self) -> KaraokePayload: + """The raw payload associated with this filter. + + This property returns a copy. + """ + return self._payload.copy() + def __str__(self) -> str: + return "Karaoke" -class Timescale(BaseFilter): - """A timescale filter. + def __repr__(self) -> str: + return f"" - Increases or decreases the speed, pitch, and/or rate of tracks. - The default values provided for ``speed``, ``pitch`` and ``rate`` will play the track normally. +class Timescale: + """Timescale Filter class. - Parameters - ---------- - speed: float - A multiplier for the track playback speed. Should be more than or equal to 0.0. - pitch: float - A multiplier for the track pitch. Should be more than or equal to 0.0. - rate: float - A multiplier for the track rate (pitch + speed). Should be more than or equal to 0.0. + Changes the speed, pitch, and rate. """ - def __init__( - self, - *, - speed: float = 1.0, - pitch: float = 1.0, - rate: float = 1.0, - ) -> None: + def __init__(self, payload: TimescalePayload) -> None: + self._payload = payload - if speed < 0: - raise ValueError("'speed' must be more than or equal to 0.") - if pitch < 0: - raise ValueError("'pitch' must be more than or equal to 0.") - if rate < 0: - raise ValueError("'rate' must be more than or equal to 0.") + def set(self, **options: Unpack[TimescalePayload]) -> Self: + """Set the properties of the this filter. - super().__init__(name="Timescale") + This method accepts keyword argument pairs. + This method does not override existing settings if they are not provided. - self.speed: float = speed - self.pitch: float = pitch - self.rate: float = rate + Parameters + ---------- + speed: Optional[float] + The playback speed. + pitch: Optional[float] + The pitch. + rate: Optional[float] + The rate. + """ + self._payload.update(options) + return self - def __repr__(self) -> str: - return f"" + def reset(self) -> Self: + """Reset this filter to its defaults.""" + self._payload: TimescalePayload = {} + return self @property - def _payload(self) -> dict[str, float]: - return { - "speed": self.speed, - "pitch": self.pitch, - "rate": self.rate, - } + def payload(self) -> TimescalePayload: + """The raw payload associated with this filter. + This property returns a copy. + """ + return self._payload.copy() + + def __str__(self) -> str: + return "Timescale" -class Tremolo(BaseFilter): - """A tremolo filter. + def __repr__(self) -> str: + return f"" - Creates a shuddering effect by quickly changing the volume. - The default values provided for ``frequency`` and ``depth`` will play the track normally. +class Tremolo: + """The Tremolo Filter class. - Parameters - ---------- - frequency: float - How quickly the volume should change. Should be more than 0.0. - depth: float - How much the volume should change. Should be more than 0.0 and less than or equal to 1.0. + Uses amplification to create a shuddering effect, where the volume quickly oscillates. + Demo: https://en.wikipedia.org/wiki/File:Fuse_Electronics_Tremolo_MK-III_Quick_Demo.ogv """ - def __init__( - self, - *, - frequency: float = 2.0, - depth: float = 0.5 - ) -> None: + def __init__(self, payload: TremoloPayload) -> None: + self._payload = payload - if frequency < 0: - raise ValueError("'frequency' must be more than 0.0.") - if not 0 < depth <= 1: - raise ValueError("'depth' must be more than 0.0 and less than or equal to 1.0.") + def set(self, **options: Unpack[TremoloPayload]) -> Self: + """Set the properties of the this filter. - super().__init__(name="Tremolo") + This method accepts keyword argument pairs. + This method does not override existing settings if they are not provided. - self.frequency: float = frequency - self.depth: float = depth + Parameters + ---------- + frequency: Optional[float] + The frequency. + depth: Optional[float] + The tremolo depth. + """ + self._payload.update(options) + return self - def __repr__(self) -> str: - return f"" + def reset(self) -> Self: + """Reset this filter to its defaults.""" + self._payload: TremoloPayload = {} + return self @property - def _payload(self) -> dict[str, float]: - return { - "frequency": self.frequency, - "depth": self.depth - } + def payload(self) -> TremoloPayload: + """The raw payload associated with this filter. + + This property returns a copy. + """ + return self._payload.copy() + def __str__(self) -> str: + return "Tremolo" -class Vibrato(BaseFilter): - """A vibrato filter. + def __repr__(self) -> str: + return f"" - Creates a vibrating effect by quickly changing the pitch. - The default values provided for ``frequency`` and ``depth`` will play the track normally. +class Vibrato: + """The Vibrato Filter class. - Parameters - ---------- - frequency: float - How quickly the pitch should change. Should be more than 0.0 and less than or equal to 14.0. - depth: float - How much the pitch should change. Should be more than 0.0 and less than or equal to 1.0. + Similar to tremolo. While tremolo oscillates the volume, vibrato oscillates the pitch. """ - def __init__( - self, - *, - frequency: float = 2.0, - depth: float = 0.5 - ) -> None: + def __init__(self, payload: VibratoPayload) -> None: + self._payload = payload - if not 0 < frequency <= 14: - raise ValueError("'frequency' must be more than 0.0 and less than or equal to 14.0.") - if not 0 < depth <= 1: - raise ValueError("'depth' must be more than 0.0 and less than or equal to 1.0.") + def set(self, **options: Unpack[VibratoPayload]) -> Self: + """Set the properties of the this filter. - super().__init__(name="Vibrato") + This method accepts keyword argument pairs. + This method does not override existing settings if they are not provided. - self.frequency: float = frequency - self.depth: float = depth + Parameters + ---------- + frequency: Optional[float] + The frequency. + depth: Optional[float] + The vibrato depth. + """ + self._payload.update(options) + return self - def __repr__(self) -> str: - return f"" + def reset(self) -> Self: + """Reset this filter to its defaults.""" + self._payload: VibratoPayload = {} + return self @property - def _payload(self) -> dict[str, float]: - return { - "frequency": self.frequency, - "depth": self.depth - } + def payload(self) -> VibratoPayload: + """The raw payload associated with this filter. + This property returns a copy. + """ + return self._payload.copy() + + def __str__(self) -> str: + return "Vibrato" -class Rotation(BaseFilter): - """A rotation filter. + def __repr__(self) -> str: + return f"" - Rotates the audio around stereo channels which can be used to create a 3D effect. - The default value provided for ``speed`` will play the track normally. +class Rotation: + """The Rotation Filter class. - Parameters - ---------- - speed: float - The speed at which the audio should rotate. + Rotates the sound around the stereo channels/user headphones (aka Audio Panning). + It can produce an effect similar to https://youtu.be/QB9EB8mTKcc (without the reverb). """ - def __init__(self, speed: float = 5) -> None: - super().__init__(name="Rotation") + def __init__(self, payload: RotationPayload) -> None: + self._payload = payload - self.speed: float = speed + def set(self, **options: Unpack[RotationOptions]) -> Self: + """Set the properties of the this filter. - def __repr__(self) -> str: - return f"" + This method accepts keyword argument pairs. + This method does not override existing settings if they are not provided. + + Parameters + ---------- + rotation_hz: Optional[float] + The frequency of the audio rotating around the listener in Hz. ``0.2`` is similar to the example video. + """ + self._payload: RotationPayload = {"rotationHz": options.get("rotation_hz", self._payload.get("rotationHz"))} + return self + + def reset(self) -> Self: + """Reset this filter to its defaults.""" + self._payload: RotationPayload = {} + return self @property - def _payload(self) -> dict[str, float]: - return { - "rotationHz": self.speed, - } + def payload(self) -> RotationPayload: + """The raw payload associated with this filter. + This property returns a copy. + """ + return self._payload.copy() -class Distortion(BaseFilter): - """A distortion filter.""" - - def __init__( - self, - *, - sin_offset: float = 0.0, - sin_scale: float = 1.0, - cos_offset: float = 0.0, - cos_scale: float = 1.0, - tan_offset: float = 0.0, - tan_scale: float = 1.0, - offset: float = 0.0, - scale: float = 1.0 - ) -> None: - super().__init__(name="Distortion") - - 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 + def __str__(self) -> str: + return "Rotation" def __repr__(self) -> str: - return f"" + return f"" - @property - def _payload(self) -> dict[str, float]: - return { - "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 + +class Distortion: + """The Distortion Filter class. + + According to Lavalink "It can generate some pretty unique audio effects." + """ + + def __init__(self, payload: DistortionPayload) -> None: + self._payload = payload + + def set(self, **options: Unpack[DistortionOptions]) -> Self: + """Set the properties of the this filter. + + This method accepts keyword argument pairs. + This method does not override existing settings if they are not provided. + + Parameters + ---------- + sin_offset: Optional[float] + The sin offset. + sin_scale: Optional[float] + The sin scale. + cos_offset: Optional[float] + The cos offset. + cos_scale: Optional[float] + The cos scale. + tan_offset: Optional[float] + The tan offset. + tan_scale: Optional[float] + The tan scale. + offset: Optional[float] + The offset. + scale: Optional[float] + The scale. + """ + self._payload: DistortionPayload = { + "sinOffset": options.get("sin_offset", self._payload.get("sinOffset")), + "sinScale": options.get("sin_scale", self._payload.get("sinScale")), + "cosOffset": options.get("cos_offset", self._payload.get("cosOffset")), + "cosScale": options.get("cos_scale", self._payload.get("cosScale")), + "tanOffset": options.get("tan_offset", self._payload.get("tanOffset")), + "tanScale": options.get("tan_scale", self._payload.get("tanScale")), + "offset": options.get("offset", self._payload.get("offset")), + "scale": options.get("scale", self._payload.get("scale")), } + return self + def reset(self) -> Self: + """Reset this filter to its defaults.""" + self._payload: DistortionPayload = {} + return self -class ChannelMix(BaseFilter): - """A channel mix filter. + @property + def payload(self) -> DistortionPayload: + """The raw payload associated with this filter. - Allows you to control what channel audio from the track is actually played on. + This property returns a copy. + """ + return self._payload.copy() - Setting `left_to_left` and `right_to_right` to 1.0 will result in no change. - Setting all channels to 0.5 will result in all channels receiving the same audio. + def __str__(self) -> str: + return "Distortion" - The default values provided for all the parameters will play the track normally. + def __repr__(self) -> str: + return f"" + + +class ChannelMix: + """The ChannelMix Filter class. - Parameters - ---------- - left_to_left: float - The "percentage" of audio from the left channel that should actually get played on the left channel. - left_to_right: float - The "percentage" of audio from the left channel that should play on the right channel. - right_to_left: float - The "percentage" of audio from the right channel that should actually get played on the right channel. - right_to_right: float - The "percentage" of audio from the right channel that should play on the left channel. + Mixes both channels (left and right), with a configurable factor on how much each channel affects the other. + With the defaults, both channels are kept independent of each other. + + Setting all factors to ``0.5`` means both channels get the same audio. """ - def __init__( - self, - *, - left_to_left: float = 1.0, - left_to_right: float = 0.0, - right_to_left: float = 0.0, - right_to_right: float = 1.0, - ) -> None: + def __init__(self, payload: ChannelMixPayload) -> None: + self._payload = payload + + def set(self, **options: Unpack[ChannelMixOptions]) -> Self: + """Set the properties of the this filter. + + This method accepts keyword argument pairs. + This method does not override existing settings if they are not provided. + + Parameters + ---------- + left_to_left: Optional[float] + The left to left channel mix factor. Between ``0.0`` and ``1.0``. + left_to_right: Optional[float] + The left to right channel mix factor. Between ``0.0`` and ``1.0``. + right_to_left: Optional[float] + The right to left channel mix factor. Between ``0.0`` and ``1.0``. + right_to_right: Optional[float] + The right to right channel mix factor. Between ``0.0`` and ``1.0``. + """ + self._payload: ChannelMixPayload = { + "leftToLeft": options.get("left_to_left", self._payload.get("leftToLeft")), + "leftToRight": options.get("left_to_right", self._payload.get("leftToRight")), + "rightToLeft": options.get("right_to_left", self._payload.get("rightToLeft")), + "rightToRight": options.get("right_to_right", self._payload.get("rightToRight")), + } + return self - _all = (left_to_left, left_to_right, right_to_left, right_to_right) + def reset(self) -> Self: + """Reset this filter to its defaults.""" + self._payload: ChannelMixPayload = {} + return self - if any(value for value in _all if value < 0 or value > 1): - raise ValueError( - "'left_to_left', 'left_to_right', " - "'right_to_left', and 'right_to_right' " - "must all be between 0.0 and 1.0" - ) + @property + def payload(self) -> ChannelMixPayload: + """The raw payload associated with this filter. - super().__init__(name="Channel Mix") + This property returns a copy. + """ + return self._payload.copy() - self.left_to_left: float = left_to_left - self.right_to_right: float = right_to_right - self.left_to_right: float = left_to_right - self.right_to_left: float = right_to_left + def __str__(self) -> str: + return "ChannelMix" def __repr__(self) -> str: - return f"" + return f"" - @property - def _payload(self) -> dict[str, float]: - return { - "leftToLeft": self.left_to_left, - "leftToRight": self.left_to_right, - "rightToLeft": self.right_to_left, - "rightToRight": self.right_to_right, - } - @classmethod - def mono(cls) -> ChannelMix: - """Returns a ChannelMix filter that will play the track in mono.""" - return cls(left_to_left=0.5, left_to_right=0.5, right_to_left=0.5, right_to_right=0.5) +class LowPass: + """The LowPass Filter class. - @classmethod - def only_left(cls) -> ChannelMix: - """Returns a ChannelMix filter that will only play the tracks left channel.""" - return cls(left_to_left=1, left_to_right=0, right_to_left=0, right_to_right=0) + Higher frequencies get suppressed, while lower frequencies pass through this filter, thus the name low pass. + Any smoothing values equal to or less than ``1.0`` will disable the filter. + """ - @classmethod - def full_left(cls) -> ChannelMix: - """ - Returns a ChannelMix filter that will play the tracks left and right channels together only on the left channel. - """ - return cls(left_to_left=1, left_to_right=0, right_to_left=1, right_to_right=0) + def __init__(self, payload: LowPassPayload) -> None: + self._payload = payload - @classmethod - def only_right(cls) -> ChannelMix: - """Returns a ChannelMix filter that will only play the tracks right channel.""" - return cls(left_to_left=0, left_to_right=0, right_to_left=0, right_to_right=1) + def set(self, **options: Unpack[LowPassPayload]) -> Self: + """Set the properties of the this filter. - @classmethod - def full_right(cls) -> ChannelMix: + This method accepts keyword argument pairs. + This method does not override existing settings if they are not provided. + + Parameters + ---------- + smoothing: Optional[float] + The smoothing factor. """ - Returns a ChannelMix filter that will play the tracks left and right channels together only on the right channel. + self._payload.update(options) + return self + + def reset(self) -> Self: + """Reset this filter to its defaults.""" + self._payload: LowPassPayload = {} + return self + + @property + def payload(self) -> LowPassPayload: + """The raw payload associated with this filter. + + This property returns a copy. """ - return cls(left_to_left=0, left_to_right=1, right_to_left=0, right_to_right=1) + return self._payload.copy() - @classmethod - def switch(cls) -> ChannelMix: - """Returns a ChannelMix filter that will switch the tracks left and right channels.""" - return cls(left_to_left=0, left_to_right=1, right_to_left=1, right_to_right=0) + def __str__(self) -> str: + return "LowPass" + def __repr__(self) -> str: + return f"" -class LowPass(BaseFilter): - """A low pass filter. - Suppresses higher frequencies while allowing lower frequencies to pass through. +class Filters: + """The wavelink Filters class. - The default value provided for ``smoothing`` will play the track normally. + This class contains the information associated with each of Lavalinks filter objects, as Python classes. + Each filter can be ``set`` or ``reset`` individually. - Parameters - ---------- - smoothing: float - The factor by which the filter will block higher frequencies. - """ + Using ``set`` on an individual filter only updates any ``new`` values you pass. + Using ``reset`` on an individual filter, resets it's payload, and can be used before ``set`` when you want a clean + state for that filter. - def __init__( - self, - *, - smoothing: float = 20 - ) -> None: - super().__init__(name="Low Pass") + See: :meth:`~wavelink.Filters.reset` to reset **every** individual filter. - self.smoothing: float = smoothing + This class is already applied an instantiated on all new :class:`~wavelink.Player`. - def __repr__(self) -> str: - return f"" + See: :meth:`~wavelink.Player.set_filters` for information on applying this class to your :class:`~wavelink.Player`. + See: :attr:`~wavelink.Player.filters` for retrieving the applied filters. + + To retrieve the ``payload`` for this Filters class, you can call an instance of this class. + + Examples + -------- + + .. code:: python3 + + import wavelink + + # Create a brand new Filters and apply it... + # You can use player.set_filters() for an easier way to reset. + filters: wavelink.Filters = wavelink.Filters() + await player.set_filters(filters) - @property - def _payload(self) -> dict[str, float]: - return { - "smoothing": self.smoothing, - } + # Retrieve the payload of any Filters instance... + filters: wavelink.Filters = player.filters + print(filters()) -class Filter: - """A filter that can be applied to a track. - This filter accepts an instance of itself as a parameter which allows - you to keep previous filters while also applying new ones or overwriting old ones. + # Set some filters... + # You can set and reset individual filters at the same time... + filters: wavelink.Filters = player.filters + filters.timescale.set(pitch=1.2, speed=1.1, rate=1) + filters.rotation.set(rotation_hz=0.2) + filters.equalizer.reset() + await player.set_filters(filters) - .. container:: operations - .. describe:: repr(filter) + # Reset a filter... + filters: wavelink.Filters = player.filters + filters.timescale.reset() - Returns an official string representation of this filter. + await player.set_filters(filters) - Parameters - ---------- - filter: wavelink.Filter - An instance of this filter class. - equalizer: wavelink.Equalizer - An equalizer to apply to the track. - karaoke: wavelink.Karaoke - A karaoke filter to apply to the track. - timescale: wavelink.Timescale - A timescale filter to apply to the track. - tremolo: wavelink.Tremolo - A tremolo filter to apply to the track. - vibrato: wavelink.Vibrato - A vibrato filter to apply to the track. - rotation: wavelink.Rotation - A rotation filter to apply to the track. - distortion: wavelink.Distortion - A distortion filter to apply to the track. - channel_mix: wavelink.ChannelMix - A channel mix filter to apply to the track. - low_pass: wavelink.LowPass - A low pass filter to apply to the track. + # Reset all filters... + filters: wavelink.Filters = player.filters + filters.reset() + + await player.set_filters(filters) + + + # Reset and apply filters easier method... + await player.set_filters() """ - def __init__( - self, - _filter: Filter | None = None, - /, *, - equalizer: Equalizer | None = None, - karaoke: Karaoke | None = None, - timescale: Timescale | None = None, - tremolo: Tremolo | None = None, - vibrato: Vibrato | None = None, - rotation: Rotation | None = None, - distortion: Distortion | None = None, - channel_mix: ChannelMix | None = None, - low_pass: LowPass | None = None - ) -> None: - - self.filter: Filter | None = _filter - - self.equalizer: Equalizer | None = equalizer - self.karaoke: Karaoke | None = karaoke - self.timescale: Timescale | None = timescale - self.tremolo: Tremolo | None = tremolo - self.vibrato: Vibrato | None = vibrato - self.rotation: Rotation | None = rotation - self.distortion: Distortion | None = distortion - self.channel_mix: ChannelMix | None = channel_mix - self.low_pass: LowPass | None = low_pass + def __init__(self, *, data: FilterPayload | None = None) -> None: + self._volume: float | None = None + self._equalizer: Equalizer = Equalizer(None) + self._karaoke: Karaoke = Karaoke({}) + self._timescale: Timescale = Timescale({}) + self._tremolo: Tremolo = Tremolo({}) + self._vibrato: Vibrato = Vibrato({}) + self._rotation: Rotation = Rotation({}) + self._distortion: Distortion = Distortion({}) + self._channel_mix: ChannelMix = ChannelMix({}) + self._low_pass: LowPass = LowPass({}) + + if data: + self._create_from(data) + + def _create_from(self, data: FilterPayload) -> None: + self._volume = data.get("volume") + self._equalizer = Equalizer(data.get("equalizer", None)) + self._karaoke = Karaoke(data.get("karaoke", {})) + self._timescale = Timescale(data.get("timescale", {})) + self._tremolo = Tremolo(data.get("tremolo", {})) + self._vibrato = Vibrato(data.get("vibrato", {})) + self._rotation = Rotation(data.get("rotation", {})) + self._distortion = Distortion(data.get("distortion", {})) + self._channel_mix = ChannelMix(data.get("channelMix", {})) + self._low_pass = LowPass(data.get("lowPass", {})) + + def _set_with_reset(self, filters: FiltersOptions) -> None: + self._volume = filters.get("volume") + self._equalizer = filters.get("equalizer", Equalizer(None)) + self._karaoke = filters.get("karaoke", Karaoke({})) + self._timescale = filters.get("timescale", Timescale({})) + self._tremolo = filters.get("tremolo", Tremolo({})) + self._vibrato = filters.get("vibrato", Vibrato({})) + self._rotation = filters.get("rotation", Rotation({})) + self._distortion = filters.get("distortion", Distortion({})) + self._channel_mix = filters.get("channelMix", ChannelMix({})) + self._low_pass = filters.get("lowPass", LowPass({})) + + def set_filters(self, **filters: Unpack[FiltersOptions]) -> None: + # TODO: document this later maybe? + + reset: bool = filters.get("reset", False) + if reset: + self._set_with_reset(filters) + return + + self._volume = filters.get("volume", self._volume) + self._equalizer = filters.get("equalizer", self._equalizer) + self._karaoke = filters.get("karaoke", self._karaoke) + self._timescale = filters.get("timescale", self._timescale) + self._tremolo = filters.get("tremolo", self._tremolo) + self._vibrato = filters.get("vibrato", self._vibrato) + self._rotation = filters.get("rotation", self._rotation) + self._distortion = filters.get("distortion", self._distortion) + self._channel_mix = filters.get("channelMix", self._channel_mix) + self._low_pass = filters.get("lowPass", self._low_pass) + + def _reset(self) -> None: + self._volume = None + self._equalizer = Equalizer(None) + self._karaoke = Karaoke({}) + self._timescale = Timescale({}) + self._tremolo = Tremolo({}) + self._vibrato = Vibrato({}) + self._rotation = Rotation({}) + self._distortion = Distortion({}) + self._channel_mix = ChannelMix({}) + self._low_pass = LowPass({}) + + def reset(self) -> None: + """Method which resets this object to an original state. + + Using this method will clear all indivdual filters, and assign the wavelink default classes. + """ + self._reset() - def __repr__(self) -> str: - return f"" + @classmethod + def from_filters(cls, **filters: Unpack[FiltersOptions]) -> Self: + # TODO: document this later maybe? + + self = cls() + self._set_with_reset(filters) + + return self + + @property + def volume(self) -> float | None: + """Property which returns the volume ``float`` associated with this Filters payload. + + Adjusts the player volume from 0.0 to 5.0, where 1.0 is 100%. Values >1.0 may cause clipping. + """ + return self._volume + + @volume.setter + def volume(self, value: float) -> None: + self._volume = value + + @property + def equalizer(self) -> Equalizer: + """Property which returns the :class:`~wavelink.Equalizer` filter associated with this Filters payload.""" + return self._equalizer + + @property + def karaoke(self) -> Karaoke: + """Property which returns the :class:`~wavelink.Karaoke` filter associated with this Filters payload.""" + return self._karaoke @property - def _payload(self) -> dict[str, Any]: - - payload = self.filter._payload.copy() if self.filter else {} - - if self.equalizer: - payload["equalizer"] = self.equalizer._payload - if self.karaoke: - payload["karaoke"] = self.karaoke._payload - if self.timescale: - payload["timescale"] = self.timescale._payload - if self.tremolo: - payload["tremolo"] = self.tremolo._payload - if self.vibrato: - payload["vibrato"] = self.vibrato._payload - if self.rotation: - payload["rotation"] = self.rotation._payload - if self.distortion: - payload["distortion"] = self.distortion._payload - if self.channel_mix: - payload["channelMix"] = self.channel_mix._payload - if self.low_pass: - payload["lowPass"] = self.low_pass._payload + def timescale(self) -> Timescale: + """Property which returns the :class:`~wavelink.Timescale` filter associated with this Filters payload.""" + return self._timescale + + @property + def tremolo(self) -> Tremolo: + """Property which returns the :class:`~wavelink.Tremolo` filter associated with this Filters payload.""" + return self._tremolo + + @property + def vibrato(self) -> Vibrato: + """Property which returns the :class:`~wavelink.Vibrato` filter associated with this Filters payload.""" + return self._vibrato + + @property + def rotation(self) -> Rotation: + """Property which returns the :class:`~wavelink.Rotation` filter associated with this Filters payload.""" + return self._rotation + + @property + def distortion(self) -> Distortion: + """Property which returns the :class:`~wavelink.Distortion` filter associated with this Filters payload.""" + return self._distortion + + @property + def channel_mix(self) -> ChannelMix: + """Property which returns the :class:`~wavelink.ChannelMix` filter associated with this Filters payload.""" + return self._channel_mix + + @property + def low_pass(self) -> LowPass: + """Property which returns the :class:`~wavelink.LowPass` filter associated with this Filters payload.""" + return self._low_pass + + def __call__(self) -> FilterPayload: + payload: FilterPayload = { + "volume": self._volume, + "equalizer": list(self._equalizer._payload.values()), + "karaoke": self._karaoke._payload, + "timescale": self._timescale._payload, + "tremolo": self._tremolo._payload, + "vibrato": self._vibrato._payload, + "rotation": self._rotation._payload, + "distortion": self._distortion._payload, + "channelMix": self._channel_mix._payload, + "lowPass": self._low_pass._payload, + } + + for key, value in payload.copy().items(): + if not value: + del payload[key] return payload + + def __repr__(self) -> str: + return ( + f"" + ) diff --git a/wavelink/lfu.py b/wavelink/lfu.py new file mode 100644 index 00000000..d02b61c7 --- /dev/null +++ b/wavelink/lfu.py @@ -0,0 +1,187 @@ +""" +MIT License + +Copyright (c) 2019-Current PythonistaGuild, EvieePy + +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. +""" +from __future__ import annotations + +from collections import defaultdict +from dataclasses import dataclass +from typing import Any + +from .exceptions import WavelinkException + + +class CapacityZero(WavelinkException): + ... + + +class _MissingSentinel: + __slots__ = () + + def __eq__(self, other: object) -> bool: + return False + + def __bool__(self) -> bool: + return False + + def __hash__(self) -> int: + return 0 + + def __repr__(self) -> str: + return "..." + + +class _NotFoundSentinel(_MissingSentinel): + def __repr__(self) -> str: + return "NotFound" + + +MISSING: Any = _MissingSentinel() +NotFound: Any = _NotFoundSentinel() + + +class DLLNode: + __slots__ = ("value", "previous", "later") + + def __init__(self, value: Any | None = None, previous: DLLNode | None = None, later: DLLNode | None = None) -> None: + self.value = value + self.previous = previous + self.later = later + + +@dataclass +class DataNode: + key: Any + value: Any + frequency: int + node: DLLNode + + +class LFUCache: + def __init__(self, *, capacity: int) -> None: + self._capacity = capacity + self._cache: dict[Any, DataNode] = {} + + self._freq_map: defaultdict[int, DLL] = defaultdict(DLL) + self._min: int = 1 + self._used: int = 0 + + def __len__(self) -> int: + return len(self._cache) + + def __getitem__(self, key: Any) -> Any: + if key not in self._cache: + raise KeyError(f'"{key}" could not be found in LFU.') + + return self.get(key) + + def __setitem__(self, key: Any, value: Any) -> None: + return self.put(key, value) + + @property + def capacity(self) -> int: + return self._capacity + + def get(self, key: Any, default: Any = MISSING) -> Any: + if key not in self._cache: + return default if default is not MISSING else NotFound + + data: DataNode = self._cache[key] + self._freq_map[data.frequency].remove(data.node) + self._freq_map[data.frequency + 1].append(data.node) + + self._cache[key] = DataNode(key=key, value=data.value, frequency=data.frequency + 1, node=data.node) + self._min += self._min == data.frequency and not self._freq_map[data.frequency] + + return data.value + + def put(self, key: Any, value: Any) -> None: + if self._capacity <= 0: + raise CapacityZero("Unable to place item in LFU as capacity has been set to 0 or below.") + + if key in self._cache: + self._cache[key].value = value + self.get(key) + return + + if self._used == self._capacity: + least_freq: DLL = self._freq_map[self._min] + least_freq_key: DLLNode | None = least_freq.popleft() + + if least_freq_key: + self._cache.pop(least_freq_key.value) + self._used -= 1 + + data: DataNode = DataNode(key=key, value=value, frequency=1, node=DLLNode(key)) + self._freq_map[data.frequency].append(data.node) + self._cache[key] = data + + self._used += 1 + self._min = 1 + + +class DLL: + __slots__ = ("head", "tail") + + def __init__(self) -> None: + self.head: DLLNode = DLLNode() + self.tail: DLLNode = DLLNode() + + self.head.later, self.tail.previous = self.tail, self.head + + def append(self, node: DLLNode) -> None: + tail_prev: DLLNode | None = self.tail.previous + tail: DLLNode | None = self.tail + + assert tail_prev and tail + + tail_prev.later = node + tail.previous = node + + node.later = tail + node.previous = tail_prev + + def popleft(self) -> DLLNode | None: + node: DLLNode | None = self.head.later + if node is None: + return + + self.remove(node) + return node + + def remove(self, node: DLLNode | None) -> None: + if node is None: + return + + node_prev: DLLNode | None = node.previous + node_later: DLLNode | None = node.later + + assert node_prev and node_later + + node_prev.later = node_later + node_later.previous = node_prev + + node.later = None + node.previous = None + + def __bool__(self) -> bool: + return self.head.later != self.tail diff --git a/wavelink/node.py b/wavelink/node.py index 5261c2cc..bfe79cd7 100644 --- a/wavelink/node.py +++ b/wavelink/node.py @@ -1,7 +1,7 @@ """ MIT License -Copyright (c) 2019-Present PythonistaGuild +Copyright (c) 2019-Current PythonistaGuild, EvieePy Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -24,551 +24,782 @@ from __future__ import annotations import logging -import random -import re -import string -from typing import TYPE_CHECKING, Any, TypeVar +import secrets +import urllib.parse +from collections.abc import Iterable +from typing import TYPE_CHECKING, Any, Literal, TypeAlias import aiohttp import discord -from discord.enums import try_enum -from discord.utils import MISSING, classproperty -import urllib.parse +from discord.utils import classproperty from . import __version__ -from .enums import LoadType, NodeStatus -from .exceptions import * +from .enums import NodeStatus +from .exceptions import ( + AuthorizationFailedException, + InvalidClientException, + InvalidNodeException, + LavalinkException, + LavalinkLoadException, + NodeException, +) +from .lfu import LFUCache +from .tracks import Playable, Playlist from .websocket import Websocket if TYPE_CHECKING: from .player import Player - from .tracks import * - from .types.request import Request - from .ext import spotify as spotify_ + from .types.request import Request, UpdateSessionRequest + from .types.response import ( + EmptyLoadedResponse, + ErrorLoadedResponse, + ErrorResponse, + InfoResponse, + PlayerResponse, + PlaylistLoadedResponse, + SearchLoadedResponse, + StatsResponse, + TrackLoadedResponse, + UpdateResponse, + ) + from .types.tracks import TrackPayload - PlayableT = TypeVar('PlayableT', bound=Playable) - + LoadedResponse: TypeAlias = ( + TrackLoadedResponse | SearchLoadedResponse | PlaylistLoadedResponse | EmptyLoadedResponse | ErrorLoadedResponse + ) -__all__ = ('Node', 'NodePool') + +__all__ = ("Node", "Pool") logger: logging.Logger = logging.getLogger(__name__) -# noinspection PyShadowingBuiltins -class Node: - """The base Wavelink Node. +Method = Literal["GET", "POST", "PATCH", "DELETE", "PUT", "OPTIONS"] - The Node is responsible for keeping the Websocket alive, tracking the state of Players - and fetching/decoding Tracks and Playlists. - .. note:: - - The Node class should only be created once per Lavalink connection. - To retrieve a Node use the appropriate :class:`NodePool` methods instead. +class Node: + """The Node represents a connection to Lavalink. - .. warning:: + The Node is responsible for keeping the websocket alive, resuming session, sending API requests and keeping track + of connected all :class:`~wavelink.Player`. - The Node will not be connected until passed to :meth:`NodePool.connect`. + .. container:: operations + .. describe:: node == other - .. container:: operations + Equality check to determine whether this Node is equal to another reference of a Node. .. describe:: repr(node) - Returns an official string representation of this Node. - + The official string representation of this Node. Parameters ---------- - id: Optional[str] - The unique identifier for this Node. If not passed, one will be generated randomly. + identifier: str | None + A unique identifier for this Node. Could be ``None`` to generate a random one on creation. uri: str - The uri to connect to your Lavalink server. E.g ``http://localhost:2333``. + The URL/URI that wavelink will use to connect to Lavalink. Usually this is in the form of something like: + ``http://localhost:2333`` which includes the port. But you could also provide a domain which won't require a + port like ``https://lavalink.example.com`` or a public IP address and port like ``http://111.333.444.55:2333``. password: str - The password used to connect to your Lavalink server. - secure: Optional[bool] - Whether the connection should use https/wss. - use_http: Optional[bool] - Whether to use http:// over ws:// when connecting to the Lavalink websocket. Defaults to False. - session: Optional[aiohttp.ClientSession] - The session to use for this Node. If no session is passed a default will be used. - heartbeat: float - The time in seconds to send a heartbeat ack. Defaults to 15.0. - retries: Optional[int] - The amount of times this Node will try to reconnect after a disconnect. - If not set the Node will try unlimited times. - - Attributes - ---------- - heartbeat: float - The time in seconds to send a heartbeat ack. Defaults to 15.0. - client: :class:`discord.Client` - The discord client used to connect this Node. Could be None if this Node has not been connected. + The password used to connect and authorize this Node. + session: aiohttp.ClientSession | None + An optional :class:`aiohttp.ClientSession` used to connect this Node over websocket and REST. + If ``None``, one will be generated for you. Defaults to ``None``. + heartbeat: Optional[float] + A ``float`` in seconds to ping your websocket keep alive. Usually you would not change this. + retries: int | None + A ``int`` of retries to attempt when connecting or reconnecting this Node. When the retries are exhausted + the Node will be closed and cleaned-up. ``None`` will retry forever. Defaults to ``None``. + client: :class:`discord.Client` | None + The :class:`discord.Client` or subclasses, E.g. ``commands.Bot`` used to connect this Node. If this is *not* + passed you must pass this to :meth:`wavelink.Pool.connect`. + resume_timeout: Optional[int] + The seconds this Node should configure Lavalink for resuming its current session in case of network issues. + If this is ``0`` or below, resuming will be disabled. Defaults to ``60``. """ def __init__( - self, - *, - id: str | None = None, - uri: str, - password: str, - secure: bool = False, - use_http: bool = False, - session: aiohttp.ClientSession = MISSING, - heartbeat: float = 15.0, - retries: int | None = None, + self, + *, + identifier: str | None = None, + uri: str, + password: str, + session: aiohttp.ClientSession | None = None, + heartbeat: float = 15.0, + retries: int | None = None, + client: discord.Client | None = None, + resume_timeout: int = 60, ) -> None: - if id is None: - id = ''.join(random.sample(string.ascii_letters + string.digits, 12)) - - self._id: str = id - self._uri: str = uri - self._secure: bool = secure - self._use_http: bool = use_http - host: str = re.sub(r'(?:http|ws)s?://', '', self._uri) - self._host: str = f'{"https://" if secure else "http://"}{host}' - self._password: str = password - - self._session: aiohttp.ClientSession = session - self.heartbeat: float = heartbeat - self._retries: int | None = retries - - self.client: discord.Client | None = None - self._websocket: Websocket = MISSING + self._identifier = identifier or secrets.token_urlsafe(12) + self._uri = uri.removesuffix("/") + self._password = password + self._session = session or aiohttp.ClientSession() + self._heartbeat = heartbeat + self._retries = retries + self._client = client + self._resume_timeout = resume_timeout + + self._status: NodeStatus = NodeStatus.DISCONNECTED + self._has_closed: bool = False self._session_id: str | None = None self._players: dict[int, Player] = {} - self._invalidated: dict[int, Player] = {} + self._total_player_count: int | None = None - self._status: NodeStatus = NodeStatus.DISCONNECTED - self._major_version: int | None = None + self._spotify_enabled: bool = False - self._spotify: spotify_.SpotifyClient | None = None + self._websocket: Websocket | None = None def __repr__(self) -> str: - return f'Node(id="{self._id}", uri="{self.uri}", status={self.status})' + return f"Node(identifier={self.identifier}, uri={self.uri}, status={self.status}, players={len(self.players)})" def __eq__(self, other: object) -> bool: - return self.id == other.id if isinstance(other, Node) else NotImplemented + if not isinstance(other, Node): + return NotImplemented + + return other.identifier == self.identifier + + @property + def headers(self) -> dict[str, str]: + """A property that returns the headers configured for sending API and websocket requests. + + .. warning:: + + This includes your Node password. Please be vigilant when using this property. + """ + assert self.client is not None + assert self.client.user is not None + + data = { + "Authorization": self.password, + "User-Id": str(self.client.user.id), + "Client-Name": f"Wavelink/{__version__}", + } + + return data @property - def id(self) -> str: - """The Nodes unique identifier.""" - return self._id + def identifier(self) -> str: + """The unique identifier for this :class:`Node`. + + + .. versionchanged:: 3.0.0 + + This property was previously known as ``id``. + """ + return self._identifier @property def uri(self) -> str: - """The URI used to connect this Node to Lavalink.""" - return self._host + """The URI used to connect this :class:`Node` to Lavalink.""" + return self._uri @property - def password(self) -> str: - """The password used to connect this Node to Lavalink.""" - return self._password + def status(self) -> NodeStatus: + """The current :class:`Node` status. + + Refer to: :class:`~wavelink.NodeStatus` + """ + return self._status @property def players(self) -> dict[int, Player]: - """A mapping of Guild ID to Player.""" + """A mapping of :attr:`discord.Guild.id` to :class:`~wavelink.Player`.""" return self._players @property - def status(self) -> NodeStatus: - """The connection status of this Node. + def client(self) -> discord.Client | None: + """Returns the :class:`discord.Client` associated with this :class:`Node`. + + Could be ``None`` if it has not been set yet. - DISCONNECTED - CONNECTING - CONNECTED + + .. versionadded:: 3.0.0 """ - return self._status + return self._client - def get_player(self, guild_id: int, /) -> Player | None: - """Return the :class:`player.Player` associated with the provided guild ID. + @property + def password(self) -> str: + """Returns the password used to connect this :class:`Node` to Lavalink. + + .. versionadded:: 3.0.0 + """ + return self._password + + @property + def heartbeat(self) -> float: + """Returns the duration in seconds that the :class:`Node` websocket should send a heartbeat. + + .. versionadded:: 3.0.0 + """ + return self._heartbeat - If no :class:`player.Player` is found, returns None. + @property + def session_id(self) -> str | None: + """Returns the Lavalink session ID. Could be None if this :class:`Node` has not connected yet. + + .. versionadded:: 3.0.0 + """ + return self._session_id + + async def _pool_closer(self) -> None: + try: + await self._session.close() + except: + pass + + if not self._has_closed: + await self.close() + + async def close(self) -> None: + """Method to close this Node and cleanup. + + After this method has finished, the event ``on_wavelink_node_closed`` will be fired. + + This method renders the Node websocket disconnected and disconnects all players. + """ + disconnected: list[Player] = [] + + for player in self._players.values(): + try: + await player._destroy() + except LavalinkException: + pass + + disconnected.append(player) + + if self._websocket is not None: + await self._websocket.cleanup() + + self._status = NodeStatus.DISCONNECTED + self._session_id = None + self._players = {} + + self._has_closed = True + + # Dispatch Node Closed Event... node, list of disconnected players + if self.client is not None: + self.client.dispatch("wavelink_node_closed", self, disconnected) + + async def _connect(self, *, client: discord.Client | None) -> None: + client_ = self._client or client + + if not client_: + raise InvalidClientException(f"Unable to connect {self!r} as you have not provided a valid discord.Client.") + + self._client = client_ + + self._has_closed = False + if not self._session or self._session.closed: + self._session = aiohttp.ClientSession() + + websocket: Websocket = Websocket(node=self) + self._websocket = websocket + await websocket.connect() + + async def send( + self, method: Method = "GET", *, path: str, data: Any | None = None, params: dict[str, Any] | None = None + ) -> Any: + """Method for making requests to the Lavalink node. + + .. warning:: + + Usually you wouldn't use this method. Please use the built in methods of :class:`~Node`, :class:`~Pool` + and :class:`~wavelink.Player`, unless you need to send specific plugin data to Lavalink. + + Using this method may have unwanted side effects on your players and/or nodes. Parameters ---------- - guild_id: int - The Guild ID to return a Player for. + method: Optional[str] + The method to use when making this request. Available methods are + "GET", "POST", "PATCH", "PUT", "DELETE" and "OPTIONS". Defaults to "GET". + path: str + The path to make this request to. E.g. "/v4/stats". + data: Any | None + The optional JSON data to send along with your request to Lavalink. This should be a dict[str, Any] + and able to be converted to JSON. + params: Optional[dict[str, Any]] + An optional dict of query parameters to send with your request to Lavalink. If you include your query + parameters in the ``path`` parameter, do not pass them here as well. E.g. {"thing": 1, "other": 2} + would equate to "?thing=1&other=2". Returns ------- - Optional[:class:`player.Player`] - """ - return self._players.get(guild_id, None) + Any + The response from Lavalink which will either be None, a str or JSON. - async def _connect(self, client: discord.Client) -> None: - if client.user is None: - raise RuntimeError('') + Raises + ------ + LavalinkException + An error occurred while making this request to Lavalink. + NodeException + An error occured while making this request to Lavalink, + and Lavalink was unable to send any error information. + """ + clean_path: str = path.removesuffix("/") + uri: str = f"{self.uri}/{clean_path}" - if not self._session: - self._session = aiohttp.ClientSession(headers={'Authorization': self._password}) + if params is None: + params = {} - self.client = client + async with self._session.request( + method=method, url=uri, params=params, json=data, headers=self.headers + ) as resp: + if resp.status == 204: + return - self._websocket = Websocket(node=self) + if resp.status >= 300: + try: + exc_data: ErrorResponse = await resp.json() + except Exception as e: + logger.warning(f"An error occured making a request on {self!r}: {e}") + raise NodeException(status=resp.status) - await self._websocket.connect() + raise LavalinkException(data=exc_data) - async with self._session.get(f'{self._host}/version') as resp: - version: str = await resp.text() + try: + rdata: Any = await resp.json() + except aiohttp.ContentTypeError: + pass + else: + return rdata - if version.endswith('-SNAPSHOT'): - self._major_version = 3 + try: + body: str = await resp.text() + except aiohttp.ClientError: return - try: - version_tuple = tuple(int(v) for v in version.split('.')) - except ValueError: - logging.warning(f'Lavalink "{version}" is unknown and may not be compatible with: ' - f'Wavelink "{__version__}". Wavelink is assuming the Lavalink version.') + return body - self._major_version = 3 - return + async def _fetch_players(self) -> list[PlayerResponse]: + uri: str = f"{self.uri}/v4/sessions/{self.session_id}/players" - if version_tuple[0] < 3: - raise InvalidLavalinkVersion(f'Wavelink "{__version__}" is not compatible with Lavalink "{version}".') + async with self._session.get(url=uri, headers=self.headers) as resp: + if resp.status == 200: + resp_data: list[PlayerResponse] = await resp.json() + return resp_data - if version_tuple[0] == 3 and version_tuple[1] < 7: - raise InvalidLavalinkVersion(f'Wavelink "{__version__}" is not compatible with ' - f'Lavalink versions under "3.7".') + else: + try: + exc_data: ErrorResponse = await resp.json() + except Exception as e: + logger.warning(f"An error occured making a request on {self!r}: {e}") + raise NodeException(status=resp.status) - self._major_version = version_tuple[0] - logger.info(f'Lavalink version "{version}" connected for Node: {self.id}') + raise LavalinkException(data=exc_data) - async def _send(self, - *, - method: str, - path: str, - guild_id: int | str | None = None, - query: str | None = None, - data: Request | None = None, - ) -> dict[str, Any] | None: + async def _fetch_player(self, guild_id: int, /) -> PlayerResponse: + uri: str = f"{self.uri}/v4/sessions/{self.session_id}/players/{guild_id}" - uri: str = f'{self._host}/' \ - f'v{self._major_version}/' \ - f'{path}' \ - f'{f"/{guild_id}" if guild_id else ""}' \ - f'{f"?{query}" if query else ""}' + async with self._session.get(url=uri, headers=self.headers) as resp: + if resp.status == 200: + resp_data: PlayerResponse = await resp.json() + return resp_data - logger.debug(f'Node {self} is sending payload to [{method}] "{uri}" with payload: {data}') + else: + try: + exc_data: ErrorResponse = await resp.json() + except Exception as e: + logger.warning(f"An error occured making a request on {self!r}: {e}") + raise NodeException(status=resp.status) - async with self._session.request(method=method, url=uri, json=data or {}) as resp: - rdata: dict[str | int, Any] | None = None + raise LavalinkException(data=exc_data) - if resp.content_type == 'application/json': - rdata = await resp.json() + async def _update_player(self, guild_id: int, /, *, data: Request, replace: bool = False) -> PlayerResponse: + no_replace: bool = not replace - logger.debug(f'Node {self} received payload from Lavalink after sending to "{uri}" with response: ' - f'') + uri: str = f"{self.uri}/v4/sessions/{self.session_id}/players/{guild_id}?noReplace={no_replace}" - if resp.status >= 300: - raise InvalidLavalinkResponse(f'An error occurred when attempting to reach: "{uri}".', - status=resp.status) + async with self._session.patch(url=uri, json=data, headers=self.headers) as resp: + if resp.status == 200: + resp_data: PlayerResponse = await resp.json() + return resp_data - if resp.status == 204: - return + else: + try: + exc_data: ErrorResponse = await resp.json() + except Exception as e: + logger.warning(f"An error occured making a request on {self!r}: {e}") + raise NodeException(status=resp.status) - return rdata + raise LavalinkException(data=exc_data) - async def get_tracks(self, cls: type[PlayableT], query: str) -> list[PlayableT]: - """|coro| + async def _destroy_player(self, guild_id: int, /) -> None: + uri: str = f"{self.uri}/v4/sessions/{self.session_id}/players/{guild_id}" - Search for and retrieve Tracks based on the query and cls provided. + async with self._session.delete(url=uri, headers=self.headers) as resp: + if resp.status == 204: + return - .. note:: + try: + exc_data: ErrorResponse = await resp.json() + except Exception as e: + logger.warning(f"An error occured making a request on {self!r}: {e}") + raise NodeException(status=resp.status) - If the query is not a Local search or direct URL, you will need to provide a search prefix. - E.g. ``ytsearch:`` for a YouTube search. + raise LavalinkException(data=exc_data) - Parameters - ---------- - cls: type[PlayableT] - The type of Playable tracks that should be returned. - query: str - The query to search for and return tracks. + async def _update_session(self, *, data: UpdateSessionRequest) -> UpdateResponse: + uri: str = f"{self.uri}/v4/sessions/{self.session_id}" - Returns - ------- - list[PlayableT] - A list of found tracks converted to the provided cls. - """ - logger.debug(f'Node {self} is requesting tracks with query "{query}".') + async with self._session.patch(url=uri, json=data, headers=self.headers) as resp: + if resp.status == 200: + resp_data: UpdateResponse = await resp.json() + return resp_data - data = await self._send(method='GET', path='loadtracks', query=f'identifier={query}') - load_type = try_enum(LoadType, data.get("loadType")) + else: + try: + exc_data: ErrorResponse = await resp.json() + except Exception as e: + logger.warning(f"An error occured making a request on {self!r}: {e}") + raise NodeException(status=resp.status) - if load_type is LoadType.load_failed: - # TODO - Proper Exception... + raise LavalinkException(data=exc_data) - raise ValueError('Track Failed to load.') + async def _fetch_tracks(self, query: str) -> LoadedResponse: + uri: str = f"{self.uri}/v4/loadtracks?identifier={query}" - if load_type is LoadType.no_matches: - return [] + async with self._session.get(url=uri, headers=self.headers) as resp: + if resp.status == 200: + resp_data: LoadedResponse = await resp.json() + return resp_data - if load_type is LoadType.track_loaded: - track_data = data["tracks"][0] - return [cls(track_data)] + else: + try: + exc_data: ErrorResponse = await resp.json() + except Exception as e: + logger.warning(f"An error occured making a request on {self!r}: {e}") + raise NodeException(status=resp.status) - if load_type is not LoadType.search_result: - # TODO - Proper Exception... + raise LavalinkException(data=exc_data) - raise ValueError('Track Failed to load.') + async def _decode_track(self) -> TrackPayload: + ... - return [cls(track_data) for track_data in data["tracks"]] + async def _decode_tracks(self) -> list[TrackPayload]: + ... - async def get_playlist(self, cls: Playlist, query: str): - """|coro| + async def _fetch_info(self) -> InfoResponse: + uri: str = f"{self.uri}/v4/info" - Search for and return a :class:`tracks.Playlist` given an identifier. + async with self._session.get(url=uri, headers=self.headers) as resp: + if resp.status == 200: + resp_data: InfoResponse = await resp.json() + return resp_data - Parameters - ---------- - cls: Type[:class:`tracks.Playlist`] - The type of which playlist should be returned, this must subclass :class:`tracks.Playlist`. - query: str - The playlist's identifier. This may be a YouTube playlist URL for example. + else: + try: + exc_data: ErrorResponse = await resp.json() + except Exception as e: + logger.warning(f"An error occured making a request on {self!r}: {e}") + raise NodeException(status=resp.status) - Returns - ------- - Optional[:class:`tracks.Playlist`]: - The related wavelink track object or ``None`` if none was found. + raise LavalinkException(data=exc_data) - Raises - ------ - ValueError - Loading the playlist failed. - WavelinkException - An unspecified error occurred when loading the playlist. - """ - logger.debug(f'Node {self} is requesting a playlist with query "{query}".') + async def _fetch_stats(self) -> StatsResponse: + uri: str = f"{self.uri}/v4/stats" - encoded_query = urllib.parse.quote(query) - data = await self._send(method='GET', path='loadtracks', query=f'identifier={encoded_query}') + async with self._session.get(url=uri, headers=self.headers) as resp: + if resp.status == 200: + resp_data: StatsResponse = await resp.json() + return resp_data - load_type = try_enum(LoadType, data.get("loadType")) + else: + try: + exc_data: ErrorResponse = await resp.json() + except Exception as e: + logger.warning(f"An error occured making a request on {self!r}: {e}") + raise NodeException(status=resp.status) - if load_type is LoadType.load_failed: - # TODO Proper exception... - raise ValueError('Tracks failed to Load.') + raise LavalinkException(data=exc_data) - if load_type is LoadType.no_matches: - return None + async def _fetch_version(self) -> str: + uri: str = f"{self.uri}/version" - if load_type is not LoadType.playlist_loaded: - raise WavelinkException("Track failed to load.") + async with self._session.get(url=uri, headers=self.headers) as resp: + if resp.status == 200: + return await resp.text() - return cls(data) + try: + exc_data: ErrorResponse = await resp.json() + except Exception as e: + logger.warning(f"An error occured making a request on {self!r}: {e}") + raise NodeException(status=resp.status) - async def build_track(self, *, cls: type[PlayableT], encoded: str) -> PlayableT: - """|coro| + raise LavalinkException(data=exc_data) - Build a track from the provided encoded string with the given Track class. + def get_player(self, guild_id: int, /) -> Player | None: + """Return a :class:`~wavelink.Player` associated with the provided :attr:`discord.Guild.id`. Parameters ---------- - cls: type[PlayableT] - The type of Playable track that should be returned. - encoded: str - The Tracks unique encoded string. - """ - encoded_query = urllib.parse.quote(encoded) - data = await self._send(method='GET', path='decodetrack', query=f'encodedTrack={encoded_query}') + guild_id: int + The :attr:`discord.Guild.id` to retrieve a :class:`~wavelink.Player` for. - logger.debug(f'Node {self} built encoded track with encoding "{encoded}". Response data: {data}') - return cls(data=data) + Returns + ------- + Optional[:class:`~wavelink.Player`] + The Player associated with this guild ID. Could be None if no :class:`~wavelink.Player` exists + for this guild. + """ + return self._players.get(guild_id, None) -# noinspection PyShadowingBuiltins -class NodePool: - """The Wavelink NodePool is responsible for keeping track of all :class:`Node`. +class Pool: + """The wavelink Pool represents a collection of :class:`~wavelink.Node` and helper methods for searching tracks. - Attributes - ---------- - nodes: dict[str, :class:`Node`] - A mapping of :class:`Node` identifier to :class:`Node`. + To connect a :class:`~wavelink.Node` please use this Pool. + .. note:: - .. warning:: - - This class should never be initialised. All methods are class methods. + All methods and attributes on this class are class level, not instance. Do not create an instance of this class. """ __nodes: dict[str, Node] = {} + __cache: LFUCache | None = None @classmethod async def connect( - cls, - *, - client: discord.Client, - nodes: list[Node], - spotify: spotify_.SpotifyClient | None = None + cls, *, nodes: Iterable[Node], client: discord.Client | None = None, cache_capacity: int | None = None ) -> dict[str, Node]: - """|coro| - - Connect a list of Nodes. + """Connect the provided Iterable[:class:`Node`] to Lavalink. Parameters ---------- - client: :class:`discord.Client` - The discord Client or Bot used to connect the Nodes. - nodes: list[:class:`Node`] - A list of Nodes to connect. - spotify: Optional[:class:`ext.spotify.SpotifyClient`] - The spotify Client to use when searching for Spotify Tracks. + nodes: Iterable[:class:`Node`] + The :class:`Node`'s to connect to Lavalink. + client: :class:`discord.Client` | None + The :class:`discord.Client` to use to connect the :class:`Node`. If the Node already has a client + set, this method will **not** override it. Defaults to None. + cache_capacity: int | None + An optional integer of the amount of track searches to cache. This is an experimental mode. + Passing ``None`` will disable this experiment. Defaults to ``None``. Returns ------- dict[str, :class:`Node`] - A mapping of :class:`Node` identifier to :class:`Node`. - """ - if client.user is None: - raise RuntimeError('') + A mapping of :attr:`Node.identifier` to :class:`Node` associated with the :class:`Pool`. + + + Raises + ------ + AuthorizationFailedException + The node password was incorrect. + InvalidClientException + The :class:`discord.Client` passed was not valid. + NodeException + The node failed to connect properly. Please check that your Lavalink version is version 4. + + .. versionchanged:: 3.0.0 + + The ``client`` parameter is no longer required. + Added the ``cache_capacity`` parameter. + """ for node in nodes: + client_ = node.client or client + + if node.identifier in cls.__nodes: + msg: str = f'Unable to connect {node!r} as you already have a node with identifier "{node.identifier}"' + logger.error(msg) - if spotify: - node._spotify = spotify + continue - if node.id in cls.__nodes: - logger.error(f'A Node with the ID "{node.id}" already exists on the NodePool. Disregarding.') + if node.status in (NodeStatus.CONNECTING, NodeStatus.CONNECTED): + logger.error(f"Unable to connect {node!r} as it is already in a connecting or connected state.") continue try: - await node._connect(client) - except AuthorizationFailed: - logger.error(f'The Node <{node!r}> failed to authenticate properly. ' - f'Please check your password and try again.') + await node._connect(client=client_) + except InvalidClientException as e: + logger.error(e) + except AuthorizationFailedException: + logger.error(f"Failed to authenticate {node!r} on Lavalink with the provided password.") + except NodeException: + logger.error( + f"Failed to connect to {node!r}. Check that your Lavalink major version is '4' " + "and that you are trying to connect to Lavalink on the correct port." + ) + else: + cls.__nodes[node.identifier] = node + + if cache_capacity is not None and cls.nodes: + if cache_capacity <= 0: + logger.warning("LFU Request cache capacity must be > 0. Not enabling cache.") + else: - cls.__nodes[node.id] = node + cls.__cache = LFUCache(capacity=cache_capacity) + logger.info("Experimental request caching has been toggled ON. To disable run Pool.toggle_cache()") return cls.nodes - @classproperty - def nodes(cls) -> dict[str, Node]: - """A mapping of :class:`Node` identifier to :class:`Node`.""" - return cls.__nodes + @classmethod + async def reconnect(cls) -> dict[str, Node]: + for node in cls.__nodes.values(): + if node.status is not NodeStatus.DISCONNECTED: + continue + + try: + await node._connect(client=None) + except InvalidClientException as e: + logger.error(e) + except AuthorizationFailedException: + logger.error(f"Failed to authenticate {node!r} on Lavalink with the provided password.") + except NodeException: + logger.error( + f"Failed to connect to {node!r}. Check that your Lavalink major version is '4' " + "and that you are trying to connect to Lavalink on the correct port." + ) + + return cls.nodes @classmethod - def get_node(cls, id: str | None = None) -> Node: - """Retrieve a :class:`Node` with the given ID or best, if no ID was passed. + async def close(cls) -> None: + """Close and clean up all :class:`~wavelink.Node` on this Pool. - Parameters - ---------- - id: Optional[str] - The unique identifier of the :class:`Node` to retrieve. If not passed the best :class:`Node` - will be fetched. + This calls :meth:`wavelink.Node.close` on each node. - Returns - ------- - :class:`Node` - Raises - ------ - InvalidNode - The given id does nto resolve to a :class:`Node` or no :class:`Node` has been connected. + .. versionadded:: 3.0.0 """ - if id: - if id not in cls.__nodes: - raise InvalidNode(f'A Node with ID "{id}" does not exist on the Wavelink NodePool.') + for node in cls.__nodes.values(): + await node.close() + + @classproperty + def nodes(cls) -> dict[str, Node]: + """A mapping of :attr:`Node.identifier` to :class:`Node` that have previously been successfully connected. - return cls.__nodes[id] - if not cls.__nodes: - raise InvalidNode('No Node currently exists on the Wavelink NodePool.') + .. versionchanged:: 3.0.0 - nodes = cls.__nodes.values() - return sorted(nodes, key=lambda n: len(n.players))[0] + This property now returns a copy. + """ + nodes = cls.__nodes.copy() + return nodes @classmethod - def get_connected_node(cls) -> Node: - """Get the best available connected :class:`Node`. + def get_node(cls, identifier: str | None = None, /) -> Node: + """Retrieve a :class:`Node` from the :class:`Pool` with the given identifier. - Returns - ------- - :class:`Node` - The best available connected Node. + If no identifier is provided, this method returns the ``best`` node. + + Parameters + ---------- + identifier: str | None + An optional identifier to retrieve a :class:`Node`. Raises ------ - InvalidNode - No Nodes are currently in the connected state. + InvalidNodeException + Raised when a Node can not be found, or no :class:`Node` exists on the :class:`Pool`. + + + .. versionchanged:: 3.0.0 + + The ``id`` parameter was changed to ``identifier`` and is positional only. """ + if identifier: + if identifier not in cls.__nodes: + raise InvalidNodeException(f'A Node with the identifier "{identifier}" does not exist.') + + return cls.__nodes[identifier] nodes: list[Node] = [n for n in cls.__nodes.values() if n.status is NodeStatus.CONNECTED] if not nodes: - raise InvalidNode('There are no Nodes on the Wavelink NodePool that are currently in the connected state.') + raise InvalidNodeException("No nodes are currently assigned to the wavelink.Pool in a CONNECTED state.") - return sorted(nodes, key=lambda n: len(n.players))[0] + return sorted(nodes, key=lambda n: n._total_player_count or len(n.players))[0] @classmethod - async def get_tracks(cls_, # type: ignore - query: str, - /, - *, - cls: type[PlayableT], - node: Node | None = None - ) -> list[PlayableT]: - """|coro| - - Helper method to retrieve tracks from the NodePool without fetching a :class:`Node`. + async def fetch_tracks(cls, query: str, /) -> list[Playable] | Playlist: + """Search for a list of :class:`~wavelink.Playable` or a :class:`~wavelink.Playlist`, with the given query. Parameters ---------- query: str - The query to search for and return tracks. - cls: type[PlayableT] - The type of Playable tracks that should be returned. - node: Optional[:class:`Node`] - The node to use for retrieving tracks. If not passed, the best :class:`Node` will be used. - Defaults to None. + The query to search tracks for. If this is not a URL based search you should provide the appropriate search + prefix, e.g. "ytsearch:Rick Roll" Returns ------- - list[PlayableT] - A list of found tracks converted to the provided cls. + list[Playable] | Playlist + A list of :class:`~wavelink.Playable` or a :class:`~wavelink.Playlist` + based on your search ``query``. Could be an empty list, if no tracks were found. + + Raises + ------ + LavalinkLoadException + Exception raised when Lavalink fails to load results based on your query. + + + .. versionchanged:: 3.0.0 + + This method was previously known as both ``.get_tracks`` and ``.get_playlist``. This method now searches + for both :class:`~wavelink.Playable` and :class:`~wavelink.Playlist` and returns the appropriate type, + or an empty list if no results were found. + + This method no longer accepts the ``cls`` parameter. """ - if not node: - node = cls_.get_connected_node() - return await node.get_tracks(cls=cls, query=query) + # TODO: Documentation Extension for `.. positional-only::` marker. + encoded_query: str = urllib.parse.quote(query) - @classmethod - async def get_playlist(cls_, # type: ignore - query: str, - /, - *, - cls: Playlist, - node: Node | None = None - ) -> Playlist: - """|coro| + if cls.__cache is not None: + potential: list[Playable] | Playlist = cls.__cache.get(encoded_query, None) - Helper method to retrieve a playlist from the NodePool without fetching a :class:`Node`. + if potential: + return potential + node: Node = cls.get_node() + resp: LoadedResponse = await node._fetch_tracks(encoded_query) - .. warning:: + if resp["loadType"] == "track": + track = Playable(data=resp["data"]) - The only playlists currently supported are :class:`tracks.YouTubePlaylist` and - :class:`tracks.YouTubePlaylist`. + if cls.__cache is not None and not track.is_stream: + cls.__cache.put(encoded_query, [track]) + return [track] - Parameters - ---------- - query: str - The query to search for and return a playlist. - cls: type[PlayableT] - The type of Playlist that should be returned. - node: Optional[:class:`Node`] - The node to use for retrieving tracks. If not passed, the best :class:`Node` will be used. - Defaults to None. + elif resp["loadType"] == "search": + tracks = [Playable(data=tdata) for tdata in resp["data"]] - Returns - ------- - Playlist - A Playlist with its tracks. - """ - if not node: - node = cls_.get_connected_node() + if cls.__cache is not None: + cls.__cache.put(encoded_query, tracks) + + return tracks + + if resp["loadType"] == "playlist": + playlist: Playlist = Playlist(data=resp["data"]) - return await node.get_playlist(cls=cls, query=query) + if cls.__cache is not None: + cls.__cache.put(encoded_query, playlist) + + return playlist + + elif resp["loadType"] == "empty": + return [] + + elif resp["loadType"] == "error": + raise LavalinkLoadException(data=resp["data"]) + + else: + return [] + + @classmethod + def cache(cls, capacity: int | None | bool = None) -> None: + if capacity in (None, False) or capacity <= 0: + cls.__cache = None + return + + if not isinstance(capacity, int): # type: ignore + raise ValueError("The LFU cache expects an integer, None or bool.") + + cls.__cache = LFUCache(capacity=capacity) + + @classmethod + def has_cache(cls) -> bool: + return cls.__cache is not None diff --git a/wavelink/payloads.py b/wavelink/payloads.py index 662b670d..272c3cda 100644 --- a/wavelink/payloads.py +++ b/wavelink/payloads.py @@ -1,7 +1,7 @@ """ MIT License -Copyright (c) 2019-Present PythonistaGuild +Copyright (c) 2019-Current PythonistaGuild, EvieePy Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -23,73 +23,276 @@ """ from __future__ import annotations -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, cast -from discord.enums import try_enum +import wavelink -from .enums import TrackEventType, DiscordVoiceCloseType +from .enums import DiscordVoiceCloseType if TYPE_CHECKING: + from .node import Node from .player import Player from .tracks import Playable - from .types.events import EventOp + from .types.state import PlayerState + from .types.stats import CPUStats, FrameStats, MemoryStats + from .types.websocket import StatsOP, TrackExceptionPayload -__all__ = ('TrackEventPayload', 'WebsocketClosedPayload') +__all__ = ( + "TrackStartEventPayload", + "TrackEndEventPayload", + "TrackExceptionEventPayload", + "TrackStuckEventPayload", + "WebsocketClosedEventPayload", + "PlayerUpdateEventPayload", + "StatsEventPayload", + "NodeReadyEventPayload", + "StatsEventMemory", + "StatsEventCPU", + "StatsEventFrames", +) -class TrackEventPayload: - """The Wavelink Track Event Payload. - .. warning:: +class NodeReadyEventPayload: + """Payload received in the :func:`on_wavelink_node_ready` event. - This class should not be created manually, instead you will receive it from the - various wavelink track events. + Attributes + ---------- + node: :class:`~wavelink.Node` + The node that has connected or reconnected. + resumed: bool + Whether this node was successfully resumed. + session_id: str + The session ID associated with this node. + """ + + def __init__(self, node: Node, resumed: bool, session_id: str) -> None: + self.node = node + self.resumed = resumed + self.session_id = session_id + + +class TrackStartEventPayload: + """Payload received in the :func:`on_wavelink_track_start` event. + + Attributes + ---------- + player: :class:`~wavelink.Player` | None + The player associated with this event. Could be None. + track: :class:`~wavelink.Playable` + The track received from Lavalink regarding this event. + original: :class:`~wavelink.Playable` | None + The original track associated this event. E.g. the track that was passed to :meth:`~wavelink.Player.play` or + inserted into the queue, with all your additional attributes assigned. Could be ``None``. + """ + + def __init__(self, player: Player | None, track: Playable) -> None: + self.player = player + self.track = track + self.original: Playable | None = None + + if player: + self.original = player._original + + +class TrackEndEventPayload: + """Payload received in the :func:`on_wavelink_track_end` event. + + Attributes + ---------- + player: :class:`~wavelink.Player` | None + The player associated with this event. Could be None. + track: :class:`~wavelink.Playable` + The track received from Lavalink regarding this event. + reason: str + The reason Lavalink ended this track. + original: :class:`~wavelink.Playable` | None + The original track associated this event. E.g. the track that was passed to :meth:`~wavelink.Player.play` or + inserted into the queue, with all your additional attributes assigned. Could be ``None``. + """ + + def __init__(self, player: Player | None, track: Playable, reason: str) -> None: + self.player = player + self.track = track + self.reason = reason + self.original: Playable | None = None + + if player: + self.original = player._previous + + +class TrackExceptionEventPayload: + """Payload received in the :func:`on_wavelink_track_exception` event. Attributes ---------- - event: :class:`TrackEventType` - An enum of the type of event. - track: :class:`Playable` - The track associated with this event. - original: Optional[:class:`Playable`] - The original requested track before conversion. Could be None. - player: :class:`player.Player` - The player associated with this event. - reason: Optional[str] - The reason this event was fired. + player: :class:`~wavelink.Player` | None + The player associated with this event. Could be None. + track: :class:`~wavelink.Playable` + The track received from Lavalink regarding this event. + exception: TrackExceptionPayload + The exception data received via Lavalink. """ - def __init__(self, *, data: EventOp, track: Playable, original: Playable | None, player: Player) -> None: - self.event: TrackEventType = try_enum(TrackEventType, data['type']) - self.track: Playable = track - self.original: Playable | None = original - self.player: Player = player + def __init__(self, player: Player | None, track: Playable, exception: TrackExceptionPayload) -> None: + self.player = cast(wavelink.Player, player) + self.track = track + self.exception = exception - self.reason: str = data.get('reason') +class TrackStuckEventPayload: + """Payload received in the :func:`on_wavelink_track_stuck` event. + + Attributes + ---------- + player: :class:`~wavelink.Player` | None + The player associated with this event. Could be None. + track: :class:`~wavelink.Playable` + The track received from Lavalink regarding this event. + threshold: int + The Lavalink threshold associated with this event. + """ -class WebsocketClosedPayload: - """The Wavelink WebsocketClosed Event Payload. + def __init__(self, player: Player | None, track: Playable, threshold: int) -> None: + self.player = cast(wavelink.Player, player) + self.track = track + self.threshold = threshold - .. warning:: - This class should not be created manually, instead you will receive it from the - wavelink `on_wavelink_websocket_closed` event. +class WebsocketClosedEventPayload: + """Payload received in the :func:`on_wavelink_websocket_closed` event. Attributes ---------- - code: :class:`DiscordVoiceCloseType` - An Enum representing the close code from Discord. - reason: Optional[str] - The reason the Websocket was closed. - by_discord: bool - Whether the websocket was closed by Discord. - player: :class:`player.Player` - The player associated with this event. + player: :class:`~wavelink.Player` | None + The player associated with this event. Could be None. + code: :class:`wavelink.DiscordVoiceCloseType` + The close code enum value. + reason: str + The reason the websocket was closed. + by_remote: bool + ``True`` if discord closed the websocket. ``False`` otherwise. """ - def __init__(self, *, data: dict[str, Any], player: Player) -> None: - self.code: DiscordVoiceCloseType = try_enum(DiscordVoiceCloseType, data['code']) - self.reason: str = data.get('reason') - self.by_discord: bool = data.get('byRemote') - self.player: Player = player + def __init__(self, player: Player | None, code: int, reason: str, by_remote: bool) -> None: + self.player = player + self.code: DiscordVoiceCloseType = DiscordVoiceCloseType(code) + self.reason = reason + self.by_remote = by_remote + + +class PlayerUpdateEventPayload: + """Payload received in the :func:`on_wavelink_player_update` event. + + Attributes + ---------- + player: :class:`~wavelink.Player` | None + The player associated with this event. Could be None. + time: int + Unix timestamp in milliseconds, when this event fired. + position: int + The position of the currently playing track in milliseconds. + connected: bool + Whether Lavalink is connected to the voice gateway. + ping: int + The ping of the node to the Discord voice server in milliseconds (-1 if not connected). + """ + + def __init__(self, player: Player | None, state: PlayerState) -> None: + self.player = cast(wavelink.Player, player) + self.time: int = state["time"] + self.position: int = state["position"] + self.connected: bool = state["connected"] + self.ping: int = state["ping"] + + +class StatsEventMemory: + """Represents Memory Stats. + + Attributes + ---------- + free: int + The amount of free memory in bytes. + used: int + The amount of used memory in bytes. + allocated: int + The amount of allocated memory in bytes. + reservable: int + The amount of reservable memory in bytes. + """ + + def __init__(self, data: MemoryStats) -> None: + self.free: int = data["free"] + self.used: int = data["used"] + self.allocated: int = data["allocated"] + self.reservable: int = data["reservable"] + + +class StatsEventCPU: + """Represents CPU Stats. + + Attributes + ---------- + cores: int + The number of CPU cores available on the node. + system_load: float + The system load of the node. + lavalink_load: float + The load of Lavalink on the node. + """ + + def __init__(self, data: CPUStats) -> None: + self.cores: int = data["cores"] + self.system_load: float = data["systemLoad"] + self.lavalink_load: float = data["lavalinkLoad"] + + +class StatsEventFrames: + """Represents Frame Stats. + + Attributes + ---------- + sent: int + The amount of frames sent to Discord. + nulled: int + The amount of frames that were nulled. + deficit: int + The difference between sent frames and the expected amount of frames. + """ + + def __init__(self, data: FrameStats) -> None: + self.sent: int = data["sent"] + self.nulled: int = data["nulled"] + self.deficit: int = data["deficit"] + + +class StatsEventPayload: + """Payload received in the :func:`on_wavelink_stats_update` event. + + Attributes + ---------- + players: int + The amount of players connected to the node (Lavalink). + playing: int + The amount of players playing a track. + uptime: int + The uptime of the node in milliseconds. + memory: :class:`wavelink.StatsEventMemory` + See Also: :class:`wavelink.StatsEventMemory` + cpu: :class:`wavelink.StatsEventCPU` + See Also: :class:`wavelink.StatsEventCPU` + frames: :class:`wavelink.StatsEventFrames` + See Also: :class:`wavelink.StatsEventFrames` + """ + + def __init__(self, data: StatsOP) -> None: + self.players: int = data["players"] + self.playing: int = data["playingPlayers"] + self.uptime: int = data["uptime"] + + self.memory: StatsEventMemory = StatsEventMemory(data=data["memory"]) + self.cpu: StatsEventCPU = StatsEventCPU(data=data["cpu"]) + self.frames: StatsEventFrames | None = None + + if data["frameStats"]: + self.frames = StatsEventFrames(data=data["frameStats"]) diff --git a/wavelink/player.py b/wavelink/player.py index 761a0f48..6d0fb59a 100644 --- a/wavelink/player.py +++ b/wavelink/player.py @@ -1,7 +1,7 @@ """ MIT License -Copyright (c) 2019-Present PythonistaGuild +Copyright (c) 2019-Current PythonistaGuild, EvieePy Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -23,806 +23,861 @@ """ from __future__ import annotations -import datetime +import asyncio import logging -from typing import TYPE_CHECKING, Any, Union +import random +import time +from collections import deque +from typing import TYPE_CHECKING, Any, TypeAlias +import async_timeout import discord +from discord.abc import Connectable from discord.utils import MISSING -from .enums import * -from .exceptions import * -from .ext import spotify -from .filters import Filter -from .node import Node, NodePool -from .payloads import TrackEventPayload +import wavelink + +from .enums import AutoPlayMode, NodeStatus, QueueMode +from .exceptions import ( + ChannelTimeoutException, + InvalidChannelStateException, + LavalinkException, + LavalinkLoadException, + QueueEmpty, +) +from .filters import Filters +from .node import Pool +from .payloads import PlayerUpdateEventPayload, TrackEndEventPayload from .queue import Queue -from .tracks import * - +from .tracks import Playable, Playlist if TYPE_CHECKING: - from discord.types.voice import GuildVoiceState, VoiceServerUpdate + from discord.types.voice import GuildVoiceState as GuildVoiceStatePayload + from discord.types.voice import VoiceServerUpdate as VoiceServerUpdatePayload from typing_extensions import Self - from .types.events import PlayerState, PlayerUpdateOp - from .types.request import EncodedTrackRequest, Request - from .types.state import DiscordVoiceState - -__all__ = ("Player",) + from .node import Node + from .types.request import Request as RequestPayload + from .types.state import PlayerVoiceState, VoiceState + VocalGuildChannel = discord.VoiceChannel | discord.StageChannel logger: logging.Logger = logging.getLogger(__name__) -VoiceChannel = Union[ - discord.VoiceChannel, discord.StageChannel -] # todo: VocalGuildChannel? +T_a: TypeAlias = list[Playable] | Playlist class Player(discord.VoiceProtocol): - """Wavelink Player class. - - This class is used as a :class:`~discord.VoiceProtocol` and inherits all its members. - - You must pass this class to :meth:`discord.VoiceChannel.connect` with ``cls=...``. This ensures the player is - set up correctly and put into the discord.py voice client cache. - - You **can** make an instance of this class *before* passing it to - :meth:`discord.VoiceChannel.connect` with ``cls=...``, but you **must** still pass it. - - Once you have connected this class you do not need to store it anywhere as it will be stored on the - :class:`~wavelink.Node` and in the discord.py voice client cache. Meaning you can access this player where you - have access to a :class:`~wavelink.NodePool`, the specific :class:`~wavelink.Node` or a :class:`~discord.Guild` - including in :class:`~discord.ext.commands.Context` and :class:`~discord.Interaction`. - - Examples - -------- - - .. code:: python3 - - # Connect the player... - player: wavelink.Player = await ctx.author.voice.channel.connect(cls=wavelink.Player) - - # Retrieve the player later... - player: wavelink.Player = ctx.guild.voice_client + """The Player is a :class:`discord.VoiceProtocol` used to connect your :class:`discord.Client` to a + :class:`discord.VoiceChannel`. + The player controls the music elements of the bot including playing tracks, the queue, connecting etc. + See Also: The various methods available. .. note:: - The Player class comes with an in-built queue. See :class:`queue.Queue` for more information on how this queue - works. - - Parameters - ---------- - nodes: Optional[list[:class:`node.Node`]] - An optional list of :class:`node.Node` to use with this Player. If no Nodes are provided - the best connected Node will be used. - swap_node_on_disconnect: bool - If a list of :class:`node.Node` is provided the Player will swap Nodes on Node disconnect. - Defaults to True. - - Attributes - ---------- - client: :class:`discord.Client` - The discord Client or Bot associated with this Player. - channel: :class:`discord.VoiceChannel` - The channel this player is currently connected to. - nodes: list[:class:`node.Node`] - The list of Nodes this player is currently using. - current_node: :class:`node.Node` - The Node this player is currently using. - queue: :class:`queue.Queue` - The wavelink built in Queue. See :class:`queue.Queue`. This queue always takes precedence over the auto_queue. - Meaning any songs in this queue will be played before auto_queue songs. - auto_queue: :class:`queue.Queue` - The built-in AutoPlay Queue. This queue keeps track of recommended songs only. - When a song is retrieved from this queue in the AutoPlay event, - it is added to the main :attr:`wavelink.Queue.history` queue. + Since the Player is a :class:`discord.VoiceProtocol`, it is attached to the various ``voice_client`` attributes + in discord.py, including ``guild.voice_client``, ``ctx.voice_client`` and ``interaction.voice_client``. """ - def __call__(self, client: discord.Client, channel: VoiceChannel) -> Self: - self.client = client - self.channel = channel + channel: VocalGuildChannel + + def __call__(self, client: discord.Client, channel: VocalGuildChannel) -> Self: + super().__init__(client, channel) + + self._guild = channel.guild return self def __init__( - self, - client: discord.Client = MISSING, - channel: VoiceChannel = MISSING, - *, - nodes: list[Node] | None = None, - swap_node_on_disconnect: bool = True + self, client: discord.Client = MISSING, channel: Connectable = MISSING, *, nodes: list[Node] | None = None ) -> None: + super().__init__(client, channel) + self.client: discord.Client = client - self.channel: VoiceChannel | None = channel - - self.nodes: list[Node] - self.current_node: Node - - if swap_node_on_disconnect and not nodes: - nodes = list(NodePool.nodes.values()) - self.nodes = sorted(nodes, key=lambda n: len(n.players)) - self.current_node = self.nodes[0] - elif nodes: - nodes = sorted(nodes, key=lambda n: len(n.players)) - self.current_node = nodes[0] - self.nodes = nodes - else: - self.current_node = NodePool.get_connected_node() - self.nodes = [self.current_node] + self._guild: discord.Guild | None = None - if not self.client: - if self.current_node.client is None: - raise RuntimeError('') - self.client = self.current_node.client + self._voice_state: PlayerVoiceState = {"voice": {}} - self._guild: discord.Guild | None = None - self._voice_state: DiscordVoiceState = {} - self._player_state: dict[str, Any] = {} + self._node: Node + if not nodes: + self._node = Pool.get_node() + else: + self._node = sorted(nodes, key=lambda n: len(n.players))[0] - self.swap_on_disconnect: bool = swap_node_on_disconnect + if self.client is MISSING and self.node.client: + self.client = self.node.client - self.last_update: datetime.datetime | None = None - self.last_position: int = 0 + self._last_update: int | None = None + self._last_position: int = 0 + self._ping: int = -1 - self._ping: int = 0 + self._connected: bool = False + self._connection_event: asyncio.Event = asyncio.Event() - self.queue: Queue = Queue() self._current: Playable | None = None self._original: Playable | None = None + self._previous: Playable | None = None - self._volume: int = 50 + self.queue: Queue = Queue() + self.auto_queue: Queue = Queue() + + self._volume: int = 100 self._paused: bool = False - self._track_seeds: list[str] = [] - self._autoplay: bool = False - self.auto_queue: Queue = Queue() - self._auto_threshold: int = 100 - self._filter: Filter | None = None + self._auto_cutoff: int = 20 + self._auto_weight: int = 3 + self._previous_seeds_cutoff: int = self._auto_cutoff * self._auto_weight + self._history_count: int | None = None + + self._autoplay: AutoPlayMode = AutoPlayMode.disabled + self.__previous_seeds: asyncio.Queue[str] = asyncio.Queue(maxsize=self._previous_seeds_cutoff) + + self._auto_lock: asyncio.Lock = asyncio.Lock() + self._error_count: int = 0 - self._destroyed: bool = False + self._filters: Filters = Filters() - async def _auto_play_event(self, payload: TrackEventPayload) -> None: - logger.debug(f'Player {self.guild.id} entered autoplay event.') + async def _auto_play_event(self, payload: TrackEndEventPayload) -> None: + if self._autoplay is AutoPlayMode.disabled: + return - if not self.autoplay: - logger.debug(f'Player {self.guild.id} autoplay is set to False. Exiting autoplay event.') + if self._error_count >= 3: + logger.warning( + "AutoPlay was unable to continue as you have received too many consecutive errors." + "Please check the error log on Lavalink." + ) return - if payload.reason == 'REPLACED': - logger.debug(f'Player {self.guild.id} autoplay reason is REPLACED. Exiting autoplay event.') + if payload.reason == "replaced": + self._error_count = 0 return - if self.queue.loop: - logger.debug(f'Player {self.guild.id} autoplay default queue.loop is set to True.') + elif payload.reason == "loadFailed": + self._error_count += 1 + + else: + self._error_count = 0 + if self.node.status is not NodeStatus.CONNECTED: + logger.warning(f'"Unable to use AutoPlay on Player for Guild "{self.guild}" due to disconnected Node.') + return + + if not isinstance(self.queue, Queue) or not isinstance(self.auto_queue, Queue): # type: ignore + logger.warning(f'"Unable to use AutoPlay on Player for Guild "{self.guild}" due to unsupported Queue.') + return + + if self.queue.mode is QueueMode.loop: + await self._do_partial(history=False) + + elif self.queue.mode is QueueMode.loop_all: + await self._do_partial() + + elif self._autoplay is AutoPlayMode.partial or self.queue: + await self._do_partial() + + elif self._autoplay is AutoPlayMode.enabled: + async with self._auto_lock: + await self._do_recommendation() + + async def _do_partial(self, *, history: bool = True) -> None: + if self._current is None: try: - track = self.queue.get() + track: Playable = self.queue.get() except QueueEmpty: - logger.debug(f'Player {self.guild.id} autoplay default queue.loop is set to True ' - f'but no track was available. Exiting autoplay event.') return - logger.debug(f'Player {self.guild.id} autoplay default queue.loop is set to True. Looping track "{track}"') - await self.play(track) - return + await self.play(track, add_history=history) - if self.queue: - track = self.queue.get() + async def _do_recommendation(self): + assert self.guild is not None + assert self.queue.history is not None and self.auto_queue.history is not None - populate = len(self.auto_queue) < self._auto_threshold - await self.play(track, populate=populate) + if len(self.auto_queue) > self._auto_cutoff + 1: + track: Playable = self.auto_queue.get() + self.auto_queue.history.put(track) - logger.debug(f'Player {self.guild.id} autoplay found track in default queue, populate={populate}.') + await self.play(track, add_history=False) return - if self.queue.loop_all: - await self.play(self.queue.get()) - return + weighted_history: list[Playable] = self.queue.history[::-1][: max(5, 5 * self._auto_weight)] + weighted_upcoming: list[Playable] = self.auto_queue[: max(3, int((5 * self._auto_weight) / 3))] + choices: list[Playable | None] = [*weighted_history, *weighted_upcoming, self._current, self._previous] - if not self.auto_queue: - logger.debug(f'Player {self.guild.id} has no auto queue. Exiting autoplay event.') - return + # Filter out tracks which are None... + _previous: deque[str] = self.__previous_seeds._queue # type: ignore + seeds: list[Playable] = [t for t in choices if t is not None and t.identifier not in _previous] + random.shuffle(seeds) - await self.queue.put_wait(await self.auto_queue.get_wait()) - populate = self.auto_queue.is_empty + spotify: list[str] = [t.identifier for t in seeds if t.source == "spotify"] + youtube: list[str] = [t.identifier for t in seeds if t.source == "youtube"] - track = await self.queue.get_wait() - await self.play(track, populate=populate) + spotify_query: str | None = None + youtube_query: str | None = None - logger.debug(f'Player {self.guild.id} playing track "{track}" from autoplay with populate={populate}.') + count: int = len(self.queue.history) + changed_by: int = min(3, count) if self._history_count is None else count - self._history_count - @property - def autoplay(self) -> bool: - """Returns a ``bool`` indicating whether the :class:`~Player` is in AutoPlay mode or not. + if changed_by > 0: + self._history_count = count + + changed_history: list[Playable] = self.queue.history[::-1] + + added: int = 0 + for i in range(min(changed_by, 3)): + track: Playable = changed_history[i] + + if added == 2 and track.source == "spotify": + break + + if track.source == "spotify": + spotify.insert(0, track.identifier) + added += 1 + + elif track.source == "youtube": + youtube[0] = track.identifier + + if spotify: + spotify_seeds: list[str] = spotify[:3] + spotify_query = f"sprec:seed_tracks={','.join(spotify_seeds)}&limit=10" + + for s_seed in spotify_seeds: + self._add_to_previous_seeds(s_seed) + + if youtube: + ytm_seed: str = youtube[0] + youtube_query = f"https://music.youtube.com/watch?v={ytm_seed}8&list=RD{ytm_seed}" + self._add_to_previous_seeds(ytm_seed) + + async def _search(query: str | None) -> T_a: + if query is None: + return [] + + try: + search: wavelink.Search = await Pool.fetch_tracks(query) + except (LavalinkLoadException, LavalinkException): + return [] + + if not search: + return [] - This property can be set to ``True`` or ``False``. + tracks: list[Playable] + if isinstance(search, Playlist): + tracks = search.tracks.copy() + else: + tracks = search - When ``autoplay`` is ``True``, the player will automatically handle fetching and playing the next track from - the queue. It also searches tracks in the ``auto_queue``, a special queue populated with recommended tracks, - from the Spotify API or YouTube Recommendations. + return tracks + results: tuple[T_a, T_a] = await asyncio.gather(_search(spotify_query), _search(youtube_query)) - .. note:: + # track for result in results for track in result... + filtered_r: list[Playable] = [t for r in results for t in r] - You can still use the :func:`~wavelink.on_wavelink_track_end` event when ``autoplay`` is ``True``, - but it is recommended to **not** do any queue logic or invoking play from this event. + if not filtered_r: + logger.debug(f'Player "{self.guild.id}" could not load any songs via AutoPlay.') + return - Most users are able to use ``autoplay`` and :func:`~wavelink.on_wavelink_track_start` together to handle - their logic. E.g. sending a message when a track starts playing. + if not self._current: + now: Playable = filtered_r.pop(1) + now._recommended = True + self.auto_queue.history.put(now) + await self.play(now, add_history=False) - .. note:: + # Possibly adjust these thresholds? + history: list[Playable] = ( + self.auto_queue[:40] + self.queue[:40] + self.queue.history[:-41:-1] + self.auto_queue.history[:-61:-1] + ) - The ``auto_queue`` will be populated when you play a :class:`~wavelink.ext.spotify.SpotifyTrack` or - :class:`~wavelink.YouTubeTrack`, and have initially set ``populate`` to ``True`` in - :meth:`~wavelink.Player.play`. See :meth:`~wavelink.Player.play` for more info. + added: int = 0 + for track in filtered_r: + if track in history: + continue + track._recommended = True + added += await self.auto_queue.put_wait(track) + + random.shuffle(self.auto_queue._queue) + logger.debug(f'Player "{self.guild.id}" added "{added}" tracks to the auto_queue via AutoPlay.') + + @property + def autoplay(self) -> AutoPlayMode: + """A property which returns the :class:`wavelink.AutoPlayMode` the player is currently in. - .. versionadded:: 2.0 + This property can be set with any :class:`wavelink.AutoPlayMode` enum value. - .. versionchanged:: 2.6.0 + .. versionchanged:: 3.0.0 - The autoplay event now populates the ``auto_queue`` when playing :class:`~wavelink.YouTubeTrack` **or** - :class:`~wavelink.ext.spotify.SpotifyTrack`. + This property now accepts and returns a :class:`wavelink.AutoPlayMode` enum value. """ return self._autoplay @autoplay.setter - def autoplay(self, value: bool) -> None: - """Set AutoPlay to True or False.""" + def autoplay(self, value: Any) -> None: + if not isinstance(value, AutoPlayMode): + raise ValueError("Please provide a valid 'wavelink.AutoPlayMode' to set.") + self._autoplay = value - def is_connected(self) -> bool: - """Whether the player is connected to a voice channel.""" - return self.channel is not None and self.channel is not MISSING + @property + def node(self) -> Node: + """The :class:`Player`'s currently selected :class:`Node`. - def is_playing(self) -> bool: - """Whether the Player is currently playing a track.""" - return self.current is not None and not self._paused - def is_paused(self) -> bool: - """Whether the Player is currently paused.""" - return self._paused + .. versionchanged:: 3.0.0 - @property - def volume(self) -> int: - """The current volume of the Player.""" - return self._volume + This property was previously known as ``current_node``. + """ + return self._node @property def guild(self) -> discord.Guild | None: - """The discord Guild associated with the Player.""" + """Returns the :class:`Player`'s associated :class:`discord.Guild`. + + Could be None if this :class:`Player` has not been connected. + """ return self._guild @property - def position(self) -> float: - """The position of the currently playing track in milliseconds.""" + def connected(self) -> bool: + """Returns a bool indicating if the player is currently connected to a voice channel. - if not self.is_playing(): - return 0 + .. versionchanged:: 3.0.0 - if self.is_paused(): - return min(self.last_position, self.current.duration) # type: ignore - - delta = (datetime.datetime.now(datetime.timezone.utc) - self.last_update).total_seconds() * 1000 - position = self.last_position + delta + This property was previously known as ``is_connected``. + """ + return self.channel and self._connected - return min(position, self.current.duration) + @property + def current(self) -> Playable | None: + """Returns the currently playing :class:`~wavelink.Playable` or None if no track is playing.""" + return self._current @property - def ping(self) -> int: - """The ping to the discord endpoint in milliseconds. + def volume(self) -> int: + """Returns an int representing the currently set volume, as a percentage. - .. versionadded:: 2.0 + See: :meth:`set_volume` for setting the volume. """ - return self._ping + return self._volume @property - def current(self) -> Playable | None: - """The currently playing Track if there is one. + def filters(self) -> Filters: + """Property which returns the :class:`~wavelink.Filters` currently assigned to the Player. + + See: :meth:`~wavelink.Player.set_filters` for setting the players filters. - Could be ``None`` if no Track is playing. + .. versionchanged:: 3.0.0 + + This property was previously known as ``filter``. """ - return self._current + return self._filters @property - def filter(self) -> dict[str, Any]: - """The currently applied filter.""" - return self._filter._payload + def paused(self) -> bool: + """Returns the paused status of the player. A currently paused player will return ``True``. - async def _update_event(self, data: PlayerUpdateOp | None) -> None: - assert self._guild is not None + See: :meth:`pause` and :meth:`play` for setting the paused status. + """ + return self._paused - if data is None: - if self.swap_on_disconnect: + @property + def ping(self) -> int: + """Returns the ping in milliseconds as int between your connected Lavalink Node and Discord (Players Channel). - if len(self.nodes) < 2: - return + Returns ``-1`` if no player update event has been received or the player is not connected. + """ + return self._ping - new: Node = [n for n in self.nodes if n != self.current_node and n.status is NodeStatus.CONNECTED][0] - del self.current_node._players[self._guild.id] + @property + def playing(self) -> bool: + """Returns whether the :class:`~Player` is currently playing a track and is connected. - if not new: - return + Due to relying on validation from Lavalink, this property may in some cases return ``True`` directly after + skipping/stopping a track, although this is not the case when disconnecting the player. - self.current_node: Node = new - new._players[self._guild.id] = self + This property will return ``True`` in cases where the player is paused *and* has a track loaded. - await self._dispatch_voice_update() - await self._swap_state() - return + .. versionchanged:: 3.0.0 - data.pop('op') # type: ignore - self._player_state.update(**data) + This property used to be known as the `is_playing()` method. + """ + return self._connected and self._current is not None - state: PlayerState = data['state'] - self.last_update = datetime.datetime.fromtimestamp(state.get("time", 0) / 1000, datetime.timezone.utc) - self.last_position = state.get('position', 0) + @property + def position(self) -> int: + """Returns the position of the currently playing :class:`~wavelink.Playable` in milliseconds. - self._ping = state['ping'] + This property relies on information updates from Lavalink. - async def on_voice_server_update(self, data: VoiceServerUpdate) -> None: - """|coro| + In cases there is no :class:`~wavelink.Playable` loaded or the player is not connected, + this property will return ``0``. - An abstract method that is called when initially connecting to voice. This corresponds to VOICE_SERVER_UPDATE. + This property will return ``0`` if no update has been received from Lavalink. - .. warning:: + .. versionchanged:: 3.0.0 - Do not override this method. + This property now uses a monotonic clock. """ - self._voice_state['token'] = data['token'] - self._voice_state['endpoint'] = data['endpoint'] + if self.current is None or not self.playing: + return 0 - await self._dispatch_voice_update() + if not self.connected: + return 0 - async def on_voice_state_update(self, data: GuildVoiceState) -> None: - """|coro| + if self._last_update is None: + return 0 - An abstract method that is called when the client’s voice state has changed. - This corresponds to VOICE_STATE_UPDATE. + if self.paused: + return self._last_position - .. warning:: + position: int = int((time.monotonic_ns() - self._last_update) / 1000000) + self._last_position + return min(position, self.current.length) - Do not override this method. - """ - assert self._guild is not None + async def _update_event(self, payload: PlayerUpdateEventPayload) -> None: + # Convert nanoseconds into milliseconds... + self._last_update = time.monotonic_ns() + self._last_position = payload.position + + self._ping = payload.ping + async def on_voice_state_update(self, data: GuildVoiceStatePayload, /) -> None: channel_id = data["channel_id"] if not channel_id: await self._destroy() return - self._voice_state['session_id'] = data['session_id'] + self._connected = True + + self._voice_state["voice"]["session_id"] = data["session_id"] self.channel = self.client.get_channel(int(channel_id)) # type: ignore - if not self._guild: - self._guild = self.channel.guild # type: ignore - assert self._guild is not None - self.current_node._players[self._guild.id] = self + async def on_voice_server_update(self, data: VoiceServerUpdatePayload, /) -> None: + self._voice_state["voice"]["token"] = data["token"] + self._voice_state["voice"]["endpoint"] = data["endpoint"] - async def _dispatch_voice_update(self, data: DiscordVoiceState | None = None) -> None: - assert self._guild is not None + await self._dispatch_voice_update() - data = data or self._voice_state + async def _dispatch_voice_update(self) -> None: + assert self.guild is not None + data: VoiceState = self._voice_state["voice"] try: - session_id: str = data['session_id'] - token: str = data['token'] - endpoint: str = data['endpoint'] + session_id: str = data["session_id"] + token: str = data["token"] except KeyError: return - voice: Request = {'voice': {'sessionId': session_id, 'token': token, 'endpoint': endpoint}} - self._player_state.update(**voice) - - resp: dict[str, Any] = await self.current_node._send(method='PATCH', - path=f'sessions/{self.current_node._session_id}/players', - guild_id=self._guild.id, - data=voice) - - logger.debug(f'Player {self.guild.id} is dispatching VOICE_UPDATE: {resp}') - - def _connection_check(self, channel: VoiceChannel) -> None: - if channel.permissions_for(channel.guild.me).administrator: + endpoint: str | None = data.get("endpoint", None) + if not endpoint: return - if not channel.permissions_for(channel.guild.me).connect: - logger.debug(f'Player tried connecting to channel "{channel.id}", but does not have correct permissions.') + request: RequestPayload = {"voice": {"sessionId": session_id, "token": token, "endpoint": endpoint}} - raise InvalidChannelPermissions('You do not have connect permissions to join this channel.') + try: + await self.node._update_player(self.guild.id, data=request) + except LavalinkException: + await self.disconnect() + else: + self._connection_event.set() - limit: int = channel.user_limit - total: int = len(channel.members) + logger.debug(f"Player {self.guild.id} is dispatching VOICE_UPDATE.") - if limit == 0: - pass + async def connect( + self, *, timeout: float = 10.0, reconnect: bool, self_deaf: bool = False, self_mute: bool = False + ) -> None: + """ - elif total >= limit: - msg: str = f'There are currently too many users in this channel. <{total}/{limit}>' - logger.debug(f'Player tried connecting to channel "{channel.id}", but the it is full. <{total}/{limit}>') + .. warning:: + + Do not use this method directly on the player. See: :meth:`discord.VoiceChannel.connect` for more details. - raise InvalidChannelPermissions(msg) - async def connect(self, *, timeout: float, reconnect: bool, **kwargs: Any) -> None: - if self.channel is None: - self._invalidate(before_connect=True) + Pass the :class:`wavelink.Player` to ``cls=`` in :meth:`discord.VoiceChannel.connect`. - msg: str = 'Please use the method "discord.VoiceChannel.connect" and pass this player to cls=' - logger.debug(f'Player tried connecting without a channel. {msg}') - raise InvalidChannelStateError(msg) + Raises + ------ + ChannelTimeoutException + Connecting to the voice channel timed out. + InvalidChannelStateException + You tried to connect this player without an appropriate voice channel. + """ + if self.channel is MISSING: + msg: str = 'Please use "discord.VoiceChannel.connect(cls=...)" and pass this Player to cls.' + raise InvalidChannelStateException(f"Player tried to connect without a valid channel: {msg}") if not self._guild: self._guild = self.channel.guild - self.current_node._players[self._guild.id] = self - - try: - self._connection_check(self.channel) - except InvalidChannelPermissions as e: - self._invalidate(before_connect=True) - raise e + self.node._players[self._guild.id] = self - await self.channel.guild.change_voice_state(channel=self.channel, **kwargs) - logger.info(f'Player {self.guild.id} connected to channel: {self.channel}') + assert self.guild is not None + await self.guild.change_voice_state(channel=self.channel, self_mute=self_mute, self_deaf=self_deaf) - async def move_to(self, channel: discord.VoiceChannel) -> None: - """|coro| + try: + async with async_timeout.timeout(timeout): + await self._connection_event.wait() + except (asyncio.TimeoutError, asyncio.CancelledError): + msg = f"Unable to connect to {self.channel} as it exceeded the timeout of {timeout} seconds." + raise ChannelTimeoutException(msg) - Moves the player to a different voice channel. + async def move_to( + self, + channel: VocalGuildChannel | None, + *, + timeout: float = 10.0, + self_deaf: bool | None = None, + self_mute: bool | None = None, + ) -> None: + """Method to move the player to another channel. Parameters - ----------- - channel: :class:`discord.VoiceChannel` - The channel to move to. Must be a voice channel. + ---------- + channel: :class:`discord.VoiceChannel` | :class:`discord.StageChannel` + The new channel to move to. + timeout: float + The timeout in ``seconds`` before raising. Defaults to 10.0. + self_deaf: bool | None + Whether to deafen when moving. Defaults to ``None`` which keeps the current setting or ``False`` + if they can not be determined. + self_mute: bool | None + Whether to self mute when moving. Defaults to ``None`` which keeps the current setting or ``False`` + if they can not be determined. + + Raises + ------ + ChannelTimeoutException + Connecting to the voice channel timed out. + InvalidChannelStateException + You tried to connect this player without an appropriate guild. """ - self._connection_check(channel) + if not self.guild: + raise InvalidChannelStateException("Player tried to move without a valid guild.") + + self._connection_event.clear() + voice: discord.VoiceState | None = self.guild.me.voice + + if self_deaf is None and voice: + self_deaf = voice.self_deaf - await self.guild.change_voice_state(channel=channel) - logger.info(f'Player {self.guild.id} moved to channel: {channel}') + if self_mute is None and voice: + self_mute = voice.self_mute - async def play(self, - track: Playable | spotify.SpotifyTrack, - replace: bool = True, - start: int | None = None, - end: int | None = None, - volume: int | None = None, - *, - populate: bool = False - ) -> Playable: - """|coro| + self_deaf = bool(self_deaf) + self_mute = bool(self_mute) - Play a WaveLink Track. + await self.guild.change_voice_state(channel=channel, self_mute=self_mute, self_deaf=self_deaf) + + if channel is None: + return + + try: + async with async_timeout.timeout(timeout): + await self._connection_event.wait() + except (asyncio.TimeoutError, asyncio.CancelledError): + msg = f"Unable to connect to {channel} as it exceeded the timeout of {timeout} seconds." + raise ChannelTimeoutException(msg) + + async def play( + self, + track: Playable, + *, + replace: bool = True, + start: int = 0, + end: int | None = None, + volume: int | None = None, + paused: bool | None = None, + add_history: bool = True, + filters: Filters | None = None, + ) -> Playable: + """Play the provided :class:`~wavelink.Playable`. Parameters ---------- - track: :class:`tracks.Playable` - The :class:`tracks.Playable` or :class:`~wavelink.ext.spotify.SpotifyTrack` track to start playing. + track: :class:`~wavelink.Playable` + The track to being playing. replace: bool - Whether this track should replace the current track. Defaults to ``True``. - start: Optional[int] - The position to start the track at in milliseconds. - Defaults to ``None`` which will start the track at the beginning. + Whether this track should replace the currently playing track, if there is one. Defaults to ``True``. + start: int + The position to start playing the track at in milliseconds. + Defaults to ``0`` which will start the track from the beginning. end: Optional[int] The position to end the track at in milliseconds. - Defaults to ``None`` which means it will play until the end. + Defaults to ``None`` which means this track will play until the very end. volume: Optional[int] Sets the volume of the player. Must be between ``0`` and ``1000``. - Defaults to ``None`` which will not change the volume. - populate: bool - Whether to populate the AutoPlay queue. Defaults to ``False``. + Defaults to ``None`` which will not change the current volume. + See Also: :meth:`set_volume` + paused: bool | None + Whether the player should be paused, resumed or retain current status when playing this track. + Setting this parameter to ``True`` will pause the player. Setting this parameter to ``False`` will + resume the player if it is currently paused. Setting this parameter to ``None`` will not change the status + of the player. Defaults to ``None``. + add_history: Optional[bool] + If this argument is set to ``True``, the :class:`~Player` will add this track into the + :class:`wavelink.Queue` history, if loading the track was successful. If ``False`` this track will not be + added to your history. This does not directly affect the ``AutoPlay Queue`` but will alter how ``AutoPlay`` + recommends songs in the future. Defaults to ``True``. + filters: Optional[:class:`~wavelink.Filters`] + An Optional[:class:`~wavelink.Filters`] to apply when playing this track. Defaults to ``None``. + If this is ``None`` the currently set filters on the player will be applied. - .. versionadded:: 2.0 Returns ------- - :class:`~tracks.Playable` - The track that is now playing. - + :class:`~wavelink.Playable` + The track that began playing. - .. note:: - If you pass a :class:`~wavelink.YouTubeTrack` **or** :class:`~wavelink.ext.spotify.SpotifyTrack` and set - ``populate=True``, **while** :attr:`~wavelink.Player.autoplay` is set to ``True``, this method will populate - the ``auto_queue`` with recommended songs. When the ``auto_queue`` is low on tracks this method will - automatically populate the ``auto_queue`` with more tracks, and continue this cycle until either the - player has been disconnected or :attr:`~wavelink.Player.autoplay` is set to ``False``. + .. versionchanged:: 3.0.0 + Added the ``paused`` parameter. Parameters ``replace``, ``start``, ``end``, ``volume`` and ``paused`` + are now all keyword-only arguments. - Example - ------- - - .. code:: python3 - - tracks: list[wavelink.YouTubeTrack] = await wavelink.YouTubeTrack.search(...) - if not tracks: - # Do something as no tracks were found... - return - - await player.queue.put_wait(tracks[0]) + Added the ``add_history`` keyword-only argument. - if not player.is_playing(): - await player.play(player.queue.get(), populate=True) - - - .. versionchanged:: 2.6.0 - - This method now accepts :class:`~wavelink.YouTubeTrack` or :class:`~wavelink.ext.spotify.SpotifyTrack` - when populating the ``auto_queue``. + Added the ``filters`` keyword-only argument. """ - assert self._guild is not None - - if isinstance(track, YouTubeTrack) and self.autoplay and populate: - query: str = f'https://www.youtube.com/watch?v={track.identifier}&list=RD{track.identifier}' - - try: - recos: YouTubePlaylist = await self.current_node.get_playlist(query=query, cls=YouTubePlaylist) - recos: list[YouTubeTrack] = getattr(recos, 'tracks', []) + assert self.guild is not None - queues = set(self.queue) | set(self.auto_queue) | set(self.auto_queue.history) | {track} + original_vol: int = self._volume + vol: int = volume or self._volume - for track_ in recos: - if track_ in queues: - continue + if vol != self._volume: + self._volume = vol - await self.auto_queue.put_wait(track_) + if replace or not self._current: + self._current = track + self._original = track - self.auto_queue.shuffle() - except ValueError: - pass - - elif isinstance(track, spotify.SpotifyTrack): - original = track - track = await track.fulfill(player=self, cls=YouTubeTrack, populate=populate) - - if populate: - self.auto_queue.shuffle() - - for attr, value in original.__dict__.items(): - if hasattr(track, attr): - logger.warning(f'Player {self.guild.id} was unable to set attribute "{attr}" ' - f'when converting a SpotifyTrack as it conflicts with the new track type.') - continue + old_previous = self._previous + self._previous = self._current - setattr(track, attr, value) - - data = { - 'encodedTrack': track.encoded, - 'position': start or 0, - 'volume': volume or self._volume + pause: bool + if paused is not None: + pause = paused + else: + pause = self._paused + + if filters: + self._filters = filters + + request: RequestPayload = { + "encodedTrack": track.encoded, + "volume": vol, + "position": start, + "endTime": end, + "paused": pause, + "filters": self._filters(), } - if end: - data['endTime'] = end - - self._current = track - self._original = track - try: - - resp: dict[str, Any] = await self.current_node._send( - method='PATCH', - path=f'sessions/{self.current_node._session_id}/players', - guild_id=self._guild.id, - data=data, - query=f'noReplace={not replace}' - ) - - except InvalidLavalinkResponse as e: + await self.node._update_player(self.guild.id, data=request, replace=replace) + except LavalinkException as e: self._current = None self._original = None - logger.debug(f'Player {self._guild.id} attempted to load track: {track}, but failed: {e}') + self._previous = old_previous + self._volume = original_vol raise e - self._player_state['track'] = resp['track']['encoded'] + self._paused = pause - if not (self.queue.loop and self.queue._loaded): + if add_history: + assert self.queue.history is not None self.queue.history.put(track) - self.queue._loaded = track - - logger.debug(f'Player {self._guild.id} loaded and started playing track: {track}.') return track - async def set_volume(self, value: int) -> None: - """|coro| - - Set the Player volume. + async def pause(self, value: bool, /) -> None: + """Set the paused or resume state of the player. Parameters ---------- - value: int - A volume value between 0 and 1000. - """ - assert self._guild is not None + value: bool + A bool indicating whether the player should be paused or resumed. True indicates that the player should be + ``paused``. False will resume the player if it is currently paused. - self._volume = max(min(value, 1000), 0) - resp: dict[str, Any] = await self.current_node._send(method='PATCH', - path=f'sessions/{self.current_node._session_id}/players', - guild_id=self._guild.id, - data={'volume': self._volume}) + .. versionchanged:: 3.0.0 + + This method now expects a positional-only bool value. The ``resume`` method has been removed. + """ + assert self.guild is not None - logger.debug(f'Player {self.guild.id} volume was set to {self._volume}.') + request: RequestPayload = {"paused": value} + await self.node._update_player(self.guild.id, data=request) - async def seek(self, position: int) -> None: - """|coro| + self._paused = value - Seek to the provided position, in milliseconds. + async def seek(self, position: int = 0, /) -> None: + """Seek to the provided position in the currently playing track, in milliseconds. Parameters ---------- position: int - The position to seek to in milliseconds. + The position to seek to in milliseconds. To restart the song from the beginning, + you can disregard this parameter or set position to 0. + + + .. versionchanged:: 3.0.0 + + The ``position`` parameter is now positional-only, and has a default of 0. """ + assert self.guild is not None + if not self._current: return - resp: dict[str, Any] = await self.current_node._send(method='PATCH', - path=f'sessions/{self.current_node._session_id}/players', - guild_id=self._guild.id, - data={'position': position}) + request: RequestPayload = {"position": position} + await self.node._update_player(self.guild.id, data=request) + + async def set_filters(self, filters: Filters | None = None, /, *, seek: bool = False) -> None: + """Set the :class:`wavelink.Filters` on the player. - logger.debug(f'Player {self.guild.id} seeked current track to position {position}.') + Parameters + ---------- + filters: Optional[:class:`~wavelink.Filters`] + The filters to set on the player. Could be ```None`` to reset the currently applied filters. + Defaults to ``None``. + seek: bool + Whether to seek immediately when applying these filters. Seeking uses more resources, but applies the + filters immediately. Defaults to ``False``. - async def pause(self) -> None: - """|coro| - Pauses the Player. - """ - assert self._guild is not None + .. versionchanged:: 3.0.0 - resp: dict[str, Any] = await self.current_node._send(method='PATCH', - path=f'sessions/{self.current_node._session_id}/players', - guild_id=self._guild.id, - data={'paused': True}) + This method now accepts a positional-only argument of filters, which now defaults to None. Filters + were redesigned in this version, see: :class:`wavelink.Filters`. - self._paused = True - logger.debug(f'Player {self.guild.id} was paused.') - async def resume(self) -> None: - """|coro| + .. versionchanged:: 3.0.0 - Resumes the Player. + This method was previously known as ``set_filter``. """ - assert self._guild is not None + assert self.guild is not None - resp: dict[str, Any] = await self.current_node._send(method='PATCH', - path=f'sessions/{self.current_node._session_id}/players', - guild_id=self._guild.id, - data={'paused': False}) + if filters is None: + filters = Filters() - self._paused = False - logger.debug(f'Player {self.guild.id} was resumed.') + request: RequestPayload = {"filters": filters()} + await self.node._update_player(self.guild.id, data=request) + self._filters = filters - async def stop(self, *, force: bool = True) -> None: - """|coro| + if self.playing and seek: + await self.seek(self.position) - Stops the currently playing Track. + async def set_volume(self, value: int = 100, /) -> None: + """Set the :class:`Player` volume, as a percentage, between 0 and 1000. + + By default, every player is set to 100 on creation. If a value outside 0 to 1000 is provided it will be + clamped. Parameters ---------- - force: Optional[bool] - Whether to stop the currently playing track and proceed to the next regardless if :attr:`~Queue.loop` - is ``True``. Defaults to ``True``. + value: int + A volume value between 0 and 1000. To reset the player to 100, you can disregard this parameter. - .. versionchanged:: 2.6 + .. versionchanged:: 3.0.0 - Added the ``force`` keyword argument. + The ``value`` parameter is now positional-only, and has a default of 100. """ - assert self._guild is not None + assert self.guild is not None + vol: int = max(min(value, 1000), 0) - if force: - self.queue._loaded = None + request: RequestPayload = {"volume": vol} + await self.node._update_player(self.guild.id, data=request) - resp: dict[str, Any] = await self.current_node._send(method='PATCH', - path=f'sessions/{self.current_node._session_id}/players', - guild_id=self._guild.id, - data={'encodedTrack': None}) + self._volume = vol - self._player_state['track'] = None - logger.debug(f'Player {self.guild.id} was stopped.') + async def disconnect(self, **kwargs: Any) -> None: + """Disconnect the player from the current voice channel and remove it from the :class:`~wavelink.Node`. - async def set_filter( - self, - _filter: Filter, - /, *, - seek: bool = False - ) -> None: - """|coro| + This method will cause any playing track to stop and potentially trigger the following events: - Set the player's filter. + - ``on_wavelink_track_end`` + - ``on_wavelink_websocket_closed`` - Parameters - ---------- - filter: :class:`wavelink.Filter` - The filter to apply to the player. - seek: bool - Whether to seek the player to its current position - which will apply the filter immediately. Defaults to ``False``. - """ - assert self._guild is not None - - self._filter = _filter - data: Request = {"filters": _filter._payload} - - resp: dict[str, Any] = await self.current_node._send(method='PATCH', - path=f'sessions/{self.current_node._session_id}/players', - guild_id=self._guild.id, - data=data) + .. warning:: - if self.is_playing() and seek: - await self.seek(int(self.position)) + Please do not re-use a :class:`Player` instance that has been disconnected, unwanted side effects are + possible. + """ + assert self.guild - logger.debug(f'Player {self.guild.id} set filter to: {_filter}') + await self._destroy() + await self.guild.change_voice_state(channel=None) - def _invalidate(self, *, silence: bool = False, before_connect: bool = False) -> None: - self.current_node._players.pop(self._guild.id, None) + async def stop(self, *, force: bool = True) -> Playable | None: + """An alias to :meth:`skip`. - if not before_connect: - self.current_node._invalidated[self._guild.id] = self + See Also: :meth:`skip` for more information. - try: - self.cleanup() - except AttributeError: - pass - except Exception as e: - logger.debug(f'Failed to cleanup player, most likely due to never having been connected: {e}') + .. versionchanged:: 3.0.0 - self._voice_state = {} - self._player_state = {} - self.channel = None + This method is now known as ``skip``, but the alias ``stop`` has been kept for backwards compatability. + """ + return await self.skip(force=force) - if not silence: - logger.debug(f'Player {self._guild.id} was invalidated.') + async def skip(self, *, force: bool = True) -> Playable | None: + """Stop playing the currently playing track. - async def _destroy(self, *, guild_id: int | None = None) -> None: - if self._destroyed: - return + Parameters + ---------- + force: bool + Whether the track should skip looping, if :class:`wavelink.Queue` has been set to loop. + Defaults to ``True``. - self._invalidate(silence=True) + Returns + ------- + :class:`~wavelink.Playable` | None + The currently playing track that was skipped, or ``None`` if no track was playing. - guild_id = guild_id or self._guild.id - await self.current_node._send(method='DELETE', - path=f'sessions/{self.current_node._session_id}/players', - guild_id=guild_id) + .. versionchanged:: 3.0.0 - self._destroyed = True - self.current_node._invalidated.pop(guild_id, None) - logger.debug(f'Player {guild_id} was destroyed.') + This method was previously known as ``stop``. To avoid confusion this method is now known as ``skip``. + This method now returns the :class:`~wavelink.Playable` that was skipped. + """ + assert self.guild is not None + old: Playable | None = self._current - async def disconnect(self, **kwargs) -> None: - """|coro| + if force: + self.queue._loaded = None - Disconnect the Player from voice and cleanup the Player state. + request: RequestPayload = {"encodedTrack": None} + await self.node._update_player(self.guild.id, data=request, replace=True) - .. versionchanged:: 2.5 + return old - The discord.py Voice Client cache and Player are invalidated as soon as this is called. - """ - self._invalidate() - await self.guild.change_voice_state(channel=None) + def _invalidate(self) -> None: + self._connected = False + self._connection_event.clear() - logger.debug(f'Player {self._guild.id} was disconnected.') + try: + self.cleanup() + except (AttributeError, KeyError): + pass - async def _swap_state(self) -> None: - assert self._guild is not None + async def _destroy(self) -> None: + assert self.guild - try: - self._player_state['track'] - except KeyError: - return + self._invalidate() + player: Player | None = self.node._players.pop(self.guild.id, None) - data: EncodedTrackRequest = {'encodedTrack': self._player_state['track'], 'position': self.position} - resp: dict[str, Any] = await self.current_node._send(method='PATCH', - path=f'sessions/{self.current_node._session_id}/players', - guild_id=self._guild.id, - data=data) + if player: + try: + await self.node._destroy_player(self.guild.id) + except LavalinkException: + pass - logger.debug(f'Player {self.guild.id} is swapping State: {resp}') + def _add_to_previous_seeds(self, seed: str) -> None: + # Helper method to manage previous seeds. + if self.__previous_seeds.full(): + self.__previous_seeds.get_nowait() + self.__previous_seeds.put_nowait(seed) diff --git a/wavelink/py.typed b/wavelink/py.typed new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/wavelink/py.typed @@ -0,0 +1 @@ + diff --git a/wavelink/queue.py b/wavelink/queue.py index 452451dd..3c04307f 100644 --- a/wavelink/queue.py +++ b/wavelink/queue.py @@ -1,7 +1,7 @@ """ MIT License -Copyright (c) 2019-Present PythonistaGuild +Copyright (c) 2019-Current PythonistaGuild, EvieePy Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -24,529 +24,224 @@ from __future__ import annotations import asyncio -from collections import deque -from collections.abc import AsyncIterator, Iterable, Iterator -from copy import copy import random +from collections import deque +from collections.abc import Iterator +from typing import overload +from .enums import QueueMode from .exceptions import QueueEmpty -from .tracks import Playable, YouTubePlaylist, SoundCloudPlaylist -from .ext import spotify - - -__all__ = ( - 'BaseQueue', - 'Queue' -) - - -class BaseQueue: - """BaseQueue for wavelink. - - All queues inherit from this queue. - - See :class:`Queue` for the default :class:`~wavelink.Player` queue. - Internally this queue uses a :class:`collections.deque`. - - .. warning:: - - It is not advisable to edit the internal :class:`collections.deque` directly. - - - .. container:: operations - - .. describe:: str(queue) - - Returns a string showing all Playable objects appearing as a list in the queue. - - .. describe:: repr(queue) - - Returns an official string representation of this queue. - - .. describe:: if queue - - Returns True if members are in the queue. False if the queue is empty. - - .. describe:: queue(track) - - Adds a member to the queue. - - .. describe:: len(queue) - - Returns an int with the count of members in this queue. - - .. describe:: queue[2] - - Returns a member at the given position. - Does **not** remove the item from queue. - - .. describe:: queue[4] = track - - Inserts an item into the queue at the given position. - - .. describe:: del queue[1] - - Deletes a member from the queue at the given position. - - .. describe:: for track in queue +from .tracks import Playable, Playlist - Iterates over the queue. - Does **not** remove items when iterating. +__all__ = ("Queue",) - .. describe:: reversed(queue) - - Reverse a reversed version of the queue. - - .. describe:: if track in queue - - Checks whether a track is in the queue. - - .. describe:: queue = queue + [track, track1, track2, ...] - - Return a new queue containing all new and old members from the given iterable. - - .. describe:: queue += [track, track1, track2, ...] - - Add items to queue from the given iterable. - """ +class _Queue: def __init__(self) -> None: - self._queue: deque[Playable, spotify.SpotifyTrack] = deque() + self._queue: deque[Playable] = deque() def __str__(self) -> str: - """String showing all Playable objects appearing as a list.""" - return str([f"'{t}'" for t in self]) + return ", ".join([f'"{p}"' for p in self]) def __repr__(self) -> str: - """Official representation displaying member count.""" - return ( - f"BaseQueue(member_count={self.count})") + return f"BaseQueue(items={len(self._queue)})" def __bool__(self) -> bool: - """Treats the queue as a ``bool``, with it evaluating ``True`` when it contains members. - - Example - ------- - - .. code:: python3 - - if player.queue: - # queue contains members, do something... - """ - return bool(self.count) - - def __call__(self, item: Playable | spotify.SpotifyTrack) -> None: - """Allows the queue instance to be called directly in order to add a member. - - Example - ------- + return bool(self._queue) - .. code:: python3 - - player.queue(track) # adds track to the queue... - """ + def __call__(self, item: Playable | Playlist) -> None: self.put(item) def __len__(self) -> int: - """Return the number of members in the queue. - - Example - ------- - - .. code:: python3 - - print(len(player.queue)) - """ - return self.count - - def __getitem__(self, index: int) -> Playable | spotify.SpotifyTrack: - """Returns a member at the given position. + return len(self._queue) - Does **not** remove the item from queue. + @overload + def __getitem__(self, index: int) -> Playable: + ... - Example - ------- + @overload + def __getitem__(self, index: slice) -> list[Playable]: + ... - .. code:: python3 - - track = player.queue[2] - """ - if not isinstance(index, int): - raise ValueError("'int' type required.'") + def __getitem__(self, index: int | slice) -> Playable | list[Playable]: + if isinstance(index, slice): + return list(self._queue)[index] return self._queue[index] - def __setitem__(self, index: int, item: Playable | spotify.SpotifyTrack): - """Inserts an item at the given position. - - Example - ------- - - .. code:: python3 - - player.queue[4] = track - """ - 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. - - Example - ------- - - .. code:: python3 - - del player.queue[1] - """ - self._queue.__delitem__(index) - - def __iter__(self) -> Iterator[Playable | spotify.SpotifyTrack]: - """Iterate over members in the queue. - - Does **not** remove items when iterating. - - Example - ------- - - .. code:: python3 - - for track in player.queue: - print(track) - """ + def __iter__(self) -> Iterator[Playable]: return self._queue.__iter__() - def __reversed__(self) -> Iterator[Playable | spotify.SpotifyTrack]: - """Iterate over members in a reverse order. - - Example - ------- - - .. code:: python3 - - for track in reversed(player.queue): - print(track) - """ - return self._queue.__reversed__() - - def __contains__(self, item: Playable | spotify.SpotifyTrack) -> bool: - """Check if a track is a member of the queue. - - Example - ------- - - .. code:: python3 - - if track in player.queue: - # track is in the queue... - """ + def __contains__(self, item: object) -> bool: return item in self._queue - def __add__(self, other: Iterable[Playable | spotify.SpotifyTrack]): - """Return a new queue containing all members, including old members. - - Example - ------- - - .. code:: python3 - - player.queue = player.queue + [track1, track2, ...] - """ - 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: Iterable[Playable] | Playable): - """Add items to queue from an iterable. - - Example - ------- - - .. code:: python3 - - player.queue += [track1, track2, ...] - """ - if isinstance(other, (Playable, spotify.SpotifyTrack)): - 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) -> Playable | spotify.SpotifyTrack: - if self.is_empty: - raise QueueEmpty("No items currently in the queue.") - - return self._queue.popleft() - - def _drop(self) -> Playable | spotify.SpotifyTrack: - return self._queue.pop() - - def _index(self, item: Playable | spotify.SpotifyTrack) -> int: - return self._queue.index(item) - - def _put(self, item: Playable | spotify.SpotifyTrack) -> None: - self._queue.append(item) - - def _insert(self, index: int, item: Playable | spotify.SpotifyTrack) -> None: - self._queue.insert(index, item) - @staticmethod - def _check_playable(item: Playable | spotify.SpotifyTrack) -> Playable | spotify.SpotifyTrack: - if not isinstance(item, (Playable, spotify.SpotifyTrack)): - raise TypeError("Only Playable objects are supported.") - - return item - - @classmethod - def _check_playable_container(cls, iterable: Iterable) -> list[Playable | spotify.SpotifyTrack]: - iterable = list(iterable) - - for item in iterable: - cls._check_playable(item) + def _check_compatability(item: object) -> None: + if not isinstance(item, Playable): + raise TypeError("This queue is restricted to Playable objects.") - return iterable + def _get(self) -> Playable: + if not self: + raise QueueEmpty("There are no items currently in this queue.") - @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) - - def get(self) -> Playable | spotify.SpotifyTrack: - """Return next immediately available item in queue if any. - - Raises :exc:`~wavelink.QueueEmpty` if no items in queue. - """ - if self.is_empty: - raise QueueEmpty("No items currently in the queue.") + return self._queue.popleft() + def get(self) -> Playable: return self._get() - def pop(self) -> Playable | spotify.SpotifyTrack: - """Return item from the right end side of the queue. - - Raises :exc:`~wavelink.QueueEmpty` if no items in queue. - """ - if self.is_empty: - raise QueueEmpty("No items currently in the queue.") - - return self._queue.pop() - - def find_position(self, item: Playable | spotify.SpotifyTrack) -> int: - """Find the position a given item within the queue. + def _check_atomic(self, item: list[Playable] | Playlist) -> None: + for track in item: + self._check_compatability(track) - Raises :exc:`ValueError` if item is not in the queue. - """ - return self._index(self._check_playable(item)) - - def put(self, item: Playable | spotify.SpotifyTrack) -> None: - """Put the given item into the back of the queue. - - If the provided ``item`` is a :class:`~wavelink.YouTubePlaylist` or :class:`~wavelink.SoundCloudPlaylist`, all - tracks from this playlist will be put into the queue. - - - .. note:: - - Inserting playlists is currently only supported via this method, which means you can only insert them into - the back of the queue. Future versions of wavelink may add support for inserting playlists from a specific - index, or at the front of the queue. + def _put(self, item: Playable) -> None: + self._check_compatability(item) + self._queue.append(item) + def put(self, item: Playable | Playlist, /, *, atomic: bool = True) -> int: + added: int = 0 - .. versionchanged:: 2.6.0 + if isinstance(item, Playlist): + if atomic: + self._check_atomic(item) - Added support for directly adding a :class:`~wavelink.YouTubePlaylist` or :class:`~wavelink.SoundCloudPlaylist` to the queue. - """ - self._check_playable(item) + for track in item: + try: + self._put(track) + added += 1 + except TypeError: + pass - if isinstance(item, (YouTubePlaylist, SoundCloudPlaylist)): - for track in item.tracks: - self._put(track) else: self._put(item) + added += 1 - def put_at_index(self, index: int, item: Playable | spotify.SpotifyTrack) -> None: - """Put the given item into the queue at the specified index.""" - self._insert(index, self._check_playable(item)) - - def put_at_front(self, item: Playable | spotify.SpotifyTrack) -> None: - """Put the given item into the front of the queue.""" - self.put_at_index(0, item) - - def shuffle(self) -> None: - """Shuffles the queue in place. This does **not** return anything. - - Example - ------- - - .. code:: python3 + return added - player.queue.shuffle() - # Your queue has now been shuffled... - - - .. versionadded:: 2.5 - """ - random.shuffle(self._queue) - def extend(self, iterable: Iterable[Playable | spotify.SpotifyTrack], *, atomic: bool = True) -> None: - """Add the members of the given iterable to the end of the queue. +class Queue(_Queue): + """The default custom wavelink Queue designed specifically for :class:`wavelink.Player`. - 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. - """ - if atomic: - iterable = self._check_playable_container(iterable) - - for item in iterable: - self.put(item) - - def copy(self): - """Create a copy of the current queue including its members.""" - new_queue = self.__class__() - new_queue._queue = copy(self._queue) + .. container:: operations - return new_queue + .. describe:: str(queue) - def clear(self) -> None: - """Remove all items from the queue. + A string representation of this queue. + .. describe:: repr(queue) - .. note:: + The official string representation of this queue. - This does not reset the queue. See :meth:`~Queue.reset` for resetting the :class:`Queue` assigned to the - player. - """ - self._queue.clear() + .. describe:: if queue + Bool check whether this queue has items or not. -class Queue(BaseQueue): - """Main Queue class. + .. describe:: queue(track) - **All** :class:`~wavelink.Player` have this queue assigned to them. + Put a track in the queue. - .. note:: + .. describe:: len(queue) - This queue inherits from :class:`BaseQueue` but has access to special async methods and loop logic. + The amount of tracks in the queue. - .. warning:: + .. describe:: queue[1] - The :attr:`.history` queue is a :class:`BaseQueue` and has **no** access to async methods or loop logic. + Peek at an item in the queue. Does not change the queue. + .. describe:: for item in queue - .. container:: operations + Iterate over the queue. - .. describe:: async for track in queue + .. describe:: if item in queue - Pops members as it iterates the queue asynchronously, waiting for new members when exhausted. - **Does** remove items when iterating. + Check whether a specific track is in the queue. Attributes ---------- - history: :class:`BaseQueue` - The history queue stores information about all previous played tracks for the :class:`~wavelink.Player`'s - session. + history: :class:`wavelink.Queue` + A queue of tracks that have been added to history. + + Even though the history queue is the same class as this Queue some differences apply. + Mainly you can not set the ``mode``. """ - def __init__(self): + def __init__(self, history: bool = True) -> None: super().__init__() - self.history: BaseQueue = BaseQueue() + self.history: Queue | None = None - self._loop: bool = False - self._loop_all: bool = False + if history: + self.history = Queue(history=False) - self._loaded = None - self._waiters = deque() - self._finished = asyncio.Event() + self._loaded: Playable | None = None + self._mode: QueueMode = QueueMode.normal + self._waiters: deque[asyncio.Future[None]] = deque() + self._finished: asyncio.Event = asyncio.Event() self._finished.set() - async def __aiter__(self) -> AsyncIterator[Playable | spotify.SpotifyTrack]: - """Pops members as it iterates the queue, waiting for new members when exhausted. + self._lock: asyncio.Lock = asyncio.Lock() - **Does** remove items when iterating. - - Example - ------- - - .. code:: python3 + def __str__(self) -> str: + return ", ".join([f'"{p}"' for p in self]) - async for track in player.queue: - # If there is no item in the queue, this will wait for an item to be inserted. + def __repr__(self) -> str: + return f"Queue(items={len(self)}, history={self.history!r})" - # Do something with track here... - """ - while True: - yield await self.get_wait() + def _wakeup_next(self) -> None: + while self._waiters: + waiter = self._waiters.popleft() - def get(self) -> Playable | spotify.SpotifyTrack: - return self._get() + if not waiter.done(): + waiter.set_result(None) + break - def _get(self) -> Playable | spotify.SpotifyTrack: - if self.loop and self._loaded: + def _get(self) -> Playable: + if self.mode is QueueMode.loop and self._loaded: return self._loaded - if self.loop_all and self.is_empty: + if self.mode is QueueMode.loop_all and not self: + assert self.history is not None + self._queue.extend(self.history._queue) self.history.clear() - item = super()._get() + track: Playable = super()._get() + self._loaded = track - self._loaded = item - return item + return track - async def _put(self, item: Playable | spotify.SpotifyTrack) -> None: - self._check_playable(item) + def get(self) -> Playable: + """Retrieve a track from the left side of the queue. E.g. the first. - if isinstance(item, (YouTubePlaylist, SoundCloudPlaylist)): - for track in item.tracks: - super()._put(track) - await asyncio.sleep(0) - else: - super()._put(item) - await asyncio.sleep(0) - - self._wakeup_next() + This method does not block. - def _insert(self, index: int, item: Playable | spotify.SpotifyTrack) -> None: - super()._insert(index, item) - self._wakeup_next() + Returns + ------- + :class:`wavelink.Playable` + The track retrieved from the queue. - def _wakeup_next(self) -> None: - while self._waiters: - waiter = self._waiters.popleft() - if not waiter.done(): - waiter.set_result(None) - break + Raises + ------ + QueueEmpty + The queue was empty when retrieving a track. + """ + return self._get() - async def get_wait(self) -> Playable | spotify.SpotifyTrack: - """|coro| + async def get_wait(self) -> Playable: + """This method returns the first :class:`wavelink.Playable` if one is present or + waits indefinitely until one is. - Return the next item in queue once available. + This method is asynchronous. - .. note:: + Returns + ------- + :class:`wavelink.Playable` + The track retrieved from the queue. - This will wait until an item is available to be retrieved. """ - while self.is_empty: - loop = asyncio.get_event_loop() - waiter = loop.create_future() + while not self: + loop: asyncio.AbstractEventLoop = asyncio.get_event_loop() + waiter: asyncio.Future[None] = loop.create_future() self._waiters.append(waiter) @@ -560,130 +255,151 @@ async def get_wait(self) -> Playable | spotify.SpotifyTrack: except ValueError: # pragma: no branch pass - if not self.is_empty and not waiter.cancelled(): # pragma: no cover + if self and not waiter.cancelled(): # pragma: no cover # something went wrong with this waiter, move on to next self._wakeup_next() raise return self.get() - async def put_wait(self, item: Playable | spotify.SpotifyTrack) -> None: - """|coro| + def put(self, item: Playable | Playlist, /, *, atomic: bool = True) -> int: + """Put an item into the end of the queue. - Put an item into the queue asynchronously using ``await``. + Accepts a :class:`wavelink.Playable` or :class:`wavelink.Playlist` - If the provided ``item`` is a :class:`~wavelink.YouTubePlaylist` or :class:`~wavelink.SoundCloudPlaylist`, all - tracks from this playlist will be put into the queue. + Parameters + ---------- + item: :class:`wavelink.Playable` | :class:`wavelink.Playlist` + The item to enter into the queue. + atomic: bool + Whether the items should be inserted atomically. If set to ``True`` this method won't enter any tracks if + it encounters an error. Defaults to ``True`` - .. note:: - - Inserting playlists is currently only supported via this method, which means you can only insert them into - the back of the queue. Future versions of wavelink may add support for inserting playlists from a specific - index, or at the front of the queue. - - - .. versionchanged:: 2.6.0 - - Added support for directly adding a :class:`~wavelink.YouTubePlaylist` or :class:`~wavelink.SoundCloudPlaylist` to the queue. - - + Returns + ------- + int + The amount of tracks added to the queue. """ - await self._put(item) + added: int = super().put(item, atomic=atomic) - def put(self, item: Playable | spotify.SpotifyTrack) -> None: - """Put the given item into the back of the queue. + self._wakeup_next() + return added - If the provided ``item`` is a :class:`~wavelink.YouTubePlaylist` or :class:`~wavelink.SoundCloudPlaylist`, all - tracks from this playlist will be put into the queue. + async def put_wait(self, item: list[Playable] | Playable | Playlist, /, *, atomic: bool = True) -> int: + """Put an item or items into the end of the queue asynchronously. + Accepts a :class:`wavelink.Playable` or :class:`wavelink.Playlist` or list[:class:`wavelink.Playable`] .. note:: - Inserting playlists is currently only supported via this method, which means you can only insert them into - the back of the queue. Future versions of wavelink may add support for inserting playlists from a specific - index, or at the front of the queue. + This method implements a lock to preserve insert order. + Parameters + ---------- + item: :class:`wavelink.Playable` | :class:`wavelink.Playlist` | list[:class:`wavelink.Playable`] + The item or items to enter into the queue. + atomic: bool + Whether the items should be inserted atomically. If set to ``True`` this method won't enter any tracks if + it encounters an error. Defaults to ``True`` - .. versionchanged:: 2.6.0 - Added support for directly adding a :class:`~wavelink.YouTubePlaylist` or :class:`~wavelink.SoundCloudPlaylist` to the queue. + Returns + ------- + int + The amount of tracks added to the queue. """ - self._check_playable(item) + added: int = 0 - if isinstance(item, (YouTubePlaylist, SoundCloudPlaylist)): - for track in item.tracks: - super()._put(track) - else: - super()._put(item) + async with self._lock: + if isinstance(item, list | Playlist): + if atomic: + super()._check_atomic(item) - self._wakeup_next() + for track in item: + try: + super()._put(track) + added += 1 + except TypeError: + pass - def reset(self) -> None: - """Clears the state of all queues, including the history queue. + await asyncio.sleep(0) - - sets loop and loop_all to ``False``. - - removes all items from the queue and history queue. - - cancels any waiting queues. - """ - self.clear() - self.history.clear() + else: + super()._put(item) + added += 1 + await asyncio.sleep(0) - for waiter in self._waiters: - waiter.cancel() + self._wakeup_next() + return added - self._waiters.clear() + async def delete(self, index: int, /) -> None: + """Method to delete an item in the queue by index. - self._loaded = None - self._loop = False - self._loop_all = False + This method is asynchronous and implements/waits for a lock. - @property - def loop(self) -> bool: - """Whether the queue will loop the currently playing song. + Raises + ------ + IndexError + No track exists at this index. - Can be set to True or False. - Defaults to False. - Returns - ------- - bool + Examples + -------- + .. code:: python3 - .. versionadded:: 2.0 + await queue.delete(1) + # Deletes the track at index 1 (The second track). """ - return self._loop + async with self._lock: + self._queue.__delitem__(index) - @loop.setter - def loop(self, value: bool) -> None: - if not isinstance(value, bool): - raise ValueError('The "loop" property can only be set with a bool.') + def shuffle(self) -> None: + """Shuffles the queue in place. This does **not** return anything. - self._loop = value + Example + ------- - @property - def loop_all(self) -> bool: - """Whether the queue will loop all songs in the history queue. + .. code:: python3 + + player.queue.shuffle() + # Your queue has now been shuffled... + """ + random.shuffle(self._queue) + + def clear(self) -> None: + """Remove all items from the queue. - Can be set to True or False. - Defaults to False. .. note:: - If `loop` is set to True, this has no effect until `loop` is set to False. + This does not reset the queue. - Returns + Example ------- - bool + .. code:: python3 + + player.queue.clear() + # Your queue is now empty... + """ + self._queue.clear() + + @property + def mode(self) -> QueueMode: + """Property which returns a :class:`~wavelink.QueueMode` indicating which mode the + :class:`~wavelink.Queue` is in. + + This property can be set with any :class:`~wavelink.QueueMode`. - .. versionadded:: 2.0 + .. versionadded:: 3.0.0 """ - return self._loop_all + return self._mode - @loop_all.setter - def loop_all(self, value: bool) -> None: - if not isinstance(value, bool): - raise ValueError('The "loop_all" property can only be set with a bool.') + @mode.setter + def mode(self, value: QueueMode) -> None: + if not hasattr(self, "_mode"): + raise AttributeError("This queues mode can not be set.") - self._loop_all = value + self._mode = value diff --git a/wavelink/tracks.py b/wavelink/tracks.py index b55891c7..42283a86 100644 --- a/wavelink/tracks.py +++ b/wavelink/tracks.py @@ -1,7 +1,7 @@ """ MIT License -Copyright (c) 2019-Present PythonistaGuild +Copyright (c) 2019-Current PythonistaGuild, EvieePy Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -23,120 +23,116 @@ """ from __future__ import annotations -import abc -from typing import TYPE_CHECKING, ClassVar, Literal, overload, Optional, Any +from collections.abc import Iterator +from typing import TYPE_CHECKING, Any, TypeAlias, overload -import aiohttp import yarl -from discord.ext import commands + +import wavelink from .enums import TrackSource -from .exceptions import NoTracksError -from .node import Node, NodePool if TYPE_CHECKING: - from typing_extensions import Self + from .types.tracks import ( + PlaylistInfoPayload, + PlaylistPayload, + TrackInfoPayload, + TrackPayload, + ) - from .types.track import Track as TrackPayload -__all__ = ( - 'Playable', - 'Playlist', - 'YouTubeTrack', - 'GenericTrack', - 'YouTubeMusicTrack', - 'SoundCloudTrack', - 'YouTubePlaylist', - 'SoundCloudPlaylist' -) +__all__ = ("Search", "Album", "Artist", "Playable", "Playlist", "PlaylistInfo") -_source_mapping: dict[str, TrackSource] = { - 'youtube': TrackSource.YouTube +_source_mapping: dict[TrackSource | str | None, str] = { + TrackSource.YouTube: "ytsearch", + TrackSource.SoundCloud: "scsearch", + TrackSource.YouTubeMusic: "ytmsearch", } -class Playlist(metaclass=abc.ABCMeta): - """An ABC that defines the basic structure of a lavalink playlist resource. +Search: TypeAlias = "list[Playable] | Playlist" + + +class Album: + """Container class representing Album data received via Lavalink. Attributes ---------- - data: Dict[str, Any] - The raw data supplied by Lavalink. + name: str | None + The album name. Could be ``None``. + url: str | None + The album url. Could be ``None``. """ - def __init__(self, data: dict[str, Any]): - self.data: dict[str, Any] = data + def __init__(self, *, data: dict[Any, Any]) -> None: + self.name: str | None = data.get("albumName") + self.url: str | None = data.get("albumUrl") + + +class Artist: + """Container class representing Artist data received via Lavalink. + + Attributes + ---------- + url: str | None + The artist url. Could be ``None``. + artwork: str | None + The artist artwork url. Could be ``None``. + """ + def __init__(self, *, data: dict[Any, Any]) -> None: + self.url: str | None = data.get("artistUrl") + self.artwork: str | None = data.get("artistArtworkUrl") -class Playable(metaclass=abc.ABCMeta): - """Base ABC Track used in all the Wavelink Track types. +class Playable: + """The Wavelink Playable object which represents all tracks in Wavelink 3. + + .. note:: + + You should not construct this class manually. .. container:: operations .. describe:: str(track) - Returns a string representing the tracks name. + The title of this playable. .. describe:: repr(track) - Returns an official string representation of this track. - - .. describe:: track == other_track - - Check whether a track is equal to another. A track is equal when they have the same Base64 Encoding. + The official string representation of this playable. + .. describe:: track == other - Attributes - ---------- - data: dict[str, Any] - The raw data received via Lavalink. - encoded: str - The encoded Track string. - is_seekable: bool - Whether the Track is seekable. - is_stream: bool - Whether the Track is a stream. - length: int - The length of the track in milliseconds. - duration: int - An alias for length. - position: int - The position the track will start in milliseconds. Defaults to 0. - title: str - The Track title. - source: :class:`wavelink.TrackSource` - The source this Track was fetched from. - uri: Optional[str] - The URI of this track. Could be None. - author: Optional[str] - The author of this track. Could be None. - identifier: Optional[str] - The Youtube/YoutubeMusic identifier for this track. Could be None. + Whether this track is equal to another. Checks both the track encoding and identifier. """ - PREFIX: ClassVar[str] = '' - - def __init__(self, data: TrackPayload) -> None: - self.data: TrackPayload = data - self.encoded: str = data['encoded'] + def __init__(self, data: TrackPayload, *, playlist: PlaylistInfo | None = None) -> None: + info: TrackInfoPayload = data["info"] - info = data['info'] - self.is_seekable: bool = info.get('isSeekable', False) - self.is_stream: bool = info.get('isStream', False) - self.length: int = info.get('length', 0) - self.duration: int = self.length - self.position: int = info.get('position', 0) + self._encoded: str = data["encoded"] + self._identifier: str = info["identifier"] + self._is_seekable: bool = info["isSeekable"] + self._author: str = info["author"] + self._length: int = info["length"] + self._is_stream: bool = info["isStream"] + self._position: int = info["position"] + self._title: str = info["title"] + self._uri: str | None = info.get("uri") + self._artwork: str | None = info.get("artworkUrl") + self._isrc: str | None = info.get("isrc") + self._source: str = info["sourceName"] - self.title: str = info.get('title', 'Unknown Title') + plugin: dict[Any, Any] = data["pluginInfo"] + self._album: Album = Album(data=plugin) + self._artist: Artist = Artist(data=plugin) - source: str | None = info.get('sourceName') - self.source: TrackSource = _source_mapping.get(source, TrackSource.Unknown) + self._preview_url: str | None = plugin.get("previewUrl") + self._is_preview: bool | None = plugin.get("isPreview") - self.uri: str | None = info.get('uri') - self.author: str | None = info.get('author') - self.identifier: str | None = info.get('identifier') + self._playlist = playlist + self._recommended: bool = False def __hash__(self) -> int: return hash(self.encoded) @@ -145,247 +141,424 @@ def __str__(self) -> str: return self.title def __repr__(self) -> str: - return f'Playable: source={self.source}, title={self.title}' + return f"Playable(source={self.source}, title={self.title}, identifier={self.identifier})" def __eq__(self, other: object) -> bool: - if isinstance(other, Playable): - return self.encoded == other.encoded - return NotImplemented - - @overload - @classmethod - async def search(cls, - query: str, - /, - *, - node: Node | None = ... - ) -> list[Self]: - ... + if not isinstance(other, Playable): + return NotImplemented - @overload - @classmethod - async def search(cls, - query: str, - /, - *, - node: Node | None = ... - ) -> YouTubePlaylist: - ... + return self.encoded == other.encoded or self.identifier == other.identifier - @overload - @classmethod - async def search(cls, - query: str, - /, - *, - node: Node | None = ... - ) -> SoundCloudPlaylist: - ... + @property + def encoded(self) -> str: + """Property returning the encoded track string from Lavalink.""" + return self._encoded - @classmethod - async def search(cls, - query: str, - /, - *, - node: Node | None = None - ) -> list[Self]: - """Search and retrieve tracks for the given query. + @property + def identifier(self) -> str: + """Property returning the identifier of this track from its source. - Parameters - ---------- - query: str - The query to search for. - node: Optional[:class:`wavelink.Node`] - The node to use when searching for tracks. If no :class:`wavelink.Node` is passed, - one will be fetched via the :class:`wavelink.NodePool`. + E.g. YouTube ID or Spotify ID. """ + return self._identifier - check = yarl.URL(query) + @property + def is_seekable(self) -> bool: + """Property returning a bool whether this track can be used in seeking.""" + return self._is_seekable + + @property + def author(self) -> str: + """Property returning the name of the author of this track.""" + return self._author - if str(check.host) == 'youtube.com' or str(check.host) == 'www.youtube.com' and check.query.get("list") or \ - cls.PREFIX == 'ytpl:': + @property + def length(self) -> int: + """Property returning the tracks duration in milliseconds as an int.""" + return self._length - playlist = await NodePool.get_playlist(query, cls=YouTubePlaylist, node=node) - return playlist - elif str(check.host) == 'soundcloud.com' or str(check.host) == 'www.soundcloud.com' and 'sets' in check.parts: + @property + def is_stream(self) -> bool: + """Property returning a bool indicating whether this track is a stream.""" + return self._is_stream - playlist = await NodePool.get_playlist(query, cls=SoundCloudPlaylist, node=node) - return playlist - elif check.host: - tracks = await NodePool.get_tracks(query, cls=cls, node=node) - else: - tracks = await NodePool.get_tracks(f'{cls.PREFIX}{query}', cls=cls, node=node) + @property + def position(self) -> int: + """Property returning starting position of this track in milliseconds as an int.""" + return self._position - return tracks + @property + def title(self) -> str: + """Property returning the title/name of this track.""" + return self._title - @classmethod - async def convert(cls, ctx: commands.Context, argument: str) -> Self: - """Converter which searches for and returns the first track. + @property + def uri(self) -> str | None: + """Property returning the URL to this track. Could be ``None``.""" + return self._uri - Used as a type hint in a - `discord.py command `_. - """ - results = await cls.search(argument) + @property + def artwork(self) -> str | None: + """Property returning the URL of the artwork of this track. Could be ``None``.""" + return self._artwork - if not results: - raise commands.BadArgument("Could not find any songs matching that query.") + @property + def isrc(self) -> str | None: + """Property returning the ISRC (International Standard Recording Code) of this track. Could be ``None``.""" + return self._isrc - if issubclass(cls, YouTubePlaylist): - return results # type: ignore + @property + def source(self) -> str: + """Property returning the source of this track as a ``str``. - return results[0] + E.g. "spotify" or "youtube". + """ + return self._source + @property + def album(self) -> Album: + """Property returning album data for this track.""" + return self._album -class GenericTrack(Playable): - """Generic Wavelink Track. + @property + def artist(self) -> Artist: + """Property returning artist data for this track.""" + return self._artist - Use this track for searching for Local songs or direct URLs. - """ - ... + @property + def preview_url(self) -> str | None: + """Property returning the preview URL for this track. Could be ``None``.""" + return self._preview_url + @property + def is_preview(self) -> bool | None: + """Property returning a bool indicating if this track is a preview. Could be ``None`` if unknown.""" + return self._is_preview -class YouTubeTrack(Playable): + @property + def playlist(self) -> PlaylistInfo | None: + """Property returning a :class:`wavelink.PlaylistInfo`. Could be ``None`` + if this track is not a part of a playlist. + """ + return self._playlist - PREFIX: str = 'ytsearch:' + @property + def recommended(self) -> bool: + """Property returning a bool indicating whether this track was recommended via AutoPlay.""" + return self._recommended - def __init__(self, data: TrackPayload) -> None: - super().__init__(data) + @classmethod + async def search(cls, query: str, /, *, source: TrackSource | str | None = TrackSource.YouTubeMusic) -> Search: + """Search for a list of :class:`~wavelink.Playable` or a :class:`~wavelink.Playlist`, with the given query. - self._thumb: str = f"https://img.youtube.com/vi/{self.identifier}/maxresdefault.jpg" + .. note:: + + This method differs from :meth:`wavelink.Pool.fetch_tracks` in that it will apply a relevant search prefix + for you when a URL is **not** provided. This prefix can be controlled via the ``source`` keyword argument. - @property - def thumbnail(self) -> str: - """The URL to the thumbnail of this video. .. note:: - Due to YouTube limitations this may not always return a valid thumbnail. - Use :meth:`.fetch_thumbnail` to fallback. + This method of searching is preferred over, :meth:`wavelink.Pool.fetch_tracks`. - Returns - ------- - str - The URL to the video thumbnail. - """ - return self._thumb - thumb = thumbnail + Parameters + ---------- + query: str + The query to search tracks for. If this is **not** a URL based search this method will provide an + appropriate search prefix based on what is provided to the ``source`` keyword only parameter, + or it's default. - async def fetch_thumbnail(self, *, node: Node | None = None) -> str: - """Fetch the max resolution thumbnail with a fallback if it does not exist. + If this query **is a URL**, a search prefix will **not** be used. + source: :class:`TrackSource` | str | None + This parameter determines which search prefix to use when searching for tracks. + If ``None`` is provided, no prefix will be used, however this behaviour is default regardless of what + is provided **when a URL is found**. - This sets and overrides the default ``thumbnail`` and ``thumb`` properties. + For basic searches, E.g. YouTube, YouTubeMusic and SoundCloud, see: :class:`wavelink.TrackSource`. + Otherwise, a ``str`` may be provided for plugin based searches, E.g. "spsearch:" for the + LavaSrc Spotify based search. - .. note:: + Defaults to :attr:`wavelink.TrackSource.YouTubeMusic` which is equivalent to "ytmsearch:". - This method uses an API request to fetch the thumbnail. Returns ------- - str - The URL to the video thumbnail. + :class:`wavelink.Search` + A union of either list[:class:`Playable`] or :class:`Playlist`. Could return and empty list, + if no tracks or playlist were found. + + Raises + ------ + LavalinkLoadException + Exception raised when Lavalink fails to load results based on your query. + + + Examples + -------- + + .. code:: python3 + + # Search for tracks, with the default "ytsearch:" prefix. + tracks: wavelink.Search = await wavelink.Playable.search("Ocean Drive") + if not tracks: + # No tracks were found... + ... + + # Search for tracks, with a URL. + tracks: wavelink.Search = await wavelink.Playable.search("https://www.youtube.com/watch?v=KDxJlW6cxRk") + ... + + # Search for tracks, using Spotify and the LavaSrc Plugin. + tracks: wavelink.Search = await wavelink.Playable.search("4b93D55xv3YCH5mT4p6HPn", source="spsearch") + ... + + # Search for tracks, using Spotify and the LavaSrc Plugin, with a URL. + tracks: wavelink.Search = await wavelink.Playable.search("https://open.spotify.com/track/4b93D55xv3YCH5mT4p6HPn") + ... + + # Search for a playlist, using Spotify and the LavaSrc Plugin. + # or alternatively any other playlist URL from another source like YouTube. + tracks: wavelink.Search = await wavelink.Playable.search("https://open.spotify.com/playlist/37i9dQZF1DWXRqgorJj26U") + ... + + + .. versionchanged:: 3.0.0 + + This method has been changed significantly in version ``3.0.0``. This method has been simplified to provide + an easier interface for searching tracks. See the above documentation and examples. + + You can no longer provide a :class:`wavelink.Node` to use for searching as this method will now select the + most appropriate node from the :class:`wavelink.Pool`. """ - if not node: - node = NodePool.get_node() + prefix: TrackSource | str | None = _source_mapping.get(source, source) + check = yarl.URL(query) + + if check.host: + tracks: Search = await wavelink.Pool.fetch_tracks(query) + return tracks + + if not prefix: + term: str = query + else: + assert not isinstance(prefix, TrackSource) + term: str = f"{prefix.removesuffix(':')}:{query}" + + tracks: Search = await wavelink.Pool.fetch_tracks(term) + return tracks - session: aiohttp.ClientSession = node._session - url: str = f"https://img.youtube.com/vi/{self.identifier}/maxresdefault.jpg" - async with session.get(url=url) as resp: - if resp.status == 404: - url = f'https://img.youtube.com/vi/{self.identifier}/hqdefault.jpg' +class Playlist: + """The wavelink Playlist container class. - self._thumb = url - return url + This class is created and returned via both :meth:`Playable.search` and :meth:`wavelink.Pool.fetch_tracks`. + It contains various information about the playlist and a list of :class:`Playable` that can be used directly in + :meth:`wavelink.Player.play`. See below for various supported operations. -class YouTubeMusicTrack(YouTubeTrack): - """A track created using a search to YouTube Music.""" - PREFIX: str = "ytmsearch:" + .. warning:: + You should not instantiate this class manually, + use :meth:`Playable.search` or :meth:`wavelink.Pool.fetch_tracks` instead. -class SoundCloudTrack(Playable): - """A track created using a search to SoundCloud.""" - PREFIX: str = "scsearch:" + .. warning:: + You can not use ``.search`` directly on this class, see: :meth:`Playable.search`. -class YouTubePlaylist(Playable, Playlist): - """Represents a Lavalink YouTube playlist object. + + .. note:: + + This class can be directly added to :class:`wavelink.Queue` identical to :class:`Playable`. When added, + all tracks contained in this playlist, will be individually added to the :class:`wavelink.Queue`. .. container:: operations - .. describe:: str(playlist) + .. describe:: str(x) + + Return the name associated with this playlist. + + .. describe:: repr(x) + + Return the official string representation of this playlist. + + .. describe:: x == y + + Compare the equality of playlist. + + .. describe:: len(x) + + Return an integer representing the amount of tracks contained in this playlist. + + .. describe:: x[0] + + Return a track contained in this playlist with the given index. + + .. describe:: x[0:2] + + Return a slice of tracks contained in this playlist. + + .. describe:: for x in y + + Iterate over the tracks contained in this playlist. + + .. describe:: reversed(x) + + Reverse the tracks contained in this playlist. - Returns a string representing the playlists name. + .. describe:: x in y + + Check if a :class:`Playable` is contained in this playlist. Attributes ---------- name: str - The name of the playlist. - tracks: :class:`YouTubeTrack` - The list of :class:`YouTubeTrack` in the playlist. - selected_track: Optional[int] - The selected video in the playlist. This could be ``None``. + The name of this playlist. + selected: int + The index of the selected track from Lavalink. + tracks: list[:class:`Playable`] + A list of :class:`Playable` contained in this playlist. + type: str | None + An optional ``str`` identifying the type of playlist this is. Only available when a plugin is used. + url: str | None + An optional ``str`` to the URL of this playlist. Only available when a plugin is used. + artwork: str | None + An optional ``str`` to the artwork of this playlist. Only available when a plugin is used. + author: str | None + An optional ``str`` of the author of this playlist. Only available when a plugin is used. """ - PREFIX: str = "ytpl:" - - def __init__(self, data: dict): - self.tracks: list[YouTubeTrack] = [] - self.name: str = data["playlistInfo"]["name"] - - self.selected_track: Optional[int] = data["playlistInfo"].get("selectedTrack") - if self.selected_track is not None: - self.selected_track = int(self.selected_track) + def __init__(self, data: PlaylistPayload) -> None: + info: PlaylistInfoPayload = data["info"] + self.name: str = info["name"] + self.selected: int = info["selectedTrack"] - for track_data in data["tracks"]: - track = YouTubeTrack(track_data) - self.tracks.append(track) + playlist_info: PlaylistInfo = PlaylistInfo(data) + self.tracks: list[Playable] = [Playable(data=track, playlist=playlist_info) for track in data["tracks"]] - self.source = TrackSource.YouTube + plugin: dict[Any, Any] = data["pluginInfo"] + self.type: str | None = plugin.get("type") + self.url: str | None = plugin.get("url") + self.artwork: str | None = plugin.get("artworkUrl") + self.author: str | None = plugin.get("author") def __str__(self) -> str: return self.name + def __repr__(self) -> str: + return f"Playlist(name={self.name}, tracks={len(self.tracks)})" -class SoundCloudPlaylist(Playable, Playlist): - """Represents a Lavalink SoundCloud playlist object. + def __eq__(self, other: object) -> bool: + if not isinstance(other, Playlist): + return NotImplemented + return self.name == other.name and self.tracks == other.tracks - .. container:: operations + def __len__(self) -> int: + return len(self.tracks) + + @overload + def __getitem__(self, index: int) -> Playable: + ... + + @overload + def __getitem__(self, index: slice) -> list[Playable]: + ... + + def __getitem__(self, index: int | slice) -> Playable | list[Playable]: + return self.tracks[index] + + def __iter__(self) -> Iterator[Playable]: + return self.tracks.__iter__() + + def __reversed__(self) -> Iterator[Playable]: + return self.tracks.__reversed__() + + def __contains__(self, item: Playable) -> bool: + return item in self.tracks + + def pop(self, index: int = -1) -> Playable: + return self.tracks.pop(index) - .. describe:: str(playlist) + def track_extras(self, **attrs: object) -> None: + """Method which sets attributes to all :class:`Playable` in this playlist, with the provided keyword arguments. - Returns a string representing the playlists name. + This is useful when you need to attach state to your :class:`Playable`, E.g. create a requester attribute. + .. warning:: + + If you try to override any existing property of :class:`Playable` this method will fail. + + + Parameters + ---------- + **attrs + The keyword arguments to set as attribute name=value on each :class:`Playable`. + + Examples + -------- + + .. code:: python3 + + playlist.track_extras(requester=ctx.author) + + track: wavelink.Playable = playlist[0] + print(track.requester) + """ + for track in self.tracks: + for name, value in attrs.items(): + setattr(track, name, value) + + +class PlaylistInfo: + """The wavelink PlaylistInfo container class. + + It contains various information about the playlist but **does not** contain the tracks associated with this + playlist. + + This class is used to provided information about the original :class:`wavelink.Playlist` on tracks. Attributes ---------- name: str - The name of the playlist. - tracks: :class:`SoundCloudTrack` - The list of :class:`SoundCloudTrack` in the playlist. - selected_track: Optional[int] - The selected video in the playlist. This could be ``None``. + The name of this playlist. + selected: int + The index of the selected track from Lavalink. + tracks: int + The amount of tracks this playlist originally contained. + type: str | None + An optional ``str`` identifying the type of playlist this is. Only available when a plugin is used. + url: str | None + An optional ``str`` to the URL of this playlist. Only available when a plugin is used. + artwork: str | None + An optional ``str`` to the artwork of this playlist. Only available when a plugin is used. + author: str | None + An optional ``str`` of the author of this playlist. Only available when a plugin is used. """ - def __init__(self, data: dict): - self.tracks: list[SoundCloudTrack] = [] - self.name: str = data["playlistInfo"]["name"] + __slots__ = ("name", "selected", "tracks", "type", "url", "artwork", "author") - self.selected_track: Optional[int] = data["playlistInfo"].get("selectedTrack") - if self.selected_track is not None: - self.selected_track = int(self.selected_track) + def __init__(self, data: PlaylistPayload) -> None: + info: PlaylistInfoPayload = data["info"] + self.name: str = info["name"] + self.selected: int = info["selectedTrack"] - for track_data in data["tracks"]: - track = SoundCloudTrack(track_data) - self.tracks.append(track) + self.tracks: int = len(data["tracks"]) - self.source = TrackSource.SoundCloud + plugin: dict[Any, Any] = data["pluginInfo"] + self.type: str | None = plugin.get("type") + self.url: str | None = plugin.get("url") + self.artwork: str | None = plugin.get("artworkUrl") + self.author: str | None = plugin.get("author") def __str__(self) -> str: return self.name + + def __repr__(self) -> str: + return f"PlaylistInfo(name={self.name}, tracks={self.tracks})" + + def __len__(self) -> int: + return self.tracks diff --git a/wavelink/types/__init__.py b/wavelink/types/__init__.py new file mode 100644 index 00000000..6fac424b --- /dev/null +++ b/wavelink/types/__init__.py @@ -0,0 +1,23 @@ +""" +MIT License + +Copyright (c) 2019-Current PythonistaGuild, EvieePy + +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. +""" diff --git a/wavelink/types/events.py b/wavelink/types/events.py deleted file mode 100644 index 1051ec0f..00000000 --- a/wavelink/types/events.py +++ /dev/null @@ -1,36 +0,0 @@ -from typing import TYPE_CHECKING, Literal, TypedDict - -if TYPE_CHECKING: - from typing_extensions import NotRequired, TypeAlias - - -_TrackStartEventType = Literal["TrackStartEvent"] -_OtherEventOpType = Literal["TrackEndEvent", "TrackExceptionEvent", "TrackStuckEvent", "WebSocketClosedEvent"] - -EventType = Literal[_TrackStartEventType, _OtherEventOpType] - - -class _BaseEventOp(TypedDict): - op: Literal["event"] - guildId: str - -class TrackStartEvent(_BaseEventOp): - type: _TrackStartEventType - encodedTrack: str - -class _OtherEventOp(_BaseEventOp): - type: _OtherEventOpType - -EventOp: TypeAlias = "TrackStartEvent | _OtherEventOp" - - -class PlayerState(TypedDict): - time: int - position: NotRequired[int] - connected: bool - ping: int - - -class PlayerUpdateOp(TypedDict): - guildId: str - state: PlayerState \ No newline at end of file diff --git a/wavelink/types/filters.py b/wavelink/types/filters.py new file mode 100644 index 00000000..08a07f51 --- /dev/null +++ b/wavelink/types/filters.py @@ -0,0 +1,92 @@ +""" +MIT License + +Copyright (c) 2019-Current PythonistaGuild, EvieePy + +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. +""" +from typing import Any, TypedDict + + +class Equalizer(TypedDict): + band: int + gain: float + + +class Karaoke(TypedDict, total=False): + level: float | None + monoLevel: float | None + filterBand: float | None + filterWidth: float | None + + +class Timescale(TypedDict, total=False): + speed: float | None + pitch: float | None + rate: float | None + + +class Tremolo(TypedDict, total=False): + frequency: float | None + depth: float | None + + +class Vibrato(TypedDict, total=False): + frequency: float | None + depth: float | None + + +class Rotation(TypedDict, total=False): + rotationHz: float | None + + +class Distortion(TypedDict, total=False): + sinOffset: float | None + sinScale: float | None + cosOffset: float | None + cosScale: float | None + tanOffset: float | None + tanScale: float | None + offset: float | None + scale: float | None + + +class ChannelMix(TypedDict, total=False): + leftToLeft: float | None + leftToRight: float | None + rightToLeft: float | None + rightToRight: float | None + + +class LowPass(TypedDict, total=False): + smoothing: float | None + + +class FilterPayload(TypedDict, total=False): + volume: float | None + equalizer: list[Equalizer] | None + karaoke: Karaoke + timescale: Timescale + tremolo: Tremolo + vibrato: Vibrato + rotation: Rotation + distortion: Distortion + channelMix: ChannelMix + lowPass: LowPass + pluginFilters: dict[str, Any] diff --git a/wavelink/types/request.py b/wavelink/types/request.py index a53a5bc6..5325eb5f 100644 --- a/wavelink/types/request.py +++ b/wavelink/types/request.py @@ -1,25 +1,49 @@ +""" +MIT License + +Copyright (c) 2019-Current PythonistaGuild, EvieePy + +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. +""" from __future__ import annotations -from typing import TYPE_CHECKING, TypedDict - -from .state import VoiceState +from typing import TYPE_CHECKING, TypeAlias, TypedDict if TYPE_CHECKING: - from typing_extensions import TypeAlias + from typing_extensions import NotRequired + + from .filters import FilterPayload -class Filters(TypedDict): - ... +class VoiceRequest(TypedDict): + token: str + endpoint: str | None + sessionId: str class _BaseRequest(TypedDict, total=False): - voice: VoiceState + voice: VoiceRequest position: int - endTime: int + endTime: int | None volume: int paused: bool - filters: Filters - voice: VoiceState + filters: FilterPayload class EncodedTrackRequest(_BaseRequest): @@ -30,4 +54,9 @@ class IdentifierRequest(_BaseRequest): identifier: str -Request: TypeAlias = '_BaseRequest | EncodedTrackRequest | IdentifierRequest' +class UpdateSessionRequest(TypedDict): + resuming: NotRequired[bool] + timeout: NotRequired[int] + + +Request: TypeAlias = _BaseRequest | EncodedTrackRequest | IdentifierRequest diff --git a/wavelink/types/response.py b/wavelink/types/response.py new file mode 100644 index 00000000..a2e73ba6 --- /dev/null +++ b/wavelink/types/response.py @@ -0,0 +1,127 @@ +""" +MIT License + +Copyright (c) 2019-Current PythonistaGuild, EvieePy + +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. +""" +from typing import TYPE_CHECKING, Literal, TypedDict + +if TYPE_CHECKING: + from typing_extensions import Never, NotRequired + + from .filters import FilterPayload + from .state import PlayerState, VoiceState + from .stats import CPUStats, FrameStats, MemoryStats + from .tracks import PlaylistPayload, TrackPayload + + +class ErrorResponse(TypedDict): + timestamp: int + status: int + error: str + trace: NotRequired[str] + message: str + path: str + + +class LoadedErrorPayload(TypedDict): + message: str + severity: str + cause: str + + +class PlayerResponse(TypedDict): + guildId: str + track: NotRequired[TrackPayload] + volume: int + paused: bool + state: PlayerState + voice: VoiceState + filters: FilterPayload + + +class UpdateResponse(TypedDict): + resuming: bool + timeout: int + + +class TrackLoadedResponse(TypedDict): + loadType: Literal["track"] + data: TrackPayload + + +class PlaylistLoadedResponse(TypedDict): + loadType: Literal["playlist"] + data: PlaylistPayload + + +class SearchLoadedResponse(TypedDict): + loadType: Literal["search"] + data: list[TrackPayload] + + +class EmptyLoadedResponse(TypedDict): + loadType: Literal["empty"] + data: dict[Never, Never] + + +class ErrorLoadedResponse(TypedDict): + loadType: Literal["error"] + data: LoadedErrorPayload + + +class VersionPayload(TypedDict): + semver: str + major: int + minor: int + patch: int + preRelease: NotRequired[str] + build: NotRequired[str] + + +class GitPayload(TypedDict): + branch: str + commit: str + commitTime: int + + +class PluginPayload(TypedDict): + name: str + version: str + + +class InfoResponse(TypedDict): + version: VersionPayload + buildTime: int + git: GitPayload + jvm: str + lavaplayer: str + sourceManagers: list[str] + filters: list[str] + plugins: list[PluginPayload] + + +class StatsResponse(TypedDict): + players: int + playingPlayers: int + uptime: int + memory: MemoryStats + cpu: CPUStats + frameStats: NotRequired[FrameStats] diff --git a/wavelink/types/state.py b/wavelink/types/state.py index bf5b8384..03f09269 100644 --- a/wavelink/types/state.py +++ b/wavelink/types/state.py @@ -1,16 +1,47 @@ +""" +MIT License + +Copyright (c) 2019-Current PythonistaGuild, EvieePy + +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. +""" from typing import TYPE_CHECKING, TypedDict if TYPE_CHECKING: - from discord.types.voice import GuildVoiceState, VoiceServerUpdate - from typing_extensions import NotRequired, TypeAlias - -class VoiceState(TypedDict): + from typing_extensions import NotRequired + + +class PlayerState(TypedDict): + time: int + position: int + connected: bool + ping: int + + +class VoiceState(TypedDict, total=False): token: str - endpoint: str - sessionId: str - connected: NotRequired[bool] - ping: NotRequired[int] + endpoint: str | None + session_id: str -class DiscordVoiceState(GuildVoiceState, VoiceServerUpdate): - ... +class PlayerVoiceState(TypedDict): + voice: VoiceState + channel_id: NotRequired[str] + track: NotRequired[str] + position: NotRequired[int] diff --git a/examples/connecting.py b/wavelink/types/stats.py similarity index 59% rename from examples/connecting.py rename to wavelink/types/stats.py index 07ee615b..1261b5d8 100644 --- a/examples/connecting.py +++ b/wavelink/types/stats.py @@ -1,7 +1,7 @@ """ MIT License -Copyright (c) 2019-Present PythonistaGuild +Copyright (c) 2019-Current PythonistaGuild, EvieePy Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -21,23 +21,23 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ -import discord -import wavelink -from discord.ext import commands +from typing import TypedDict -class Bot(commands.Bot): +class MemoryStats(TypedDict): + free: int + used: int + allocated: int + reservable: int - def __init__(self) -> None: - intents = discord.Intents.default() - super().__init__(intents=intents, command_prefix='?') - async def on_ready(self) -> None: - print(f'Logged in {self.user} | {self.user.id}') +class CPUStats(TypedDict): + cores: int + systemLoad: float + lavalinkLoad: float - async def setup_hook(self) -> None: - # Wavelink 2.0 has made connecting Nodes easier... Simply create each Node - # and pass it to NodePool.connect with the client/bot. - node: wavelink.Node = wavelink.Node(uri='http://localhost:2333', password='youshallnotpass') - await wavelink.NodePool.connect(client=self, nodes=[node]) +class FrameStats(TypedDict): + sent: int + nulled: int + deficit: int diff --git a/wavelink/types/track.py b/wavelink/types/track.py deleted file mode 100644 index 64ee6531..00000000 --- a/wavelink/types/track.py +++ /dev/null @@ -1,19 +0,0 @@ -from __future__ import annotations - -from typing import TypedDict - - -class TrackInfo(TypedDict): - identifier: str - isSeekable: bool - author: str - length: int - isStream: bool - position: int - title: str - uri: str | None - sourceName: str - -class Track(TypedDict): - encoded: str - info: TrackInfo \ No newline at end of file diff --git a/wavelink/types/tracks.py b/wavelink/types/tracks.py new file mode 100644 index 00000000..b4c6fdda --- /dev/null +++ b/wavelink/types/tracks.py @@ -0,0 +1,58 @@ +""" +MIT License + +Copyright (c) 2019-Current PythonistaGuild, EvieePy + +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. +""" +from typing import TYPE_CHECKING, Any, TypedDict + +if TYPE_CHECKING: + from typing_extensions import NotRequired + + +class TrackInfoPayload(TypedDict): + identifier: str + isSeekable: bool + author: str + length: int + isStream: bool + position: int + title: str + uri: NotRequired[str] + artworkUrl: NotRequired[str] + isrc: NotRequired[str] + sourceName: str + + +class PlaylistInfoPayload(TypedDict): + name: str + selectedTrack: int + + +class TrackPayload(TypedDict): + encoded: str + info: TrackInfoPayload + pluginInfo: dict[Any, Any] + + +class PlaylistPayload(TypedDict): + info: PlaylistInfoPayload + tracks: list[TrackPayload] + pluginInfo: dict[Any, Any] diff --git a/wavelink/types/websocket.py b/wavelink/types/websocket.py new file mode 100644 index 00000000..f5bb9d02 --- /dev/null +++ b/wavelink/types/websocket.py @@ -0,0 +1,113 @@ +""" +MIT License + +Copyright (c) 2019-Current PythonistaGuild, EvieePy + +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. +""" +from __future__ import annotations + +from typing import TYPE_CHECKING, Literal, TypeAlias, TypedDict + +if TYPE_CHECKING: + from typing_extensions import NotRequired + + from .state import PlayerState + from .stats import CPUStats, FrameStats, MemoryStats + from .tracks import TrackPayload + + +class TrackExceptionPayload(TypedDict): + message: NotRequired[str] + severity: str + cause: str + + +class ReadyOP(TypedDict): + op: Literal["ready"] + resumed: bool + sessionId: str + + +class PlayerUpdateOP(TypedDict): + op: Literal["playerUpdate"] + guildId: str + state: PlayerState + + +class StatsOP(TypedDict): + op: Literal["stats"] + players: int + playingPlayers: int + uptime: int + memory: MemoryStats + cpu: CPUStats + frameStats: FrameStats + + +class TrackStartEvent(TypedDict): + op: Literal["event"] + guildId: str + type: Literal["TrackStartEvent"] + track: TrackPayload + + +class TrackEndEvent(TypedDict): + op: Literal["event"] + guildId: str + type: Literal["TrackEndEvent"] + track: TrackPayload + reason: str + + +class TrackExceptionEvent(TypedDict): + op: Literal["event"] + guildId: str + type: Literal["TrackExceptionEvent"] + track: TrackPayload + exception: TrackExceptionPayload + + +class TrackStuckEvent(TypedDict): + op: Literal["event"] + guildId: str + type: Literal["TrackStuckEvent"] + track: TrackPayload + thresholdMs: int + + +class WebsocketClosedEvent(TypedDict): + op: Literal["event"] + guildId: str + type: Literal["WebSocketClosedEvent"] + code: int + reason: str + byRemote: bool + + +WebsocketOP: TypeAlias = ( + ReadyOP + | PlayerUpdateOP + | StatsOP + | TrackStartEvent + | TrackEndEvent + | TrackExceptionEvent + | TrackStuckEvent + | WebsocketClosedEvent +) diff --git a/wavelink/websocket.py b/wavelink/websocket.py index 5472b8ef..96222563 100644 --- a/wavelink/websocket.py +++ b/wavelink/websocket.py @@ -1,7 +1,7 @@ """ MIT License -Copyright (c) 2019-Present PythonistaGuild +Copyright (c) 2019-Current PythonistaGuild, EvieePy Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -25,257 +25,250 @@ import asyncio import logging -from typing import TYPE_CHECKING, Any, Optional +from typing import TYPE_CHECKING, Any import aiohttp -import wavelink - from . import __version__ from .backoff import Backoff -from .enums import NodeStatus, TrackEventType -from .exceptions import * -from .payloads import TrackEventPayload, WebsocketClosedPayload +from .enums import NodeStatus +from .exceptions import AuthorizationFailedException, NodeException +from .payloads import * +from .tracks import Playable if TYPE_CHECKING: from .node import Node from .player import Player + from .types.request import UpdateSessionRequest + from .types.response import InfoResponse + from .types.state import PlayerState + from .types.websocket import TrackExceptionPayload, WebsocketOP logger: logging.Logger = logging.getLogger(__name__) class Websocket: - - __slots__ = ( - 'node', - 'socket', - 'retries', - 'retry', - '_original_attempts', - 'backoff', - '_listener_task', - '_reconnect_task' - ) - def __init__(self, *, node: Node) -> None: - self.node: Node = node - self.socket: aiohttp.ClientWebSocketResponse | None = None - - self.retries: int | None = node._retries - self.retry: float = 1 - self._original_attempts: int | None = node._retries + self.node = node self.backoff: Backoff = Backoff() - self._listener_task: asyncio.Task | None = None - self._reconnect_task: asyncio.Task | None = None + self.socket: aiohttp.ClientWebSocketResponse | None = None + self.keep_alive_task: asyncio.Task[None] | None = None @property def headers(self) -> dict[str, str]: assert self.node.client is not None assert self.node.client.user is not None - return { - 'Authorization': self.node.password, - 'User-Id': str(self.node.client.user.id), - 'Client-Name': f'Wavelink/{__version__}' + data = { + "Authorization": self.node.password, + "User-Id": str(self.node.client.user.id), + "Client-Name": f"Wavelink/{__version__}", } + if self.node.session_id: + data["Session-Id"] = self.node.session_id + + return data + def is_connected(self) -> bool: return self.socket is not None and not self.socket.closed - async def connect(self) -> None: - if self.node.status is NodeStatus.CONNECTED: - logger.error(f'Node {self.node} websocket tried connecting in an already connected state. ' - f'Disregarding.') - return + async def _update_node(self) -> None: + if self.node._resume_timeout > 0: + udata: UpdateSessionRequest = {"resuming": True, "timeout": self.node._resume_timeout} + await self.node._update_session(data=udata) + + info: InfoResponse = await self.node._fetch_info() + if "spotify" in info["sourceManagers"]: + self.node._spotify_enabled = True + async def connect(self) -> None: self.node._status = NodeStatus.CONNECTING - if self._listener_task: + if self.keep_alive_task: try: - self._listener_task.cancel() + self.keep_alive_task.cancel() except Exception as e: - logger.debug(f'Node {self.node} encountered an error while cancelling the websocket listener: {e}. ' - f'This is likely not an issue and will not affect connection.') - - uri: str = self.node._host.removeprefix('https://').removeprefix('http://') - - if self.node._use_http: - uri: str = f'{"https://" if self.node._secure else "http://"}{uri}' - else: - uri: str = f'{"wss://" if self.node._secure else "ws://"}{uri}' + logger.debug( + "Failed to cancel websocket keep alive while connecting. " + f"This is most likely not a problem and will not affect websocket connection: '{e}'" + ) + retries: int | None = self.node._retries + session: aiohttp.ClientSession = self.node._session heartbeat: float = self.node.heartbeat + uri: str = f"{self.node.uri.removesuffix('/')}/v4/websocket" + github: str = "https://github.com/PythonistaGuild/Wavelink/issues" - try: - self.socket = await self.node._session.ws_connect(url=uri, heartbeat=heartbeat, headers=self.headers) - except Exception as e: - if isinstance(e, aiohttp.WSServerHandshakeError) and e.status == 401: - raise AuthorizationFailed from e - else: - logger.error(f'An error occurred connecting to node: "{self.node}". {e}') - - if self.is_connected(): - self.retries = self._original_attempts - self._reconnect_task = None - # TODO - Configure Resuming... - else: - await self._reconnect() - return - - self._listener_task = asyncio.create_task(self._listen()) - - async def _reconnect(self) -> None: - self.node._status = NodeStatus.CONNECTING - self.retry = self.backoff.calculate() + while True: + try: + self.socket = await session.ws_connect(url=uri, heartbeat=heartbeat, headers=self.headers) # type: ignore + except Exception as e: + if isinstance(e, aiohttp.WSServerHandshakeError) and e.status == 401: + await self.cleanup() + raise AuthorizationFailedException from e + elif isinstance(e, aiohttp.WSServerHandshakeError) and e.status == 404: + await self.cleanup() + raise NodeException from e + else: + logger.warning( + f'An unexpected error occurred while connecting {self.node!r} to Lavalink: "{e}"\n' + f"If this error persists or wavelink is unable to reconnect, please see: {github}" + ) + + if self.is_connected(): + self.keep_alive_task = asyncio.create_task(self.keep_alive()) + break + + if retries == 0: + logger.warning( + f"{self.node!r} was unable to successfully connect/reconnect to Lavalink after " + f'"{retries + 1}" connection attempt. This Node has exhausted the retry count.' + ) - if self.retries == 0: - logger.error(f'Node {self.node} websocket was unable to connect, ' - f'and has exhausted the reconnection attempt limit. ' - 'Please check your Lavalink Node is started and your connection details are correct.') + await self.cleanup() + break - await self.cleanup() - return + if retries: + retries -= 1 - retries = f'{self.retries} attempt(s) remaining.' if self.retries else '' - logger.error(f'Node {self.node} websocket was unable to connect, retrying connection in: ' - f'"{self.retry}" seconds. {retries}') + delay: float = self.backoff.calculate() + logger.info(f'{self.node!r} retrying websocket connection in "{delay}" seconds.') - if self.retries: - self.retries -= 1 + await asyncio.sleep(delay) - await asyncio.sleep(self.retry) - await self.connect() + async def keep_alive(self) -> None: + assert self.socket is not None - async def _listen(self) -> None: while True: - message = await self.socket.receive() - - if message.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.CLOSING): + message: aiohttp.WSMessage = await self.socket.receive() - for player in self.node.players.copy().values(): - await player._update_event(data=None) + if message.type in ( # pyright: ignore[reportUnknownMemberType] + aiohttp.WSMsgType.CLOSED, + aiohttp.WSMsgType.CLOSING, + ): + asyncio.create_task(self.connect()) + break - self._reconnect_task = asyncio.create_task(self._reconnect()) - return - - if message.data == 1011: - logger.error(f'Node {self.node} websocket encountered an internal error which can not be resolved. ' - 'Make sure your Lavalink sever is up to date, and try restarting.') - - await self.cleanup() - return - - if message.data is None: - logger.info(f'Node {self.node} websocket received a message from Lavalink with empty data. ' - f'Disregarding.') + if message.data is None: # pyright: ignore[reportUnknownMemberType] + logger.debug("Received an empty message from Lavalink websocket. Disregarding.") continue - data = message.json() - logger.debug(f'Node {self.node} websocket received a message from Lavalink: {data}') + data: WebsocketOP = message.json() - op = data.get('op', None) - if not op: - logger.info(f'Node {self.node} websocket payload "op" from Lavalink was None. Disregarding.') - continue + if data["op"] == "ready": + resumed: bool = data["resumed"] + session_id: str = data["sessionId"] - if op == 'ready': self.node._status = NodeStatus.CONNECTED - self.node._session_id = data['sessionId'] + self.node._session_id = session_id - self.dispatch('node_ready', self.node) + await self._update_node() - elif op == 'stats': - payload = ... - logger.debug(f'Node {self.node} websocket received a Stats Update payload: {data}') - self.dispatch('stats_update', data) + ready_payload: NodeReadyEventPayload = NodeReadyEventPayload( + node=self.node, resumed=resumed, session_id=session_id + ) + self.dispatch("node_ready", ready_payload) - elif op == 'event': - logger.debug(f'Node {self.node} websocket received an event payload: {data}') - player = self.get_player(data) + elif data["op"] == "playerUpdate": + playerup: Player | None = self.get_player(data["guildId"]) + state: PlayerState = data["state"] - if data['type'] == 'WebSocketClosedEvent': - player = player or self.node._invalidated.get(int(data['guildId']), None) + updatepayload: PlayerUpdateEventPayload = PlayerUpdateEventPayload(player=playerup, state=state) + self.dispatch("player_update", updatepayload) - if not player: - logger.debug(f'Node {self.node} received a WebsocketClosedEvent in an "unknown" state. ' - f'Disregarding.') - continue + if playerup: + asyncio.create_task(playerup._update_event(updatepayload)) - if self.node._invalidated.get(player.guild.id): - await player._destroy() + elif data["op"] == "stats": + statspayload: StatsEventPayload = StatsEventPayload(data=data) + self.node._total_player_count = statspayload.players + self.dispatch("stats_update", statspayload) - logger.debug(f'Node {self.node} websocket acknowledged "WebsocketClosedEvent": ' - f'. ' - f'Cleanup on player {player.guild.id} has been completed.') + elif data["op"] == "event": + player: Player | None = self.get_player(data["guildId"]) - payload: WebsocketClosedPayload = WebsocketClosedPayload(data=data, player=player) + if data["type"] == "TrackStartEvent": + track: Playable = Playable(data["track"]) - self.dispatch('websocket_closed', payload) - continue + startpayload: TrackStartEventPayload = TrackStartEventPayload(player=player, track=track) + self.dispatch("track_start", startpayload) - if player is None: - logger.debug(f'Node {self.node} received a payload from Lavalink without an attached player. ' - f'Disregarding.') - continue + elif data["type"] == "TrackEndEvent": + track: Playable = Playable(data["track"]) + reason: str = data["reason"] - track = await self.node.build_track(cls=wavelink.GenericTrack, encoded=data['encodedTrack']) - payload: TrackEventPayload = TrackEventPayload( - data=data, - track=track, - player=player, - original=player._original - ) + if player and reason != "replaced": + player._current = None + + endpayload: TrackEndEventPayload = TrackEndEventPayload(player=player, track=track, reason=reason) + self.dispatch("track_end", endpayload) - if payload.event is TrackEventType.END and payload.reason != 'REPLACED': - player._current = None + if player: + asyncio.create_task(player._auto_play_event(endpayload)) - self.dispatch('track_event', payload) + elif data["type"] == "TrackExceptionEvent": + track: Playable = Playable(data["track"]) + exception: TrackExceptionPayload = data["exception"] - if payload.event is TrackEventType.END: - self.dispatch('track_end', payload) - asyncio.create_task(player._auto_play_event(payload)) + excpayload: TrackExceptionEventPayload = TrackExceptionEventPayload( + player=player, track=track, exception=exception + ) + self.dispatch("track_exception", excpayload) - elif payload.event is TrackEventType.START: - self.dispatch('track_start', payload) + elif data["type"] == "TrackStuckEvent": + track: Playable = Playable(data["track"]) + threshold: int = data["thresholdMs"] - elif op == 'playerUpdate': - player = self.get_player(data) - if player is None: - logger.debug(f'Node {self.node} received a payload from Lavalink without an attached player. ' - f'Disregarding.') - continue + stuckpayload: TrackStuckEventPayload = TrackStuckEventPayload( + player=player, track=track, threshold=threshold + ) + self.dispatch("track_stuck", stuckpayload) - await player._update_event(data) - self.dispatch("player_update", data) - logger.debug(f'Node {self.node} websocket received Player Update payload: {data}') + elif data["type"] == "WebSocketClosedEvent": + code: int = data["code"] + reason: str = data["reason"] + by_remote: bool = data["byRemote"] + wcpayload: WebsocketClosedEventPayload = WebsocketClosedEventPayload( + player=player, code=code, reason=reason, by_remote=by_remote + ) + self.dispatch("websocket_closed", wcpayload) + + else: + logger.debug(f"Received unknown event type from Lavalink '{data['type']}'. Disregarding.") else: - logger.warning(f'Received unknown payload from Lavalink: <{data}>. ' - f'If this continues consider making a ticket on the Wavelink GitHub. ' - f'https://github.com/PythonistaGuild/Wavelink') + logger.debug(f"'Received an unknown OP from Lavalink '{data['op']}'. Disregarding.") - def get_player(self, payload: dict[str, Any]) -> Optional['Player']: - return self.node.players.get(int(payload['guildId']), None) + def get_player(self, guild_id: str | int) -> Player | None: + return self.node.get_player(int(guild_id)) + + def dispatch(self, event: str, /, *args: Any, **kwargs: Any) -> None: + assert self.node.client is not None - def dispatch(self, event, *args: Any, **kwargs: Any) -> None: self.node.client.dispatch(f"wavelink_{event}", *args, **kwargs) - logger.debug(f'Node {self.node} is dispatching an event: "on_wavelink_{event}".') + logger.debug(f"{self.node!r} dispatched the event 'on_wavelink_{event}'") - # noinspection PyBroadException async def cleanup(self) -> None: - try: - await self.socket.close() - except AttributeError: - pass + if self.socket: + try: + await self.socket.close() + except: + pass - try: - self._listener_task.cancel() - except Exception: - pass + if self.keep_alive_task: + try: + self.keep_alive_task.cancel() + except: + pass self.node._status = NodeStatus.DISCONNECTED + self.node._session_id = None + self.node._players = {} + + self.node._websocket = None - logger.debug(f'Successfully cleaned up websocket for node: {self.node}') + logger.debug(f"Successfully cleaned up the websocket for {self.node!r}")