Compare commits
No commits in common. "main" and "1.0.6" have entirely different histories.
|
|
@ -3,15 +3,3 @@
|
||||||
__pycache/
|
__pycache/
|
||||||
dist/
|
dist/
|
||||||
pomice.egg-info/
|
pomice.egg-info/
|
||||||
docs/_build/
|
|
||||||
build/
|
|
||||||
.gitpod.yml
|
|
||||||
.python-version
|
|
||||||
Pipfile.lock
|
|
||||||
.mypy_cache/
|
|
||||||
.vscode/
|
|
||||||
.idea/
|
|
||||||
.venv/
|
|
||||||
*.code-workspace
|
|
||||||
*.ini
|
|
||||||
.pypirc
|
|
||||||
|
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
# See https://pre-commit.com for more information
|
|
||||||
# See https://pre-commit.com/hooks.html for more hooks
|
|
||||||
repos:
|
|
||||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
|
||||||
rev: v4.5.0
|
|
||||||
hooks:
|
|
||||||
- id: check-ast
|
|
||||||
- id: check-builtin-literals
|
|
||||||
- id: debug-statements
|
|
||||||
- id: end-of-file-fixer
|
|
||||||
- id: requirements-txt-fixer
|
|
||||||
- id: trailing-whitespace
|
|
||||||
- repo: https://github.com/psf/black
|
|
||||||
rev: 23.10.1
|
|
||||||
hooks:
|
|
||||||
- id: black
|
|
||||||
language_version: python3.13
|
|
||||||
- repo: https://github.com/asottile/pyupgrade
|
|
||||||
rev: v3.15.0
|
|
||||||
hooks:
|
|
||||||
- id: pyupgrade
|
|
||||||
args: [--py37-plus, --keep-runtime-typing]
|
|
||||||
- repo: https://github.com/asottile/reorder-python-imports
|
|
||||||
rev: v3.12.0
|
|
||||||
hooks:
|
|
||||||
- id: reorder-python-imports
|
|
||||||
- repo: https://github.com/asottile/add-trailing-comma
|
|
||||||
rev: v3.1.0
|
|
||||||
hooks:
|
|
||||||
- id: add-trailing-comma
|
|
||||||
|
|
||||||
default_language_version:
|
|
||||||
python: python3.13
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
version: 2
|
|
||||||
|
|
||||||
build:
|
|
||||||
os: ubuntu-22.04
|
|
||||||
tools:
|
|
||||||
python: "3.8"
|
|
||||||
|
|
||||||
|
|
||||||
sphinx:
|
|
||||||
configuration: docs/conf.py
|
|
||||||
|
|
||||||
|
|
||||||
python:
|
|
||||||
install:
|
|
||||||
- requirements: docs/requirements_rtd.txt
|
|
||||||
18
Makefile
18
Makefile
|
|
@ -1,18 +0,0 @@
|
||||||
prepare:
|
|
||||||
pipenv install --dev
|
|
||||||
pipenv run pre-commit install
|
|
||||||
|
|
||||||
shell:
|
|
||||||
pipenv shell
|
|
||||||
|
|
||||||
lint:
|
|
||||||
pipenv run pre-commit run --all-files
|
|
||||||
|
|
||||||
test:
|
|
||||||
pipenv run mypy
|
|
||||||
|
|
||||||
serve-docs:
|
|
||||||
@cd docs;\
|
|
||||||
make html;\
|
|
||||||
cd _build/html;\
|
|
||||||
python -m http.server;\
|
|
||||||
21
Pipfile
21
Pipfile
|
|
@ -1,21 +0,0 @@
|
||||||
[[source]]
|
|
||||||
url = "https://pypi.org/simple"
|
|
||||||
verify_ssl = true
|
|
||||||
name = "pypi"
|
|
||||||
|
|
||||||
[packages]
|
|
||||||
orjson = "*"
|
|
||||||
"discord.py" = {extras = ["voice"], version = "*"}
|
|
||||||
websockets = "*"
|
|
||||||
|
|
||||||
[dev-packages]
|
|
||||||
mypy = "*"
|
|
||||||
pre-commit = "*"
|
|
||||||
furo = "*"
|
|
||||||
sphinx = "*"
|
|
||||||
myst-parser = "*"
|
|
||||||
black = "*"
|
|
||||||
typing-extensions = "*"
|
|
||||||
|
|
||||||
[requires]
|
|
||||||
python_version = "3.8"
|
|
||||||
33
README.md
33
README.md
|
|
@ -1,18 +1,13 @@
|
||||||
# Pomice
|
# Pomice
|
||||||
|
The modern [Lavalink](https://github.com/freyacodes/Lavalink) wrapper designed for [discord.py](https://github.com/Rapptz/discord.py)
|
||||||
|
|
||||||

|
 
|
||||||
|
|
||||||
|
This library is heavily based off of/uses code from the following libraries:
|
||||||
[](https://github.com/cloudwithax/pomice/blob/main/LICENSE)  [](https://github.com/psf/black)
|
- [Wavelink](https://github.com/PythonistaGuild/Wavelink)
|
||||||
[](https://discord.gg/r64qjTSHG8) [](https://pomice.readthedocs.io/en/latest/)
|
- [spotify.py](https://github.com/mental32/spotify.py)
|
||||||
|
- [Slate](https://github.com/Axelancerr/slate)
|
||||||
|
- [Granitepy](https://github.com/twitch0001/granitepy)
|
||||||
Pomice is a fully asynchronous Python library designed for communicating with [Lavalink](https://github.com/freyacodes/Lavalink) seamlessly within the [discord.py](https://github.com/Rapptz/discord.py) library. It features 100% coverage of the [Lavalink](https://github.com/freyacodes/Lavalink) spec that can be accessed with easy-to-understand functions along with Spotify and Apple Music querying capabilities using built-in custom clients, making it easier to develop your next big music bot.
|
|
||||||
|
|
||||||
## Quick Links
|
|
||||||
- [Discord Server](https://discord.gg/r64qjTSHG8)
|
|
||||||
- [Read the Docs](https://pomice.readthedocs.io/en/latest/)
|
|
||||||
- [PyPI Homepage](https://pypi.org/project/pomice/)
|
|
||||||
|
|
||||||
|
|
||||||
# Install
|
# Install
|
||||||
|
|
@ -28,15 +23,9 @@ pip install pomice
|
||||||
pip install git+https://github.com/cloudwithax/pomice
|
pip install git+https://github.com/cloudwithax/pomice
|
||||||
```
|
```
|
||||||
|
|
||||||
# Support And Documentation
|
|
||||||
|
|
||||||
The official documentation is [here](https://pomice.readthedocs.io/en/latest/)
|
|
||||||
|
|
||||||
You can join our support server [here](https://discord.gg/r64qjTSHG8)
|
|
||||||
|
|
||||||
|
|
||||||
# Examples
|
# Examples
|
||||||
In-depth examples are located in the [examples folder](https://github.com/cloudwithax/pomice/tree/main/examples)
|
In-depth examples are located in the examples folder
|
||||||
|
|
||||||
Here's a quick example:
|
Here's a quick example:
|
||||||
|
|
||||||
|
|
@ -66,7 +55,7 @@ class Music(commands.Cog):
|
||||||
def __init__(self, bot) -> None:
|
def __init__(self, bot) -> None:
|
||||||
self.bot = bot
|
self.bot = bot
|
||||||
|
|
||||||
self.pomice = pomice.NodePool()
|
self.obsidian = pomice.NodePool()
|
||||||
|
|
||||||
async def start_nodes(self):
|
async def start_nodes(self):
|
||||||
await self.pomice.create_node(bot=self.bot, host='127.0.0.1', port='3030',
|
await self.pomice.create_node(bot=self.bot, host='127.0.0.1', port='3030',
|
||||||
|
|
@ -96,7 +85,7 @@ class Music(commands.Cog):
|
||||||
|
|
||||||
player = ctx.voice_client
|
player = ctx.voice_client
|
||||||
|
|
||||||
results = await player.get_tracks(query=f'{search}')
|
results = await player.get_tracks(query=f'ytsearch:{search}')
|
||||||
|
|
||||||
if not results:
|
if not results:
|
||||||
raise commands.CommandError('No results were found for that search term.')
|
raise commands.CommandError('No results were found for that search term.')
|
||||||
|
|
@ -114,7 +103,7 @@ bot.run("token here")
|
||||||
# FAQ
|
# FAQ
|
||||||
Why is it saying "Cannot connect to host"?
|
Why is it saying "Cannot connect to host"?
|
||||||
|
|
||||||
- You need to have a Lavalink node setup before you can use this library. Download it [here](https://github.com/freyacodes/Lavalink/releases/latest)
|
- You need to have a Lavalink node setup before you can use this library. Download it [here](https://github.com/freyacodes/Lavalink/releases/tag/3.3.2.5)
|
||||||
|
|
||||||
What experience do I need?
|
What experience do I need?
|
||||||
|
|
||||||
|
|
|
||||||
BIN
banner.jpg
BIN
banner.jpg
Binary file not shown.
|
Before Width: | Height: | Size: 72 KiB |
|
|
@ -1,20 +0,0 @@
|
||||||
# Minimal makefile for Sphinx documentation
|
|
||||||
#
|
|
||||||
|
|
||||||
# You can set these variables from the command line, and also
|
|
||||||
# from the environment for the first two.
|
|
||||||
SPHINXOPTS ?=
|
|
||||||
SPHINXBUILD ?= sphinx-build
|
|
||||||
SOURCEDIR = .
|
|
||||||
BUILDDIR = _build
|
|
||||||
|
|
||||||
# Put it first so that "make" without argument is like "make help".
|
|
||||||
help:
|
|
||||||
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
|
|
||||||
|
|
||||||
.PHONY: help Makefile
|
|
||||||
|
|
||||||
# Catch-all target: route all unknown targets to Sphinx using the new
|
|
||||||
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
|
|
||||||
%: Makefile
|
|
||||||
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
```{eval-rst}
|
|
||||||
Enums
|
|
||||||
-------------------
|
|
||||||
|
|
||||||
.. automodule:: pomice.enums
|
|
||||||
:members:
|
|
||||||
:undoc-members:
|
|
||||||
:show-inheritance:
|
|
||||||
```
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
```{eval-rst}
|
|
||||||
Events
|
|
||||||
--------------------
|
|
||||||
|
|
||||||
.. automodule:: pomice.events
|
|
||||||
:members:
|
|
||||||
:undoc-members:
|
|
||||||
:show-inheritance:
|
|
||||||
```
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
```{eval-rst}
|
|
||||||
Exceptions
|
|
||||||
------------------------
|
|
||||||
|
|
||||||
.. automodule:: pomice.exceptions
|
|
||||||
:members:
|
|
||||||
:undoc-members:
|
|
||||||
:show-inheritance:
|
|
||||||
```
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
```{eval-rst}
|
|
||||||
Filters
|
|
||||||
---------------------
|
|
||||||
|
|
||||||
.. automodule:: pomice.filters
|
|
||||||
:members:
|
|
||||||
:undoc-members:
|
|
||||||
:show-inheritance:
|
|
||||||
```
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
# API Reference
|
|
||||||
|
|
||||||
|
|
||||||
Here, you will find the different classes and methods used within Pomice.
|
|
||||||
|
|
||||||
|
|
||||||
```{toctree}
|
|
||||||
:maxdepth: 1
|
|
||||||
enums.md
|
|
||||||
events.md
|
|
||||||
exceptions.md
|
|
||||||
filters.md
|
|
||||||
objects.md
|
|
||||||
player.md
|
|
||||||
pool.md
|
|
||||||
queue.md
|
|
||||||
utils.md
|
|
||||||
```
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
```{eval-rst}
|
|
||||||
Objects
|
|
||||||
---------------------
|
|
||||||
|
|
||||||
.. automodule:: pomice.objects
|
|
||||||
:members:
|
|
||||||
:undoc-members:
|
|
||||||
:show-inheritance:
|
|
||||||
```
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
```{eval-rst}
|
|
||||||
Player
|
|
||||||
--------------------
|
|
||||||
|
|
||||||
.. automodule:: pomice.player
|
|
||||||
:members:
|
|
||||||
:undoc-members:
|
|
||||||
:show-inheritance:
|
|
||||||
```
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
```{eval-rst}
|
|
||||||
Pool
|
|
||||||
------------------
|
|
||||||
|
|
||||||
.. automodule:: pomice.pool
|
|
||||||
:members:
|
|
||||||
:undoc-members:
|
|
||||||
:show-inheritance:
|
|
||||||
```
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
```{eval-rst}
|
|
||||||
Queue
|
|
||||||
------------------
|
|
||||||
|
|
||||||
.. automodule:: pomice.queue
|
|
||||||
:members:
|
|
||||||
:undoc-members:
|
|
||||||
:show-inheritance:
|
|
||||||
```
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
```{eval-rst}
|
|
||||||
Utils
|
|
||||||
-------------------
|
|
||||||
|
|
||||||
.. automodule:: pomice.utils
|
|
||||||
:members:
|
|
||||||
:undoc-members:
|
|
||||||
:show-inheritance:
|
|
||||||
```
|
|
||||||
121
docs/conf.py
121
docs/conf.py
|
|
@ -1,121 +0,0 @@
|
||||||
# type: ignore
|
|
||||||
import importlib
|
|
||||||
import inspect
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
from typing import Any
|
|
||||||
from typing import Dict
|
|
||||||
|
|
||||||
sys.path.insert(0, os.path.abspath("."))
|
|
||||||
sys.path.insert(0, os.path.abspath(".."))
|
|
||||||
|
|
||||||
|
|
||||||
project = "Pomice"
|
|
||||||
copyright = "2023, cloudwithax"
|
|
||||||
author = "cloudwithax"
|
|
||||||
|
|
||||||
release = "2.2"
|
|
||||||
|
|
||||||
|
|
||||||
extensions = [
|
|
||||||
"sphinx.ext.autodoc",
|
|
||||||
"sphinx.ext.autosummary",
|
|
||||||
"sphinx.ext.linkcode",
|
|
||||||
"myst_parser",
|
|
||||||
]
|
|
||||||
|
|
||||||
myst_enable_extensions = [
|
|
||||||
"amsmath",
|
|
||||||
"colon_fence",
|
|
||||||
"deflist",
|
|
||||||
"dollarmath",
|
|
||||||
"fieldlist",
|
|
||||||
"html_admonition",
|
|
||||||
"html_image",
|
|
||||||
"replacements",
|
|
||||||
"smartquotes",
|
|
||||||
"strikethrough",
|
|
||||||
"substitution",
|
|
||||||
"tasklist",
|
|
||||||
]
|
|
||||||
|
|
||||||
myst_heading_anchors = 3
|
|
||||||
|
|
||||||
|
|
||||||
templates_path = ["_templates"]
|
|
||||||
|
|
||||||
exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
|
|
||||||
|
|
||||||
# We need to include this because discord.py has special tags
|
|
||||||
# they inlcude within their docstrings that dont parse
|
|
||||||
# right within our docs
|
|
||||||
|
|
||||||
rst_prolog = """
|
|
||||||
.. |coro| replace:: This function is a |coroutine_link|_.
|
|
||||||
.. |maybecoro| replace:: This function *could be a* |coroutine_link|_.
|
|
||||||
.. |coroutine_link| replace:: *coroutine*
|
|
||||||
.. _coroutine_link: https://docs.python.org/3/library/asyncio-task.html#coroutine
|
|
||||||
"""
|
|
||||||
|
|
||||||
html_theme = "furo"
|
|
||||||
|
|
||||||
html_static_path = ["_static"]
|
|
||||||
|
|
||||||
html_title = "Pomice"
|
|
||||||
|
|
||||||
language = "en"
|
|
||||||
|
|
||||||
html_theme_options: Dict[str, Any] = {
|
|
||||||
"footer_icons": [
|
|
||||||
{
|
|
||||||
"name": "GitHub",
|
|
||||||
"url": "https://github.com/cloudwithax/pomice",
|
|
||||||
"html": """
|
|
||||||
<svg stroke="currentColor" fill="currentColor" stroke-width="0" viewBox="0 0 16 16">
|
|
||||||
<path fill-rule="evenodd" d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8z"></path>
|
|
||||||
</svg>
|
|
||||||
""",
|
|
||||||
"class": "",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"source_repository": "https://github.com/cloudwithax/pomice",
|
|
||||||
"source_branch": "main",
|
|
||||||
"source_directory": "docs/",
|
|
||||||
}
|
|
||||||
|
|
||||||
# Grab lines from source files and embed into the docs
|
|
||||||
# so theres a point of reference
|
|
||||||
|
|
||||||
|
|
||||||
def linkcode_resolve(domain, info):
|
|
||||||
# i absolutely MUST add this here or else
|
|
||||||
# the docs will not build. fuck sphinx
|
|
||||||
try:
|
|
||||||
if domain != "py":
|
|
||||||
return None
|
|
||||||
if not info["module"]:
|
|
||||||
return None
|
|
||||||
|
|
||||||
mod = importlib.import_module(info["module"])
|
|
||||||
if "." in info["fullname"]:
|
|
||||||
objname, attrname = info["fullname"].split(".")
|
|
||||||
obj = getattr(mod, objname)
|
|
||||||
try:
|
|
||||||
obj = getattr(obj, attrname)
|
|
||||||
except AttributeError:
|
|
||||||
return None
|
|
||||||
else:
|
|
||||||
obj = getattr(mod, info["fullname"])
|
|
||||||
|
|
||||||
try:
|
|
||||||
file = inspect.getsourcefile(obj)
|
|
||||||
lines = inspect.getsourcelines(obj)
|
|
||||||
except TypeError:
|
|
||||||
# e.g. object is a typing.Union
|
|
||||||
return None
|
|
||||||
file = os.path.relpath(file, os.path.abspath(".."))
|
|
||||||
start, end = lines[1], lines[1] + len(lines[0]) - 1
|
|
||||||
|
|
||||||
return f"https://github.com/cloudwithax/pomice/blob/main/{file}#L{start}-L{end}"
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
27
docs/faq.md
27
docs/faq.md
|
|
@ -1,27 +0,0 @@
|
||||||
# Frequently Asked Questions
|
|
||||||
> Why is it saying "Cannot connect to host"?
|
|
||||||
|
|
||||||
Here are some common issues:
|
|
||||||
- You don't have a Lavalink node installed
|
|
||||||
- You have a Lavalink node, but it's not configured properly
|
|
||||||
- You have a Lavalink node and it's configured properly, but is unreachable due to firewall rules or a malformed network configuration.
|
|
||||||
|
|
||||||
If you are experiencing the first issue, you can download Lavalink [here.](https://github.com/freyacodes/Lavalink/releases/latest)
|
|
||||||
|
|
||||||
As for the other listed issues, either consult the Lavalink docs or go through the proper support channels for your specfic issue at hand.
|
|
||||||
|
|
||||||
For any other issues not listed here, please consult your preferred resource for more information.
|
|
||||||
|
|
||||||
> What experience do I need?
|
|
||||||
|
|
||||||
This library assumes that you have some experience with Python, asynchronous programming and the discord.py library.
|
|
||||||
|
|
||||||
> How do I install Pomice?
|
|
||||||
|
|
||||||
Refer to the [Installation](installation.md) section.
|
|
||||||
|
|
||||||
> How do I use Pomice?
|
|
||||||
|
|
||||||
If you are interested in learning how Pomice works, refer to the [API Reference](api/index.md) section.
|
|
||||||
|
|
||||||
If you want a quick example, refer to the [Quickstart](quickstart.md) section.
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
# Use the Events class
|
|
||||||
|
|
||||||
Pomice has different events that are triggered depending on events that Lavalink emits:
|
|
||||||
- `Event.TrackEndEvent()`
|
|
||||||
- `Event.TrackExceptionEvent()`
|
|
||||||
- `Event.TrackStartEvent()`
|
|
||||||
- `Event.TrackStuckEvent()`
|
|
||||||
- `Event.WebsoocketClosedEvent()`
|
|
||||||
- `Event.WebsocketOpenEvent()`
|
|
||||||
|
|
||||||
|
|
||||||
The classes listed here are as they appear in Pomice. When you use them within your application,
|
|
||||||
the way you use them will be different. Here's an example on how you would use the `TrackStartEvent` within an event listener in a cog:
|
|
||||||
|
|
||||||
```py
|
|
||||||
@commands.Cog.listener
|
|
||||||
async def on_pomice_track_start(self, player: Player, track: Track):
|
|
||||||
...
|
|
||||||
```
|
|
||||||
|
|
||||||
## Event definitions
|
|
||||||
|
|
||||||
Each event within Pomice has an event definition you can use to listen for said event within
|
|
||||||
your application. Here are all the definitions:
|
|
||||||
|
|
||||||
- `Event.TrackEndEvent()` -> `on_pomice_track_end`
|
|
||||||
- `Event.TrackExceptionEvent()` -> `on_pomice_track_exception`
|
|
||||||
- `Event.TrackStartEvent()` -> `on_pomice_track_start`
|
|
||||||
- `Event.TrackStuckEvent()` -> `on_pomice_track_stuck`
|
|
||||||
- `Event.WebsocketClosedEvent()` -> `on_pomice_websocket_closed`
|
|
||||||
- `Event.WebsocketOpenEvent()` -> `on_pomice_websocket_open`
|
|
||||||
|
|
||||||
|
|
||||||
All events related to tracks carry a `Player` object so you can access player-specific functions
|
|
||||||
and properties for further evaluation. They also carry a `Track` object so you can access track-specific functions and properties for further evaluation as well.
|
|
||||||
|
|
||||||
`Event.TrackEndEvent()` carries the reason for the track ending. If the track ends suddenly, you can use the reason provided to determine a solution.
|
|
||||||
|
|
||||||
`Event.TrackExceptionEvent()` carries the exception, or reason why the track failed to play. The format for the exception is `REASON: [SEVERITY]`.
|
|
||||||
|
|
||||||
`Event.TrackStuckEvent()` carries the threshold, or amount of time Lavalink will wait before it discards the stuck track and stops it from playing.
|
|
||||||
|
|
||||||
`Event.WebsocketClosedEvent()` carries a payload object that contains a `Guild` object, the code number, the reason for disconnect and whether or not it was by the
|
|
||||||
remote, or the node.
|
|
||||||
|
|
||||||
`Event.WebsocketOpenEvent()` carries a target, which is usually the node IP, and the SSRC, a 32-bit integer uniquely identifying the source of the RTP packets sent from
|
|
||||||
Lavalink.
|
|
||||||
|
|
@ -1,186 +0,0 @@
|
||||||
# Use the Filter class
|
|
||||||
|
|
||||||
Pomice takes full advantage of the Lavalink filter system by using a unique system to apply filters on top of one another. We call this system "filter stacking". With this system, we can stack any filter on top of one another to produce one-of-a-kind audio effects on playback while still being able to easily manage each filters.
|
|
||||||
|
|
||||||
|
|
||||||
## Types of filters
|
|
||||||
|
|
||||||
Lavalink, and by extension, Pomice, has different types of filters you can use.
|
|
||||||
|
|
||||||
Here are the different types and what they do:
|
|
||||||
|
|
||||||
:::{list-table}
|
|
||||||
:header-rows: 1
|
|
||||||
|
|
||||||
* - Type
|
|
||||||
- Class
|
|
||||||
- Description
|
|
||||||
|
|
||||||
* - Channel Mix
|
|
||||||
- `pomice.ChannelMix()`
|
|
||||||
- Adjusts stereo panning of a track.
|
|
||||||
|
|
||||||
* - Distortion
|
|
||||||
- `pomice.Distortion()`
|
|
||||||
- Generates a distortion effect on a track.
|
|
||||||
|
|
||||||
* - Equalizer
|
|
||||||
- `pomice.Equalizer()`
|
|
||||||
- Represents a 15 band equalizer. You can adjust the dynamic of the sound using this filter.
|
|
||||||
|
|
||||||
* - Karaoke
|
|
||||||
- `pomice.Karaoke()`
|
|
||||||
- Filters the vocals from the track.
|
|
||||||
|
|
||||||
* - Low Pass
|
|
||||||
- `pomice.LowPass()`
|
|
||||||
- Filters out high frequencies and only lets low frequencies pass through.
|
|
||||||
|
|
||||||
* - Rotation
|
|
||||||
- `pomice.Rotation()`
|
|
||||||
- Produces a stereo-like panning effect, which sounds like the audio is being rotated around the listener’s head
|
|
||||||
|
|
||||||
* - Timescale
|
|
||||||
- `pomice.Timescale()`
|
|
||||||
- Adjusts the speed and pitch of a track.
|
|
||||||
|
|
||||||
* - Tremolo
|
|
||||||
- `pomice.Tremolo()`
|
|
||||||
- Rapidly changes the volume of the track, producing a wavering tone.
|
|
||||||
|
|
||||||
* - Vibrato
|
|
||||||
- `pomice.Vibrato()`
|
|
||||||
- Rapidly changes the pitch of the track.
|
|
||||||
|
|
||||||
:::
|
|
||||||
|
|
||||||
|
|
||||||
Each filter has individual values you can adjust to fine-tune the sound of the filter. If you want to see what values each filter has, refer to [](../api/filters.md).
|
|
||||||
|
|
||||||
If you are stuck on what values adjust what, some filters include presets that you can apply to get a certain sound, i.e: `pomice.Timescale` has the `vaporwave()` and `nightcore()` and so on. You can also play around with the values and generate your own unique sound if you'd like.
|
|
||||||
|
|
||||||
## Adding a filter
|
|
||||||
|
|
||||||
:::{important}
|
|
||||||
|
|
||||||
You must have the `Player` class initialized first before using this. Refer to [](player.md)
|
|
||||||
|
|
||||||
:::
|
|
||||||
|
|
||||||
To add a filter, we need to use `Player.add_filter()`
|
|
||||||
|
|
||||||
|
|
||||||
```py
|
|
||||||
|
|
||||||
await Player.add_filter(...)
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
After you have initialized your function, we need to fill in the proper parameters:
|
|
||||||
|
|
||||||
:::{list-table}
|
|
||||||
:header-rows: 1
|
|
||||||
|
|
||||||
* - Name
|
|
||||||
- Type
|
|
||||||
- Description
|
|
||||||
|
|
||||||
* - `filter`
|
|
||||||
- `Filter`
|
|
||||||
- The filter to apply
|
|
||||||
|
|
||||||
* - `fast_apply`
|
|
||||||
- `bool`
|
|
||||||
- If set to `True`, the specified filter will apply (almost) instantly if a song is playing. Default value is `False`.
|
|
||||||
|
|
||||||
:::
|
|
||||||
|
|
||||||
After you set those parameters, your function should look something like this:
|
|
||||||
|
|
||||||
```py
|
|
||||||
|
|
||||||
await Player.add_filter(
|
|
||||||
filter=<your filter object here>,
|
|
||||||
fast_apply=<True/False>
|
|
||||||
)
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
After running this function, you should see your currently playing track sound different depending on the filter you chose.
|
|
||||||
|
|
||||||
## Removing a filter
|
|
||||||
|
|
||||||
:::{important}
|
|
||||||
|
|
||||||
You must have the `Player` class initialized first before using this. Refer to [](player.md)
|
|
||||||
|
|
||||||
:::
|
|
||||||
|
|
||||||
|
|
||||||
To remove a filter, we need to use `Player.remove_filter()`
|
|
||||||
|
|
||||||
```py
|
|
||||||
|
|
||||||
await Player.remove_filter(...)
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
After you have initialized your function, we need to fill in the proper parameters:
|
|
||||||
|
|
||||||
:::{list-table}
|
|
||||||
:header-rows: 1
|
|
||||||
|
|
||||||
* - Name
|
|
||||||
- Type
|
|
||||||
- Description
|
|
||||||
|
|
||||||
* - `filter`
|
|
||||||
- `Filter`
|
|
||||||
- The filter to remove
|
|
||||||
|
|
||||||
* - `fast_apply`
|
|
||||||
- `bool`
|
|
||||||
- If set to `True`, the specified filter will be removed (almost) instantly if a song is playing. Default value is `False`.
|
|
||||||
|
|
||||||
:::
|
|
||||||
|
|
||||||
After you set those parameters, your function should look something like this:
|
|
||||||
|
|
||||||
```py
|
|
||||||
|
|
||||||
await Player.remove_filter(
|
|
||||||
filter=<your filter object here>,
|
|
||||||
fast_apply=<True/False>
|
|
||||||
)
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
After running this function, you should see your currently playing track sound different depending on the filter you chose to remove.
|
|
||||||
|
|
||||||
|
|
||||||
## Resetting all filters
|
|
||||||
|
|
||||||
:::{important}
|
|
||||||
|
|
||||||
You must have the `Player` class initialized first before using this. Refer to [](player.md)
|
|
||||||
|
|
||||||
:::
|
|
||||||
|
|
||||||
To reset all filters, we need to use `Player.reset_filters()`
|
|
||||||
|
|
||||||
```py
|
|
||||||
|
|
||||||
await Player.reset_filters()
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
After you have initialized your function, you can optionally include the `fast_apply` parameter, which is a boolean. If this is set to `True`, it'll remove all filters (almost) instantly if theres a track playing.
|
|
||||||
|
|
||||||
```py
|
|
||||||
|
|
||||||
await Player.reset_filters(fast_apply=<True/False>)
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
# How Do I?
|
|
||||||
|
|
||||||
This section covers all the basic functions of Pomice and how to use them.
|
|
||||||
|
|
||||||
If you find the [API Reference](../api/index.md) section too confusing or would
|
|
||||||
rather have a straightforward explanation as to how to use a certain function,
|
|
||||||
this is for you.
|
|
||||||
|
|
||||||
```{toctree}
|
|
||||||
pool.md
|
|
||||||
node.md
|
|
||||||
player.md
|
|
||||||
filters.md
|
|
||||||
queue.md
|
|
||||||
events.md
|
|
||||||
```
|
|
||||||
182
docs/hdi/node.md
182
docs/hdi/node.md
|
|
@ -1,182 +0,0 @@
|
||||||
# Use the Node class
|
|
||||||
|
|
||||||
The `Node` class is one of the main classes you will be interacting with when using Pomice.
|
|
||||||
|
|
||||||
The `Node` class has a couple functions you will be using frequently:
|
|
||||||
|
|
||||||
- `Node.get_player()`
|
|
||||||
- `Node.get_tracks()`
|
|
||||||
- `Node.get_recommendations()`
|
|
||||||
|
|
||||||
|
|
||||||
There are also properties the `Node` class has to access certain values:
|
|
||||||
|
|
||||||
:::{list-table}
|
|
||||||
:header-rows: 1
|
|
||||||
|
|
||||||
* - Property
|
|
||||||
- Type
|
|
||||||
- Description
|
|
||||||
|
|
||||||
* - `Node.bot`
|
|
||||||
- `Client`
|
|
||||||
- Returns the discord.py client linked to this node.
|
|
||||||
|
|
||||||
* - `Node.is_connected`
|
|
||||||
- `bool`
|
|
||||||
- Returns whether this node is connected or not.
|
|
||||||
|
|
||||||
* - `Node.latency` `Node.ping`
|
|
||||||
- `float`
|
|
||||||
- Returns the latency of the node.
|
|
||||||
|
|
||||||
* - `Node.player_count`
|
|
||||||
- `int`
|
|
||||||
- Returns how many players are connected to this node.
|
|
||||||
|
|
||||||
* - `Node.players`
|
|
||||||
- `Dict[int, Player]`
|
|
||||||
- Returns a dict containing the guild ID and the player object.
|
|
||||||
|
|
||||||
* - `Node.pool`
|
|
||||||
- `NodePool`
|
|
||||||
- Returns the pool this node is apart of.
|
|
||||||
|
|
||||||
* - `Node.stats`
|
|
||||||
- `NodeStats`
|
|
||||||
- Returns the nodes stats.
|
|
||||||
|
|
||||||
:::
|
|
||||||
|
|
||||||
## Getting a player
|
|
||||||
|
|
||||||
To get a player from the nodes list of players, we need to use `Node.get_player()`
|
|
||||||
|
|
||||||
```py
|
|
||||||
|
|
||||||
await Node.get_player(...)
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
After you have initialized your function, you need to specify the `guild_id` of the player.
|
|
||||||
|
|
||||||
```py
|
|
||||||
|
|
||||||
await Node.get_player(guild_id=<your guild ID here>)
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
If the node finds a player with the guild ID you provided, it'll return the [](../api/player.md) object associated with the guild ID.
|
|
||||||
|
|
||||||
|
|
||||||
## Getting tracks
|
|
||||||
|
|
||||||
To get tracks using Lavalink, we need to use `Node.get_tracks()`
|
|
||||||
|
|
||||||
You can also use `Player.get_tracks()` to do the same thing, but this can be used to fetch tracks regardless if a player exists.
|
|
||||||
|
|
||||||
```py
|
|
||||||
|
|
||||||
await Node.get_tracks(...)
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
After you have initialized your function, we need to fill in the proper parameters:
|
|
||||||
|
|
||||||
:::{list-table}
|
|
||||||
:header-rows: 1
|
|
||||||
|
|
||||||
* - Name
|
|
||||||
- Type
|
|
||||||
- Description
|
|
||||||
|
|
||||||
* - `query`
|
|
||||||
- `str`
|
|
||||||
- The string you want to search up
|
|
||||||
|
|
||||||
* - `ctx`
|
|
||||||
- `Optional[commands.Context]`
|
|
||||||
- Optional value which sets a `Context` object on the tracks you search.
|
|
||||||
|
|
||||||
* - `search_type`
|
|
||||||
- `SearchType`
|
|
||||||
- Enum which sets the provider to search from. Default value is `SearchType.ytsearch`
|
|
||||||
|
|
||||||
* - `filters`
|
|
||||||
- `Optional[List[Filter]]`
|
|
||||||
- Optional value which sets the filters that should apply when the track is played on the tracks you search.
|
|
||||||
|
|
||||||
:::
|
|
||||||
|
|
||||||
After you set those parameters, your function should look something like this:
|
|
||||||
|
|
||||||
```py
|
|
||||||
|
|
||||||
await Node.get_tracks(
|
|
||||||
query="<your query here>",
|
|
||||||
ctx=<optional ctx object here>,
|
|
||||||
search_type=<optional search type here>,
|
|
||||||
filters=[<optional filters here>]
|
|
||||||
)
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
:::{important}
|
|
||||||
|
|
||||||
All querying of Spotify and Apple Music tracks or playlists is handled in this function if you enabled that functionality when creating your node.
|
|
||||||
If you want to enable it, refer to [](pool.md#adding-a-node)
|
|
||||||
|
|
||||||
:::
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
You should get a list of `Track` in return after running this function for you to then do whatever you want with it.
|
|
||||||
Ideally, you should be putting all tracks into some sort of a queue. If you would like to learn about how to use
|
|
||||||
our queue implementation, you can refer to [](queue.md)
|
|
||||||
|
|
||||||
|
|
||||||
## Getting recommendations
|
|
||||||
|
|
||||||
To get recommadations using Lavalink, we need to use `Node.get_recommendations()`
|
|
||||||
|
|
||||||
You can also use `Player.get_recommendations()` to do the same thing, but this can be used to fetch recommendations regardless if a player exists.
|
|
||||||
|
|
||||||
```py
|
|
||||||
|
|
||||||
await Node.get_recommendations(...)
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
After you have initialized your function, we need to fill in the proper parameters:
|
|
||||||
|
|
||||||
:::{list-table}
|
|
||||||
:header-rows: 1
|
|
||||||
|
|
||||||
* - Name
|
|
||||||
- Type
|
|
||||||
- Description
|
|
||||||
|
|
||||||
* - `track`
|
|
||||||
- `Track`
|
|
||||||
- The track to fetch recommendations for
|
|
||||||
|
|
||||||
* - `ctx`
|
|
||||||
- `Optional[commands.Context]`
|
|
||||||
- Optional value which sets a `Context` object on the recommendations you fetch.
|
|
||||||
|
|
||||||
:::
|
|
||||||
|
|
||||||
After you set those parameters, your function should look something like this:
|
|
||||||
|
|
||||||
```py
|
|
||||||
|
|
||||||
await Node.get_recommendations(
|
|
||||||
track=<your track object here>,
|
|
||||||
ctx=<optional ctx object here>,
|
|
||||||
)
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
You should get a list of `Track` in return after running this function for you to then do whatever you want with it.
|
|
||||||
Ideally, you should be putting all tracks into some sort of a queue. If you would like to learn about how to use
|
|
||||||
our queue implementation, you can refer to [](queue.md)
|
|
||||||
|
|
@ -1,501 +0,0 @@
|
||||||
# Use the Player class
|
|
||||||
|
|
||||||
The `Player` class is the class you will be interacting with the most within Pomice.
|
|
||||||
|
|
||||||
The `Player` class has a couple functions you will be using frequently:
|
|
||||||
|
|
||||||
- `Player.add_filter()`
|
|
||||||
- `Player.destroy()`
|
|
||||||
- `Player.get_recommendations()`
|
|
||||||
- `Player.get_tracks()`
|
|
||||||
- `Player.play()`
|
|
||||||
- `Player.remove_filter()`
|
|
||||||
- `Player.reset_filters()`
|
|
||||||
- `Player.seek()`
|
|
||||||
- `Player.set_pause()`
|
|
||||||
- `Player.set_volume()`
|
|
||||||
- `Player.stop()`
|
|
||||||
|
|
||||||
|
|
||||||
There are also properties the `Player` class has to access certain values:
|
|
||||||
|
|
||||||
|
|
||||||
:::{list-table}
|
|
||||||
:header-rows: 1
|
|
||||||
|
|
||||||
* - Property
|
|
||||||
- Type
|
|
||||||
- Description
|
|
||||||
|
|
||||||
* - `Player.bot`
|
|
||||||
- `Client`
|
|
||||||
- Returns the bot associated with this player instance.
|
|
||||||
|
|
||||||
* - `Player.current`
|
|
||||||
- `Track`
|
|
||||||
- Returns the currently playing track.
|
|
||||||
|
|
||||||
* - `Player.filters`
|
|
||||||
- `Filters`
|
|
||||||
- Returns the helper class for interacting with filters.
|
|
||||||
|
|
||||||
* - `Player.guild`
|
|
||||||
- `Guild`
|
|
||||||
- Returns the guild associated with the player.
|
|
||||||
|
|
||||||
* - `Player.is_connected`
|
|
||||||
- `bool`
|
|
||||||
- Returns whether or not the player is connected.
|
|
||||||
|
|
||||||
* - `Player.is_dead`
|
|
||||||
- `bool`
|
|
||||||
- Returns whether the player is dead or not. A player is considered dead if it has been destroyed and removed from stored players.
|
|
||||||
|
|
||||||
* - `Player.is_paused`
|
|
||||||
- `bool`
|
|
||||||
- Returns whether or not the player has a track which is paused or not.
|
|
||||||
|
|
||||||
* - `Player.is_playing`
|
|
||||||
- `bool`
|
|
||||||
- Returns whether or not the player is actively playing a track.
|
|
||||||
|
|
||||||
* - `Player.node`
|
|
||||||
- `Node`
|
|
||||||
- Returns the node the player is connected to.
|
|
||||||
|
|
||||||
* - `Player.position`
|
|
||||||
- `float`
|
|
||||||
- Returns the player’s position in a track in milliseconds.
|
|
||||||
|
|
||||||
* - `Player.adjusted_position`
|
|
||||||
- `float`
|
|
||||||
- Returns the player’s position in a track in milliseconds, adjusted for rate if affected.
|
|
||||||
|
|
||||||
* - `Player.adjusted_length`
|
|
||||||
- `float`
|
|
||||||
- Returns the current track length in milliseconds, adjusted for rate if affected.
|
|
||||||
|
|
||||||
* - `Player.rate`
|
|
||||||
- `float`
|
|
||||||
- Returns the players current rate, which represents the speed of the currently playing track. This rate is affected by the `Timescale` filter.
|
|
||||||
|
|
||||||
* - `Player.volume`
|
|
||||||
- `int`
|
|
||||||
- Returns the players current volume.
|
|
||||||
|
|
||||||
:::
|
|
||||||
|
|
||||||
## Getting tracks
|
|
||||||
|
|
||||||
To get tracks using Lavalink, we need to use `Player.get_tracks()`
|
|
||||||
|
|
||||||
You can also use `Node.get_tracks()` to do the same thing but without having a player.
|
|
||||||
|
|
||||||
```py
|
|
||||||
|
|
||||||
await Player.get_tracks(...)
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
After you have initialized your function, we need to fill in the proper parameters:
|
|
||||||
|
|
||||||
:::{list-table}
|
|
||||||
:header-rows: 1
|
|
||||||
|
|
||||||
* - Name
|
|
||||||
- Type
|
|
||||||
- Description
|
|
||||||
|
|
||||||
* - `query`
|
|
||||||
- `str`
|
|
||||||
- The string you want to search up
|
|
||||||
|
|
||||||
* - `ctx`
|
|
||||||
- `Optional[commands.Context]`
|
|
||||||
- Optional value which sets a `Context` object on the tracks you search.
|
|
||||||
|
|
||||||
* - `search_type`
|
|
||||||
- `SearchType`
|
|
||||||
- Enum which sets the provider to search from. Default value is `SearchType.ytsearch`
|
|
||||||
|
|
||||||
* - `filters`
|
|
||||||
- `Optional[List[Filter]]`
|
|
||||||
- Optional value which sets the filters that should apply when the track is played on the tracks you search.
|
|
||||||
|
|
||||||
:::
|
|
||||||
|
|
||||||
After you set those parameters, your function should look something like this:
|
|
||||||
|
|
||||||
```py
|
|
||||||
|
|
||||||
await Player.get_tracks(
|
|
||||||
query="<your query here>",
|
|
||||||
ctx=<optional ctx object here>,
|
|
||||||
search_type=<optional search type here>,
|
|
||||||
filters=[<optional filters here>]
|
|
||||||
)
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
:::{important}
|
|
||||||
|
|
||||||
All querying of Spotify and Apple Music tracks or playlists is handled in this function if you enabled that functionality when creating your node.
|
|
||||||
If you want to enable it, refer to [](pool.md#adding-a-node)
|
|
||||||
|
|
||||||
:::
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
You should get a list of `Track` in return after running this function for you to then do whatever you want with it.
|
|
||||||
Ideally, you should be putting all tracks into some sort of a queue. If you would like to learn about how to use
|
|
||||||
our queue implementation, you can refer to [](queue.md)
|
|
||||||
|
|
||||||
|
|
||||||
## Getting recommendations
|
|
||||||
|
|
||||||
To get recommendations using Lavalink, we need to use `Player.get_recommendations()`
|
|
||||||
|
|
||||||
You can also use `Node.get_recommendations()` to do the same thing without having a player.
|
|
||||||
|
|
||||||
```py
|
|
||||||
|
|
||||||
await Player.get_recommendations(...)
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
After you have initialized your function, we need to fill in the proper parameters:
|
|
||||||
|
|
||||||
:::{list-table}
|
|
||||||
:header-rows: 1
|
|
||||||
|
|
||||||
* - Name
|
|
||||||
- Type
|
|
||||||
- Description
|
|
||||||
|
|
||||||
* - `track`
|
|
||||||
- `Track`
|
|
||||||
- The track to fetch recommendations for
|
|
||||||
|
|
||||||
* - `ctx`
|
|
||||||
- `Optional[commands.Context]`
|
|
||||||
- Optional value which sets a `Context` object on the recommendations you fetch.
|
|
||||||
|
|
||||||
:::
|
|
||||||
|
|
||||||
After you set those parameters, your function should look something like this:
|
|
||||||
|
|
||||||
```py
|
|
||||||
|
|
||||||
await Player.get_recommendations(
|
|
||||||
track=<your track object here>,
|
|
||||||
ctx=<optional ctx object here>,
|
|
||||||
)
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
You should get a list of `Track` in return after running this function for you to then do whatever you want with it.
|
|
||||||
Ideally, you should be putting all tracks into some sort of a queue. If you would like to learn about how to use
|
|
||||||
our queue implementation, you can refer to [](queue.md)
|
|
||||||
|
|
||||||
## Connecting a player
|
|
||||||
|
|
||||||
To connect a player to a channel you need to pass the `Player` class into your `channel.connect()` function:
|
|
||||||
|
|
||||||
```py
|
|
||||||
|
|
||||||
await voice_channel.connect(cls=Player)
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
This will instance the player and make it available to your guild. If you want to access your player after instancing it,
|
|
||||||
you must use either `Guild.voice_client` or `Context.voice_client`.
|
|
||||||
|
|
||||||
## Controlling the player
|
|
||||||
|
|
||||||
There are a few functions to control the player:
|
|
||||||
|
|
||||||
- `Player.destroy()`
|
|
||||||
- `Player.play()`
|
|
||||||
- `Player.seek()`
|
|
||||||
- `Player.set_pause()`
|
|
||||||
- `Player.set_volume()`
|
|
||||||
- `Player.stop()`
|
|
||||||
|
|
||||||
### Destroying a player
|
|
||||||
|
|
||||||
To destroy a player, we need to use `Player.destroy()`
|
|
||||||
|
|
||||||
```py
|
|
||||||
|
|
||||||
await Player.destroy()
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
### Playing a track
|
|
||||||
|
|
||||||
To play a track, we need to use `Player.play()`
|
|
||||||
|
|
||||||
```py
|
|
||||||
|
|
||||||
await Player.play(...)
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
After you have initialized your function, we need to fill in the proper parameters:
|
|
||||||
|
|
||||||
:::{list-table}
|
|
||||||
:header-rows: 1
|
|
||||||
|
|
||||||
* - Name
|
|
||||||
- Type
|
|
||||||
- Description
|
|
||||||
|
|
||||||
* - `track`
|
|
||||||
- `Track`
|
|
||||||
- The track to play
|
|
||||||
|
|
||||||
* - `start`
|
|
||||||
- `int`
|
|
||||||
- The time (in milliseconds) to start the track at. Default value is `0`
|
|
||||||
|
|
||||||
* - `end`
|
|
||||||
- `int`
|
|
||||||
- The time (in milliseconds) to end the track at. Default value is `0`
|
|
||||||
|
|
||||||
* - `ignore_if_playing`
|
|
||||||
- `bool`
|
|
||||||
- If set, ignores the current track playing and replaces it with this track. Default value is `False`
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
:::
|
|
||||||
|
|
||||||
After you set those parameters, your function should look something like this:
|
|
||||||
|
|
||||||
```py
|
|
||||||
|
|
||||||
await Player.play(
|
|
||||||
track=<your track object here>,
|
|
||||||
start=<your optional start time here>,
|
|
||||||
end=<your optional end time here>,
|
|
||||||
ignore_if_playing=<your optional boolean here>
|
|
||||||
)
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
After running this function, it should return the `Track` you specified when running the function. This means the track is now playing.
|
|
||||||
|
|
||||||
|
|
||||||
### Seeking to a position
|
|
||||||
|
|
||||||
To seek to a position, we need to use `Player.seek()`
|
|
||||||
|
|
||||||
```py
|
|
||||||
|
|
||||||
await Player.seek(...)
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
After you have initialized your function, we need to include the `position` parameter, which is an amount in milliseconds:
|
|
||||||
|
|
||||||
```py
|
|
||||||
|
|
||||||
await Player.seek(position=<your pos here>)
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
After running this function, your currently playing track should seek to your specified position
|
|
||||||
|
|
||||||
|
|
||||||
### Pausing/unpausing the player
|
|
||||||
|
|
||||||
|
|
||||||
To pause/unpause the player, we need to use `Player.set_pause()`
|
|
||||||
|
|
||||||
```py
|
|
||||||
|
|
||||||
await Player.set_pause(...)
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
After you have initialized your function, we need to include the `pause` parameter, which is a boolean:
|
|
||||||
|
|
||||||
```py
|
|
||||||
|
|
||||||
await Player.set_pause(pause=<True/False>)
|
|
||||||
|
|
||||||
```
|
|
||||||
After running this function, your currently playing track should either pause or unpause depending on what you set.
|
|
||||||
|
|
||||||
### Setting the player volume
|
|
||||||
|
|
||||||
To set the volume the player, we need to use `Player.set_volume()`
|
|
||||||
|
|
||||||
```py
|
|
||||||
|
|
||||||
await Player.set_volume(...)
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
:::{important}
|
|
||||||
Lavalink accept ranges from 0 to 500 for this parameter. Inputting a value either higher or lower
|
|
||||||
than this amount will **not work.**
|
|
||||||
:::
|
|
||||||
|
|
||||||
After you have initialized your function, we need to include the `amount` parameter, which is an integer:
|
|
||||||
|
|
||||||
```py
|
|
||||||
|
|
||||||
await Player.set_volume(amount=<int>)
|
|
||||||
|
|
||||||
```
|
|
||||||
After running this function, your currently playing track should adjust in volume depending on the amount you set.
|
|
||||||
|
|
||||||
### Stopping the player
|
|
||||||
|
|
||||||
To stop the player, we need to use `Player.stop()`
|
|
||||||
|
|
||||||
```py
|
|
||||||
|
|
||||||
await Player.stop()
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
### Moving the player to another channel
|
|
||||||
|
|
||||||
To move the player to another channel, we need to use `Player.move_to()`
|
|
||||||
|
|
||||||
```py
|
|
||||||
|
|
||||||
await Player.move_to(...)
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
After you have initialized your function, we need to include the `channel` parameter, which is a `VoiceChannel`:
|
|
||||||
|
|
||||||
```py
|
|
||||||
|
|
||||||
await Player.move_to(channel)
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
After running this function, your player should be in the new voice channel. All voice state updates should also be handled.
|
|
||||||
|
|
||||||
|
|
||||||
## Controlling filters
|
|
||||||
|
|
||||||
Pomice has an extensive suite of filter management tools to help you make the most of Lavalink and it's filters.
|
|
||||||
|
|
||||||
Here are some of the functions you will be using to control filters:
|
|
||||||
|
|
||||||
- `Player.add_filter()`
|
|
||||||
- `Player.remove_filter()`
|
|
||||||
- `Player.reset_filters()`
|
|
||||||
|
|
||||||
|
|
||||||
### Adding a filter
|
|
||||||
|
|
||||||
|
|
||||||
To add a filter, we need to use `Player.add_filter()`
|
|
||||||
|
|
||||||
```py
|
|
||||||
|
|
||||||
await Player.add_filter(...)
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
After you have initialized your function, we need to fill in the proper parameters:
|
|
||||||
|
|
||||||
:::{list-table}
|
|
||||||
:header-rows: 1
|
|
||||||
|
|
||||||
* - Name
|
|
||||||
- Type
|
|
||||||
- Description
|
|
||||||
|
|
||||||
* - `filter`
|
|
||||||
- `Filter`
|
|
||||||
- The filter to apply
|
|
||||||
|
|
||||||
* - `fast_apply`
|
|
||||||
- `bool`
|
|
||||||
- If set to `True`, the specified filter will apply (almost) instantly if a song is playing. Default value is `False`.
|
|
||||||
|
|
||||||
:::
|
|
||||||
|
|
||||||
After you set those parameters, your function should look something like this:
|
|
||||||
|
|
||||||
```py
|
|
||||||
|
|
||||||
await Player.add_filter(
|
|
||||||
filter=<your filter object here>,
|
|
||||||
fast_apply=<True/False>
|
|
||||||
)
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
After running this function, you should see your currently playing track sound different depending on the filter you chose.
|
|
||||||
|
|
||||||
### Removing a filter
|
|
||||||
|
|
||||||
|
|
||||||
To remove a filter, we need to use `Player.remove_filter()`
|
|
||||||
|
|
||||||
```py
|
|
||||||
|
|
||||||
await Player.remove_filter(...)
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
After you have initialized your function, we need to fill in the proper parameters:
|
|
||||||
|
|
||||||
:::{list-table}
|
|
||||||
:header-rows: 1
|
|
||||||
|
|
||||||
* - Name
|
|
||||||
- Type
|
|
||||||
- Description
|
|
||||||
|
|
||||||
* - `filter`
|
|
||||||
- `Filter`
|
|
||||||
- The filter to remove
|
|
||||||
|
|
||||||
* - `fast_apply`
|
|
||||||
- `bool`
|
|
||||||
- If set to `True`, the specified filter will be removed (almost) instantly if a song is playing. Default value is `False`.
|
|
||||||
|
|
||||||
:::
|
|
||||||
|
|
||||||
After you set those parameters, your function should look something like this:
|
|
||||||
|
|
||||||
```py
|
|
||||||
|
|
||||||
await Player.remove_filter(
|
|
||||||
filter=<your filter object here>,
|
|
||||||
fast_apply=<True/False>
|
|
||||||
)
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
After running this function, you should see your currently playing track sound different depending on the filter you chose to remove.
|
|
||||||
|
|
||||||
|
|
||||||
### Resetting all filters
|
|
||||||
|
|
||||||
To reset all filters, we need to use `Player.reset_filters()`
|
|
||||||
|
|
||||||
```py
|
|
||||||
|
|
||||||
await Player.reset_filters()
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
After you have initialized your function, you can optionally include the `fast_apply` parameter, which is a boolean. If this is set to `True`, it'll remove all filters (almost) instantly if theres a track playing.
|
|
||||||
|
|
||||||
```py
|
|
||||||
|
|
||||||
await Player.reset_filters(fast_apply=<True/False>)
|
|
||||||
|
|
||||||
```
|
|
||||||
156
docs/hdi/pool.md
156
docs/hdi/pool.md
|
|
@ -1,156 +0,0 @@
|
||||||
# Use the NodePool class
|
|
||||||
|
|
||||||
The `NodePool` class is the first class you will use when using Pomice.
|
|
||||||
|
|
||||||
The `NodePool` Class has three main functions you can use:
|
|
||||||
|
|
||||||
- `NodePool.create_node()`
|
|
||||||
- `NodePool.get_node()`
|
|
||||||
- `NodePool.get_best_node()`
|
|
||||||
|
|
||||||
|
|
||||||
## Adding a node
|
|
||||||
|
|
||||||
To add a node to our `NodePool`, we need to run `NodePool.create_node()`.
|
|
||||||
|
|
||||||
```py
|
|
||||||
|
|
||||||
await NodePool.create_node(...)
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
After you have initialized your function, we need to fill in the proper parameters:
|
|
||||||
|
|
||||||
|
|
||||||
:::{list-table}
|
|
||||||
:header-rows: 1
|
|
||||||
|
|
||||||
* - Name
|
|
||||||
- Type
|
|
||||||
- Description
|
|
||||||
|
|
||||||
* - `bot`
|
|
||||||
- `Client`
|
|
||||||
- A discord.py `Client` object (can be either a `Client` or a `commands.Bot`)
|
|
||||||
|
|
||||||
* - `host`
|
|
||||||
- `str`
|
|
||||||
- The IP/URL of your Lavalink node. Remember not to include the port in this field
|
|
||||||
|
|
||||||
* - `port`
|
|
||||||
- `int`
|
|
||||||
- The port your Lavalink node uses. By default, Lavalink uses `2333`.
|
|
||||||
|
|
||||||
* - `identifier`
|
|
||||||
- `str`
|
|
||||||
- The identifier your `Node` object uses to distinguish itself.
|
|
||||||
|
|
||||||
* - `password`
|
|
||||||
- `str`
|
|
||||||
- The password used to connect to your node.
|
|
||||||
|
|
||||||
* - `spotify_client_id`
|
|
||||||
- `Optional[str]`
|
|
||||||
- Your Spotify client ID goes here. You need this along with the client secret if you want to use Spotify functionality within Pomice.
|
|
||||||
|
|
||||||
* - `spotify_client_secret`
|
|
||||||
- `Optional[str]`
|
|
||||||
- Your Spotify client secret goes here. You need this along with the client ID if you want to use Spotify functionality within Pomice.
|
|
||||||
|
|
||||||
* - `apple_music`
|
|
||||||
- `bool`
|
|
||||||
- Set this value to `True` if you want to use Apple Music functionality within Pomice. Apple Music will **not work** if you don't enable this.
|
|
||||||
|
|
||||||
* - `fallback`
|
|
||||||
- `bool`
|
|
||||||
- Set this value to `True` if you want Pomice to automatically switch all players to another available node if one disconnects.
|
|
||||||
You must have two or more nodes to be able to do this.
|
|
||||||
|
|
||||||
* - `logger`
|
|
||||||
- `Optional[logging.Logger]`
|
|
||||||
- If you would like to receive logging information from Pomice, set this to your logger class
|
|
||||||
|
|
||||||
|
|
||||||
:::
|
|
||||||
|
|
||||||
|
|
||||||
All the other parameters not listed here have default values that are either set within the function or set later in the initialization of the node. If you would like to set these parameters to a different value, you are free to do so.
|
|
||||||
|
|
||||||
After you set those parameters, your function should look something like this:
|
|
||||||
|
|
||||||
```py
|
|
||||||
|
|
||||||
await NodePool.create_node(
|
|
||||||
bot=bot,
|
|
||||||
host="<your ip here>",
|
|
||||||
port=<your port here>,
|
|
||||||
identifier="<your id here>",
|
|
||||||
password="<your password here>",
|
|
||||||
spotify_client_id="<your spotify client id here>",
|
|
||||||
spotify_client_secret="<your spotify client secret here>"
|
|
||||||
apple_music=<True/False>,
|
|
||||||
fallback=<True/False>,
|
|
||||||
logger=<your logger here>
|
|
||||||
)
|
|
||||||
|
|
||||||
```
|
|
||||||
:::{important}
|
|
||||||
|
|
||||||
For features like Spotify and Apple Music, you are **not required** to fill in anything for them if you do not want to use them. If you do end up queuing a Spotify or Apple Music track, it is **up to you** on how you decide to handle it, whether it be through your own methods or a Lavalink plugin.
|
|
||||||
|
|
||||||
:::
|
|
||||||
|
|
||||||
Now that you have your Node object created, move on to [Using a node](node.md) to see what you can do with your `Node` object.
|
|
||||||
|
|
||||||
## Getting a node
|
|
||||||
|
|
||||||
To get a node from the node pool, we need to use `NodePool.get_node()`
|
|
||||||
|
|
||||||
```py
|
|
||||||
|
|
||||||
await NodePool.get_node(...)
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
After you have initialized your function, you can specify a identifier if you want to grab a specified node:
|
|
||||||
|
|
||||||
```py
|
|
||||||
|
|
||||||
await NodePool.get_node(identifier="<your id here>")
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
If you do not set a identifier, it'll return a random node from the pool.
|
|
||||||
|
|
||||||
|
|
||||||
## Getting the best node
|
|
||||||
|
|
||||||
To get a node from the node pool based on certain requirements, we need to use `NodePool.get_best_node()`
|
|
||||||
|
|
||||||
```py
|
|
||||||
|
|
||||||
await NodePool.get_best_node(...)
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
After you have initialized your function, you need to specify a `NodeAlgorithm` to use to grab your node from the pool.
|
|
||||||
The available algorithms are `by_ping` and `by_players`.
|
|
||||||
If you want to view what they do, refer to the `NodeAlgorithm` enum in the [](../api/enums.md) section.
|
|
||||||
|
|
||||||
```py
|
|
||||||
|
|
||||||
await NodePool.get_best_node(algorithm=NodeAlgorithm.xyz)
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
## Disconnecting all nodes from the pool
|
|
||||||
|
|
||||||
To disconnect all nodes from the pool, we need to use `NodePool.disconnect()`
|
|
||||||
|
|
||||||
```py
|
|
||||||
|
|
||||||
await NodePool.disconnect()
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
After running this function, all nodes in the pool should disconnect and no longer be available to use.
|
|
||||||
|
|
@ -1,224 +0,0 @@
|
||||||
# Use the Queue class
|
|
||||||
|
|
||||||
Pomice has an optional queue system that works seamlessly with the library. This queue system introduce quality-of-life features that every music application should ideally have like queue shuffling, queue jumping, and looping.
|
|
||||||
|
|
||||||
|
|
||||||
To use the queue system with Pomice, you must first subclass the `Player` class within your application like so:
|
|
||||||
|
|
||||||
```py
|
|
||||||
|
|
||||||
from pomice import Player
|
|
||||||
|
|
||||||
class CustomPlayer(Player):
|
|
||||||
...
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
After you have initialized your subclass, you can add a `queue` variable to your class so you can access your queue when you initialize your player:
|
|
||||||
|
|
||||||
```py
|
|
||||||
|
|
||||||
from pomice import Player, Queue
|
|
||||||
|
|
||||||
class CustomPlayer(Player):
|
|
||||||
...
|
|
||||||
self.queue = Queue()
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
## Adding a song to the queue
|
|
||||||
|
|
||||||
To add a song to the queue, we must use `Queue.put()`
|
|
||||||
|
|
||||||
```py
|
|
||||||
|
|
||||||
Queue.put()
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
After you have initialized your function, we need to include the `item` parameter, which is a `Track`:
|
|
||||||
|
|
||||||
```py
|
|
||||||
|
|
||||||
Queue.put(item=<your Track here>)
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
After running the function, your track should be in the queue.
|
|
||||||
|
|
||||||
## Getting a track from the queue
|
|
||||||
|
|
||||||
To get a track from the queue, we need to do a few things.
|
|
||||||
|
|
||||||
To get a track using its position within the queue, you first need to get the position as a number, also known as its index. If you dont have the index and instead want to search for its index using keywords, you have to implement a fuzzy searching algorithm to find said track using a search query as an input and have it compare that query against the titles of the tracks in the queue. After that, you can get the `Track` object by [getting it with its index](queue.md#getting-track-with-its-index)
|
|
||||||
|
|
||||||
### Getting index of track
|
|
||||||
|
|
||||||
If you have the `Track` object and want to get its index within the queue, we can use `Queue.find_position()`
|
|
||||||
|
|
||||||
```py
|
|
||||||
|
|
||||||
Queue.find_position()
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
After you have initialized your function, we need to include the `item` parameter, which is a `Track`:
|
|
||||||
|
|
||||||
```py
|
|
||||||
|
|
||||||
Queue.find_position(item=<your Track here>)
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
After running the function, it should return the position of the track as an integer.
|
|
||||||
|
|
||||||
|
|
||||||
### Getting track with its index
|
|
||||||
|
|
||||||
If you have the index of the track and want to get the `Track` object, you first need to get the raw queue list:
|
|
||||||
|
|
||||||
```py
|
|
||||||
|
|
||||||
queue = Queue.get_queue()
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
After you have your queue, you can use basic list splicing to get the track object:
|
|
||||||
|
|
||||||
```py
|
|
||||||
|
|
||||||
track = queue[<index>]
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
## Getting the next track in the queue
|
|
||||||
|
|
||||||
To get the next track in the queue, we need to use `Queue.get()`
|
|
||||||
|
|
||||||
```py
|
|
||||||
|
|
||||||
Queue.get()
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
After running this function, it'll return the first track from the queue and remove it.
|
|
||||||
|
|
||||||
:::{note}
|
|
||||||
|
|
||||||
If you have a queue loop mode set, this behavior will be overridden since the queue is not allowed to remove tracks from the queue if its looping.
|
|
||||||
|
|
||||||
:::
|
|
||||||
|
|
||||||
## Removing a track from the queue
|
|
||||||
|
|
||||||
|
|
||||||
To remove a track from the queue, we must use `Queue.remove()`
|
|
||||||
|
|
||||||
```py
|
|
||||||
|
|
||||||
Queue.remove()
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
After you have initialized your function, we need to include the `item` parameter, which is a `Track`:
|
|
||||||
|
|
||||||
```py
|
|
||||||
|
|
||||||
Queue.remove(item=<your Track here>)
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
:::{important}
|
|
||||||
|
|
||||||
Your `Track` object must be in the queue if you want to remove it. Make sure you follow [](queue.md#getting-a-track-from-the-queue) before running this function.
|
|
||||||
|
|
||||||
:::
|
|
||||||
|
|
||||||
After running this function, your track should be removed from the queue.
|
|
||||||
|
|
||||||
|
|
||||||
## Shuffling the queue
|
|
||||||
|
|
||||||
To shuffle the queue, we must use `Queue.shuffle()`
|
|
||||||
|
|
||||||
```py
|
|
||||||
|
|
||||||
Queue.shuffle()
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
After running this function, your queue should be in a different order than it was originally.
|
|
||||||
|
|
||||||
:::{tip}
|
|
||||||
|
|
||||||
This function works best if theres atleast **3** tracks in the queue. The more tracks, the more variation the shuffle has.
|
|
||||||
|
|
||||||
:::
|
|
||||||
|
|
||||||
|
|
||||||
## Looping the queue
|
|
||||||
|
|
||||||
To loop the queue, we must use `Queue.set_loop_mode()`
|
|
||||||
|
|
||||||
```py
|
|
||||||
|
|
||||||
Queue.set_loop_mode(...)
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
After you have initialized your function, we need to include the `mode` parameter, which is a `LoopMode` enum:
|
|
||||||
|
|
||||||
```py
|
|
||||||
|
|
||||||
Queue.set_loop_mode(mode=LoopMode.<mode>)
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
The two types of `LoopMode` enums are `LoopMode.QUEUE` and `LoopMode.TRACK`. `QUEUE` loops the entire queue and `TRACK` loops the current track.
|
|
||||||
|
|
||||||
After running the function, your queue will now loop using the mode you specify.
|
|
||||||
|
|
||||||
### Resetting the loop mode
|
|
||||||
|
|
||||||
To reset the loop mode, we must use `Queue.disable_loop()`
|
|
||||||
|
|
||||||
```py
|
|
||||||
|
|
||||||
Queue.disable_loop()
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
:::{important}
|
|
||||||
|
|
||||||
You must have a loop mode set before using this function. It will **not work** if you do not a loop mode set
|
|
||||||
|
|
||||||
:::
|
|
||||||
|
|
||||||
After running the function, your queue should return to its normal functionality.
|
|
||||||
|
|
||||||
## Jumping to a track in the queue
|
|
||||||
|
|
||||||
To jump to a track in the queue, we must use `Queue.jump()`
|
|
||||||
|
|
||||||
|
|
||||||
```py
|
|
||||||
|
|
||||||
Queue.jump(...)
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
After you have initialized your function, we need to include the `item` parameter, which is a `Track`:
|
|
||||||
|
|
||||||
```py
|
|
||||||
|
|
||||||
Queue.jump(item=<your Track here>)
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
:::{important}
|
|
||||||
|
|
||||||
Your `Track` object must be in the queue if you want to jump to it. Make sure you follow [](queue.md#getting-a-track-from-the-queue) before running this function.
|
|
||||||
|
|
||||||
:::
|
|
||||||
|
|
||||||
After running this function, any items before the specified item will be removed, effectively "jumping" to the specified item in the queue. The next item obtained using `Queue.get()` will be your specified track.
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
---
|
|
||||||
hide-toc: true
|
|
||||||
---
|
|
||||||
|
|
||||||
# Pomice
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Pomice is a fully asynchronous Python library designed for communicating with [Lavalink](https://github.com/freyacodes/Lavalink) seamlessly within the [discord.py](https://github.com/Rapptz/discord.py) library. It features 100% API coverage of the entire [Lavalink](https://github.com/freyacodes/Lavalink) spec that can be accessed with easy-to-understand functions. We also include Spotify and Apple Music querying capabilites using built-in custom clients, making it easier to develop your next big music bot.
|
|
||||||
|
|
||||||
|
|
||||||
## Quick Links:
|
|
||||||
- [Installation](installation.md)
|
|
||||||
- [Quickstart](quickstart.md)
|
|
||||||
- [Frequently Asked Questions](faq.md)
|
|
||||||
- [How Do I?](hdi/index.md)
|
|
||||||
- [API Reference](api/index.md)
|
|
||||||
|
|
||||||
|
|
||||||
```{toctree}
|
|
||||||
:caption: Before You Start
|
|
||||||
:hidden:
|
|
||||||
installation
|
|
||||||
quickstart
|
|
||||||
faq
|
|
||||||
```
|
|
||||||
|
|
||||||
```{toctree}
|
|
||||||
:caption: How Do I?
|
|
||||||
:hidden:
|
|
||||||
hdi/index.md
|
|
||||||
```
|
|
||||||
|
|
||||||
```{toctree}
|
|
||||||
:caption: API Reference
|
|
||||||
:hidden:
|
|
||||||
api/index.md
|
|
||||||
```
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
# Installation
|
|
||||||
|
|
||||||
This library is designed to work with the Lavalink audio delivery system,
|
|
||||||
which directly interfaces with Discord to provide buttery smooth audio without
|
|
||||||
wasting your precious system resources.
|
|
||||||
|
|
||||||
Pomice is made with convenience to the user, in that everything is easy to use
|
|
||||||
and is out of your way, while also being customizable.
|
|
||||||
|
|
||||||
In order to start using this library, please download a Lavalink node to start,
|
|
||||||
you can get it [here](https://github.com/freyacodes/Lavalink/releases/latest)
|
|
||||||
|
|
||||||
After you have your Lavalink node set up, you can install Pomice:
|
|
||||||
|
|
||||||
```
|
|
||||||
pip install pomice
|
|
||||||
```
|
|
||||||
|
|
||||||
Pomice will handle installing all required dependencies for you so you can
|
|
||||||
start coding with it without a hitch.
|
|
||||||
|
|
||||||
After you installed Pomice, get familiar with how it works by starting out with [an example.](quickstart.md)
|
|
||||||
|
|
||||||
If you need more than just a quick example, get our drop-in [advanced cog](https://github.com/cloudwithax/pomice/blob/main/examples/advanced.py)
|
|
||||||
to take advantage of all of Pomice's features.
|
|
||||||
You are free to use this as a base to add on to for any music features you want to implement within your application.
|
|
||||||
|
|
||||||
If you want to jump into the library and learn how to do everything you need, refer to the [How Do I?](hdi/index.md) section.
|
|
||||||
|
|
||||||
If you want a deeper look into how the library works beyond the [How Do I?](hdi/index.md) guide, refer to the [API Reference](api/index.md) section.
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
@ECHO OFF
|
|
||||||
|
|
||||||
pushd %~dp0
|
|
||||||
|
|
||||||
REM Command file for Sphinx documentation
|
|
||||||
|
|
||||||
if "%SPHINXBUILD%" == "" (
|
|
||||||
set SPHINXBUILD=sphinx-build
|
|
||||||
)
|
|
||||||
set SOURCEDIR=.
|
|
||||||
set BUILDDIR=_build
|
|
||||||
|
|
||||||
if "%1" == "" goto help
|
|
||||||
|
|
||||||
%SPHINXBUILD% >NUL 2>NUL
|
|
||||||
if errorlevel 9009 (
|
|
||||||
echo.
|
|
||||||
echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
|
|
||||||
echo.installed, then set the SPHINXBUILD environment variable to point
|
|
||||||
echo.to the full path of the 'sphinx-build' executable. Alternatively you
|
|
||||||
echo.may add the Sphinx directory to PATH.
|
|
||||||
echo.
|
|
||||||
echo.If you don't have Sphinx installed, grab it from
|
|
||||||
echo.https://www.sphinx-doc.org/
|
|
||||||
exit /b 1
|
|
||||||
)
|
|
||||||
|
|
||||||
%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
|
|
||||||
goto end
|
|
||||||
|
|
||||||
:help
|
|
||||||
%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
|
|
||||||
|
|
||||||
:end
|
|
||||||
popd
|
|
||||||
|
|
@ -1,83 +0,0 @@
|
||||||
# Quick Jumpstart
|
|
||||||
|
|
||||||
|
|
||||||
If you want a quick example as to how to start with Pomice, look below:
|
|
||||||
|
|
||||||
```py
|
|
||||||
import pomice
|
|
||||||
import discord
|
|
||||||
import re
|
|
||||||
|
|
||||||
from discord.ext import commands
|
|
||||||
|
|
||||||
URL_REG = re.compile(r"https?://(?:www\.)?.+")
|
|
||||||
|
|
||||||
|
|
||||||
class MyBot(commands.Bot):
|
|
||||||
def __init__(self) -> None:
|
|
||||||
super().__init__(
|
|
||||||
command_prefix="!",
|
|
||||||
activity=discord.Activity(
|
|
||||||
type=discord.ActivityType.listening, name="to music!"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
self.add_cog(Music(self))
|
|
||||||
|
|
||||||
async def on_ready(self) -> None:
|
|
||||||
print("I'm online!")
|
|
||||||
await self.cogs["Music"].start_nodes()
|
|
||||||
|
|
||||||
|
|
||||||
class Music(commands.Cog):
|
|
||||||
def __init__(self, bot) -> None:
|
|
||||||
self.bot = bot
|
|
||||||
|
|
||||||
self.pomice = pomice.NodePool()
|
|
||||||
|
|
||||||
async def start_nodes(self):
|
|
||||||
await self.pomice.create_node(
|
|
||||||
bot=self.bot,
|
|
||||||
host="127.0.0.1",
|
|
||||||
port="3030",
|
|
||||||
password="youshallnotpass",
|
|
||||||
identifier="MAIN",
|
|
||||||
)
|
|
||||||
print(f"Node is ready!")
|
|
||||||
|
|
||||||
@commands.command(name="join", aliases=["connect"])
|
|
||||||
async def join(
|
|
||||||
self, ctx: commands.Context, *, channel: discord.TextChannel = None
|
|
||||||
) -> None:
|
|
||||||
if not channel:
|
|
||||||
channel = getattr(ctx.author.voice, "channel", None)
|
|
||||||
if not channel:
|
|
||||||
raise commands.CheckFailure(
|
|
||||||
"You must be in a voice channel to use this command"
|
|
||||||
"without specifying the channel argument."
|
|
||||||
)
|
|
||||||
|
|
||||||
await ctx.author.voice.channel.connect(cls=pomice.Player)
|
|
||||||
await ctx.send(f"Joined the voice channel `{channel}`")
|
|
||||||
|
|
||||||
@commands.command(name="play")
|
|
||||||
async def play(self, ctx, *, search: str) -> None:
|
|
||||||
if not ctx.voice_client:
|
|
||||||
await ctx.invoke(self.join)
|
|
||||||
|
|
||||||
player = ctx.voice_client
|
|
||||||
|
|
||||||
results = await player.get_tracks(query=f"{search}")
|
|
||||||
|
|
||||||
if not results:
|
|
||||||
raise commands.CommandError("No results were found for that search term.")
|
|
||||||
|
|
||||||
if isinstance(results, pomice.Playlist):
|
|
||||||
await player.play(track=results.tracks[0])
|
|
||||||
else:
|
|
||||||
await player.play(track=results[0])
|
|
||||||
|
|
||||||
|
|
||||||
bot = MyBot()
|
|
||||||
bot.run("token here")
|
|
||||||
```
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
aiohttp
|
|
||||||
discord.py[voice]
|
|
||||||
furo
|
|
||||||
myst_parser
|
|
||||||
orjson
|
|
||||||
websockets
|
|
||||||
|
|
@ -1,391 +0,0 @@
|
||||||
# type: ignore
|
|
||||||
"""
|
|
||||||
This example aims to show the full capabilities of the library.
|
|
||||||
This is in the form of a drop-in cog you can use and modify to your liking.
|
|
||||||
This example aims to include everything you would need to make a fully functioning music bot,
|
|
||||||
from a queue system, advanced queue control and more.
|
|
||||||
"""
|
|
||||||
import math
|
|
||||||
from contextlib import suppress
|
|
||||||
|
|
||||||
import discord
|
|
||||||
from discord.ext import commands
|
|
||||||
|
|
||||||
import pomice
|
|
||||||
|
|
||||||
|
|
||||||
class Player(pomice.Player):
|
|
||||||
"""Custom pomice Player class."""
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
|
|
||||||
self.queue = pomice.Queue()
|
|
||||||
self.controller: discord.Message = None
|
|
||||||
# Set context here so we can send a now playing embed
|
|
||||||
self.context: commands.Context = None
|
|
||||||
self.dj: discord.Member = None
|
|
||||||
|
|
||||||
self.pause_votes = set()
|
|
||||||
self.resume_votes = set()
|
|
||||||
self.skip_votes = set()
|
|
||||||
self.shuffle_votes = set()
|
|
||||||
self.stop_votes = set()
|
|
||||||
|
|
||||||
async def do_next(self) -> None:
|
|
||||||
# Clear the votes for a new song
|
|
||||||
self.pause_votes.clear()
|
|
||||||
self.resume_votes.clear()
|
|
||||||
self.skip_votes.clear()
|
|
||||||
self.shuffle_votes.clear()
|
|
||||||
self.stop_votes.clear()
|
|
||||||
|
|
||||||
# Check if theres a controller still active and deletes it
|
|
||||||
if self.controller:
|
|
||||||
with suppress(discord.HTTPException):
|
|
||||||
await self.controller.delete()
|
|
||||||
|
|
||||||
# Queue up the next track, else teardown the player
|
|
||||||
try:
|
|
||||||
track: pomice.Track = self.queue.get()
|
|
||||||
except pomice.QueueEmpty:
|
|
||||||
return await self.teardown()
|
|
||||||
|
|
||||||
await self.play(track)
|
|
||||||
|
|
||||||
# Call the controller (a.k.a: The "Now Playing" embed) and check if one exists
|
|
||||||
|
|
||||||
if track.is_stream:
|
|
||||||
embed = discord.Embed(
|
|
||||||
title="Now playing",
|
|
||||||
description=f":red_circle: **LIVE** [{track.title}]({track.uri}) [{track.requester.mention}]",
|
|
||||||
)
|
|
||||||
self.controller = await self.context.send(embed=embed)
|
|
||||||
else:
|
|
||||||
embed = discord.Embed(
|
|
||||||
title=f"Now playing",
|
|
||||||
description=f"[{track.title}]({track.uri}) [{track.requester.mention}]",
|
|
||||||
)
|
|
||||||
self.controller = await self.context.send(embed=embed)
|
|
||||||
|
|
||||||
async def teardown(self):
|
|
||||||
"""Clear internal states, remove player controller and disconnect."""
|
|
||||||
with suppress((discord.HTTPException), (KeyError)):
|
|
||||||
await self.destroy()
|
|
||||||
if self.controller:
|
|
||||||
await self.controller.delete()
|
|
||||||
|
|
||||||
async def set_context(self, ctx: commands.Context):
|
|
||||||
"""Set context for the player"""
|
|
||||||
self.context = ctx
|
|
||||||
self.dj = ctx.author
|
|
||||||
|
|
||||||
|
|
||||||
class Music(commands.Cog):
|
|
||||||
def __init__(self, bot: commands.Bot) -> None:
|
|
||||||
self.bot = bot
|
|
||||||
|
|
||||||
# In order to initialize a node, or really do anything in this library,
|
|
||||||
# you need to make a node pool
|
|
||||||
self.pomice = pomice.NodePool()
|
|
||||||
|
|
||||||
# Start the node
|
|
||||||
bot.loop.create_task(self.start_nodes())
|
|
||||||
|
|
||||||
async def start_nodes(self):
|
|
||||||
# Waiting for the bot to get ready before connecting to nodes.
|
|
||||||
await self.bot.wait_until_ready()
|
|
||||||
|
|
||||||
# You can pass in Spotify credentials to enable Spotify querying.
|
|
||||||
# If you do not pass in valid Spotify credentials, Spotify querying will not work
|
|
||||||
await self.pomice.create_node(
|
|
||||||
bot=self.bot,
|
|
||||||
host="127.0.0.1",
|
|
||||||
port=3030,
|
|
||||||
password="youshallnotpass",
|
|
||||||
identifier="MAIN",
|
|
||||||
)
|
|
||||||
print(f"Node is ready!")
|
|
||||||
|
|
||||||
def required(self, ctx: commands.Context):
|
|
||||||
"""Method which returns required votes based on amount of members in a channel."""
|
|
||||||
player: Player = ctx.voice_client
|
|
||||||
channel = self.bot.get_channel(int(player.channel.id))
|
|
||||||
required = math.ceil((len(channel.members) - 1) / 2.5)
|
|
||||||
|
|
||||||
if ctx.command.name == "stop":
|
|
||||||
if len(channel.members) == 3:
|
|
||||||
required = 2
|
|
||||||
|
|
||||||
return required
|
|
||||||
|
|
||||||
def is_privileged(self, ctx: commands.Context):
|
|
||||||
"""Check whether the user is an Admin or DJ."""
|
|
||||||
player: Player = ctx.voice_client
|
|
||||||
|
|
||||||
return player.dj == ctx.author or ctx.author.guild_permissions.kick_members
|
|
||||||
|
|
||||||
# The following are events from pomice.events
|
|
||||||
# We are using these so that if the track either stops or errors,
|
|
||||||
# we can just skip to the next track
|
|
||||||
|
|
||||||
# Of course, you can modify this to do whatever you like
|
|
||||||
|
|
||||||
@commands.Cog.listener()
|
|
||||||
async def on_pomice_track_end(self, player: Player, track, _):
|
|
||||||
await player.do_next()
|
|
||||||
|
|
||||||
@commands.Cog.listener()
|
|
||||||
async def on_pomice_track_stuck(self, player: Player, track, _):
|
|
||||||
await player.do_next()
|
|
||||||
|
|
||||||
@commands.Cog.listener()
|
|
||||||
async def on_pomice_track_exception(self, player: Player, track, _):
|
|
||||||
await player.do_next()
|
|
||||||
|
|
||||||
@commands.command(aliases=["joi", "j", "summon", "su", "con", "connect"])
|
|
||||||
async def join(self, ctx: commands.Context, *, channel: discord.VoiceChannel = None) -> None:
|
|
||||||
if not channel:
|
|
||||||
channel = getattr(ctx.author.voice, "channel", None)
|
|
||||||
if not channel:
|
|
||||||
return await ctx.send(
|
|
||||||
"You must be in a voice channel in order to use this command!",
|
|
||||||
)
|
|
||||||
|
|
||||||
# With the release of discord.py 1.7, you can now add a compatible
|
|
||||||
# VoiceProtocol class as an argument in VoiceChannel.connect().
|
|
||||||
# This library takes advantage of that and is how you initialize a player.
|
|
||||||
await ctx.author.voice.channel.connect(cls=Player)
|
|
||||||
player: Player = ctx.voice_client
|
|
||||||
|
|
||||||
# Set the player context so we can use it so send messages
|
|
||||||
await player.set_context(ctx=ctx)
|
|
||||||
await ctx.send(f"Joined the voice channel `{channel.name}`")
|
|
||||||
|
|
||||||
@commands.command(aliases=["disconnect", "dc", "disc", "lv", "fuckoff"])
|
|
||||||
async def leave(self, ctx: commands.Context):
|
|
||||||
if not (player := ctx.voice_client):
|
|
||||||
return await ctx.send(
|
|
||||||
"You must have the bot in a channel in order to use this command",
|
|
||||||
delete_after=7,
|
|
||||||
)
|
|
||||||
|
|
||||||
await player.destroy()
|
|
||||||
await ctx.send("Player has left the channel.")
|
|
||||||
|
|
||||||
@commands.command(aliases=["pla", "p"])
|
|
||||||
async def play(self, ctx: commands.Context, *, search: str) -> None:
|
|
||||||
# Checks if the player is in the channel before we play anything
|
|
||||||
if not (player := ctx.voice_client):
|
|
||||||
await ctx.author.voice.channel.connect(cls=Player)
|
|
||||||
player: Player = ctx.voice_client
|
|
||||||
await player.set_context(ctx=ctx)
|
|
||||||
|
|
||||||
# If you search a keyword, Pomice will automagically search the result using YouTube
|
|
||||||
# You can pass in "search_type=" as an argument to change the search type
|
|
||||||
# i.e: player.get_tracks("query", search_type=SearchType.ytmsearch)
|
|
||||||
# will search up any keyword results on YouTube Music
|
|
||||||
|
|
||||||
# We will also set the context here to get special features, like a track.requester object
|
|
||||||
results = await player.get_tracks(search, ctx=ctx)
|
|
||||||
|
|
||||||
if not results:
|
|
||||||
return await ctx.send("No results were found for that search term", delete_after=7)
|
|
||||||
|
|
||||||
if isinstance(results, pomice.Playlist):
|
|
||||||
for track in results.tracks:
|
|
||||||
player.queue.put(track)
|
|
||||||
else:
|
|
||||||
track = results[0]
|
|
||||||
player.queue.put(track)
|
|
||||||
|
|
||||||
if not player.is_playing:
|
|
||||||
await player.do_next()
|
|
||||||
|
|
||||||
@commands.command(aliases=["pau", "pa"])
|
|
||||||
async def pause(self, ctx: commands.Context):
|
|
||||||
"""Pause the currently playing song."""
|
|
||||||
if not (player := ctx.voice_client):
|
|
||||||
return await ctx.send(
|
|
||||||
"You must have the bot in a channel in order to use this command",
|
|
||||||
delete_after=7,
|
|
||||||
)
|
|
||||||
|
|
||||||
if player.is_paused or not player.is_connected:
|
|
||||||
return
|
|
||||||
|
|
||||||
if self.is_privileged(ctx):
|
|
||||||
await ctx.send("An admin or DJ has paused the player.", delete_after=10)
|
|
||||||
player.pause_votes.clear()
|
|
||||||
|
|
||||||
return await player.set_pause(True)
|
|
||||||
|
|
||||||
required = self.required(ctx)
|
|
||||||
player.pause_votes.add(ctx.author)
|
|
||||||
|
|
||||||
if len(player.pause_votes) >= required:
|
|
||||||
await ctx.send("Vote to pause passed. Pausing player.", delete_after=10)
|
|
||||||
player.pause_votes.clear()
|
|
||||||
await player.set_pause(True)
|
|
||||||
else:
|
|
||||||
await ctx.send(
|
|
||||||
f"{ctx.author.mention} has voted to pause the player. Votes: {len(player.pause_votes)}/{required}",
|
|
||||||
delete_after=15,
|
|
||||||
)
|
|
||||||
|
|
||||||
@commands.command(aliases=["res", "r"])
|
|
||||||
async def resume(self, ctx: commands.Context):
|
|
||||||
"""Resume a currently paused player."""
|
|
||||||
if not (player := ctx.voice_client):
|
|
||||||
return await ctx.send(
|
|
||||||
"You must have the bot in a channel in order to use this command",
|
|
||||||
delete_after=7,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not player.is_paused or not player.is_connected:
|
|
||||||
return
|
|
||||||
|
|
||||||
if self.is_privileged(ctx):
|
|
||||||
await ctx.send("An admin or DJ has resumed the player.", delete_after=10)
|
|
||||||
player.resume_votes.clear()
|
|
||||||
|
|
||||||
return await player.set_pause(False)
|
|
||||||
|
|
||||||
required = self.required(ctx)
|
|
||||||
player.resume_votes.add(ctx.author)
|
|
||||||
|
|
||||||
if len(player.resume_votes) >= required:
|
|
||||||
await ctx.send("Vote to resume passed. Resuming player.", delete_after=10)
|
|
||||||
player.resume_votes.clear()
|
|
||||||
await player.set_pause(False)
|
|
||||||
else:
|
|
||||||
await ctx.send(
|
|
||||||
f"{ctx.author.mention} has voted to resume the player. Votes: {len(player.resume_votes)}/{required}",
|
|
||||||
delete_after=15,
|
|
||||||
)
|
|
||||||
|
|
||||||
@commands.command(aliases=["n", "nex", "next", "sk"])
|
|
||||||
async def skip(self, ctx: commands.Context):
|
|
||||||
"""Skip the currently playing song."""
|
|
||||||
if not (player := ctx.voice_client):
|
|
||||||
return await ctx.send(
|
|
||||||
"You must have the bot in a channel in order to use this command",
|
|
||||||
delete_after=7,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not player.is_connected:
|
|
||||||
return
|
|
||||||
|
|
||||||
if self.is_privileged(ctx):
|
|
||||||
await ctx.send("An admin or DJ has skipped the song.", delete_after=10)
|
|
||||||
player.skip_votes.clear()
|
|
||||||
|
|
||||||
return await player.stop()
|
|
||||||
|
|
||||||
if ctx.author == player.current.requester:
|
|
||||||
await ctx.send("The song requester has skipped the song.", delete_after=10)
|
|
||||||
player.skip_votes.clear()
|
|
||||||
|
|
||||||
return await player.stop()
|
|
||||||
|
|
||||||
required = self.required(ctx)
|
|
||||||
player.skip_votes.add(ctx.author)
|
|
||||||
|
|
||||||
if len(player.skip_votes) >= required:
|
|
||||||
await ctx.send("Vote to skip passed. Skipping song.", delete_after=10)
|
|
||||||
player.skip_votes.clear()
|
|
||||||
await player.stop()
|
|
||||||
else:
|
|
||||||
await ctx.send(
|
|
||||||
f"{ctx.author.mention} has voted to skip the song. Votes: {len(player.skip_votes)}/{required} ",
|
|
||||||
delete_after=15,
|
|
||||||
)
|
|
||||||
|
|
||||||
@commands.command()
|
|
||||||
async def stop(self, ctx: commands.Context):
|
|
||||||
"""Stop the player and clear all internal states."""
|
|
||||||
if not (player := ctx.voice_client):
|
|
||||||
return await ctx.send(
|
|
||||||
"You must have the bot in a channel in order to use this command",
|
|
||||||
delete_after=7,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not player.is_connected:
|
|
||||||
return
|
|
||||||
|
|
||||||
if self.is_privileged(ctx):
|
|
||||||
await ctx.send("An admin or DJ has stopped the player.", delete_after=10)
|
|
||||||
return await player.teardown()
|
|
||||||
|
|
||||||
required = self.required(ctx)
|
|
||||||
player.stop_votes.add(ctx.author)
|
|
||||||
|
|
||||||
if len(player.stop_votes) >= required:
|
|
||||||
await ctx.send("Vote to stop passed. Stopping the player.", delete_after=10)
|
|
||||||
await player.teardown()
|
|
||||||
else:
|
|
||||||
await ctx.send(
|
|
||||||
f"{ctx.author.mention} has voted to stop the player. Votes: {len(player.stop_votes)}/{required}",
|
|
||||||
delete_after=15,
|
|
||||||
)
|
|
||||||
|
|
||||||
@commands.command(aliases=["mix", "shuf"])
|
|
||||||
async def shuffle(self, ctx: commands.Context):
|
|
||||||
"""Shuffle the players queue."""
|
|
||||||
if not (player := ctx.voice_client):
|
|
||||||
return await ctx.send(
|
|
||||||
"You must have the bot in a channel in order to use this command",
|
|
||||||
delete_after=7,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not player.is_connected:
|
|
||||||
return
|
|
||||||
|
|
||||||
if player.queue.qsize() < 3:
|
|
||||||
return await ctx.send(
|
|
||||||
"The queue is empty. Add some songs to shuffle the queue.",
|
|
||||||
delete_after=15,
|
|
||||||
)
|
|
||||||
|
|
||||||
if self.is_privileged(ctx):
|
|
||||||
await ctx.send("An admin or DJ has shuffled the queue.", delete_after=10)
|
|
||||||
player.shuffle_votes.clear()
|
|
||||||
return player.queue.shuffle()
|
|
||||||
|
|
||||||
required = self.required(ctx)
|
|
||||||
player.shuffle_votes.add(ctx.author)
|
|
||||||
|
|
||||||
if len(player.shuffle_votes) >= required:
|
|
||||||
await ctx.send("Vote to shuffle passed. Shuffling the queue.", delete_after=10)
|
|
||||||
player.shuffle_votes.clear()
|
|
||||||
player.queue.shuffle()
|
|
||||||
else:
|
|
||||||
await ctx.send(
|
|
||||||
f"{ctx.author.mention} has voted to shuffle the queue. Votes: {len(player.shuffle_votes)}/{required}",
|
|
||||||
delete_after=15,
|
|
||||||
)
|
|
||||||
|
|
||||||
@commands.command(aliases=["v", "vol"])
|
|
||||||
async def volume(self, ctx: commands.Context, *, vol: int):
|
|
||||||
"""Change the players volume, between 1 and 100."""
|
|
||||||
if not (player := ctx.voice_client):
|
|
||||||
return await ctx.send(
|
|
||||||
"You must have the bot in a channel in order to use this command",
|
|
||||||
delete_after=7,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not player.is_connected:
|
|
||||||
return
|
|
||||||
|
|
||||||
if not self.is_privileged(ctx):
|
|
||||||
return await ctx.send("Only the DJ or admins may change the volume.")
|
|
||||||
|
|
||||||
if not 0 < vol < 101:
|
|
||||||
return await ctx.send("Please enter a value between 1 and 100.")
|
|
||||||
|
|
||||||
await player.set_volume(vol)
|
|
||||||
await ctx.send(f"Set the volume to **{vol}**%", delete_after=7)
|
|
||||||
|
|
||||||
|
|
||||||
async def setup(bot: commands.Bot):
|
|
||||||
await bot.add_cog(Music(bot))
|
|
||||||
|
|
@ -1,136 +0,0 @@
|
||||||
# type: ignore
|
|
||||||
import discord
|
|
||||||
from discord.ext import commands
|
|
||||||
|
|
||||||
import pomice
|
|
||||||
|
|
||||||
|
|
||||||
class MyBot(commands.Bot):
|
|
||||||
def __init__(self) -> None:
|
|
||||||
super().__init__(
|
|
||||||
command_prefix="!",
|
|
||||||
activity=discord.Activity(
|
|
||||||
type=discord.ActivityType.listening,
|
|
||||||
name="to music!",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
self.add_cog(Music(self))
|
|
||||||
self.loop.create_task(self.cogs["Music"].start_nodes())
|
|
||||||
|
|
||||||
async def on_ready(self) -> None:
|
|
||||||
print("I'm online!")
|
|
||||||
|
|
||||||
|
|
||||||
class Music(commands.Cog):
|
|
||||||
def __init__(self, bot: commands.Bot) -> None:
|
|
||||||
self.bot = bot
|
|
||||||
|
|
||||||
# In order to initialize a node, or really do anything in this library,
|
|
||||||
# you need to make a node pool
|
|
||||||
self.pomice = pomice.NodePool()
|
|
||||||
|
|
||||||
async def start_nodes(self):
|
|
||||||
# You can pass in Spotify credentials to enable Spotify querying.
|
|
||||||
# If you do not pass in valid Spotify credentials, Spotify querying will not work
|
|
||||||
await self.pomice.create_node(
|
|
||||||
bot=self.bot,
|
|
||||||
host="127.0.0.1",
|
|
||||||
port=3030,
|
|
||||||
password="youshallnotpass",
|
|
||||||
identifier="MAIN",
|
|
||||||
)
|
|
||||||
print(f"Node is ready!")
|
|
||||||
|
|
||||||
@commands.command(aliases=["connect"])
|
|
||||||
async def join(self, ctx: commands.Context, *, channel: discord.VoiceChannel = None) -> None:
|
|
||||||
if not channel:
|
|
||||||
channel = getattr(ctx.author.voice, "channel", None)
|
|
||||||
if not channel:
|
|
||||||
raise commands.CheckFailure(
|
|
||||||
"You must be in a voice channel to use this command "
|
|
||||||
"without specifying the channel argument.",
|
|
||||||
)
|
|
||||||
|
|
||||||
# With the release of discord.py 1.7, you can now add a compatible
|
|
||||||
# VoiceProtocol class as an argument in VoiceChannel.connect().
|
|
||||||
# This library takes advantage of that and is how you initialize a player.
|
|
||||||
await ctx.author.voice.channel.connect(cls=pomice.Player)
|
|
||||||
await ctx.send(f"Joined the voice channel `{channel}`")
|
|
||||||
|
|
||||||
@commands.command(aliases=["dc", "disconnect"])
|
|
||||||
async def leave(self, ctx: commands.Context):
|
|
||||||
if not ctx.voice_client:
|
|
||||||
raise commands.CommandError("No player detected")
|
|
||||||
|
|
||||||
player: pomice.Player = ctx.voice_client
|
|
||||||
|
|
||||||
await player.destroy()
|
|
||||||
await ctx.send("Player has left the channel.")
|
|
||||||
|
|
||||||
@commands.command(aliases=["p"])
|
|
||||||
async def play(self, ctx: commands.Context, *, search: str) -> None:
|
|
||||||
# Checks if the player is in the channel before we play anything
|
|
||||||
if not ctx.voice_client:
|
|
||||||
await ctx.invoke(self.join)
|
|
||||||
|
|
||||||
player: pomice.Player = ctx.voice_client
|
|
||||||
|
|
||||||
# If you search a keyword, Pomice will automagically search the result using YouTube
|
|
||||||
# You can pass in "search_type=" as an argument to change the search type
|
|
||||||
# i.e: player.get_tracks("query", search_type=SearchType.ytmsearch)
|
|
||||||
# will search up any keyword results on YouTube Music
|
|
||||||
results = await player.get_tracks(search)
|
|
||||||
|
|
||||||
if not results:
|
|
||||||
raise commands.CommandError(
|
|
||||||
"No results were found for that search term.",
|
|
||||||
)
|
|
||||||
|
|
||||||
if isinstance(results, pomice.Playlist):
|
|
||||||
await player.play(track=results.tracks[0])
|
|
||||||
else:
|
|
||||||
await player.play(track=results[0])
|
|
||||||
|
|
||||||
@commands.command()
|
|
||||||
async def pause(self, ctx: commands.Context):
|
|
||||||
if not ctx.voice_client:
|
|
||||||
raise commands.CommandError("No player detected")
|
|
||||||
|
|
||||||
player: pomice.Player = ctx.voice_client
|
|
||||||
|
|
||||||
if player.is_paused:
|
|
||||||
return await ctx.send("Player is already paused!")
|
|
||||||
|
|
||||||
await player.set_pause(pause=True)
|
|
||||||
await ctx.send("Player has been paused")
|
|
||||||
|
|
||||||
@commands.command()
|
|
||||||
async def resume(self, ctx: commands.Context):
|
|
||||||
if not ctx.voice_client:
|
|
||||||
raise commands.CommandError("No player detected")
|
|
||||||
|
|
||||||
player: pomice.Player = ctx.voice_client
|
|
||||||
|
|
||||||
if not player.is_paused:
|
|
||||||
return await ctx.send("Player is already playing!")
|
|
||||||
|
|
||||||
await player.set_pause(pause=False)
|
|
||||||
await ctx.send("Player has been resumed")
|
|
||||||
|
|
||||||
@commands.command()
|
|
||||||
async def stop(self, ctx: commands.Context):
|
|
||||||
if not ctx.voice_client:
|
|
||||||
raise commands.CommandError("No player detected")
|
|
||||||
|
|
||||||
player: pomice.Player = ctx.voice_client
|
|
||||||
|
|
||||||
if not player.is_playing:
|
|
||||||
return await ctx.send("Player is already stopped!")
|
|
||||||
|
|
||||||
await player.stop()
|
|
||||||
await ctx.send("Player has been stopped")
|
|
||||||
|
|
||||||
|
|
||||||
bot = MyBot()
|
|
||||||
bot.run("token")
|
|
||||||
|
|
@ -1,37 +1,24 @@
|
||||||
"""
|
"""Pomice wrapper for Lavalink, made possible by cloudwithax 2021"""
|
||||||
Pomice
|
|
||||||
~~~~~~
|
|
||||||
The modern Lavalink wrapper designed for discord.py.
|
|
||||||
|
|
||||||
Copyright (c) 2024, cloudwithax
|
|
||||||
|
|
||||||
Licensed under GPL-3.0
|
|
||||||
"""
|
|
||||||
import discord
|
import discord
|
||||||
|
|
||||||
if not discord.version_info.major >= 2:
|
if discord.__version__ != '2.0.0a':
|
||||||
|
|
||||||
class DiscordPyOutdated(Exception):
|
class DiscordPyOutdated(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
raise DiscordPyOutdated(
|
raise DiscordPyOutdated(
|
||||||
"You must have discord.py (v2.0 or greater) to use this library. "
|
"You must have discord.py 2.0 to use this library. "
|
||||||
"Uninstall your current version and install discord.py 2.0 "
|
"Uninstall your current version and install discord.py 2.0 "
|
||||||
"using 'pip install discord.py'",
|
"using 'pip install git+https://github.com/Rapptz/discord.py@master'"
|
||||||
)
|
)
|
||||||
|
|
||||||
__version__ = "2.10.0"
|
__version__ = "1.0.6"
|
||||||
__title__ = "pomice"
|
__title__ = "pomice"
|
||||||
__author__ = "cloudwithax"
|
__author__ = "cloudwithax"
|
||||||
__license__ = "GPL-3.0"
|
|
||||||
__copyright__ = "Copyright (c) 2023, cloudwithax"
|
|
||||||
|
|
||||||
from .enums import *
|
from .enums import SearchType
|
||||||
from .events import *
|
from .events import *
|
||||||
from .exceptions import *
|
from .exceptions import *
|
||||||
from .filters import *
|
from .filters import *
|
||||||
from .objects import *
|
from .objects import *
|
||||||
from .queue import *
|
from .player import Player
|
||||||
from .player import *
|
|
||||||
from .pool import *
|
from .pool import *
|
||||||
from .routeplanner import *
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
"""Apple Music module for Pomice, made possible by cloudwithax 2023"""
|
|
||||||
from .client import *
|
|
||||||
from .exceptions import *
|
|
||||||
from .objects import *
|
|
||||||
|
|
@ -1,298 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import base64
|
|
||||||
import logging
|
|
||||||
import re
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import AsyncGenerator
|
|
||||||
from typing import Dict
|
|
||||||
from typing import List
|
|
||||||
from typing import Optional
|
|
||||||
from typing import Union
|
|
||||||
|
|
||||||
import aiohttp
|
|
||||||
import orjson as json
|
|
||||||
|
|
||||||
from .exceptions import *
|
|
||||||
from .objects import *
|
|
||||||
|
|
||||||
__all__ = ("Client",)
|
|
||||||
|
|
||||||
AM_URL_REGEX = re.compile(
|
|
||||||
r"https?://music\.apple\.com/(?P<country>[a-zA-Z]{2})/(?P<type>album|playlist|song|artist)/(?P<name>.+?)/(?P<id>[^/?]+?)(?:/)?(?:\?.*)?$",
|
|
||||||
)
|
|
||||||
AM_SINGLE_IN_ALBUM_REGEX = re.compile(
|
|
||||||
r"https?://music\.apple\.com/(?P<country>[a-zA-Z]{2})/(?P<type>album|playlist|song|artist)/(?P<name>.+)/(?P<id>[^/?]+)(\?i=)(?P<id2>[^&]+)(?:&.*)?$",
|
|
||||||
)
|
|
||||||
|
|
||||||
AM_SCRIPT_REGEX = re.compile(r'<script.*?src="(/assets/index-.*?)"')
|
|
||||||
|
|
||||||
AM_REQ_URL = "https://api.music.apple.com/v1/catalog/{country}/{type}s/{id}"
|
|
||||||
AM_BASE_URL = "https://api.music.apple.com"
|
|
||||||
|
|
||||||
|
|
||||||
class Client:
|
|
||||||
"""The base Apple Music client for Pomice.
|
|
||||||
This will do all the heavy lifting of getting tracks from Apple Music
|
|
||||||
and translating it to a valid Lavalink track. No client auth is required here.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, *, playlist_concurrency: int = 6) -> None:
|
|
||||||
self.expiry: datetime = datetime(1970, 1, 1)
|
|
||||||
self.token: str = ""
|
|
||||||
self.headers: Dict[str, str] = {}
|
|
||||||
self.session: aiohttp.ClientSession = None # type: ignore
|
|
||||||
self._log = logging.getLogger(__name__)
|
|
||||||
# Concurrency knob for parallel playlist page retrieval
|
|
||||||
self._playlist_concurrency = max(1, playlist_concurrency)
|
|
||||||
|
|
||||||
async def _set_session(self, session: aiohttp.ClientSession) -> None:
|
|
||||||
self.session = session
|
|
||||||
|
|
||||||
async def request_token(self) -> None:
|
|
||||||
# First lets get the raw response from the main page
|
|
||||||
|
|
||||||
resp = await self.session.get("https://music.apple.com")
|
|
||||||
|
|
||||||
if resp.status != 200:
|
|
||||||
raise AppleMusicRequestException(
|
|
||||||
f"Error while fetching results: {resp.status} {resp.reason}",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Looking for script tag that fits criteria
|
|
||||||
|
|
||||||
text = await resp.text()
|
|
||||||
match = re.search(AM_SCRIPT_REGEX, text)
|
|
||||||
|
|
||||||
if not match:
|
|
||||||
raise AppleMusicRequestException(
|
|
||||||
"Could not find valid script URL in response.",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Found the script file, lets grab our token
|
|
||||||
|
|
||||||
result = match.group(1)
|
|
||||||
asset_url = result
|
|
||||||
|
|
||||||
resp = await self.session.get("https://music.apple.com" + asset_url)
|
|
||||||
|
|
||||||
if resp.status != 200:
|
|
||||||
raise AppleMusicRequestException(
|
|
||||||
f"Error while fetching results: {resp.status} {resp.reason}",
|
|
||||||
)
|
|
||||||
|
|
||||||
text = await resp.text()
|
|
||||||
match = re.search('"(eyJ.+?)"', text)
|
|
||||||
if not match:
|
|
||||||
raise AppleMusicRequestException(
|
|
||||||
"Could not find token in response.",
|
|
||||||
)
|
|
||||||
result = match.group(1)
|
|
||||||
|
|
||||||
self.token = result
|
|
||||||
self.headers = {
|
|
||||||
"Authorization": f"Bearer {result}",
|
|
||||||
"Origin": "https://apple.com",
|
|
||||||
}
|
|
||||||
token_split = self.token.split(".")[1]
|
|
||||||
token_json = base64.b64decode(
|
|
||||||
token_split + "=" * (-len(token_split) % 4),
|
|
||||||
).decode()
|
|
||||||
token_data = json.loads(token_json)
|
|
||||||
self.expiry = datetime.fromtimestamp(token_data["exp"])
|
|
||||||
if self._log:
|
|
||||||
self._log.debug(f"Fetched Apple Music bearer token successfully")
|
|
||||||
|
|
||||||
async def search(self, query: str) -> Union[Album, Playlist, Song, Artist]:
|
|
||||||
if not self.token or datetime.utcnow() > self.expiry:
|
|
||||||
await self.request_token()
|
|
||||||
|
|
||||||
result = AM_URL_REGEX.match(query)
|
|
||||||
if not result:
|
|
||||||
raise InvalidAppleMusicURL(
|
|
||||||
"The Apple Music link provided is not valid.",
|
|
||||||
)
|
|
||||||
|
|
||||||
country = result.group("country")
|
|
||||||
type = result.group("type")
|
|
||||||
id = result.group("id")
|
|
||||||
|
|
||||||
if type == "album" and (sia_result := AM_SINGLE_IN_ALBUM_REGEX.match(query)):
|
|
||||||
# apple music likes to generate links for singles off an album
|
|
||||||
# by adding a param at the end of the url
|
|
||||||
# so we're gonna scan for that and correct it
|
|
||||||
id = sia_result.group("id2")
|
|
||||||
type = "song"
|
|
||||||
request_url = AM_REQ_URL.format(country=country, type=type, id=id)
|
|
||||||
else:
|
|
||||||
request_url = AM_REQ_URL.format(country=country, type=type, id=id)
|
|
||||||
|
|
||||||
resp = await self.session.get(request_url, headers=self.headers)
|
|
||||||
|
|
||||||
if resp.status != 200:
|
|
||||||
raise AppleMusicRequestException(
|
|
||||||
f"Error while fetching results: {resp.status} {resp.reason}",
|
|
||||||
)
|
|
||||||
|
|
||||||
data: dict = await resp.json(loads=json.loads)
|
|
||||||
if self._log:
|
|
||||||
self._log.debug(
|
|
||||||
f"Made request to Apple Music API with status {resp.status} and response {data}",
|
|
||||||
)
|
|
||||||
|
|
||||||
data = data["data"][0]
|
|
||||||
|
|
||||||
if type == "song":
|
|
||||||
return Song(data)
|
|
||||||
|
|
||||||
elif type == "album":
|
|
||||||
return Album(data)
|
|
||||||
|
|
||||||
elif type == "artist":
|
|
||||||
resp = await self.session.get(
|
|
||||||
f"{request_url}/view/top-songs",
|
|
||||||
headers=self.headers,
|
|
||||||
)
|
|
||||||
if resp.status != 200:
|
|
||||||
raise AppleMusicRequestException(
|
|
||||||
f"Error while fetching results: {resp.status} {resp.reason}",
|
|
||||||
)
|
|
||||||
|
|
||||||
top_tracks: dict = await resp.json(loads=json.loads)
|
|
||||||
artist_tracks: dict = top_tracks["data"]
|
|
||||||
|
|
||||||
return Artist(data, tracks=artist_tracks)
|
|
||||||
else:
|
|
||||||
track_data: dict = data["relationships"]["tracks"]
|
|
||||||
album_tracks: List[Song] = [Song(track) for track in track_data["data"]]
|
|
||||||
|
|
||||||
if not len(album_tracks):
|
|
||||||
raise AppleMusicRequestException(
|
|
||||||
"This playlist is empty and therefore cannot be queued.",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Apple Music uses cursor pagination with 'next'. We'll fetch subsequent pages
|
|
||||||
# concurrently by first collecting cursors in rolling waves.
|
|
||||||
next_cursor = track_data.get("next")
|
|
||||||
semaphore = asyncio.Semaphore(self._playlist_concurrency)
|
|
||||||
|
|
||||||
async def fetch_page(url: str) -> List[Song]:
|
|
||||||
async with semaphore:
|
|
||||||
resp = await self.session.get(url, headers=self.headers)
|
|
||||||
if resp.status != 200:
|
|
||||||
if self._log:
|
|
||||||
self._log.warning(
|
|
||||||
f"Apple Music page fetch failed {resp.status} {resp.reason} for {url}",
|
|
||||||
)
|
|
||||||
return []
|
|
||||||
pj: dict = await resp.json(loads=json.loads)
|
|
||||||
songs = [Song(track) for track in pj.get("data", [])]
|
|
||||||
# Return songs; we will look for pj.get('next') in streaming iterator variant
|
|
||||||
return songs, pj.get("next") # type: ignore
|
|
||||||
|
|
||||||
# We'll implement a wave-based approach similar to Spotify but need to follow cursors.
|
|
||||||
# Because we cannot know all cursors upfront, we'll iteratively fetch waves.
|
|
||||||
waves: List[List[Song]] = []
|
|
||||||
cursors: List[str] = []
|
|
||||||
if next_cursor:
|
|
||||||
cursors.append(next_cursor)
|
|
||||||
|
|
||||||
# Limit total waves to avoid infinite loops in malformed responses
|
|
||||||
max_waves = 50
|
|
||||||
wave_size = self._playlist_concurrency * 2
|
|
||||||
wave_counter = 0
|
|
||||||
while cursors and wave_counter < max_waves:
|
|
||||||
current = cursors[:wave_size]
|
|
||||||
cursors = cursors[wave_size:]
|
|
||||||
tasks = [
|
|
||||||
fetch_page(AM_BASE_URL + cursor) for cursor in current # type: ignore[arg-type]
|
|
||||||
]
|
|
||||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
||||||
for res in results:
|
|
||||||
if isinstance(res, tuple): # (songs, next)
|
|
||||||
songs, nxt = res
|
|
||||||
if songs:
|
|
||||||
waves.append(songs)
|
|
||||||
if nxt:
|
|
||||||
cursors.append(nxt)
|
|
||||||
wave_counter += 1
|
|
||||||
|
|
||||||
for w in waves:
|
|
||||||
album_tracks.extend(w)
|
|
||||||
|
|
||||||
return Playlist(data, album_tracks)
|
|
||||||
|
|
||||||
async def iter_playlist_tracks(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
query: str,
|
|
||||||
batch_size: int = 100,
|
|
||||||
) -> AsyncGenerator[List[Song], None]:
|
|
||||||
"""Stream Apple Music playlist tracks in batches.
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
query: str
|
|
||||||
Apple Music playlist URL.
|
|
||||||
batch_size: int
|
|
||||||
Logical grouping size for yielded batches.
|
|
||||||
"""
|
|
||||||
if not self.token or datetime.utcnow() > self.expiry:
|
|
||||||
await self.request_token()
|
|
||||||
|
|
||||||
result = AM_URL_REGEX.match(query)
|
|
||||||
if not result or result.group("type") != "playlist":
|
|
||||||
raise InvalidAppleMusicURL("Provided query is not a valid Apple Music playlist URL.")
|
|
||||||
|
|
||||||
country = result.group("country")
|
|
||||||
playlist_id = result.group("id")
|
|
||||||
request_url = AM_REQ_URL.format(country=country, type="playlist", id=playlist_id)
|
|
||||||
resp = await self.session.get(request_url, headers=self.headers)
|
|
||||||
if resp.status != 200:
|
|
||||||
raise AppleMusicRequestException(
|
|
||||||
f"Error while fetching results: {resp.status} {resp.reason}",
|
|
||||||
)
|
|
||||||
data: dict = await resp.json(loads=json.loads)
|
|
||||||
playlist_data = data["data"][0]
|
|
||||||
track_data: dict = playlist_data["relationships"]["tracks"]
|
|
||||||
|
|
||||||
first_page_tracks = [Song(track) for track in track_data["data"]]
|
|
||||||
for i in range(0, len(first_page_tracks), batch_size):
|
|
||||||
yield first_page_tracks[i : i + batch_size]
|
|
||||||
|
|
||||||
next_cursor = track_data.get("next")
|
|
||||||
semaphore = asyncio.Semaphore(self._playlist_concurrency)
|
|
||||||
|
|
||||||
async def fetch(cursor: str) -> tuple[List[Song], Optional[str]]:
|
|
||||||
url = AM_BASE_URL + cursor
|
|
||||||
async with semaphore:
|
|
||||||
r = await self.session.get(url, headers=self.headers)
|
|
||||||
if r.status != 200:
|
|
||||||
if self._log:
|
|
||||||
self._log.warning(
|
|
||||||
f"Skipping Apple Music page due to {r.status} {r.reason}",
|
|
||||||
)
|
|
||||||
return [], None
|
|
||||||
pj: dict = await r.json(loads=json.loads)
|
|
||||||
songs = [Song(track) for track in pj.get("data", [])]
|
|
||||||
return songs, pj.get("next")
|
|
||||||
|
|
||||||
# Rolling waves of fetches following cursor chain
|
|
||||||
max_waves = 50
|
|
||||||
wave_size = self._playlist_concurrency * 2
|
|
||||||
waves = 0
|
|
||||||
cursors: List[str] = []
|
|
||||||
if next_cursor:
|
|
||||||
cursors.append(next_cursor)
|
|
||||||
while cursors and waves < max_waves:
|
|
||||||
current = cursors[:wave_size]
|
|
||||||
cursors = cursors[wave_size:]
|
|
||||||
results = await asyncio.gather(*[fetch(c) for c in current])
|
|
||||||
for songs, nxt in results:
|
|
||||||
if songs:
|
|
||||||
for j in range(0, len(songs), batch_size):
|
|
||||||
yield songs[j : j + batch_size]
|
|
||||||
if nxt:
|
|
||||||
cursors.append(nxt)
|
|
||||||
waves += 1
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
__all__ = (
|
|
||||||
"AppleMusicRequestException",
|
|
||||||
"InvalidAppleMusicURL",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class AppleMusicRequestException(Exception):
|
|
||||||
"""An error occurred when making a request to the Apple Music API"""
|
|
||||||
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class InvalidAppleMusicURL(Exception):
|
|
||||||
"""An invalid Apple Music URL was passed"""
|
|
||||||
|
|
||||||
pass
|
|
||||||
|
|
@ -1,92 +0,0 @@
|
||||||
"""Module for managing Apple Music objects"""
|
|
||||||
from typing import List
|
|
||||||
|
|
||||||
__all__ = (
|
|
||||||
"Song",
|
|
||||||
"Playlist",
|
|
||||||
"Album",
|
|
||||||
"Artist",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Song:
|
|
||||||
"""The base class for an Apple Music song"""
|
|
||||||
|
|
||||||
def __init__(self, data: dict) -> None:
|
|
||||||
self.name: str = data["attributes"]["name"]
|
|
||||||
self.url: str = data["attributes"]["url"]
|
|
||||||
self.isrc: str = data["attributes"]["isrc"]
|
|
||||||
self.length: float = data["attributes"]["durationInMillis"]
|
|
||||||
self.id: str = data["id"]
|
|
||||||
self.artists: str = data["attributes"]["artistName"]
|
|
||||||
self.image: str = data["attributes"]["artwork"]["url"].replace(
|
|
||||||
"{w}x{h}",
|
|
||||||
f'{data["attributes"]["artwork"]["width"]}x{data["attributes"]["artwork"]["height"]}',
|
|
||||||
)
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
return (
|
|
||||||
f"<Pomice.applemusic.Song name={self.name} artists={self.artists} "
|
|
||||||
f"length={self.length} id={self.id} isrc={self.isrc}>"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Playlist:
|
|
||||||
"""The base class for an Apple Music playlist"""
|
|
||||||
|
|
||||||
def __init__(self, data: dict, tracks: List[Song]) -> None:
|
|
||||||
self.name: str = data["attributes"]["name"]
|
|
||||||
self.owner: str = data["attributes"]["curatorName"]
|
|
||||||
self.id: str = data["id"]
|
|
||||||
self.tracks: List[Song] = tracks
|
|
||||||
self.total_tracks: int = len(tracks)
|
|
||||||
self.url: str = data["attributes"]["url"]
|
|
||||||
# we'll use the first song's image as the image for the playlist
|
|
||||||
# because apple dynamically generates playlist covers client-side
|
|
||||||
self.image = self.tracks[0].image
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
return (
|
|
||||||
f"<Pomice.applemusic.Playlist name={self.name} owner={self.owner} id={self.id} "
|
|
||||||
f"total_tracks={self.total_tracks} tracks={self.tracks}>"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Album:
|
|
||||||
"""The base class for an Apple Music album"""
|
|
||||||
|
|
||||||
def __init__(self, data: dict) -> None:
|
|
||||||
self.name: str = data["attributes"]["name"]
|
|
||||||
self.url: str = data["attributes"]["url"]
|
|
||||||
self.id: str = data["id"]
|
|
||||||
self.artists: str = data["attributes"]["artistName"]
|
|
||||||
self.total_tracks: int = data["attributes"]["trackCount"]
|
|
||||||
self.tracks: List[Song] = [Song(track) for track in data["relationships"]["tracks"]["data"]]
|
|
||||||
self.image: str = data["attributes"]["artwork"]["url"].replace(
|
|
||||||
"{w}x{h}",
|
|
||||||
f'{data["attributes"]["artwork"]["width"]}x{data["attributes"]["artwork"]["height"]}',
|
|
||||||
)
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
return (
|
|
||||||
f"<Pomice.applemusic.Album name={self.name} artists={self.artists} id={self.id} "
|
|
||||||
f"total_tracks={self.total_tracks} tracks={self.tracks}>"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Artist:
|
|
||||||
"""The base class for an Apple Music artist"""
|
|
||||||
|
|
||||||
def __init__(self, data: dict, tracks: dict) -> None:
|
|
||||||
self.name: str = f'Top tracks for {data["attributes"]["name"]}'
|
|
||||||
self.url: str = data["attributes"]["url"]
|
|
||||||
self.id: str = data["id"]
|
|
||||||
self.genres: str = ", ".join(genre for genre in data["attributes"]["genreNames"])
|
|
||||||
self.tracks: List[Song] = [Song(track) for track in tracks]
|
|
||||||
self.image: str = data["attributes"]["artwork"]["url"].replace(
|
|
||||||
"{w}x{h}",
|
|
||||||
f'{data["attributes"]["artwork"]["width"]}x{data["attributes"]["artwork"]["height"]}',
|
|
||||||
)
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
return f"<Pomice.applemusic.Artist name={self.name} id={self.id} " f"tracks={self.tracks}>"
|
|
||||||
296
pomice/enums.py
296
pomice/enums.py
|
|
@ -1,308 +1,20 @@
|
||||||
import re
|
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from enum import IntEnum
|
|
||||||
|
|
||||||
__all__ = (
|
|
||||||
"SearchType",
|
|
||||||
"TrackType",
|
|
||||||
"PlaylistType",
|
|
||||||
"NodeAlgorithm",
|
|
||||||
"LoopMode",
|
|
||||||
"RouteStrategy",
|
|
||||||
"RouteIPType",
|
|
||||||
"URLRegex",
|
|
||||||
"LogLevel",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class SearchType(Enum):
|
class SearchType(Enum):
|
||||||
"""
|
"""The enum for the different search types for Pomice.
|
||||||
The enum for the different search types for Pomice.
|
|
||||||
This feature is exclusively for the Spotify search feature of Pomice.
|
This feature is exclusively for the Spotify search feature of Pomice.
|
||||||
If you are not using this feature, this class is not necessary.
|
If you are not using this feature, this class is not necessary.
|
||||||
|
|
||||||
SearchType.ytsearch searches using regular Youtube,
|
SearchType.ytsearch searches using regular Youtube, which is best for all scenarios.
|
||||||
which is best for all scenarios.
|
|
||||||
|
|
||||||
SearchType.ytmsearch searches using YouTube Music,
|
SearchType.ytmsearch searches using YouTube Music, which is best for getting audio-only results.
|
||||||
which is best for getting audio-only results.
|
|
||||||
|
|
||||||
SearchType.scsearch searches using SoundCloud,
|
SearchType.scsearch searches using SoundCloud, which is an alternative to YouTube or YouTube Music.
|
||||||
which is an alternative to YouTube or YouTube Music.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
ytsearch = "ytsearch"
|
ytsearch = "ytsearch"
|
||||||
ytmsearch = "ytmsearch"
|
ytmsearch = "ytmsearch"
|
||||||
scsearch = "scsearch"
|
scsearch = "scsearch"
|
||||||
other = "other"
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _missing_(cls, value: object) -> "SearchType": # type: ignore[override]
|
|
||||||
return cls.other
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return self.value
|
return self.value
|
||||||
|
|
||||||
|
|
||||||
class TrackType(Enum):
|
|
||||||
"""
|
|
||||||
The enum for the different track types for Pomice.
|
|
||||||
|
|
||||||
TrackType.YOUTUBE defines that the track is from YouTube
|
|
||||||
|
|
||||||
TrackType.SOUNDCLOUD defines that the track is from SoundCloud.
|
|
||||||
|
|
||||||
TrackType.SPOTIFY defines that the track is from Spotify
|
|
||||||
|
|
||||||
TrackType.APPLE_MUSIC defines that the track is from Apple Music.
|
|
||||||
|
|
||||||
TrackType.HTTP defines that the track is from an HTTP source.
|
|
||||||
|
|
||||||
TrackType.LOCAL defines that the track is from a local source.
|
|
||||||
|
|
||||||
TrackType.OTHER defines that the track is from an unknown source (possible from 3rd-party plugins).
|
|
||||||
"""
|
|
||||||
|
|
||||||
# We don't have to define anything special for these, since these just serve as flags
|
|
||||||
YOUTUBE = "youtube"
|
|
||||||
SOUNDCLOUD = "soundcloud"
|
|
||||||
SPOTIFY = "spotify"
|
|
||||||
APPLE_MUSIC = "apple_music"
|
|
||||||
HTTP = "http"
|
|
||||||
LOCAL = "local"
|
|
||||||
OTHER = "other"
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _missing_(cls, value: object) -> "TrackType": # type: ignore[override]
|
|
||||||
return cls.OTHER
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
return self.value
|
|
||||||
|
|
||||||
|
|
||||||
class PlaylistType(Enum):
|
|
||||||
"""
|
|
||||||
The enum for the different playlist types for Pomice.
|
|
||||||
|
|
||||||
PlaylistType.YOUTUBE defines that the playlist is from YouTube
|
|
||||||
|
|
||||||
PlaylistType.SOUNDCLOUD defines that the playlist is from SoundCloud.
|
|
||||||
|
|
||||||
PlaylistType.SPOTIFY defines that the playlist is from Spotify
|
|
||||||
|
|
||||||
PlaylistType.APPLE_MUSIC defines that the playlist is from Apple Music.
|
|
||||||
|
|
||||||
PlaylistType.OTHER defines that the playlist is from an unknown source (possible from 3rd-party plugins).
|
|
||||||
"""
|
|
||||||
|
|
||||||
# We don't have to define anything special for these, since these just serve as flags
|
|
||||||
YOUTUBE = "youtube"
|
|
||||||
SOUNDCLOUD = "soundcloud"
|
|
||||||
SPOTIFY = "spotify"
|
|
||||||
APPLE_MUSIC = "apple_music"
|
|
||||||
OTHER = "other"
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _missing_(cls, value: object) -> "PlaylistType": # type: ignore[override]
|
|
||||||
return cls.OTHER
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
return self.value
|
|
||||||
|
|
||||||
|
|
||||||
class NodeAlgorithm(Enum):
|
|
||||||
"""
|
|
||||||
The enum for the different node algorithms in Pomice.
|
|
||||||
|
|
||||||
The enums in this class are to only differentiate different
|
|
||||||
methods, since the actual method is handled in the
|
|
||||||
get_best_node() method.
|
|
||||||
|
|
||||||
NodeAlgorithm.by_ping returns a node based on it's latency,
|
|
||||||
preferring a node with the lowest response time
|
|
||||||
|
|
||||||
|
|
||||||
NodeAlgorithm.by_players return a nodes based on how many players it has.
|
|
||||||
This algorithm prefers nodes with the least amount of players.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# We don't have to define anything special for these, since these just serve as flags
|
|
||||||
by_ping = "BY_PING"
|
|
||||||
by_players = "BY_PLAYERS"
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
return self.value
|
|
||||||
|
|
||||||
|
|
||||||
class LoopMode(Enum):
|
|
||||||
"""
|
|
||||||
The enum for the different loop modes.
|
|
||||||
This feature is exclusively for the queue utility of pomice.
|
|
||||||
If you are not using this feature, this class is not necessary.
|
|
||||||
|
|
||||||
LoopMode.TRACK sets the queue loop to the current track.
|
|
||||||
|
|
||||||
LoopMode.QUEUE sets the queue loop to the whole queue.
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
# We don't have to define anything special for these, since these just serve as flags
|
|
||||||
TRACK = "track"
|
|
||||||
QUEUE = "queue"
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
return self.value
|
|
||||||
|
|
||||||
|
|
||||||
class RouteStrategy(Enum):
|
|
||||||
"""
|
|
||||||
The enum for specifying the route planner strategy for Lavalink.
|
|
||||||
This feature is exclusively for the RoutePlanner class.
|
|
||||||
If you are not using this feature, this class is not necessary.
|
|
||||||
|
|
||||||
RouteStrategy.ROTATE_ON_BAN specifies that the node is rotating IPs
|
|
||||||
whenever they get banned by Youtube.
|
|
||||||
|
|
||||||
RouteStrategy.LOAD_BALANCE specifies that the node is selecting
|
|
||||||
random IPs to balance out requests between them.
|
|
||||||
|
|
||||||
RouteStrategy.NANO_SWITCH specifies that the node is switching
|
|
||||||
between IPs every CPU clock cycle.
|
|
||||||
|
|
||||||
RouteStrategy.ROTATING_NANO_SWITCH specifies that the node is switching
|
|
||||||
between IPs every CPU clock cycle and is rotating between IP blocks on
|
|
||||||
ban.
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
ROTATE_ON_BAN = "RotatingIpRoutePlanner"
|
|
||||||
LOAD_BALANCE = "BalancingIpRoutePlanner"
|
|
||||||
NANO_SWITCH = "NanoIpRoutePlanner"
|
|
||||||
ROTATING_NANO_SWITCH = "RotatingNanoIpRoutePlanner"
|
|
||||||
|
|
||||||
|
|
||||||
class RouteIPType(Enum):
|
|
||||||
"""
|
|
||||||
The enum for specifying the route planner IP block type for Lavalink.
|
|
||||||
This feature is exclusively for the RoutePlanner class.
|
|
||||||
If you are not using this feature, this class is not necessary.
|
|
||||||
|
|
||||||
RouteIPType.IPV4 specifies that the IP block type is IPV4
|
|
||||||
|
|
||||||
RouteIPType.IPV6 specifies that the IP block type is IPV6
|
|
||||||
"""
|
|
||||||
|
|
||||||
IPV4 = "Inet4Address"
|
|
||||||
IPV6 = "Inet6Address"
|
|
||||||
|
|
||||||
|
|
||||||
class URLRegex:
|
|
||||||
"""
|
|
||||||
The enum for all the URL Regexes in use by Pomice.
|
|
||||||
|
|
||||||
URLRegex.SPOTIFY_URL returns the Spotify URL Regex.
|
|
||||||
|
|
||||||
URLRegex.DISCORD_MP3_URL returns the Discord MP3 URL Regex.
|
|
||||||
|
|
||||||
URLRegex.YOUTUBE_URL returns the Youtube URL Regex.
|
|
||||||
|
|
||||||
URLRegex.YOUTUBE_PLAYLIST returns the Youtube Playlist Regex.
|
|
||||||
|
|
||||||
URLRegex.YOUTUBE_TIMESTAMP returns the Youtube Timestamp Regex.
|
|
||||||
|
|
||||||
URLRegex.AM_URL returns the Apple Music URL Regex.
|
|
||||||
|
|
||||||
URLRegex.SOUNDCLOUD_URL returns the SoundCloud URL Regex.
|
|
||||||
|
|
||||||
URLRegex.BASE_URL returns the standard URL Regex.
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Spotify share links can include query parameters like ?si=XXXX, a trailing slash,
|
|
||||||
# or an intl locale segment (e.g. /intl-en/). Broaden the regex so we still capture
|
|
||||||
# the type and id while ignoring extra parameters. This prevents the URL from being
|
|
||||||
# treated as a generic Lavalink identifier and ensures internal Spotify handling runs.
|
|
||||||
SPOTIFY_URL = re.compile(
|
|
||||||
r"https?://open\.spotify\.com/(?:intl-[a-zA-Z-]+/)?(?P<type>album|playlist|track|artist)/(?P<id>[a-zA-Z0-9]+)(?:/)?(?:\?.*)?$",
|
|
||||||
)
|
|
||||||
|
|
||||||
DISCORD_MP3_URL = re.compile(
|
|
||||||
r"https?://cdn.discordapp.com/attachments/(?P<channel_id>[0-9]+)/"
|
|
||||||
r"(?P<message_id>[0-9]+)/(?P<file>[a-zA-Z0-9_.]+)+",
|
|
||||||
)
|
|
||||||
|
|
||||||
YOUTUBE_URL = re.compile(
|
|
||||||
r"^((?:https?:)?\/\/)?((?:www|m)\.)?((?:youtube\.com|youtu.be))"
|
|
||||||
r"(\/(?:[\w\-]+\?v=|embed\/|v\/)?)([\w\-]+)(\S+)?$",
|
|
||||||
)
|
|
||||||
|
|
||||||
YOUTUBE_PLAYLIST_URL = re.compile(
|
|
||||||
r"^((?:https?:)?\/\/)?((?:www|m)\.)?((?:youtube\.com|youtu.be))/playlist\?list=.*",
|
|
||||||
)
|
|
||||||
|
|
||||||
YOUTUBE_TIMESTAMP = re.compile(
|
|
||||||
r"(?P<video>^.*?)(\?t|&start)=(?P<time>\d+)?.*",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Apple Music links sometimes append additional query parameters (e.g. &l=en, &uo=4).
|
|
||||||
# Allow arbitrary query parameters so valid links are captured and parsed.
|
|
||||||
AM_URL = re.compile(
|
|
||||||
r"https?://music\.apple\.com/(?P<country>[a-zA-Z]{2})/"
|
|
||||||
r"(?P<type>album|playlist|song|artist)/(?P<name>.+?)/(?P<id>[^/?]+?)(?:/)?(?:\?.*)?$",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Single-in-album links may also carry extra query params beyond the ?i=<trackid> token.
|
|
||||||
AM_SINGLE_IN_ALBUM_REGEX = re.compile(
|
|
||||||
r"https?://music\.apple\.com/(?P<country>[a-zA-Z]{2})/(?P<type>album|playlist|song|artist)/"
|
|
||||||
r"(?P<name>.+)/(?P<id>[^/?]+)(\?i=)(?P<id2>[^&]+)(?:&.*)?$",
|
|
||||||
)
|
|
||||||
|
|
||||||
SOUNDCLOUD_URL = re.compile(
|
|
||||||
r"((?:https?:)?\/\/)?((?:www|m)\.)?soundcloud.com\/.*/.*",
|
|
||||||
)
|
|
||||||
|
|
||||||
SOUNDCLOUD_PLAYLIST_URL = re.compile(
|
|
||||||
r"^(https?:\/\/)?(www.)?(m\.)?soundcloud\.com\/.*/sets/.*",
|
|
||||||
)
|
|
||||||
|
|
||||||
SOUNDCLOUD_TRACK_IN_SET_URL = re.compile(
|
|
||||||
r"^(https?:\/\/)?(www.)?(m\.)?soundcloud\.com/[a-zA-Z0-9-._]+/[a-zA-Z0-9-._]+(\?in)",
|
|
||||||
)
|
|
||||||
|
|
||||||
LAVALINK_SEARCH = re.compile(r"(?P<type>ytm?|sc)search:")
|
|
||||||
|
|
||||||
BASE_URL = re.compile(r"https?://(?:www\.)?.+")
|
|
||||||
|
|
||||||
|
|
||||||
class LogLevel(IntEnum):
|
|
||||||
"""
|
|
||||||
The enum for specifying the logging level within Pomice.
|
|
||||||
This class serves as shorthand for logging.<level>
|
|
||||||
This enum is exclusively for the logging feature in Pomice.
|
|
||||||
If you are not using this feature, this class is not necessary.
|
|
||||||
|
|
||||||
|
|
||||||
LogLevel.DEBUG sets the logging level to "debug".
|
|
||||||
|
|
||||||
LogLevel.INFO sets the logging level to "info".
|
|
||||||
|
|
||||||
LogLevel.WARN sets the logging level to "warn".
|
|
||||||
|
|
||||||
LogLevel.ERROR sets the logging level to "error".
|
|
||||||
|
|
||||||
LogLevel.CRITICAL sets the logging level to "CRITICAL".
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
DEBUG = 10
|
|
||||||
INFO = 20
|
|
||||||
WARN = 30
|
|
||||||
ERROR = 40
|
|
||||||
CRITICAL = 50
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_str(cls, level_str):
|
|
||||||
try:
|
|
||||||
return cls[level_str.upper()]
|
|
||||||
except KeyError:
|
|
||||||
raise ValueError(f"No such log level: {level_str}")
|
|
||||||
|
|
|
||||||
169
pomice/events.py
169
pomice/events.py
|
|
@ -1,34 +1,7 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from abc import ABC
|
|
||||||
from typing import Any
|
|
||||||
from typing import Optional
|
|
||||||
from typing import Tuple
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from discord import Client
|
|
||||||
from discord import Guild
|
|
||||||
from discord.ext import commands
|
|
||||||
|
|
||||||
from .objects import Track
|
|
||||||
from .pool import NodePool
|
from .pool import NodePool
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from .player import Player
|
|
||||||
|
|
||||||
__all__ = (
|
class PomiceEvent:
|
||||||
"PomiceEvent",
|
|
||||||
"TrackStartEvent",
|
|
||||||
"TrackEndEvent",
|
|
||||||
"TrackStuckEvent",
|
|
||||||
"TrackExceptionEvent",
|
|
||||||
"WebSocketClosedPayload",
|
|
||||||
"WebSocketClosedEvent",
|
|
||||||
"WebSocketOpenEvent",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class PomiceEvent(ABC):
|
|
||||||
"""The base class for all events dispatched by a node.
|
"""The base class for all events dispatched by a node.
|
||||||
Every event must be formatted within your bot's code as a listener.
|
Every event must be formatted within your bot's code as a listener.
|
||||||
i.e: If you want to listen for when a track starts, the event would be:
|
i.e: If you want to listen for when a track starts, the event would be:
|
||||||
|
|
@ -37,161 +10,107 @@ class PomiceEvent(ABC):
|
||||||
async def on_pomice_track_start(self, event):
|
async def on_pomice_track_start(self, event):
|
||||||
```
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "event"
|
name = "event"
|
||||||
handler_args: Tuple
|
|
||||||
|
|
||||||
def dispatch(self, bot: Client) -> None:
|
|
||||||
bot.dispatch(f"pomice_{self.name}", *self.handler_args)
|
|
||||||
|
|
||||||
|
|
||||||
class TrackStartEvent(PomiceEvent):
|
class TrackStartEvent(PomiceEvent):
|
||||||
"""Fired when a track has successfully started.
|
"""Fired when a track has successfully started.
|
||||||
Returns the player associated with the event and the pomice.Track object.
|
Returns the player associated with the track and the track ID
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "track_start"
|
def __init__(self, data):
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
__slots__ = (
|
self.name = "track_start"
|
||||||
"player",
|
self.player = NodePool.get_node().get_player(int(data["guildId"]))
|
||||||
"track",
|
self.track_id = data["track"]
|
||||||
)
|
|
||||||
|
|
||||||
def __init__(self, data: dict, player: Player):
|
|
||||||
self.player: Player = player
|
|
||||||
self.track: Optional[Track] = self.player._current
|
|
||||||
|
|
||||||
# on_pomice_track_start(player, track)
|
|
||||||
self.handler_args = self.player, self.track
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return f"<Pomice.TrackStartEvent player={self.player!r} track={self.track!r}>"
|
return f"<Pomice.TrackStartEvent track_id={self.track_id}>"
|
||||||
|
|
||||||
|
|
||||||
class TrackEndEvent(PomiceEvent):
|
class TrackEndEvent(PomiceEvent):
|
||||||
"""Fired when a track has successfully ended.
|
"""Fired when a track has successfully ended.
|
||||||
Returns the player associated with the event along with the pomice.Track object and reason.
|
Returns the player associated with the track along with the track ID and reason.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "track_end"
|
def __init__(self, data):
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
__slots__ = ("player", "track", "reason")
|
self.name = "track_end"
|
||||||
|
self.player = NodePool.get_node().get_player(int(data["guildId"]))
|
||||||
def __init__(self, data: dict, player: Player):
|
self.track_id = data["track"]
|
||||||
self.player: Player = player
|
self.reason = data["reason"]
|
||||||
self.track: Optional[Track] = self.player._ending_track
|
|
||||||
self.reason: str = data["reason"]
|
|
||||||
|
|
||||||
# on_pomice_track_end(player, track, reason)
|
|
||||||
self.handler_args = self.player, self.track, self.reason
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return (
|
return f"<Pomice.TrackEndEvent track_id={self.track_id} reason={self.reason}>"
|
||||||
f"<Pomice.TrackEndEvent player={self.player!r} track_id={self.track!r} "
|
|
||||||
f"reason={self.reason!r}>"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TrackStuckEvent(PomiceEvent):
|
class TrackStuckEvent(PomiceEvent):
|
||||||
"""Fired when a track is stuck and cannot be played. Returns the player
|
"""Fired when a track is stuck and cannot be played. Returns the player
|
||||||
associated with the event along with the pomice.Track object
|
associated with the track along with a track ID to be further parsed by the end user.
|
||||||
to be further parsed by the end user.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "track_stuck"
|
def __init__(self, data):
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
__slots__ = ("player", "track", "threshold")
|
self.name = "track_stuck"
|
||||||
|
self.player = NodePool.get_node().get_player(int(data["guildId"]))
|
||||||
|
|
||||||
def __init__(self, data: dict, player: Player):
|
self.track_id = data["track"]
|
||||||
self.player: Player = player
|
self.threshold = data["thresholdMs"]
|
||||||
self.track: Optional[Track] = self.player._ending_track
|
|
||||||
self.threshold: float = data["thresholdMs"]
|
|
||||||
|
|
||||||
# on_pomice_track_stuck(player, track, threshold)
|
|
||||||
self.handler_args = self.player, self.track, self.threshold
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return (
|
return f"<Pomice.TrackStuckEvent track_id={self.track_id} threshold={self.threshold}>"
|
||||||
f"<Pomice.TrackStuckEvent player={self.player!r} track={self.track!r} "
|
|
||||||
f"threshold={self.threshold!r}>"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TrackExceptionEvent(PomiceEvent):
|
class TrackExceptionEvent(PomiceEvent):
|
||||||
"""Fired when a track error has occured.
|
"""Fired when a track error has occured.
|
||||||
Returns the player associated with the event along with the error code and exception.
|
Returns the player associated with the track along with the error code and exception.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "track_exception"
|
def __init__(self, data):
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
__slots__ = ("player", "track", "exception")
|
self.name = "track_exception"
|
||||||
|
self.player = NodePool.get_node().get_player(int(data["guildId"]))
|
||||||
|
|
||||||
def __init__(self, data: dict, player: Player):
|
self.error = data["error"]
|
||||||
self.player: Player = player
|
self.exception = data["exception"]
|
||||||
self.track: Optional[Track] = self.player._ending_track
|
|
||||||
# Error is for Lavalink <= 3.3
|
|
||||||
self.exception: str = data.get(
|
|
||||||
"error",
|
|
||||||
"",
|
|
||||||
) or data.get("exception", "")
|
|
||||||
|
|
||||||
# on_pomice_track_exception(player, track, error)
|
|
||||||
self.handler_args = self.player, self.track, self.exception
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return f"<Pomice.TrackExceptionEvent player={self.player!r} exception={self.exception!r}>"
|
return f"<Pomice.TrackExceptionEvent> error={self.error} exeception={self.exception}"
|
||||||
|
|
||||||
|
|
||||||
class WebSocketClosedPayload:
|
class WebsocketClosedEvent(PomiceEvent):
|
||||||
__slots__ = ("guild", "code", "reason", "by_remote")
|
|
||||||
|
|
||||||
def __init__(self, data: dict):
|
|
||||||
self.guild: Optional[Guild] = NodePool.get_node().bot.get_guild(int(data["guildId"]))
|
|
||||||
self.code: int = data["code"]
|
|
||||||
self.reason: str = data["code"]
|
|
||||||
self.by_remote: bool = data["byRemote"]
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
return (
|
|
||||||
f"<Pomice.WebSocketClosedPayload guild={self.guild!r} code={self.code!r} "
|
|
||||||
f"reason={self.reason!r} by_remote={self.by_remote!r}>"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class WebSocketClosedEvent(PomiceEvent):
|
|
||||||
"""Fired when a websocket connection to a node has been closed.
|
"""Fired when a websocket connection to a node has been closed.
|
||||||
Returns the reason and the error code.
|
Returns the reason and the error code.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "websocket_closed"
|
def __init__(self, data):
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
__slots__ = ("payload",)
|
self.name = "websocket_closed"
|
||||||
|
|
||||||
def __init__(self, data: dict, _: Any) -> None:
|
self.reason = data["reason"]
|
||||||
self.payload: WebSocketClosedPayload = WebSocketClosedPayload(data)
|
self.code = data["code"]
|
||||||
|
|
||||||
# on_pomice_websocket_closed(payload)
|
|
||||||
self.handler_args = (self.payload,)
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return f"<Pomice.WebsocketClosedEvent payload={self.payload!r}>"
|
return f"<Pomice.WebsocketClosedEvent reason={self.reason} code={self.code}>"
|
||||||
|
|
||||||
|
|
||||||
class WebSocketOpenEvent(PomiceEvent):
|
class WebsocketOpenEvent(PomiceEvent):
|
||||||
"""Fired when a websocket connection to a node has been initiated.
|
"""Fired when a websocket connection to a node has been initiated.
|
||||||
Returns the target and the session SSRC.
|
Returns the target and the session SSRC.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "websocket_open"
|
def __init__(self, data):
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
__slots__ = ("target", "ssrc")
|
self.name = "websocket_open"
|
||||||
|
|
||||||
def __init__(self, data: dict, _: Any) -> None:
|
|
||||||
self.target: str = data["target"]
|
self.target: str = data["target"]
|
||||||
self.ssrc: int = data["ssrc"]
|
self.ssrc: int = data["ssrc"]
|
||||||
|
|
||||||
# on_pomice_websocket_open(target, ssrc)
|
|
||||||
self.handler_args = self.target, self.ssrc
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return f"<Pomice.WebsocketOpenEvent target={self.target!r} ssrc={self.ssrc!r}>"
|
return f"<Pomice.WebsocketOpenEvent target={self.target} ssrc={self.ssrc}>"
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,3 @@
|
||||||
__all__ = (
|
|
||||||
"PomiceException",
|
|
||||||
"NodeException",
|
|
||||||
"NodeCreationError",
|
|
||||||
"NodeConnectionFailure",
|
|
||||||
"NodeConnectionClosed",
|
|
||||||
"NodeRestException",
|
|
||||||
"NodeNotAvailable",
|
|
||||||
"NoNodesAvailable",
|
|
||||||
"TrackInvalidPosition",
|
|
||||||
"TrackLoadError",
|
|
||||||
"FilterInvalidArgument",
|
|
||||||
"FilterTagInvalid",
|
|
||||||
"FilterTagAlreadyInUse",
|
|
||||||
"InvalidSpotifyClientAuthorization",
|
|
||||||
"AppleMusicNotEnabled",
|
|
||||||
"QueueException",
|
|
||||||
"QueueFull",
|
|
||||||
"QueueEmpty",
|
|
||||||
"LavalinkVersionIncompatible",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class PomiceException(Exception):
|
class PomiceException(Exception):
|
||||||
"""Base of all Pomice exceptions."""
|
"""Base of all Pomice exceptions."""
|
||||||
|
|
||||||
|
|
@ -39,89 +16,49 @@ class NodeConnectionFailure(NodeException):
|
||||||
|
|
||||||
class NodeConnectionClosed(NodeException):
|
class NodeConnectionClosed(NodeException):
|
||||||
"""The node's connection is closed."""
|
"""The node's connection is closed."""
|
||||||
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class NodeRestException(NodeException):
|
|
||||||
"""A request made using the node's REST uri failed"""
|
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class NodeNotAvailable(PomiceException):
|
class NodeNotAvailable(PomiceException):
|
||||||
"""The node is currently unavailable."""
|
"""The node is currently unavailable."""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class NoNodesAvailable(PomiceException):
|
class NoNodesAvailable(PomiceException):
|
||||||
"""There are no nodes currently available."""
|
"""There are no nodes currently available."""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class TrackInvalidPosition(PomiceException):
|
class TrackInvalidPosition(PomiceException):
|
||||||
"""An invalid position was chosen for a track."""
|
"""An invalid position was chosen for a track."""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class TrackLoadError(PomiceException):
|
class TrackLoadError(PomiceException):
|
||||||
"""There was an error while loading a track."""
|
"""There was an error while loading a track."""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class FilterInvalidArgument(PomiceException):
|
class FilterInvalidArgument(PomiceException):
|
||||||
"""An invalid argument was passed to a filter."""
|
"""An invalid argument was passed to a filter."""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class FilterTagInvalid(PomiceException):
|
class SpotifyAlbumLoadFailed(PomiceException):
|
||||||
"""An invalid tag was passed or Pomice was unable to find a filter tag"""
|
"""The pomice Spotify client was unable to load an album."""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class FilterTagAlreadyInUse(PomiceException):
|
class SpotifyTrackLoadFailed(PomiceException):
|
||||||
"""A filter with a tag is already in use by another filter"""
|
"""The pomice Spotify client was unable to load a track."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class SpotifyPlaylistLoadFailed(PomiceException):
|
||||||
|
"""The pomice Spotify client was unable to load a playlist."""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class InvalidSpotifyClientAuthorization(PomiceException):
|
class InvalidSpotifyClientAuthorization(PomiceException):
|
||||||
"""No Spotify client authorization was provided for track searching."""
|
"""No Spotify client authorization was provided for track searching."""
|
||||||
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class AppleMusicNotEnabled(PomiceException):
|
|
||||||
"""An Apple Music Link was passed in when Apple Music functionality was not enabled."""
|
|
||||||
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class QueueException(Exception):
|
|
||||||
"""Base Pomice queue exception."""
|
|
||||||
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class QueueFull(QueueException):
|
|
||||||
"""Exception raised when attempting to add to a full Queue."""
|
|
||||||
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class QueueEmpty(QueueException):
|
|
||||||
"""Exception raised when attempting to retrieve from an empty Queue."""
|
|
||||||
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class LavalinkVersionIncompatible(PomiceException):
|
|
||||||
"""Lavalink version is incompatible. Must be using Lavalink > 3.7.0 to avoid this error."""
|
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
|
||||||
|
|
@ -1,203 +1,22 @@
|
||||||
import collections
|
|
||||||
from typing import Any
|
|
||||||
from typing import Dict
|
|
||||||
from typing import List
|
|
||||||
from typing import Optional
|
|
||||||
from typing import Tuple
|
|
||||||
|
|
||||||
from .exceptions import FilterInvalidArgument
|
from .exceptions import FilterInvalidArgument
|
||||||
|
|
||||||
__all__ = (
|
|
||||||
"Filter",
|
|
||||||
"Equalizer",
|
|
||||||
"Timescale",
|
|
||||||
"Karaoke",
|
|
||||||
"Tremolo",
|
|
||||||
"Vibrato",
|
|
||||||
"Rotation",
|
|
||||||
"Distortion",
|
|
||||||
"ChannelMix",
|
|
||||||
"LowPass",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Filter:
|
class Filter:
|
||||||
"""
|
def __init__(self):
|
||||||
The base class for all filters.
|
self.payload = None
|
||||||
You can use these filters if you have the latest Lavalink version
|
|
||||||
installed. If you do not have the latest Lavalink version,
|
|
||||||
these filters will not work.
|
|
||||||
|
|
||||||
You must specify a tag for each filter you put on.
|
|
||||||
This is necessary for the removal of filters.
|
|
||||||
"""
|
|
||||||
|
|
||||||
__slots__ = ("payload", "tag", "preload")
|
|
||||||
|
|
||||||
def __init__(self, *, tag: str):
|
|
||||||
self.payload: Optional[Dict] = None
|
|
||||||
self.tag: str = tag
|
|
||||||
self.preload: bool = False
|
|
||||||
|
|
||||||
def set_preload(self) -> bool:
|
|
||||||
"""Internal method to set whether or not the filter was preloaded."""
|
|
||||||
self.preload = True
|
|
||||||
return self.preload
|
|
||||||
|
|
||||||
|
|
||||||
class Equalizer(Filter):
|
|
||||||
"""
|
|
||||||
Filter which represents a 15 band equalizer.
|
|
||||||
You can adjust the dynamic of the sound using this filter.
|
|
||||||
i.e: Applying a bass boost filter to emphasize the bass in a song.
|
|
||||||
The format for the levels is: List[Tuple[int, float]]
|
|
||||||
"""
|
|
||||||
|
|
||||||
__slots__ = (
|
|
||||||
"eq",
|
|
||||||
"raw",
|
|
||||||
)
|
|
||||||
|
|
||||||
def __init__(self, *, tag: str, levels: list):
|
|
||||||
super().__init__(tag=tag)
|
|
||||||
|
|
||||||
self.eq = self._factory(levels)
|
|
||||||
self.raw = levels
|
|
||||||
|
|
||||||
self.payload = {"equalizer": self.eq}
|
|
||||||
|
|
||||||
def _factory(self, levels: List[Tuple[Any, Any]]) -> List[Dict]:
|
|
||||||
_dict: Dict = collections.defaultdict(int)
|
|
||||||
|
|
||||||
_dict.update(levels)
|
|
||||||
data = [{"band": i, "gain": _dict[i]} for i in range(15)]
|
|
||||||
|
|
||||||
return data
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
return f"<Pomice.EqualizerFilter tag={self.tag} eq={self.eq} raw={self.raw}>"
|
|
||||||
|
|
||||||
def __eq__(self, __value: object) -> bool:
|
|
||||||
if not isinstance(__value, Equalizer):
|
|
||||||
return False
|
|
||||||
|
|
||||||
return self.raw == __value.raw
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def flat(cls) -> "Equalizer":
|
|
||||||
"""Equalizer preset which represents a flat EQ board,
|
|
||||||
with all levels set to their default values.
|
|
||||||
"""
|
|
||||||
|
|
||||||
levels = [
|
|
||||||
(0, 0.0),
|
|
||||||
(1, 0.0),
|
|
||||||
(2, 0.0),
|
|
||||||
(3, 0.0),
|
|
||||||
(4, 0.0),
|
|
||||||
(5, 0.0),
|
|
||||||
(6, 0.0),
|
|
||||||
(7, 0.0),
|
|
||||||
(8, 0.0),
|
|
||||||
(9, 0.0),
|
|
||||||
(10, 0.0),
|
|
||||||
(11, 0.0),
|
|
||||||
(12, 0.0),
|
|
||||||
(13, 0.0),
|
|
||||||
(14, 0.0),
|
|
||||||
]
|
|
||||||
return cls(tag="flat", levels=levels)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def boost(cls) -> "Equalizer":
|
|
||||||
"""Equalizer preset which boosts the sound of a track,
|
|
||||||
making it sound fun and energetic by increasing the bass
|
|
||||||
and the highs.
|
|
||||||
"""
|
|
||||||
|
|
||||||
levels = [
|
|
||||||
(0, -0.075),
|
|
||||||
(1, 0.125),
|
|
||||||
(2, 0.125),
|
|
||||||
(3, 0.1),
|
|
||||||
(4, 0.1),
|
|
||||||
(5, 0.05),
|
|
||||||
(6, 0.075),
|
|
||||||
(7, 0.0),
|
|
||||||
(8, 0.0),
|
|
||||||
(9, 0.0),
|
|
||||||
(10, 0.0),
|
|
||||||
(11, 0.0),
|
|
||||||
(12, 0.125),
|
|
||||||
(13, 0.15),
|
|
||||||
(14, 0.05),
|
|
||||||
]
|
|
||||||
return cls(tag="boost", levels=levels)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def metal(cls) -> "Equalizer":
|
|
||||||
"""Equalizer preset which increases the mids of a track,
|
|
||||||
preferably one of the metal genre, to make it sound
|
|
||||||
more full and concert-like.
|
|
||||||
"""
|
|
||||||
|
|
||||||
levels = [
|
|
||||||
(0, 0.0),
|
|
||||||
(1, 0.1),
|
|
||||||
(2, 0.1),
|
|
||||||
(3, 0.15),
|
|
||||||
(4, 0.13),
|
|
||||||
(5, 0.1),
|
|
||||||
(6, 0.0),
|
|
||||||
(7, 0.125),
|
|
||||||
(8, 0.175),
|
|
||||||
(9, 0.175),
|
|
||||||
(10, 0.125),
|
|
||||||
(11, 0.125),
|
|
||||||
(12, 0.1),
|
|
||||||
(13, 0.075),
|
|
||||||
(14, 0.0),
|
|
||||||
]
|
|
||||||
|
|
||||||
return cls(tag="metal", levels=levels)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def piano(cls) -> "Equalizer":
|
|
||||||
"""Equalizer preset which increases the mids and highs
|
|
||||||
of a track, preferably a piano based one, to make it
|
|
||||||
stand out.
|
|
||||||
"""
|
|
||||||
|
|
||||||
levels = [
|
|
||||||
(0, -0.25),
|
|
||||||
(1, -0.25),
|
|
||||||
(2, -0.125),
|
|
||||||
(3, 0.0),
|
|
||||||
(4, 0.25),
|
|
||||||
(5, 0.25),
|
|
||||||
(6, 0.0),
|
|
||||||
(7, -0.25),
|
|
||||||
(8, -0.25),
|
|
||||||
(9, 0.0),
|
|
||||||
(10, 0.0),
|
|
||||||
(11, 0.5),
|
|
||||||
(12, 0.25),
|
|
||||||
(13, -0.025),
|
|
||||||
]
|
|
||||||
return cls(tag="piano", levels=levels)
|
|
||||||
|
|
||||||
|
|
||||||
class Timescale(Filter):
|
class Timescale(Filter):
|
||||||
"""Filter which changes the speed and pitch of a track.
|
"""Filter which changes the speed and pitch of a track.
|
||||||
You can make some very nice effects with this filter,
|
Do be warned that this filter is bugged as of the lastest Lavalink dev version
|
||||||
i.e: a vaporwave-esque filter which slows the track down
|
due to the filter patch not corresponding with the track time.
|
||||||
a certain amount to produce said effect.
|
|
||||||
|
In short this means that your track will either end prematurely or end later due to this.
|
||||||
|
This is not the library's fault.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__slots__ = ("speed", "pitch", "rate")
|
def __init__(self, *, speed: float = 1.0, pitch: float = 1.0, rate: float = 1.0):
|
||||||
|
super().__init__()
|
||||||
def __init__(self, *, tag: str, speed: float = 1.0, pitch: float = 1.0, rate: float = 1.0):
|
|
||||||
super().__init__(tag=tag)
|
|
||||||
|
|
||||||
if speed < 0:
|
if speed < 0:
|
||||||
raise FilterInvalidArgument("Timescale speed must be more than 0.")
|
raise FilterInvalidArgument("Timescale speed must be more than 0.")
|
||||||
|
|
@ -206,46 +25,16 @@ class Timescale(Filter):
|
||||||
if rate < 0:
|
if rate < 0:
|
||||||
raise FilterInvalidArgument("Timescale rate must be more than 0.")
|
raise FilterInvalidArgument("Timescale rate must be more than 0.")
|
||||||
|
|
||||||
self.speed: float = speed
|
self.speed = speed
|
||||||
self.pitch: float = pitch
|
self.pitch = pitch
|
||||||
self.rate: float = rate
|
self.rate = rate
|
||||||
|
|
||||||
self.payload: dict = {
|
self.payload = {"timescale": {"speed": self.speed,
|
||||||
"timescale": {"speed": self.speed, "pitch": self.pitch, "rate": self.rate},
|
"pitch": self.pitch,
|
||||||
}
|
"rate": self.rate}}
|
||||||
|
|
||||||
@classmethod
|
def __repr__(self):
|
||||||
def vaporwave(cls) -> "Timescale":
|
return f"<Pomice.TimescaleFilter speed={self.speed} pitch={self.pitch} rate={self.rate}>"
|
||||||
"""Timescale preset which slows down the currently playing track,
|
|
||||||
giving it the effect of a half-speed record/casette playing.
|
|
||||||
|
|
||||||
This preset will assign the tag 'vaporwave'.
|
|
||||||
"""
|
|
||||||
|
|
||||||
return cls(tag="vaporwave", speed=0.8, pitch=0.8)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def nightcore(cls) -> "Timescale":
|
|
||||||
"""Timescale preset which speeds up the currently playing track,
|
|
||||||
which matches up to nightcore, a genre of sped-up music
|
|
||||||
|
|
||||||
This preset will assign the tag 'nightcore'.
|
|
||||||
"""
|
|
||||||
|
|
||||||
return cls(tag="nightcore", speed=1.25, pitch=1.3)
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
return f"<Pomice.TimescaleFilter tag={self.tag} speed={self.speed} pitch={self.pitch} rate={self.rate}>"
|
|
||||||
|
|
||||||
def __eq__(self, __value: object) -> bool:
|
|
||||||
if not isinstance(__value, Timescale):
|
|
||||||
return False
|
|
||||||
|
|
||||||
return (
|
|
||||||
self.speed == __value.speed
|
|
||||||
and self.pitch == __value.pitch
|
|
||||||
and self.rate == __value.rate
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Karaoke(Filter):
|
class Karaoke(Filter):
|
||||||
|
|
@ -253,90 +42,54 @@ class Karaoke(Filter):
|
||||||
Best for karaoke as the filter implies.
|
Best for karaoke as the filter implies.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__slots__ = ("level", "mono_level", "filter_band", "filter_width")
|
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
tag: str,
|
level: float,
|
||||||
level: float = 1.0,
|
mono_level: float,
|
||||||
mono_level: float = 1.0,
|
filter_band: float,
|
||||||
filter_band: float = 220.0,
|
filter_width: float
|
||||||
filter_width: float = 100.0,
|
|
||||||
):
|
):
|
||||||
super().__init__(tag=tag)
|
super().__init__()
|
||||||
|
|
||||||
self.level: float = level
|
self.level = level
|
||||||
self.mono_level: float = mono_level
|
self.mono_level = mono_level
|
||||||
self.filter_band: float = filter_band
|
self.filter_band = filter_band
|
||||||
self.filter_width: float = filter_width
|
self.filter_width = filter_width
|
||||||
|
|
||||||
self.payload: dict = {
|
self.payload = {"karaoke": {"level": self.level,
|
||||||
"karaoke": {
|
|
||||||
"level": self.level,
|
|
||||||
"monoLevel": self.mono_level,
|
"monoLevel": self.mono_level,
|
||||||
"filterBand": self.filter_band,
|
"filterBand": self.filter_band,
|
||||||
"filterWidth": self.filter_width,
|
"filterWidth": self.filter_width}}
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self):
|
||||||
return (
|
return (
|
||||||
f"<Pomice.KaraokeFilter tag={self.tag} level={self.level} mono_level={self.mono_level} "
|
f"<Pomice.KaraokeFilter level={self.level} mono_level={self.mono_level} "
|
||||||
f"filter_band={self.filter_band} filter_width={self.filter_width}>"
|
f"filter_band={self.filter_band} filter_width={self.filter_width}>"
|
||||||
)
|
)
|
||||||
|
|
||||||
def __eq__(self, __value: object) -> bool:
|
|
||||||
if not isinstance(__value, Karaoke):
|
|
||||||
return False
|
|
||||||
|
|
||||||
return (
|
|
||||||
self.level == __value.level
|
|
||||||
and self.mono_level == __value.mono_level
|
|
||||||
and self.filter_band == __value.filter_band
|
|
||||||
and self.filter_width == __value.filter_width
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Tremolo(Filter):
|
class Tremolo(Filter):
|
||||||
"""Filter which produces a wavering tone in the music,
|
"""Filter which produces a wavering tone in the music,
|
||||||
causing it to sound like the music is changing in volume rapidly.
|
causing it to sound like the music is changing in volume rapidly.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__slots__ = ("frequency", "depth")
|
def __init__(self, *, frequency: float, depth: float):
|
||||||
|
super().__init__()
|
||||||
def __init__(self, *, tag: str, frequency: float = 2.0, depth: float = 0.5):
|
|
||||||
super().__init__(tag=tag)
|
|
||||||
|
|
||||||
if frequency < 0:
|
if frequency < 0:
|
||||||
raise FilterInvalidArgument(
|
raise FilterInvalidArgument("Tremolo frequency must be more than 0.")
|
||||||
"Tremolo frequency must be more than 0.",
|
|
||||||
)
|
|
||||||
if depth < 0 or depth > 1:
|
if depth < 0 or depth > 1:
|
||||||
raise FilterInvalidArgument(
|
raise FilterInvalidArgument("Tremolo depth must be between 0 and 1.")
|
||||||
"Tremolo depth must be between 0 and 1.",
|
|
||||||
)
|
|
||||||
|
|
||||||
self.frequency: float = frequency
|
self.frequency = frequency
|
||||||
self.depth: float = depth
|
self.depth = depth
|
||||||
|
|
||||||
self.payload: dict = {
|
self.payload = {"tremolo": {"frequency": self.frequency,
|
||||||
"tremolo": {
|
"depth": self.depth}}
|
||||||
"frequency": self.frequency,
|
|
||||||
"depth": self.depth,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self):
|
||||||
return (
|
return f"<Pomice.TremoloFilter frequency={self.frequency} depth={self.depth}>"
|
||||||
f"<Pomice.TremoloFilter tag={self.tag} frequency={self.frequency} depth={self.depth}>"
|
|
||||||
)
|
|
||||||
|
|
||||||
def __eq__(self, __value: object) -> bool:
|
|
||||||
if not isinstance(__value, Tremolo):
|
|
||||||
return False
|
|
||||||
|
|
||||||
return self.frequency == __value.frequency and self.depth == __value.depth
|
|
||||||
|
|
||||||
|
|
||||||
class Vibrato(Filter):
|
class Vibrato(Filter):
|
||||||
|
|
@ -344,231 +97,19 @@ class Vibrato(Filter):
|
||||||
but changes in pitch rather than volume.
|
but changes in pitch rather than volume.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__slots__ = ("frequency", "depth")
|
def __init__(self, *, frequency: float, depth: float):
|
||||||
|
|
||||||
def __init__(self, *, tag: str, frequency: float = 2.0, depth: float = 0.5):
|
|
||||||
super().__init__(tag=tag)
|
|
||||||
|
|
||||||
|
super().__init__()
|
||||||
if frequency < 0 or frequency > 14:
|
if frequency < 0 or frequency > 14:
|
||||||
raise FilterInvalidArgument(
|
raise FilterInvalidArgument("Vibrato frequency must be between 0 and 14.")
|
||||||
"Vibrato frequency must be between 0 and 14.",
|
|
||||||
)
|
|
||||||
if depth < 0 or depth > 1:
|
if depth < 0 or depth > 1:
|
||||||
raise FilterInvalidArgument(
|
raise FilterInvalidArgument("Vibrato depth must be between 0 and 1.")
|
||||||
"Vibrato depth must be between 0 and 1.",
|
|
||||||
)
|
|
||||||
|
|
||||||
self.frequency: float = frequency
|
self.frequency = frequency
|
||||||
self.depth: float = depth
|
self.depth = depth
|
||||||
|
|
||||||
self.payload: dict = {
|
self.payload = {"vibrato": {"frequency": self.frequency,
|
||||||
"vibrato": {
|
"depth": self.depth}}
|
||||||
"frequency": self.frequency,
|
|
||||||
"depth": self.depth,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self):
|
||||||
return (
|
return f"<Pomice.VibratoFilter frequency={self.frequency} depth={self.depth}>"
|
||||||
f"<Pomice.VibratoFilter tag={self.tag} frequency={self.frequency} depth={self.depth}>"
|
|
||||||
)
|
|
||||||
|
|
||||||
def __eq__(self, __value: object) -> bool:
|
|
||||||
if not isinstance(__value, Vibrato):
|
|
||||||
return False
|
|
||||||
|
|
||||||
return self.frequency == __value.frequency and self.depth == __value.depth
|
|
||||||
|
|
||||||
|
|
||||||
class Rotation(Filter):
|
|
||||||
"""Filter which produces a stereo-like panning effect, which sounds like
|
|
||||||
the audio is being rotated around the listener's head
|
|
||||||
"""
|
|
||||||
|
|
||||||
__slots__ = ("rotation_hertz",)
|
|
||||||
|
|
||||||
def __init__(self, *, tag: str, rotation_hertz: float = 5):
|
|
||||||
super().__init__(tag=tag)
|
|
||||||
|
|
||||||
self.rotation_hertz: float = rotation_hertz
|
|
||||||
self.payload: dict = {"rotation": {"rotationHz": self.rotation_hertz}}
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
return f"<Pomice.RotationFilter tag={self.tag} rotation_hertz={self.rotation_hertz}>"
|
|
||||||
|
|
||||||
def __eq__(self, __value: object) -> bool:
|
|
||||||
if not isinstance(__value, Rotation):
|
|
||||||
return False
|
|
||||||
|
|
||||||
return self.rotation_hertz == __value.rotation_hertz
|
|
||||||
|
|
||||||
|
|
||||||
class ChannelMix(Filter):
|
|
||||||
"""Filter which manually adjusts the panning of the audio, which can make
|
|
||||||
for some cool effects when done correctly.
|
|
||||||
"""
|
|
||||||
|
|
||||||
__slots__ = (
|
|
||||||
"left_to_left",
|
|
||||||
"right_to_right",
|
|
||||||
"left_to_right",
|
|
||||||
"right_to_left",
|
|
||||||
)
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
tag: str,
|
|
||||||
left_to_left: float = 1,
|
|
||||||
right_to_right: float = 1,
|
|
||||||
left_to_right: float = 0,
|
|
||||||
right_to_left: float = 0,
|
|
||||||
):
|
|
||||||
super().__init__(tag=tag)
|
|
||||||
|
|
||||||
if 0 > left_to_left > 1:
|
|
||||||
raise ValueError(
|
|
||||||
"'left_to_left' value must be more than or equal to 0 or less than or equal to 1.",
|
|
||||||
)
|
|
||||||
if 0 > right_to_right > 1:
|
|
||||||
raise ValueError(
|
|
||||||
"'right_to_right' value must be more than or equal to 0 or less than or equal to 1.",
|
|
||||||
)
|
|
||||||
if 0 > left_to_right > 1:
|
|
||||||
raise ValueError(
|
|
||||||
"'left_to_right' value must be more than or equal to 0 or less than or equal to 1.",
|
|
||||||
)
|
|
||||||
if 0 > right_to_left > 1:
|
|
||||||
raise ValueError(
|
|
||||||
"'right_to_left' value must be more than or equal to 0 or less than or equal to 1.",
|
|
||||||
)
|
|
||||||
|
|
||||||
self.left_to_left: float = left_to_left
|
|
||||||
self.left_to_right: float = left_to_right
|
|
||||||
self.right_to_left: float = right_to_left
|
|
||||||
self.right_to_right: float = right_to_right
|
|
||||||
|
|
||||||
self.payload: dict = {
|
|
||||||
"channelMix": {
|
|
||||||
"leftToLeft": self.left_to_left,
|
|
||||||
"leftToRight": self.left_to_right,
|
|
||||||
"rightToLeft": self.right_to_left,
|
|
||||||
"rightToRight": self.right_to_right,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
return (
|
|
||||||
f"<Pomice.ChannelMix tag={self.tag} left_to_left={self.left_to_left} left_to_right={self.left_to_right} "
|
|
||||||
f"right_to_left={self.right_to_left} right_to_right={self.right_to_right}>"
|
|
||||||
)
|
|
||||||
|
|
||||||
def __eq__(self, __value: object) -> bool:
|
|
||||||
if not isinstance(__value, ChannelMix):
|
|
||||||
return False
|
|
||||||
|
|
||||||
return (
|
|
||||||
self.left_to_left == __value.left_to_left
|
|
||||||
and self.left_to_right == __value.left_to_right
|
|
||||||
and self.right_to_left == __value.right_to_left
|
|
||||||
and self.right_to_right == __value.right_to_right
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Distortion(Filter):
|
|
||||||
"""Filter which generates a distortion effect. Useful for certain filter implementations where
|
|
||||||
distortion is needed.
|
|
||||||
"""
|
|
||||||
|
|
||||||
__slots__ = (
|
|
||||||
"sin_offset",
|
|
||||||
"sin_scale",
|
|
||||||
"cos_offset",
|
|
||||||
"cos_scale",
|
|
||||||
"tan_offset",
|
|
||||||
"tan_scale",
|
|
||||||
"offset",
|
|
||||||
"scale",
|
|
||||||
)
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
tag: str,
|
|
||||||
sin_offset: float = 0,
|
|
||||||
sin_scale: float = 1,
|
|
||||||
cos_offset: float = 0,
|
|
||||||
cos_scale: float = 1,
|
|
||||||
tan_offset: float = 0,
|
|
||||||
tan_scale: float = 1,
|
|
||||||
offset: float = 0,
|
|
||||||
scale: float = 1,
|
|
||||||
):
|
|
||||||
super().__init__(tag=tag)
|
|
||||||
|
|
||||||
self.sin_offset: float = sin_offset
|
|
||||||
self.sin_scale: float = sin_scale
|
|
||||||
self.cos_offset: float = cos_offset
|
|
||||||
self.cos_scale: float = cos_scale
|
|
||||||
self.tan_offset: float = tan_offset
|
|
||||||
self.tan_scale: float = tan_scale
|
|
||||||
self.offset: float = offset
|
|
||||||
self.scale: float = scale
|
|
||||||
|
|
||||||
self.payload: dict = {
|
|
||||||
"distortion": {
|
|
||||||
"sinOffset": self.sin_offset,
|
|
||||||
"sinScale": self.sin_scale,
|
|
||||||
"cosOffset": self.cos_offset,
|
|
||||||
"cosScale": self.cos_scale,
|
|
||||||
"tanOffset": self.tan_offset,
|
|
||||||
"tanScale": self.tan_scale,
|
|
||||||
"offset": self.offset,
|
|
||||||
"scale": self.scale,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
return (
|
|
||||||
f"<Pomice.Distortion tag={self.tag} sin_offset={self.sin_offset} sin_scale={self.sin_scale}> "
|
|
||||||
f"cos_offset={self.cos_offset} cos_scale={self.cos_scale} tan_offset={self.tan_offset} "
|
|
||||||
f"tan_scale={self.tan_scale} offset={self.offset} scale={self.scale}"
|
|
||||||
)
|
|
||||||
|
|
||||||
def __eq__(self, __value: object) -> bool:
|
|
||||||
if not isinstance(__value, Distortion):
|
|
||||||
return False
|
|
||||||
|
|
||||||
return (
|
|
||||||
self.sin_offset == __value.sin_offset
|
|
||||||
and self.sin_scale == __value.sin_scale
|
|
||||||
and self.cos_offset == __value.cos_offset
|
|
||||||
and self.cos_scale == __value.cos_scale
|
|
||||||
and self.tan_offset == __value.tan_offset
|
|
||||||
and self.tan_scale == __value.tan_scale
|
|
||||||
and self.offset == __value.offset
|
|
||||||
and self.scale == __value.scale
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class LowPass(Filter):
|
|
||||||
"""Filter which supresses higher frequencies and allows lower frequencies to pass.
|
|
||||||
You can also do this with the Equalizer filter, but this is an easier way to do it.
|
|
||||||
"""
|
|
||||||
|
|
||||||
__slots__ = ("smoothing", "payload")
|
|
||||||
|
|
||||||
def __init__(self, *, tag: str, smoothing: float = 20):
|
|
||||||
super().__init__(tag=tag)
|
|
||||||
|
|
||||||
self.smoothing: float = smoothing
|
|
||||||
self.payload: dict = {"lowPass": {"smoothing": self.smoothing}}
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
return f"<Pomice.LowPass tag={self.tag} smoothing={self.smoothing}>"
|
|
||||||
|
|
||||||
def __eq__(self, __value: object) -> bool:
|
|
||||||
if not isinstance(__value, LowPass):
|
|
||||||
return False
|
|
||||||
|
|
||||||
return self.smoothing == __value.smoothing
|
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,9 @@
|
||||||
from __future__ import annotations
|
from re import S
|
||||||
|
|
||||||
from typing import List
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from typing import Union
|
|
||||||
|
|
||||||
from discord import ClientUser
|
|
||||||
from discord import Member
|
|
||||||
from discord import User
|
|
||||||
from discord.ext import commands
|
from discord.ext import commands
|
||||||
|
|
||||||
from .enums import PlaylistType
|
|
||||||
from .enums import SearchType
|
from .enums import SearchType
|
||||||
from .enums import TrackType
|
|
||||||
from .filters import Filter
|
|
||||||
|
|
||||||
__all__ = (
|
|
||||||
"Track",
|
|
||||||
"Playlist",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Track:
|
class Track:
|
||||||
|
|
@ -25,85 +11,46 @@ class Track:
|
||||||
You can also pass in commands.Context to get a discord.py Context object in your track.
|
You can also pass in commands.Context to get a discord.py Context object in your track.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__slots__ = (
|
|
||||||
"track_id",
|
|
||||||
"info",
|
|
||||||
"track_type",
|
|
||||||
"filters",
|
|
||||||
"timestamp",
|
|
||||||
"original",
|
|
||||||
"_search_type",
|
|
||||||
"playlist",
|
|
||||||
"title",
|
|
||||||
"author",
|
|
||||||
"uri",
|
|
||||||
"identifier",
|
|
||||||
"isrc",
|
|
||||||
"thumbnail",
|
|
||||||
"length",
|
|
||||||
"ctx",
|
|
||||||
"requester",
|
|
||||||
"is_stream",
|
|
||||||
"is_seekable",
|
|
||||||
"position",
|
|
||||||
)
|
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
track_id: str,
|
track_id: str,
|
||||||
info: dict,
|
info: dict,
|
||||||
ctx: Optional[commands.Context] = None,
|
ctx: Optional[commands.Context] = None,
|
||||||
track_type: TrackType,
|
spotify: bool = False,
|
||||||
search_type: SearchType = SearchType.ytsearch,
|
search_type: SearchType = SearchType.ytsearch
|
||||||
filters: Optional[List[Filter]] = None,
|
|
||||||
timestamp: Optional[float] = None,
|
|
||||||
requester: Optional[Union[Member, User, ClientUser]] = None,
|
|
||||||
):
|
):
|
||||||
self.track_id: str = track_id
|
self.track_id = track_id
|
||||||
self.info: dict = info
|
self.info = info
|
||||||
self.track_type: TrackType = track_type
|
self.spotify = spotify
|
||||||
self.filters: Optional[List[Filter]] = filters
|
|
||||||
self.timestamp: Optional[float] = timestamp
|
|
||||||
|
|
||||||
if self.track_type == TrackType.SPOTIFY or self.track_type == TrackType.APPLE_MUSIC:
|
self.original: Optional[Track] = None if self.spotify else self
|
||||||
self.original: Optional[Track] = None
|
self._search_type = search_type
|
||||||
else:
|
|
||||||
self.original = self
|
|
||||||
self._search_type: SearchType = search_type
|
|
||||||
|
|
||||||
self.playlist: Optional[Playlist] = None
|
self.title = info.get("title")
|
||||||
|
self.author = info.get("author")
|
||||||
|
self.length = info.get("length")
|
||||||
|
self.ctx = ctx
|
||||||
|
self.requester = self.ctx.author if ctx else None
|
||||||
|
self.identifier = info.get("identifier")
|
||||||
|
self.uri = info.get("uri")
|
||||||
|
self.is_stream = info.get("isStream")
|
||||||
|
self.is_seekable = info.get("isSeekable")
|
||||||
|
self.position = info.get("position")
|
||||||
|
|
||||||
self.title: str = info.get("title", "Unknown Title")
|
def __eq__(self, other):
|
||||||
self.author: str = info.get("author", "Unknown Author")
|
|
||||||
self.uri: str = info.get("uri", "")
|
|
||||||
self.identifier: str = info.get("identifier", "")
|
|
||||||
self.isrc: Optional[str] = info.get("isrc", None)
|
|
||||||
self.thumbnail: Optional[str] = info.get("thumbnail")
|
|
||||||
|
|
||||||
if self.uri and self.track_type is TrackType.YOUTUBE:
|
|
||||||
self.thumbnail = f"https://img.youtube.com/vi/{self.identifier}/mqdefault.jpg"
|
|
||||||
|
|
||||||
self.length: int = info.get("length", 0)
|
|
||||||
self.is_stream: bool = info.get("isStream", False)
|
|
||||||
self.is_seekable: bool = info.get("isSeekable", False)
|
|
||||||
self.position: int = info.get("position", 0)
|
|
||||||
|
|
||||||
self.ctx: Optional[commands.Context] = ctx
|
|
||||||
self.requester: Optional[Union[Member, User, ClientUser]] = requester
|
|
||||||
if not self.requester and self.ctx:
|
|
||||||
self.requester = self.ctx.author
|
|
||||||
|
|
||||||
def __eq__(self, other: object) -> bool:
|
|
||||||
if not isinstance(other, Track):
|
if not isinstance(other, Track):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
if self.ctx and other.ctx:
|
||||||
|
return other.track_id == self.track_id and other.ctx.message.id == self.ctx.message.id
|
||||||
|
|
||||||
return other.track_id == self.track_id
|
return other.track_id == self.track_id
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self):
|
||||||
return self.title
|
return self.title
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self):
|
||||||
return f"<Pomice.track title={self.title!r} uri=<{self.uri!r}> length={self.length}>"
|
return f"<Pomice.track title={self.title!r} uri=<{self.uri!r}> length={self.length}>"
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -113,55 +60,48 @@ class Playlist:
|
||||||
You can also pass in commands.Context to get a discord.py Context object in your tracks.
|
You can also pass in commands.Context to get a discord.py Context object in your tracks.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__slots__ = (
|
|
||||||
"playlist_info",
|
|
||||||
"tracks",
|
|
||||||
"name",
|
|
||||||
"playlist_type",
|
|
||||||
"_thumbnail",
|
|
||||||
"_uri",
|
|
||||||
"selected_track",
|
|
||||||
"track_count",
|
|
||||||
)
|
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
playlist_info: dict,
|
playlist_info: dict,
|
||||||
tracks: list,
|
tracks: list,
|
||||||
playlist_type: PlaylistType,
|
ctx: Optional[commands.Context] = None,
|
||||||
|
spotify: bool = False,
|
||||||
thumbnail: Optional[str] = None,
|
thumbnail: Optional[str] = None,
|
||||||
uri: Optional[str] = None,
|
uri: Optional[str] = None,
|
||||||
):
|
):
|
||||||
self.playlist_info: dict = playlist_info
|
self.playlist_info = playlist_info
|
||||||
self.tracks: List[Track] = tracks
|
self.tracks_raw = tracks
|
||||||
self.name: str = playlist_info.get("name", "Unknown Playlist")
|
self.spotify = spotify
|
||||||
self.playlist_type: PlaylistType = playlist_type
|
|
||||||
|
|
||||||
self._thumbnail: Optional[str] = thumbnail
|
self.name = playlist_info.get("name")
|
||||||
self._uri: Optional[str] = uri
|
self.selected_track = playlist_info.get("selectedTrack")
|
||||||
|
|
||||||
for track in self.tracks:
|
self._thumbnail = thumbnail
|
||||||
track.playlist = self
|
self._uri = uri
|
||||||
|
|
||||||
self.selected_track: Optional[Track] = None
|
if self.spotify:
|
||||||
if (index := playlist_info.get("selectedTrack", -1)) != -1:
|
self.tracks = tracks
|
||||||
self.selected_track = self.tracks[index]
|
else:
|
||||||
|
self.tracks = [
|
||||||
|
Track(track_id=track["track"], info=track["info"], ctx=ctx)
|
||||||
|
for track in self.tracks_raw
|
||||||
|
]
|
||||||
|
|
||||||
self.track_count: int = len(self.tracks)
|
self.track_count = len(self.tracks)
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self):
|
||||||
return f"<Pomice.playlist name={self.name!r} track_count={len(self.tracks)}>"
|
return f"<Pomice.playlist name={self.name!r} track_count={len(self.tracks)}>"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def uri(self) -> Optional[str]:
|
def uri(self) -> Optional[str]:
|
||||||
"""Returns either an Apple Music/Spotify URL/URI, or None if its neither of those."""
|
"""Spotify album/playlist URI, or None if not a Spotify object."""
|
||||||
return self._uri
|
return self._uri
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def thumbnail(self) -> Optional[str]:
|
def thumbnail(self) -> Optional[str]:
|
||||||
"""Returns either an Apple Music/Spotify album/playlist thumbnail, or None if its neither of those."""
|
"""Spotify album/playlist thumbnail, or None if not a Spotify object."""
|
||||||
return self._thumbnail
|
return self._thumbnail
|
||||||
|
|
|
||||||
716
pomice/player.py
716
pomice/player.py
|
|
@ -1,132 +1,17 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import time
|
import time
|
||||||
from typing import Any
|
from typing import Any, Dict, Optional, Type, Union
|
||||||
from typing import Dict
|
|
||||||
from typing import List
|
|
||||||
from typing import Optional
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
from typing import Union
|
|
||||||
|
|
||||||
from discord import Client
|
import discord
|
||||||
from discord import Guild
|
from discord import Client, Guild, VoiceChannel, VoiceProtocol
|
||||||
from discord import VoiceChannel
|
|
||||||
from discord import VoiceProtocol
|
|
||||||
from discord.ext import commands
|
from discord.ext import commands
|
||||||
|
|
||||||
|
from pomice.enums import SearchType
|
||||||
|
|
||||||
from . import events
|
from . import events
|
||||||
from .enums import SearchType
|
|
||||||
from .events import PomiceEvent
|
|
||||||
from .events import TrackEndEvent
|
|
||||||
from .events import TrackStartEvent
|
|
||||||
from .exceptions import FilterInvalidArgument
|
|
||||||
from .exceptions import FilterTagAlreadyInUse
|
|
||||||
from .exceptions import FilterTagInvalid
|
|
||||||
from .exceptions import TrackInvalidPosition
|
from .exceptions import TrackInvalidPosition
|
||||||
from .exceptions import TrackLoadError
|
|
||||||
from .filters import Filter
|
from .filters import Filter
|
||||||
from .filters import Timescale
|
from .pool import Node, NodePool
|
||||||
from .objects import Playlist
|
|
||||||
from .objects import Track
|
from .objects import Track
|
||||||
from .pool import Node
|
|
||||||
from .pool import NodePool
|
|
||||||
from pomice.utils import LavalinkVersion
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from discord.types.voice import VoiceServerUpdate
|
|
||||||
from discord.types.voice import GuildVoiceState
|
|
||||||
|
|
||||||
__all__ = ("Filters", "Player")
|
|
||||||
|
|
||||||
|
|
||||||
class Filters:
|
|
||||||
"""Helper class for filters"""
|
|
||||||
|
|
||||||
__slots__ = ("_filters",)
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self._filters: List[Filter] = []
|
|
||||||
|
|
||||||
@property
|
|
||||||
def has_preload(self) -> bool:
|
|
||||||
"""Property which checks if any applied filters were preloaded"""
|
|
||||||
return any(f for f in self._filters if f.preload == True)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def has_global(self) -> bool:
|
|
||||||
"""Property which checks if any applied filters are global"""
|
|
||||||
return any(f for f in self._filters if f.preload == False)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def empty(self) -> bool:
|
|
||||||
"""Property which checks if the filter list is empty"""
|
|
||||||
return len(self._filters) == 0
|
|
||||||
|
|
||||||
def add_filter(self, *, filter: Filter) -> None:
|
|
||||||
"""Adds a filter to the list of filters applied"""
|
|
||||||
if any(f for f in self._filters if f.tag == filter.tag):
|
|
||||||
raise FilterTagAlreadyInUse(
|
|
||||||
"A filter with that tag is already in use.",
|
|
||||||
)
|
|
||||||
self._filters.append(filter)
|
|
||||||
|
|
||||||
def remove_filter(self, *, filter_tag: str) -> None:
|
|
||||||
"""Removes a filter from the list of filters applied using its filter tag"""
|
|
||||||
if not any(f for f in self._filters if f.tag == filter_tag):
|
|
||||||
raise FilterTagInvalid("A filter with that tag was not found.")
|
|
||||||
|
|
||||||
for index, filter in enumerate(self._filters):
|
|
||||||
if filter.tag == filter_tag:
|
|
||||||
del self._filters[index]
|
|
||||||
|
|
||||||
def edit_filter(self, *, filter_tag: str, to_apply: Filter) -> None:
|
|
||||||
"""Edits a filter in the list of filters applied using its filter tag and replaces it with the new filter."""
|
|
||||||
if not any(f for f in self._filters if f.tag == filter_tag):
|
|
||||||
raise FilterTagInvalid("A filter with that tag was not found.")
|
|
||||||
|
|
||||||
for index, filter in enumerate(self._filters):
|
|
||||||
if filter.tag == filter_tag:
|
|
||||||
if not type(filter) == type(to_apply):
|
|
||||||
raise FilterInvalidArgument(
|
|
||||||
"Edited filter is not the same type as the current filter.",
|
|
||||||
)
|
|
||||||
if self._filters[index] == to_apply:
|
|
||||||
raise FilterInvalidArgument("Edited filter is the same as the current filter.")
|
|
||||||
|
|
||||||
if to_apply.tag != filter_tag:
|
|
||||||
raise FilterInvalidArgument(
|
|
||||||
"Edited filter tag is not the same as the current filter tag.",
|
|
||||||
)
|
|
||||||
|
|
||||||
self._filters[index] = to_apply
|
|
||||||
|
|
||||||
def has_filter(self, *, filter_tag: str) -> bool:
|
|
||||||
"""Checks if a filter exists in the list of filters using its filter tag"""
|
|
||||||
return any(f for f in self._filters if f.tag == filter_tag)
|
|
||||||
|
|
||||||
def has_filter_type(self, *, filter_type: Filter) -> bool:
|
|
||||||
"""Checks if any filters applied match the specified filter type."""
|
|
||||||
return any(f for f in self._filters if type(f) == type(filter_type))
|
|
||||||
|
|
||||||
def reset_filters(self) -> None:
|
|
||||||
"""Removes all filters from the list"""
|
|
||||||
self._filters = []
|
|
||||||
|
|
||||||
def get_preload_filters(self) -> List[Filter]:
|
|
||||||
"""Get all preloaded filters"""
|
|
||||||
return [f for f in self._filters if f.preload == True]
|
|
||||||
|
|
||||||
def get_all_payloads(self) -> Dict[str, Any]:
|
|
||||||
"""Returns a formatted dict of all the filter payloads"""
|
|
||||||
payload: Dict[str, Any] = {}
|
|
||||||
for _filter in self._filters:
|
|
||||||
if _filter.payload:
|
|
||||||
payload.update(_filter.payload)
|
|
||||||
return payload
|
|
||||||
|
|
||||||
def get_filters(self) -> List[Filter]:
|
|
||||||
"""Returns the current list of applied filters"""
|
|
||||||
return self._filters
|
|
||||||
|
|
||||||
|
|
||||||
class Player(VoiceProtocol):
|
class Player(VoiceProtocol):
|
||||||
|
|
@ -137,61 +22,28 @@ class Player(VoiceProtocol):
|
||||||
```
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__slots__ = (
|
def __init__(self, client: Type[Client], channel: VoiceChannel):
|
||||||
"client",
|
super().__init__(client=client, channel=channel)
|
||||||
"channel",
|
|
||||||
"_bot",
|
|
||||||
"_guild",
|
|
||||||
"_node",
|
|
||||||
"_current",
|
|
||||||
"_filters",
|
|
||||||
"_volume",
|
|
||||||
"_paused",
|
|
||||||
"_is_connected",
|
|
||||||
"_last_position",
|
|
||||||
"_last_update",
|
|
||||||
"_ending_track",
|
|
||||||
"_log",
|
|
||||||
"_voice_state",
|
|
||||||
"_player_endpoint_uri",
|
|
||||||
)
|
|
||||||
|
|
||||||
def __call__(self, client: Client, channel: VoiceChannel) -> Player:
|
|
||||||
self.client = client
|
self.client = client
|
||||||
|
self._bot = client
|
||||||
self.channel = channel
|
self.channel = channel
|
||||||
self._guild = channel.guild
|
self._guild: Guild = self.channel.guild
|
||||||
|
|
||||||
return self
|
self._node = NodePool.get_node()
|
||||||
|
self._current: Track = None
|
||||||
|
self._filter: Filter = None
|
||||||
|
self._volume = 100
|
||||||
|
self._paused = False
|
||||||
|
self._is_connected = False
|
||||||
|
|
||||||
def __init__(
|
self._position = 0
|
||||||
self,
|
self._last_position = 0
|
||||||
client: Client,
|
self._last_update = 0
|
||||||
channel: VoiceChannel,
|
|
||||||
*,
|
|
||||||
node: Optional[Node] = None,
|
|
||||||
) -> None:
|
|
||||||
self.client: Client = client
|
|
||||||
self.channel: VoiceChannel = channel
|
|
||||||
self._guild = channel.guild
|
|
||||||
|
|
||||||
self._bot: Client = client
|
self._voice_state = {}
|
||||||
self._node: Node = node if node else NodePool.get_node()
|
|
||||||
self._current: Optional[Track] = None
|
|
||||||
self._filters: Filters = Filters()
|
|
||||||
self._volume: int = 100
|
|
||||||
self._paused: bool = False
|
|
||||||
self._is_connected: bool = False
|
|
||||||
|
|
||||||
self._last_position: int = 0
|
def __repr__(self):
|
||||||
self._last_update: float = 0
|
|
||||||
self._ending_track: Optional[Track] = None
|
|
||||||
self._log = self._node._log
|
|
||||||
|
|
||||||
self._voice_state: dict = {}
|
|
||||||
|
|
||||||
self._player_endpoint_uri: str = f"sessions/{self._node._session_id}/players"
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
return (
|
return (
|
||||||
f"<Pomice.player bot={self.bot} guildId={self.guild.id} "
|
f"<Pomice.player bot={self.bot} guildId={self.guild.id} "
|
||||||
f"is_connected={self.is_connected} is_playing={self.is_playing}>"
|
f"is_connected={self.is_connected} is_playing={self.is_playing}>"
|
||||||
|
|
@ -200,45 +52,25 @@ class Player(VoiceProtocol):
|
||||||
@property
|
@property
|
||||||
def position(self) -> float:
|
def position(self) -> float:
|
||||||
"""Property which returns the player's position in a track in milliseconds"""
|
"""Property which returns the player's position in a track in milliseconds"""
|
||||||
if not self.is_playing:
|
|
||||||
|
if not self.is_playing or not self.current:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
current: Track = self._current # type: ignore
|
|
||||||
if current.original:
|
|
||||||
current = current.original
|
|
||||||
|
|
||||||
if self.is_paused:
|
if self.is_paused:
|
||||||
return min(self._last_position, current.length)
|
return min(self._last_position, self.current.length)
|
||||||
|
|
||||||
difference = (time.time() * 1000) - self._last_update
|
difference = (time.time() * 1000) - self._last_update
|
||||||
position = self._last_position + difference
|
position = self._last_position + difference
|
||||||
|
|
||||||
return round(min(position, current.length))
|
if position > self.current.length:
|
||||||
|
|
||||||
@property
|
|
||||||
def rate(self) -> float:
|
|
||||||
"""Property which returns the player's current rate"""
|
|
||||||
if _filter := next((f for f in self._filters._filters if isinstance(f, Timescale)), None):
|
|
||||||
return _filter.speed or _filter.rate
|
|
||||||
return 1.0
|
|
||||||
|
|
||||||
@property
|
|
||||||
def adjusted_position(self) -> float:
|
|
||||||
"""Property which returns the player's position in a track in milliseconds adjusted for rate"""
|
|
||||||
return self.position / self.rate
|
|
||||||
|
|
||||||
@property
|
|
||||||
def adjusted_length(self) -> float:
|
|
||||||
"""Property which returns the player's track length in milliseconds adjusted for rate"""
|
|
||||||
if not self.is_playing:
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
return self.current.length / self.rate # type: ignore
|
return min(position, self.current.length)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_playing(self) -> bool:
|
def is_playing(self) -> bool:
|
||||||
"""Property which returns whether or not the player is actively playing a track."""
|
"""Property which returns whether or not the player is actively playing a track."""
|
||||||
return self._is_connected and self._current is not None
|
return self._is_connected and self.current is not None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_connected(self) -> bool:
|
def is_connected(self) -> bool:
|
||||||
|
|
@ -251,7 +83,7 @@ class Player(VoiceProtocol):
|
||||||
return self._is_connected and self._paused
|
return self._is_connected and self._paused
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def current(self) -> Optional[Track]:
|
def current(self) -> Track:
|
||||||
"""Property which returns the currently playing track"""
|
"""Property which returns the currently playing track"""
|
||||||
return self._current
|
return self._current
|
||||||
|
|
||||||
|
|
@ -271,495 +103,149 @@ class Player(VoiceProtocol):
|
||||||
return self._volume
|
return self._volume
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def filters(self) -> Filters:
|
def filter(self) -> Filter:
|
||||||
"""Property which returns the helper class for interacting with filters"""
|
"""Property which returns the currently applied filter, if one is applied"""
|
||||||
return self._filters
|
return self._filter
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def bot(self) -> Client:
|
def bot(self) -> Type[Client]:
|
||||||
"""Property which returns the bot associated with this player instance"""
|
"""Property which returns the bot associated with this player instance"""
|
||||||
return self._bot
|
return self._bot
|
||||||
|
|
||||||
@property
|
async def _update_state(self, data: dict):
|
||||||
def is_dead(self) -> bool:
|
state: dict = data.get("state")
|
||||||
"""Returns a bool representing whether the player is dead or not.
|
self._last_update = time.time() * 1000
|
||||||
A player is considered dead if it has been destroyed and removed from stored players.
|
self._is_connected = state.get("connected")
|
||||||
"""
|
self._last_position = state.get("position")
|
||||||
return self.guild.id not in self._node._players
|
|
||||||
|
|
||||||
def _adjust_end_time(self) -> Optional[str]:
|
async def _dispatch_voice_update(self, voice_data: Dict[str, Any]):
|
||||||
if self._node._version >= LavalinkVersion(3, 7, 5):
|
|
||||||
return None
|
|
||||||
|
|
||||||
return "0"
|
|
||||||
|
|
||||||
async def _update_state(self, data: dict) -> None:
|
|
||||||
state: dict = data.get("state", {})
|
|
||||||
self._last_update = int(state.get("time", 0))
|
|
||||||
self._is_connected = bool(state.get("connected"))
|
|
||||||
self._last_position = int(state.get("position", 0))
|
|
||||||
if self._log:
|
|
||||||
self._log.debug(f"Got player update state with data {state}")
|
|
||||||
|
|
||||||
async def _dispatch_voice_update(self, voice_data: Optional[Dict[str, Any]] = None) -> None:
|
|
||||||
if {"sessionId", "event"} != self._voice_state.keys():
|
if {"sessionId", "event"} != self._voice_state.keys():
|
||||||
return
|
return
|
||||||
|
|
||||||
state = voice_data or self._voice_state
|
|
||||||
|
|
||||||
data = {
|
|
||||||
"token": state["event"]["token"],
|
|
||||||
"endpoint": state["event"]["endpoint"],
|
|
||||||
"sessionId": state["sessionId"],
|
|
||||||
}
|
|
||||||
|
|
||||||
await self._node.send(
|
await self._node.send(
|
||||||
method="PATCH",
|
op="voiceUpdate",
|
||||||
path=self._player_endpoint_uri,
|
guildId=str(self.guild.id),
|
||||||
guild_id=self._guild.id,
|
**voice_data
|
||||||
data={"voice": data},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if self._log:
|
async def on_voice_server_update(self, data: dict):
|
||||||
self._log.debug(
|
|
||||||
f"Dispatched voice update to {state['event']['endpoint']} with data {data}",
|
|
||||||
)
|
|
||||||
|
|
||||||
async def on_voice_server_update(self, data: VoiceServerUpdate) -> None:
|
|
||||||
self._voice_state.update({"event": data})
|
self._voice_state.update({"event": data})
|
||||||
await self._dispatch_voice_update(self._voice_state)
|
await self._dispatch_voice_update(self._voice_state)
|
||||||
|
|
||||||
async def on_voice_state_update(self, data: GuildVoiceState) -> None:
|
async def on_voice_state_update(self, data: dict):
|
||||||
self._voice_state.update({"sessionId": data.get("session_id")})
|
self._voice_state.update({"sessionId": data.get("session_id")})
|
||||||
|
if not (channel_id := data.get("channel_id")):
|
||||||
channel_id = data.get("channel_id")
|
self.channel = None
|
||||||
if not channel_id:
|
|
||||||
await self.disconnect()
|
|
||||||
self._voice_state.clear()
|
self._voice_state.clear()
|
||||||
return
|
return
|
||||||
|
|
||||||
channel = self.guild.get_channel(int(channel_id))
|
self.channel = self.guild.get_channel(int(channel_id))
|
||||||
|
|
||||||
if self.channel != channel:
|
|
||||||
self.channel = channel
|
|
||||||
|
|
||||||
if not channel:
|
|
||||||
await self.disconnect()
|
|
||||||
self._voice_state.clear()
|
|
||||||
return
|
|
||||||
|
|
||||||
if not data.get("token"):
|
|
||||||
return
|
|
||||||
|
|
||||||
await self._dispatch_voice_update({**self._voice_state, "event": data})
|
await self._dispatch_voice_update({**self._voice_state, "event": data})
|
||||||
|
|
||||||
async def _dispatch_event(self, data: dict) -> None:
|
async def _dispatch_event(self, data: dict):
|
||||||
event_type: str = data["type"]
|
event_type = data.get("type")
|
||||||
event: PomiceEvent = getattr(events, event_type)(data, self)
|
event = getattr(events, event_type, None)
|
||||||
|
event = event(data)
|
||||||
if isinstance(event, TrackEndEvent) and event.reason not in ("REPLACED", "replaced"):
|
self.bot.dispatch(f"pomice_{event.name}", event)
|
||||||
self._current = None
|
|
||||||
|
|
||||||
event.dispatch(self._bot)
|
|
||||||
|
|
||||||
if isinstance(event, TrackStartEvent):
|
|
||||||
self._ending_track = self._current
|
|
||||||
|
|
||||||
if self._log:
|
|
||||||
self._log.debug(f"Dispatched event {data['type']} to player.")
|
|
||||||
|
|
||||||
async def _refresh_endpoint_uri(self, session_id: Optional[str]) -> None:
|
|
||||||
self._player_endpoint_uri = f"sessions/{session_id}/players"
|
|
||||||
|
|
||||||
async def _swap_node(self, *, new_node: Node) -> None:
|
|
||||||
if self.current:
|
|
||||||
data: dict = {"position": self.position, "encodedTrack": self.current.track_id}
|
|
||||||
|
|
||||||
del self._node._players[self._guild.id]
|
|
||||||
self._node = new_node
|
|
||||||
self._node._players[self._guild.id] = self
|
|
||||||
# reassign uri to update session id
|
|
||||||
await self._refresh_endpoint_uri(new_node._session_id)
|
|
||||||
await self._dispatch_voice_update()
|
|
||||||
await self._node.send(
|
|
||||||
method="PATCH",
|
|
||||||
path=self._player_endpoint_uri,
|
|
||||||
guild_id=self._guild.id,
|
|
||||||
data=data or None,
|
|
||||||
)
|
|
||||||
|
|
||||||
if self._log:
|
|
||||||
self._log.debug(f"Swapped all players to new node {new_node._identifier}.")
|
|
||||||
|
|
||||||
async def get_tracks(
|
async def get_tracks(
|
||||||
self,
|
self,
|
||||||
query: str,
|
query: str,
|
||||||
*,
|
*,
|
||||||
ctx: Optional[commands.Context] = None,
|
ctx: Optional[commands.Context] = None,
|
||||||
search_type: SearchType | None = SearchType.ytsearch,
|
search_type: SearchType = SearchType.ytsearch
|
||||||
filters: Optional[List[Filter]] = None,
|
):
|
||||||
) -> Optional[Union[List[Track], Playlist]]:
|
|
||||||
"""Fetches tracks from the node's REST api to parse into Lavalink.
|
"""Fetches tracks from the node's REST api to parse into Lavalink.
|
||||||
|
|
||||||
If you passed in Spotify API credentials when you created the node,
|
If you passed in Spotify API credentials when you created the node,
|
||||||
you can also pass in a Spotify URL of a playlist, album or track and it will be parsed
|
you can also pass in a Spotify URL of a playlist, album or track and it will be parsed
|
||||||
accordingly.
|
accordingly.
|
||||||
|
|
||||||
You can pass in a discord.py Context object to get a
|
|
||||||
Context object on any track you search.
|
|
||||||
|
|
||||||
You may also pass in a List of filters
|
|
||||||
to be applied to your track once it plays.
|
|
||||||
"""
|
|
||||||
return await self._node.get_tracks(query, ctx=ctx, search_type=search_type, filters=filters)
|
|
||||||
|
|
||||||
async def build_track(self, identifier: str, ctx: Optional[commands.Context] = None) -> Track:
|
|
||||||
"""
|
|
||||||
Builds a track using a valid track identifier
|
|
||||||
|
|
||||||
You can also pass in a discord.py Context object to get a
|
You can also pass in a discord.py Context object to get a
|
||||||
Context object on the track it builds.
|
Context object on any track you search.
|
||||||
"""
|
"""
|
||||||
|
return await self._node.get_tracks(query, ctx=ctx, search_type=search_type)
|
||||||
|
|
||||||
return await self._node.build_track(identifier, ctx=ctx)
|
async def connect(self, *, timeout: float, reconnect: bool):
|
||||||
|
await self.guild.change_voice_state(channel=self.channel)
|
||||||
async def get_recommendations(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
track: Track,
|
|
||||||
ctx: Optional[commands.Context] = None,
|
|
||||||
) -> Optional[Union[List[Track], Playlist]]:
|
|
||||||
"""
|
|
||||||
Gets recommendations from either YouTube or Spotify.
|
|
||||||
You can pass in a discord.py Context object to get a
|
|
||||||
Context object on all tracks that get recommended.
|
|
||||||
"""
|
|
||||||
return await self._node.get_recommendations(track=track, ctx=ctx)
|
|
||||||
|
|
||||||
async def connect(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
timeout: float,
|
|
||||||
reconnect: bool,
|
|
||||||
self_deaf: bool = False,
|
|
||||||
self_mute: bool = False,
|
|
||||||
) -> None:
|
|
||||||
await self.guild.change_voice_state(
|
|
||||||
channel=self.channel,
|
|
||||||
self_deaf=self_deaf,
|
|
||||||
self_mute=self_mute,
|
|
||||||
)
|
|
||||||
self._node._players[self.guild.id] = self
|
self._node._players[self.guild.id] = self
|
||||||
self._is_connected = True
|
self._is_connected = True
|
||||||
|
|
||||||
async def stop(self) -> None:
|
async def stop(self):
|
||||||
"""Stops the currently playing track."""
|
"""Stops a currently playing track."""
|
||||||
self._current = None
|
self._current = None
|
||||||
await self._node.send(
|
await self._node.send(op="stop", guildId=str(self.guild.id))
|
||||||
method="PATCH",
|
|
||||||
path=self._player_endpoint_uri,
|
|
||||||
guild_id=self._guild.id,
|
|
||||||
data={"encodedTrack": None},
|
|
||||||
)
|
|
||||||
|
|
||||||
if self._log:
|
async def disconnect(self, *, force: bool = False):
|
||||||
self._log.debug(f"Player has been stopped.")
|
await self.stop()
|
||||||
|
|
||||||
async def disconnect(self, *, force: bool = False) -> None:
|
|
||||||
"""Disconnects the player from voice."""
|
|
||||||
try:
|
|
||||||
await self.guild.change_voice_state(channel=None)
|
await self.guild.change_voice_state(channel=None)
|
||||||
finally:
|
|
||||||
self.cleanup()
|
self.cleanup()
|
||||||
|
self.channel = None
|
||||||
self._is_connected = False
|
self._is_connected = False
|
||||||
self.channel = None # type: ignore
|
del self._node._players[self.guild.id]
|
||||||
|
|
||||||
async def destroy(self) -> None:
|
async def destroy(self):
|
||||||
"""Disconnects and destroys the player, and runs internal cleanup."""
|
"""Disconnects a player and destroys the player instance."""
|
||||||
try:
|
|
||||||
await self.disconnect()
|
await self.disconnect()
|
||||||
except AttributeError:
|
await self._node.send(op="destroy", guildId=str(self.guild.id))
|
||||||
# 'NoneType' has no attribute '_get_voice_client_key' raised by self.cleanup() ->
|
|
||||||
# assume we're already disconnected and cleaned up
|
|
||||||
assert self.channel is None and not self.is_connected
|
|
||||||
|
|
||||||
self._node._players.pop(self.guild.id)
|
async def play(self, track: Track, *, start_position: int = 0) -> Track:
|
||||||
if self.node.is_connected:
|
|
||||||
await self._node.send(
|
|
||||||
method="DELETE",
|
|
||||||
path=self._player_endpoint_uri,
|
|
||||||
guild_id=self._guild.id,
|
|
||||||
)
|
|
||||||
|
|
||||||
if self._log:
|
|
||||||
self._log.debug("Player has been destroyed.")
|
|
||||||
|
|
||||||
async def play(
|
|
||||||
self,
|
|
||||||
track: Track,
|
|
||||||
*,
|
|
||||||
start: int = 0,
|
|
||||||
end: int = 0,
|
|
||||||
ignore_if_playing: bool = False,
|
|
||||||
) -> Track:
|
|
||||||
"""Plays a track. If a Spotify track is passed in, it will be handled accordingly."""
|
"""Plays a track. If a Spotify track is passed in, it will be handled accordingly."""
|
||||||
|
if track.spotify:
|
||||||
if not track._search_type:
|
search: Track = (await self._node.get_tracks(
|
||||||
track.original = track
|
f"{track._search_type}:{track.author} - {track.title}",
|
||||||
|
ctx=track.ctx
|
||||||
# Make sure we've never searched the track before
|
))[0]
|
||||||
if track._search_type and track.original is None:
|
|
||||||
# First lets try using the tracks ISRC, every track has one (hopefully)
|
|
||||||
try:
|
|
||||||
if not track.isrc:
|
|
||||||
# We have to bare raise here because theres no other way to skip this block feasibly
|
|
||||||
raise
|
|
||||||
search = (
|
|
||||||
await self._node.get_tracks(f"{track._search_type}:{track.isrc}", ctx=track.ctx)
|
|
||||||
)[
|
|
||||||
0
|
|
||||||
] # type: ignore
|
|
||||||
except Exception:
|
|
||||||
# First method didn't work, lets try just searching it up
|
|
||||||
try:
|
|
||||||
search = (
|
|
||||||
await self._node.get_tracks(
|
|
||||||
f"{track._search_type}:{track.title} - {track.author}",
|
|
||||||
ctx=track.ctx,
|
|
||||||
)
|
|
||||||
)[
|
|
||||||
0
|
|
||||||
] # type: ignore
|
|
||||||
except:
|
|
||||||
# The song wasn't able to be found, raise error
|
|
||||||
raise TrackLoadError(
|
|
||||||
"No equivalent track was able to be found.",
|
|
||||||
)
|
|
||||||
data = {
|
|
||||||
"encodedTrack": search.track_id,
|
|
||||||
"position": str(start),
|
|
||||||
"endTime": self._adjust_end_time(),
|
|
||||||
}
|
|
||||||
track.original = search
|
track.original = search
|
||||||
track.track_id = search.track_id
|
|
||||||
# Set track_id for later lavalink searches
|
|
||||||
else:
|
|
||||||
data = {
|
|
||||||
"encodedTrack": track.track_id,
|
|
||||||
"position": str(start),
|
|
||||||
"endTime": self._adjust_end_time(),
|
|
||||||
}
|
|
||||||
|
|
||||||
# Lets set the current track before we play it so any
|
|
||||||
# corresponding events can capture it correctly
|
|
||||||
|
|
||||||
self._current = track
|
|
||||||
|
|
||||||
# Remove preloaded filters if last track had any
|
|
||||||
if self.filters.has_preload:
|
|
||||||
for filter in self.filters.get_preload_filters():
|
|
||||||
await self.remove_filter(filter_tag=filter.tag)
|
|
||||||
|
|
||||||
# Global filters take precedence over track filters
|
|
||||||
# So if no global filters are detected, lets apply any
|
|
||||||
# necessary track filters
|
|
||||||
|
|
||||||
# Check if theres no global filters and if the track has any filters
|
|
||||||
# that need to be applied
|
|
||||||
|
|
||||||
if track.filters and not self.filters.has_global:
|
|
||||||
# Now apply all filters
|
|
||||||
for filter in track.filters:
|
|
||||||
await self.add_filter(_filter=filter)
|
|
||||||
|
|
||||||
# Lavalink v3.7.5 changed the way the end time parameter works
|
|
||||||
# so now the end time cannot be zero.
|
|
||||||
# If it isnt zero, it'll be set to None.
|
|
||||||
# Otherwise, it'll be set here:
|
|
||||||
|
|
||||||
if end > 0:
|
|
||||||
data["endTime"] = str(end)
|
|
||||||
|
|
||||||
await self._node.send(
|
await self._node.send(
|
||||||
method="PATCH",
|
op="play",
|
||||||
path=self._player_endpoint_uri,
|
guildId=str(self.guild.id),
|
||||||
guild_id=self._guild.id,
|
track=search.track_id,
|
||||||
data=data,
|
startTime=start_position,
|
||||||
query=f"noReplace={ignore_if_playing}",
|
endTime=search.length,
|
||||||
|
noReplace=False
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
if self._log:
|
await self._node.send(
|
||||||
self._log.debug(
|
op="play",
|
||||||
f"Playing {track.title} from uri {track.uri} with a length of {track.length}",
|
guildId=str(self.guild.id),
|
||||||
|
track=track.track_id,
|
||||||
|
startTime=start_position,
|
||||||
|
endTime=track.length,
|
||||||
|
noReplace=False
|
||||||
)
|
)
|
||||||
|
self._current = track
|
||||||
return self._current
|
return self._current
|
||||||
|
|
||||||
async def seek(self, position: float) -> float:
|
async def seek(self, position: float) -> float:
|
||||||
"""Seeks to a position in the currently playing track milliseconds"""
|
"""Seeks to a position in the currently playing track milliseconds"""
|
||||||
if not self._current or not self._current.original:
|
|
||||||
return 0.0
|
|
||||||
|
|
||||||
if position < 0 or position > self._current.original.length:
|
if position < 0 or position > self.current.length:
|
||||||
raise TrackInvalidPosition(
|
raise TrackInvalidPosition(
|
||||||
"Seek position must be between 0 and the track length",
|
f"Seek position must be between 0 and the track length"
|
||||||
)
|
)
|
||||||
|
|
||||||
await self._node.send(
|
await self._node.send(op="seek", guildId=str(self.guild.id), position=position)
|
||||||
method="PATCH",
|
return self._position
|
||||||
path=self._player_endpoint_uri,
|
|
||||||
guild_id=self._guild.id,
|
|
||||||
data={"position": position},
|
|
||||||
)
|
|
||||||
|
|
||||||
if self._log:
|
|
||||||
self._log.debug(f"Seeking to {position}.")
|
|
||||||
return self.position
|
|
||||||
|
|
||||||
async def set_pause(self, pause: bool) -> bool:
|
async def set_pause(self, pause: bool) -> bool:
|
||||||
"""Sets the pause state of the currently playing track."""
|
"""Sets the pause state of the currently playing track."""
|
||||||
await self._node.send(
|
await self._node.send(op="pause", guildId=str(self.guild.id), pause=pause)
|
||||||
method="PATCH",
|
|
||||||
path=self._player_endpoint_uri,
|
|
||||||
guild_id=self._guild.id,
|
|
||||||
data={"paused": pause},
|
|
||||||
)
|
|
||||||
self._paused = pause
|
self._paused = pause
|
||||||
|
|
||||||
if self._log:
|
|
||||||
self._log.debug(f"Player has been {'paused' if pause else 'resumed'}.")
|
|
||||||
return self._paused
|
return self._paused
|
||||||
|
|
||||||
async def set_volume(self, volume: int) -> int:
|
async def set_volume(self, volume: int) -> int:
|
||||||
"""Sets the volume of the player as an integer. Lavalink accepts values from 0 to 500."""
|
"""Sets the volume of the player as an integer. Lavalink accepts an amount from 0 to 500."""
|
||||||
await self._node.send(
|
await self._node.send(op="volume", guildId=str(self.guild.id), volume=volume)
|
||||||
method="PATCH",
|
|
||||||
path=self._player_endpoint_uri,
|
|
||||||
guild_id=self._guild.id,
|
|
||||||
data={"volume": volume},
|
|
||||||
)
|
|
||||||
self._volume = volume
|
self._volume = volume
|
||||||
|
|
||||||
if self._log:
|
|
||||||
self._log.debug(f"Player volume has been adjusted to {volume}")
|
|
||||||
return self._volume
|
return self._volume
|
||||||
|
|
||||||
async def move_to(self, channel: VoiceChannel) -> None:
|
async def set_filter(self, filter: Filter) -> Filter:
|
||||||
"""Moves the player to a new voice channel."""
|
"""Sets a filter of the player. Takes a pomice.Filter object.
|
||||||
|
This will only work if you are using the development version of Lavalink.
|
||||||
await self.guild.change_voice_state(channel=channel)
|
|
||||||
|
|
||||||
self.channel = channel
|
|
||||||
|
|
||||||
await self._dispatch_voice_update()
|
|
||||||
|
|
||||||
async def add_filter(self, _filter: Filter, fast_apply: bool = False) -> Filters:
|
|
||||||
"""Adds a filter to the player. Takes a pomice.Filter object.
|
|
||||||
This will only work if you are using a version of Lavalink that supports filters.
|
|
||||||
If you would like for the filter to apply instantly, set the `fast_apply` arg to `True`.
|
|
||||||
|
|
||||||
(You must have a song playing in order for `fast_apply` to work.)
|
|
||||||
"""
|
"""
|
||||||
|
await self._node.send(op="filters", guildId=str(self.guild.id), **filter.payload)
|
||||||
self._filters.add_filter(filter=_filter)
|
|
||||||
payload = self._filters.get_all_payloads()
|
|
||||||
await self._node.send(
|
|
||||||
method="PATCH",
|
|
||||||
path=self._player_endpoint_uri,
|
|
||||||
guild_id=self._guild.id,
|
|
||||||
data={"filters": payload},
|
|
||||||
)
|
|
||||||
|
|
||||||
if self._log:
|
|
||||||
self._log.debug(f"Filter has been applied to player with tag {_filter.tag}")
|
|
||||||
if fast_apply:
|
|
||||||
if self._log:
|
|
||||||
self._log.debug(f"Fast apply passed, now applying filter instantly.")
|
|
||||||
await self.seek(self.position)
|
|
||||||
|
|
||||||
return self._filters
|
|
||||||
|
|
||||||
async def remove_filter(self, filter_tag: str, fast_apply: bool = False) -> Filters:
|
|
||||||
"""Removes a filter from the player. Takes a filter tag.
|
|
||||||
This will only work if you are using a version of Lavalink that supports filters.
|
|
||||||
If you would like for the filter to apply instantly, set the `fast_apply` arg to `True`.
|
|
||||||
|
|
||||||
(You must have a song playing in order for `fast_apply` to work.)
|
|
||||||
"""
|
|
||||||
|
|
||||||
self._filters.remove_filter(filter_tag=filter_tag)
|
|
||||||
payload = self._filters.get_all_payloads()
|
|
||||||
await self._node.send(
|
|
||||||
method="PATCH",
|
|
||||||
path=self._player_endpoint_uri,
|
|
||||||
guild_id=self._guild.id,
|
|
||||||
data={"filters": payload},
|
|
||||||
)
|
|
||||||
if self._log:
|
|
||||||
self._log.debug(f"Filter has been removed from player with tag {filter_tag}")
|
|
||||||
if fast_apply:
|
|
||||||
if self._log:
|
|
||||||
self._log.debug(f"Fast apply passed, now removing filter instantly.")
|
|
||||||
await self.seek(self.position)
|
|
||||||
|
|
||||||
return self._filters
|
|
||||||
|
|
||||||
async def edit_filter(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
filter_tag: str,
|
|
||||||
edited_filter: Filter,
|
|
||||||
fast_apply: bool = False,
|
|
||||||
) -> Filters:
|
|
||||||
"""Edits a filter from the player using its filter tag and a new filter of the same type.
|
|
||||||
The filter to be replaced must have the same tag as the one you are replacing it with.
|
|
||||||
This will only work if you are using a version of Lavalink that supports filters.
|
|
||||||
|
|
||||||
If you would like for the filter to apply instantly, set the `fast_apply` arg to `True`.
|
|
||||||
|
|
||||||
(You must have a song playing in order for `fast_apply` to work.)
|
|
||||||
"""
|
|
||||||
|
|
||||||
self._filters.edit_filter(filter_tag=filter_tag, to_apply=edited_filter)
|
|
||||||
payload = self._filters.get_all_payloads()
|
|
||||||
await self._node.send(
|
|
||||||
method="PATCH",
|
|
||||||
path=self._player_endpoint_uri,
|
|
||||||
guild_id=self._guild.id,
|
|
||||||
data={"filters": payload},
|
|
||||||
)
|
|
||||||
if self._log:
|
|
||||||
self._log.debug(f"Filter with tag {filter_tag} has been edited to {edited_filter!r}")
|
|
||||||
if fast_apply:
|
|
||||||
if self._log:
|
|
||||||
self._log.debug(f"Fast apply passed, now editing filter instantly.")
|
|
||||||
await self.seek(self.position)
|
|
||||||
|
|
||||||
return self._filters
|
|
||||||
|
|
||||||
async def reset_filters(self, *, fast_apply: bool = False) -> None:
|
|
||||||
"""Resets all currently applied filters to their default parameters.
|
|
||||||
You must have filters applied in order for this to work.
|
|
||||||
If you would like the filters to be removed instantly, set the `fast_apply` arg to `True`.
|
|
||||||
|
|
||||||
(You must have a song playing in order for `fast_apply` to work.)
|
|
||||||
"""
|
|
||||||
|
|
||||||
if not self._filters:
|
|
||||||
raise FilterInvalidArgument(
|
|
||||||
"You must have filters applied first in order to use this method.",
|
|
||||||
)
|
|
||||||
self._filters.reset_filters()
|
|
||||||
await self._node.send(
|
|
||||||
method="PATCH",
|
|
||||||
path=self._player_endpoint_uri,
|
|
||||||
guild_id=self._guild.id,
|
|
||||||
data={"filters": {}},
|
|
||||||
)
|
|
||||||
if self._log:
|
|
||||||
self._log.debug(f"All filters have been removed from player.")
|
|
||||||
|
|
||||||
if fast_apply:
|
|
||||||
if self._log:
|
|
||||||
self._log.debug(f"Fast apply passed, now removing all filters instantly.")
|
|
||||||
await self.seek(self.position)
|
await self.seek(self.position)
|
||||||
|
self._filter = filter
|
||||||
|
return filter
|
||||||
|
|
|
||||||
1159
pomice/pool.py
1159
pomice/pool.py
File diff suppressed because it is too large
Load Diff
374
pomice/queue.py
374
pomice/queue.py
|
|
@ -1,374 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import random
|
|
||||||
from copy import copy
|
|
||||||
from typing import Iterable
|
|
||||||
from typing import Iterator
|
|
||||||
from typing import List
|
|
||||||
from typing import Optional
|
|
||||||
from typing import Union
|
|
||||||
|
|
||||||
from .enums import LoopMode
|
|
||||||
from .exceptions import QueueEmpty
|
|
||||||
from .exceptions import QueueException
|
|
||||||
from .exceptions import QueueFull
|
|
||||||
from .objects import Track
|
|
||||||
|
|
||||||
__all__ = ("Queue",)
|
|
||||||
|
|
||||||
|
|
||||||
class Queue(Iterable[Track]):
|
|
||||||
"""Queue for Pomice. This queue takes pomice.Track as an input and includes looping and shuffling."""
|
|
||||||
|
|
||||||
__slots__ = (
|
|
||||||
"max_size",
|
|
||||||
"_queue",
|
|
||||||
"_overflow",
|
|
||||||
"_loop_mode",
|
|
||||||
"_current_item",
|
|
||||||
)
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
max_size: Optional[int] = None,
|
|
||||||
*,
|
|
||||||
overflow: bool = True,
|
|
||||||
):
|
|
||||||
self.max_size: Optional[int] = max_size
|
|
||||||
self._current_item: Track
|
|
||||||
self._queue: List[Track] = []
|
|
||||||
self._overflow: bool = overflow
|
|
||||||
self._loop_mode: Optional[LoopMode] = None
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
"""String showing all Track objects appearing as a list."""
|
|
||||||
return str(list(f"'{t}'" for t in self))
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
"""Official representation with max_size and member count."""
|
|
||||||
return f"<{self.__class__.__name__} max_size={self.max_size} members={self.count}>"
|
|
||||||
|
|
||||||
def __bool__(self) -> bool:
|
|
||||||
"""Treats the queue as a bool, with it evaluating True when it contains members."""
|
|
||||||
return bool(self.count)
|
|
||||||
|
|
||||||
def __call__(self, item: Track) -> None:
|
|
||||||
"""Allows the queue instance to be called directly in order to add a member."""
|
|
||||||
self.put(item)
|
|
||||||
|
|
||||||
def __len__(self) -> int:
|
|
||||||
"""Return the number of members in the queue."""
|
|
||||||
return self.count
|
|
||||||
|
|
||||||
def __getitem__(self, index: int) -> Track:
|
|
||||||
"""Returns a member at the given position.
|
|
||||||
Does not remove item from queue.
|
|
||||||
"""
|
|
||||||
if not isinstance(index, int):
|
|
||||||
raise ValueError("'int' type required.'")
|
|
||||||
|
|
||||||
return self._queue[index]
|
|
||||||
|
|
||||||
def __setitem__(self, index: int, item: Track) -> None:
|
|
||||||
"""Inserts an item at given position."""
|
|
||||||
if not isinstance(index, int):
|
|
||||||
raise ValueError("'int' type required.'")
|
|
||||||
|
|
||||||
self.put_at_index(index, item)
|
|
||||||
|
|
||||||
def __delitem__(self, index: int) -> None:
|
|
||||||
"""Delete item at given position."""
|
|
||||||
self._queue.__delitem__(index)
|
|
||||||
|
|
||||||
def __iter__(self) -> Iterator[Track]:
|
|
||||||
"""Iterate over members in the queue.
|
|
||||||
Does not remove items when iterating.
|
|
||||||
"""
|
|
||||||
return self._queue.__iter__()
|
|
||||||
|
|
||||||
def __reversed__(self) -> Iterator[Track]:
|
|
||||||
"""Iterate over members in reverse order."""
|
|
||||||
return self._queue.__reversed__()
|
|
||||||
|
|
||||||
def __contains__(self, item: Track) -> bool:
|
|
||||||
"""Check if an item is a member of the queue."""
|
|
||||||
return item in self._queue
|
|
||||||
|
|
||||||
def __add__(self, other: Iterable[Track]) -> Queue:
|
|
||||||
"""Return a new queue containing all members.
|
|
||||||
The new queue will have the same max_size as the original.
|
|
||||||
"""
|
|
||||||
if not isinstance(other, Iterable):
|
|
||||||
raise TypeError(
|
|
||||||
f"Adding with the '{type(other)}' type is not supported.",
|
|
||||||
)
|
|
||||||
|
|
||||||
new_queue = self.copy()
|
|
||||||
new_queue.extend(other)
|
|
||||||
return new_queue
|
|
||||||
|
|
||||||
def __iadd__(self, other: Union[Iterable[Track], Track]) -> Queue:
|
|
||||||
"""Add items to queue."""
|
|
||||||
if isinstance(other, Track):
|
|
||||||
self.put(other)
|
|
||||||
return self
|
|
||||||
|
|
||||||
if isinstance(other, Iterable):
|
|
||||||
self.extend(other)
|
|
||||||
return self
|
|
||||||
|
|
||||||
raise TypeError(
|
|
||||||
f"Adding '{type(other)}' type to the queue is not supported.",
|
|
||||||
)
|
|
||||||
|
|
||||||
def _get(self) -> Track:
|
|
||||||
return self._queue.pop(0)
|
|
||||||
|
|
||||||
def _drop(self) -> Track:
|
|
||||||
return self._queue.pop()
|
|
||||||
|
|
||||||
def _index(self, item: Track) -> int:
|
|
||||||
return self._queue.index(item)
|
|
||||||
|
|
||||||
def _put(self, item: Track) -> None:
|
|
||||||
self._queue.append(item)
|
|
||||||
|
|
||||||
def _insert(self, index: int, item: Track) -> None:
|
|
||||||
self._queue.insert(index, item)
|
|
||||||
|
|
||||||
def _remove(self, item: Track) -> None:
|
|
||||||
self._queue.remove(item)
|
|
||||||
|
|
||||||
def _get_random_float(self) -> float:
|
|
||||||
return random.random()
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _check_track(item: Track) -> Track:
|
|
||||||
if not isinstance(item, Track):
|
|
||||||
raise TypeError("Only pomice.Track objects are supported.")
|
|
||||||
|
|
||||||
return item
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _check_track_container(cls, iterable: Iterable) -> List[Track]:
|
|
||||||
iterable = list(iterable)
|
|
||||||
for item in iterable:
|
|
||||||
cls._check_track(item)
|
|
||||||
|
|
||||||
return iterable
|
|
||||||
|
|
||||||
@property
|
|
||||||
def count(self) -> int:
|
|
||||||
"""Returns queue member count."""
|
|
||||||
return len(self._queue)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_empty(self) -> bool:
|
|
||||||
"""Returns True if queue has no members."""
|
|
||||||
return not bool(self.count)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_full(self) -> bool:
|
|
||||||
"""Returns True if queue item count has reached max_size."""
|
|
||||||
return False if self.max_size is None else self.count >= self.max_size
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_looping(self) -> bool:
|
|
||||||
"""Returns True if the queue is looping either a track or the queue"""
|
|
||||||
return bool(self._loop_mode)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def loop_mode(self) -> Optional[LoopMode]:
|
|
||||||
"""Returns the LoopMode enum set in the queue object"""
|
|
||||||
return self._loop_mode
|
|
||||||
|
|
||||||
@property
|
|
||||||
def size(self) -> int:
|
|
||||||
"""Returns the amount of items in the queue"""
|
|
||||||
return len(self._queue)
|
|
||||||
|
|
||||||
def get_queue(self) -> List:
|
|
||||||
"""Returns the queue as a List"""
|
|
||||||
return self._queue
|
|
||||||
|
|
||||||
def get(self) -> Track:
|
|
||||||
"""Return next immediately available item in queue if any.
|
|
||||||
Raises QueueEmpty if no items in queue.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if self._loop_mode == LoopMode.TRACK:
|
|
||||||
return self._current_item
|
|
||||||
|
|
||||||
if self.is_empty:
|
|
||||||
raise QueueEmpty("No items in the queue.")
|
|
||||||
|
|
||||||
if self._loop_mode == LoopMode.QUEUE:
|
|
||||||
# set current item to first track in queue if not set already
|
|
||||||
# otherwise exception will be raised
|
|
||||||
if not self._current_item or self._current_item not in self._queue:
|
|
||||||
if self._queue:
|
|
||||||
item = self._queue[0]
|
|
||||||
else:
|
|
||||||
raise QueueEmpty("No items in the queue.")
|
|
||||||
|
|
||||||
# set current item to first track in queue if not set already
|
|
||||||
if not self._current_item:
|
|
||||||
self._current_item = self._queue[0]
|
|
||||||
item = self._current_item
|
|
||||||
|
|
||||||
# we reached the end of the queue, go back to first track
|
|
||||||
if self._index(self._current_item) == len(self._queue) - 1:
|
|
||||||
item = self._queue[0]
|
|
||||||
|
|
||||||
# we are in the middle of the queue, go the next item
|
|
||||||
else:
|
|
||||||
index = self._index(self._current_item) + 1
|
|
||||||
item = self._queue[index]
|
|
||||||
else:
|
|
||||||
item = self._get()
|
|
||||||
|
|
||||||
self._current_item = item
|
|
||||||
return item
|
|
||||||
|
|
||||||
def pop(self) -> Track:
|
|
||||||
"""Return item from the right end side of the queue.
|
|
||||||
Raises QueueEmpty if no items in queue.
|
|
||||||
"""
|
|
||||||
if self.is_empty:
|
|
||||||
raise QueueEmpty("No items in the queue.")
|
|
||||||
|
|
||||||
return self._queue.pop()
|
|
||||||
|
|
||||||
def remove(self, item: Track) -> None:
|
|
||||||
"""
|
|
||||||
Removes a item within the queue.
|
|
||||||
Raises ValueError if item is not in queue.
|
|
||||||
"""
|
|
||||||
return self._remove(self._check_track(item))
|
|
||||||
|
|
||||||
def find_position(self, item: Track) -> int:
|
|
||||||
"""Find the position a given item within the queue.
|
|
||||||
Raises ValueError if item is not in queue.
|
|
||||||
"""
|
|
||||||
return self._index(self._check_track(item))
|
|
||||||
|
|
||||||
def put(self, item: Track) -> None:
|
|
||||||
"""Put the given item into the back of the queue."""
|
|
||||||
if self.is_full:
|
|
||||||
if not self._overflow:
|
|
||||||
raise QueueFull(
|
|
||||||
f"Queue max_size of {self.max_size} has been reached.",
|
|
||||||
)
|
|
||||||
|
|
||||||
self._drop()
|
|
||||||
|
|
||||||
return self._put(self._check_track(item))
|
|
||||||
|
|
||||||
def put_at_index(self, index: int, item: Track) -> None:
|
|
||||||
"""Put the given item into the queue at the specified index."""
|
|
||||||
if self.is_full:
|
|
||||||
if not self._overflow:
|
|
||||||
raise QueueFull(
|
|
||||||
f"Queue max_size of {self.max_size} has been reached.",
|
|
||||||
)
|
|
||||||
|
|
||||||
self._drop()
|
|
||||||
|
|
||||||
return self._insert(index, self._check_track(item))
|
|
||||||
|
|
||||||
def put_at_front(self, item: Track) -> None:
|
|
||||||
"""Put the given item into the front of the queue."""
|
|
||||||
return self.put_at_index(0, item)
|
|
||||||
|
|
||||||
def extend(self, iterable: Iterable[Track], *, atomic: bool = True) -> None:
|
|
||||||
"""
|
|
||||||
Add the members of the given iterable to the end of the queue.
|
|
||||||
If atomic is set to True, no tracks will be added upon any exceptions.
|
|
||||||
If atomic is set to False, as many tracks will be added as possible.
|
|
||||||
When overflow is enabled for the queue, `atomic=True` won't prevent dropped items.
|
|
||||||
"""
|
|
||||||
if atomic:
|
|
||||||
iterable = self._check_track_container(iterable)
|
|
||||||
|
|
||||||
if not self._overflow and self.max_size is not None:
|
|
||||||
new_len = len(iterable)
|
|
||||||
|
|
||||||
if (new_len + self.count) > self.max_size:
|
|
||||||
raise QueueFull(
|
|
||||||
f"Queue has {self.count}/{self.max_size} items, "
|
|
||||||
f"cannot add {new_len} more.",
|
|
||||||
)
|
|
||||||
|
|
||||||
for item in iterable:
|
|
||||||
self.put(item)
|
|
||||||
|
|
||||||
def copy(self) -> Queue:
|
|
||||||
"""Create a copy of the current queue including it's members."""
|
|
||||||
new_queue = self.__class__(max_size=self.max_size)
|
|
||||||
new_queue._queue = copy(self._queue)
|
|
||||||
|
|
||||||
return new_queue
|
|
||||||
|
|
||||||
def clear(self) -> None:
|
|
||||||
"""Remove all items from the queue."""
|
|
||||||
self._queue.clear()
|
|
||||||
|
|
||||||
def set_loop_mode(self, mode: LoopMode) -> None:
|
|
||||||
"""
|
|
||||||
Sets the loop mode of the queue.
|
|
||||||
Takes the LoopMode enum as an argument.
|
|
||||||
"""
|
|
||||||
self._loop_mode = mode
|
|
||||||
if self._loop_mode == LoopMode.QUEUE:
|
|
||||||
try:
|
|
||||||
index = self._index(self._current_item)
|
|
||||||
except ValueError:
|
|
||||||
index = 0
|
|
||||||
if self._current_item not in self._queue:
|
|
||||||
self._queue.insert(index, self._current_item)
|
|
||||||
self._current_item = self._queue[index]
|
|
||||||
|
|
||||||
def disable_loop(self) -> None:
|
|
||||||
"""
|
|
||||||
Disables loop mode if set.
|
|
||||||
Raises QueueException if loop mode is already None.
|
|
||||||
"""
|
|
||||||
if not self._loop_mode:
|
|
||||||
raise QueueException("Queue loop is already disabled.")
|
|
||||||
|
|
||||||
if self._loop_mode == LoopMode.QUEUE:
|
|
||||||
index = self.find_position(self._current_item) + 1
|
|
||||||
self._queue = self._queue[index:]
|
|
||||||
|
|
||||||
self._loop_mode = None
|
|
||||||
|
|
||||||
def shuffle(self) -> None:
|
|
||||||
"""Shuffles the queue."""
|
|
||||||
return random.shuffle(self._queue)
|
|
||||||
|
|
||||||
def clear_track_filters(self) -> None:
|
|
||||||
"""Clears all filters applied to tracks"""
|
|
||||||
for track in self._queue:
|
|
||||||
track.filters = None
|
|
||||||
|
|
||||||
def jump(self, item: Track) -> None:
|
|
||||||
"""
|
|
||||||
Jumps to the item specified in the queue.
|
|
||||||
|
|
||||||
If the queue is not looping, the queue will be mutated.
|
|
||||||
Otherwise, the current item will be adjusted to the item
|
|
||||||
before the specified track.
|
|
||||||
|
|
||||||
The queue is adjusted so that the next item that is retrieved
|
|
||||||
is the track that is specified, effectively 'jumping' the queue.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if self._loop_mode == LoopMode.TRACK:
|
|
||||||
raise QueueException("Jumping the queue whilst looping a track is not allowed.")
|
|
||||||
|
|
||||||
index = self.find_position(item)
|
|
||||||
if self._loop_mode == LoopMode.QUEUE:
|
|
||||||
self._current_item = self._queue[index - 1]
|
|
||||||
else:
|
|
||||||
new_queue = self._queue[index : self.size]
|
|
||||||
self._queue = new_queue
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from .pool import Node
|
|
||||||
|
|
||||||
from .utils import RouteStats
|
|
||||||
|
|
||||||
__all__ = ("RoutePlanner",)
|
|
||||||
|
|
||||||
|
|
||||||
class RoutePlanner:
|
|
||||||
"""
|
|
||||||
The base route planner class for Pomice.
|
|
||||||
Handles all requests made to the route planner API for Lavalink.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, node: Node) -> None:
|
|
||||||
self.node: Node = node
|
|
||||||
|
|
||||||
async def get_status(self) -> RouteStats:
|
|
||||||
"""Gets the status of the route planner API."""
|
|
||||||
data: dict = await self.node.send(method="GET", path="routeplanner/status")
|
|
||||||
return RouteStats(data)
|
|
||||||
|
|
||||||
async def free_address(self, ip: str) -> None:
|
|
||||||
"""Frees an address using the route planner API"""
|
|
||||||
await self.node.send(method="POST", path="routeplanner/free/address", data={"address": ip})
|
|
||||||
|
|
||||||
async def free_all_addresses(self) -> None:
|
|
||||||
"""Frees all available addresses using the route planner api"""
|
|
||||||
await self.node.send(method="POST", path="routeplanner/free/address/all")
|
|
||||||
|
|
@ -1,4 +1,28 @@
|
||||||
"""Spotify module for Pomice, made possible by cloudwithax 2023"""
|
__version__ = "0.10.2"
|
||||||
from .client import Client
|
__title__ = "spotify"
|
||||||
from .exceptions import *
|
__author__ = "mental"
|
||||||
from .objects import *
|
__license__ = "MIT"
|
||||||
|
|
||||||
|
from typing import Dict, Type
|
||||||
|
|
||||||
|
from .oauth import *
|
||||||
|
from .utils import clean as _clean_namespace
|
||||||
|
from .errors import *
|
||||||
|
from .models import *
|
||||||
|
from .client import *
|
||||||
|
from .models import SpotifyBase
|
||||||
|
from .http import HTTPClient, HTTPUserClient
|
||||||
|
|
||||||
|
__all__ = tuple(name for name in locals() if name[0] != "_")
|
||||||
|
|
||||||
|
_locals = locals() # pylint: disable=invalid-name
|
||||||
|
|
||||||
|
_types: Dict[str, Type[Union[SpotifyBase, HTTPClient]]]
|
||||||
|
with _clean_namespace(locals(), "_locals", "_clean_namespace"):
|
||||||
|
_types = dict( # pylint: disable=invalid-name
|
||||||
|
(name, _locals[name])
|
||||||
|
for name, obj in _locals.items()
|
||||||
|
if isinstance(obj, type) and issubclass(obj, SpotifyBase)
|
||||||
|
)
|
||||||
|
_types["HTTPClient"] = HTTPClient
|
||||||
|
_types["HTTPUserClient"] = HTTPUserClient
|
||||||
|
|
|
||||||
|
|
@ -1,360 +1,348 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
from typing import Optional, List, Iterable, NamedTuple, Type, Union, Dict
|
||||||
import re
|
|
||||||
import time
|
|
||||||
from base64 import b64encode
|
|
||||||
from typing import AsyncGenerator
|
|
||||||
from typing import Dict
|
|
||||||
from typing import List
|
|
||||||
from typing import Optional
|
|
||||||
from typing import Union
|
|
||||||
from urllib.parse import quote
|
|
||||||
|
|
||||||
import aiohttp
|
from .http import HTTPClient
|
||||||
import orjson as json
|
from .utils import to_id
|
||||||
|
from . import OAuth2, Artist, Album, Track, User, Playlist
|
||||||
|
|
||||||
from .exceptions import InvalidSpotifyURL
|
__all__ = ("Client", "SearchResults")
|
||||||
from .exceptions import SpotifyRequestException
|
|
||||||
from .objects import *
|
|
||||||
|
|
||||||
__all__ = ("Client",)
|
_TYPES = {"artist": Artist, "album": Album, "playlist": Playlist, "track": Track}
|
||||||
|
|
||||||
|
_SEARCH_TYPES = {"track", "playlist", "artist", "album"}
|
||||||
GRANT_URL = "https://accounts.spotify.com/api/token"
|
_SEARCH_TYPE_ERR = (
|
||||||
REQUEST_URL = "https://api.spotify.com/v1/{type}s/{id}"
|
'Bad queary type! got "%s" expected any of: track, playlist, artist, album'
|
||||||
# Keep this in sync with URLRegex.SPOTIFY_URL (enums.py). Accept intl locale segment,
|
|
||||||
# optional trailing slash, and query parameters.
|
|
||||||
SPOTIFY_URL_REGEX = re.compile(
|
|
||||||
r"https?://open\.spotify\.com/(?:intl-[a-zA-Z-]+/)?(?P<type>album|playlist|track|artist)/(?P<id>[a-zA-Z0-9]+)(?:/)?(?:\?.*)?$",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class Client:
|
class SearchResults(NamedTuple):
|
||||||
"""The base client for the Spotify module of Pomice.
|
"""A namedtuple of search results.
|
||||||
This class will do all the heavy lifting of getting all the metadata
|
|
||||||
for any Spotify URL you throw at it.
|
Attributes
|
||||||
|
----------
|
||||||
|
artists : List[:class:`Artist`]
|
||||||
|
The artists of the search.
|
||||||
|
playlists : List[:class:`Playlist`]
|
||||||
|
The playlists of the search.
|
||||||
|
albums : List[:class:`Album`]
|
||||||
|
The albums of the search.
|
||||||
|
tracks : List[:class:`Track`]
|
||||||
|
The tracks of the search.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
artists: Optional[List[Artist]] = None
|
||||||
|
playlists: Optional[List[Playlist]] = None
|
||||||
|
albums: Optional[List[Album]] = None
|
||||||
|
tracks: Optional[List[Track]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class Client:
|
||||||
|
"""Represents a Client app on Spotify.
|
||||||
|
|
||||||
|
This class is used to interact with the Spotify API.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
client_id : :class:`str`
|
||||||
|
The client id provided by spotify for the app.
|
||||||
|
client_secret : :class:`str`
|
||||||
|
The client secret for the app.
|
||||||
|
loop : Optional[:class:`asyncio.AbstractEventLoop`]
|
||||||
|
The event loop the client should run on, if no loop is specified `asyncio.get_event_loop()` is called and used instead.
|
||||||
|
|
||||||
|
Attributes
|
||||||
|
----------
|
||||||
|
client_id : :class:`str`
|
||||||
|
The applications client_id, also aliased as `id`
|
||||||
|
http : :class:`HTTPClient`
|
||||||
|
The HTTPClient that is being used.
|
||||||
|
loop : Optional[:class:`asyncio.AbstractEventLoop`]
|
||||||
|
The event loop the client is running on.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_default_http_client: Type[HTTPClient] = HTTPClient
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
client_id: str,
|
client_id: str,
|
||||||
client_secret: str,
|
client_secret: str,
|
||||||
*,
|
*,
|
||||||
playlist_concurrency: int = 10,
|
loop: Optional[asyncio.AbstractEventLoop] = None,
|
||||||
playlist_page_limit: Optional[int] = None,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
self._client_id = client_id
|
if not isinstance(client_id, str):
|
||||||
self._client_secret = client_secret
|
raise TypeError("client_id must be a string.")
|
||||||
|
|
||||||
# HTTP session will be injected by Node
|
if not isinstance(client_secret, str):
|
||||||
self.session: Optional[aiohttp.ClientSession] = None
|
raise TypeError("client_secret must be a string.")
|
||||||
|
|
||||||
self._bearer_token: Optional[str] = None
|
if loop is not None and not isinstance(loop, asyncio.AbstractEventLoop):
|
||||||
self._expiry: float = 0.0
|
raise TypeError(
|
||||||
self._auth_token = b64encode(f"{self._client_id}:{self._client_secret}".encode())
|
"loop argument must be None or an instance of asyncio.AbstractEventLoop."
|
||||||
self._grant_headers = {"Authorization": f"Basic {self._auth_token.decode()}"}
|
|
||||||
self._bearer_headers: Optional[Dict] = None
|
|
||||||
self._log = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# Performance tuning knobs
|
|
||||||
self._playlist_concurrency = max(1, playlist_concurrency)
|
|
||||||
self._playlist_page_limit = playlist_page_limit
|
|
||||||
|
|
||||||
async def _set_session(self, session: aiohttp.ClientSession) -> None:
|
|
||||||
self.session = session
|
|
||||||
|
|
||||||
async def _fetch_bearer_token(self) -> None:
|
|
||||||
_data = {"grant_type": "client_credentials"}
|
|
||||||
|
|
||||||
if not self.session:
|
|
||||||
raise SpotifyRequestException("HTTP session not initialized for Spotify client.")
|
|
||||||
resp = await self.session.post(GRANT_URL, data=_data, headers=self._grant_headers)
|
|
||||||
|
|
||||||
if resp.status != 200:
|
|
||||||
raise SpotifyRequestException(
|
|
||||||
f"Error fetching bearer token: {resp.status} {resp.reason}",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
data: dict = await resp.json(loads=json.loads)
|
self.loop = loop = loop or asyncio.get_event_loop()
|
||||||
if self._log:
|
self.http = self._default_http_client(client_id, client_secret, loop=loop)
|
||||||
self._log.debug(f"Fetched Spotify bearer token successfully")
|
|
||||||
|
|
||||||
self._bearer_token = data["access_token"]
|
def __repr__(self):
|
||||||
self._expiry = time.time() + (int(data["expires_in"]) - 10)
|
return f"<spotify.Client: {self.http.client_id!r}>"
|
||||||
self._bearer_headers = {
|
|
||||||
"Authorization": f"Bearer {self._bearer_token}",
|
|
||||||
}
|
|
||||||
|
|
||||||
async def search(self, *, query: str) -> Union[Track, Album, Artist, Playlist]:
|
async def __aenter__(self) -> "Client":
|
||||||
if not self._bearer_token or time.time() >= self._expiry:
|
return self
|
||||||
await self._fetch_bearer_token()
|
|
||||||
|
|
||||||
result = SPOTIFY_URL_REGEX.match(query)
|
async def __aexit__(self, exc_type, exc_value, traceback) -> None:
|
||||||
if not result:
|
await self.close()
|
||||||
raise InvalidSpotifyURL("The Spotify link provided is not valid.")
|
|
||||||
|
|
||||||
spotify_type = result.group("type")
|
# Properties
|
||||||
spotify_id = result.group("id")
|
|
||||||
|
|
||||||
request_url = REQUEST_URL.format(type=spotify_type, id=spotify_id)
|
@property
|
||||||
|
def client_id(self) -> str:
|
||||||
|
""":class:`str` - The Spotify client ID."""
|
||||||
|
return self.http.client_id
|
||||||
|
|
||||||
if not self.session:
|
@property
|
||||||
raise SpotifyRequestException("HTTP session not initialized for Spotify client.")
|
def id(self): # pylint: disable=invalid-name
|
||||||
resp = await self.session.get(request_url, headers=self._bearer_headers)
|
""":class:`str` - The Spotify client ID."""
|
||||||
if resp.status != 200:
|
return self.http.client_id
|
||||||
raise SpotifyRequestException(
|
|
||||||
f"Error while fetching results: {resp.status} {resp.reason}",
|
|
||||||
)
|
|
||||||
|
|
||||||
data: dict = await resp.json(loads=json.loads)
|
# Public api
|
||||||
if self._log:
|
|
||||||
self._log.debug(
|
|
||||||
f"Made request to Spotify API with status {resp.status} and response {data}",
|
|
||||||
)
|
|
||||||
|
|
||||||
if spotify_type == "track":
|
def oauth2_url(
|
||||||
return Track(data)
|
|
||||||
elif spotify_type == "album":
|
|
||||||
return Album(data)
|
|
||||||
elif spotify_type == "artist":
|
|
||||||
if not self.session:
|
|
||||||
raise SpotifyRequestException("HTTP session not initialized for Spotify client.")
|
|
||||||
resp = await self.session.get(
|
|
||||||
f"{request_url}/top-tracks?market=US",
|
|
||||||
headers=self._bearer_headers,
|
|
||||||
)
|
|
||||||
if resp.status != 200:
|
|
||||||
raise SpotifyRequestException(
|
|
||||||
f"Error while fetching results: {resp.status} {resp.reason}",
|
|
||||||
)
|
|
||||||
|
|
||||||
track_data: dict = await resp.json(loads=json.loads)
|
|
||||||
tracks = track_data["tracks"]
|
|
||||||
return Artist(data, tracks)
|
|
||||||
else:
|
|
||||||
# For playlists we optionally use a reduced fields payload to shrink response sizes.
|
|
||||||
# NB: We cannot apply fields filter to initial request because original metadata is needed.
|
|
||||||
tracks = [
|
|
||||||
Track(track["track"])
|
|
||||||
for track in data["tracks"]["items"]
|
|
||||||
if track["track"] is not None
|
|
||||||
]
|
|
||||||
|
|
||||||
if not tracks:
|
|
||||||
raise SpotifyRequestException(
|
|
||||||
"This playlist is empty and therefore cannot be queued.",
|
|
||||||
)
|
|
||||||
|
|
||||||
total_tracks = data["tracks"]["total"]
|
|
||||||
limit = data["tracks"]["limit"]
|
|
||||||
|
|
||||||
# Short‑circuit small playlists (single page)
|
|
||||||
if total_tracks <= limit:
|
|
||||||
return Playlist(data, tracks)
|
|
||||||
|
|
||||||
# Build remaining page URLs; Spotify supports offset-based pagination.
|
|
||||||
remaining_offsets = range(limit, total_tracks, limit)
|
|
||||||
page_urls: List[str] = []
|
|
||||||
fields_filter = (
|
|
||||||
"items(track(name,duration_ms,id,is_local,external_urls,external_ids,artists(name),album(images)))"
|
|
||||||
",next"
|
|
||||||
)
|
|
||||||
for idx, offset in enumerate(remaining_offsets):
|
|
||||||
if self._playlist_page_limit is not None and idx >= self._playlist_page_limit:
|
|
||||||
break
|
|
||||||
page_urls.append(
|
|
||||||
f"{request_url}/tracks?offset={offset}&limit={limit}&fields={quote(fields_filter)}",
|
|
||||||
)
|
|
||||||
|
|
||||||
if page_urls:
|
|
||||||
semaphore = asyncio.Semaphore(self._playlist_concurrency)
|
|
||||||
|
|
||||||
async def fetch_page(url: str) -> Optional[List[Track]]:
|
|
||||||
async with semaphore:
|
|
||||||
if not self.session:
|
|
||||||
raise SpotifyRequestException(
|
|
||||||
"HTTP session not initialized for Spotify client.",
|
|
||||||
)
|
|
||||||
resp = await self.session.get(url, headers=self._bearer_headers)
|
|
||||||
if resp.status != 200:
|
|
||||||
if self._log:
|
|
||||||
self._log.warning(
|
|
||||||
f"Page fetch failed {resp.status} {resp.reason} for {url}",
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
page_json: dict = await resp.json(loads=json.loads)
|
|
||||||
return [
|
|
||||||
Track(item["track"])
|
|
||||||
for item in page_json.get("items", [])
|
|
||||||
if item.get("track") is not None
|
|
||||||
]
|
|
||||||
|
|
||||||
# Chunk gather in waves to avoid creating thousands of tasks at once
|
|
||||||
aggregated: List[Track] = []
|
|
||||||
wave_size = self._playlist_concurrency * 2
|
|
||||||
for i in range(0, len(page_urls), wave_size):
|
|
||||||
wave = page_urls[i : i + wave_size]
|
|
||||||
results = await asyncio.gather(
|
|
||||||
*[fetch_page(url) for url in wave],
|
|
||||||
return_exceptions=False,
|
|
||||||
)
|
|
||||||
for result in results:
|
|
||||||
if result:
|
|
||||||
aggregated.extend(result)
|
|
||||||
|
|
||||||
tracks.extend(aggregated)
|
|
||||||
|
|
||||||
return Playlist(data, tracks)
|
|
||||||
|
|
||||||
async def iter_playlist_tracks(
|
|
||||||
self,
|
self,
|
||||||
*,
|
redirect_uri: str,
|
||||||
query: str,
|
scopes: Optional[Union[Iterable[str], Dict[str, bool]]] = None,
|
||||||
batch_size: int = 100,
|
state: Optional[str] = None,
|
||||||
) -> AsyncGenerator[List[Track], None]:
|
) -> str:
|
||||||
"""Stream playlist tracks in batches without waiting for full materialization.
|
"""Generate an oauth2 url for user authentication.
|
||||||
|
|
||||||
|
This is an alias to :meth:`OAuth2.url_only` but the
|
||||||
|
difference is that the client id is autmatically
|
||||||
|
passed in to the constructor.
|
||||||
|
|
||||||
Parameters
|
Parameters
|
||||||
----------
|
----------
|
||||||
query: str
|
redirect_uri : :class:`str`
|
||||||
Spotify playlist URL.
|
Where spotify should redirect the user to after authentication.
|
||||||
batch_size: int
|
scopes : Optional[Iterable[:class:`str`], Dict[:class:`str`, :class:`bool`]]
|
||||||
Number of tracks yielded per batch (logical grouping after fetch). Does not alter API page size.
|
The scopes to be requested.
|
||||||
|
state : Optional[:class:`str`]
|
||||||
|
Using a state value can increase your assurance that an incoming connection is the result of an
|
||||||
|
authentication request.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
url : :class:`str`
|
||||||
|
The OAuth2 url.
|
||||||
"""
|
"""
|
||||||
if not self._bearer_token or time.time() >= self._expiry:
|
return OAuth2.url_only(
|
||||||
await self._fetch_bearer_token()
|
client_id=self.http.client_id,
|
||||||
|
redirect_uri=redirect_uri,
|
||||||
match = SPOTIFY_URL_REGEX.match(query)
|
scopes=scopes,
|
||||||
if not match or match.group("type") != "playlist":
|
state=state,
|
||||||
raise InvalidSpotifyURL("Provided query is not a valid Spotify playlist URL.")
|
|
||||||
|
|
||||||
playlist_id = match.group("id")
|
|
||||||
request_url = REQUEST_URL.format(type="playlist", id=playlist_id)
|
|
||||||
if not self.session:
|
|
||||||
raise SpotifyRequestException("HTTP session not initialized for Spotify client.")
|
|
||||||
resp = await self.session.get(request_url, headers=self._bearer_headers)
|
|
||||||
if resp.status != 200:
|
|
||||||
raise SpotifyRequestException(
|
|
||||||
f"Error while fetching results: {resp.status} {resp.reason}",
|
|
||||||
)
|
|
||||||
data: dict = await resp.json(loads=json.loads)
|
|
||||||
|
|
||||||
# Yield first page immediately
|
|
||||||
first_page_tracks = [
|
|
||||||
Track(item["track"])
|
|
||||||
for item in data["tracks"]["items"]
|
|
||||||
if item.get("track") is not None
|
|
||||||
]
|
|
||||||
# Batch yield
|
|
||||||
for i in range(0, len(first_page_tracks), batch_size):
|
|
||||||
yield first_page_tracks[i : i + batch_size]
|
|
||||||
|
|
||||||
total = data["tracks"]["total"]
|
|
||||||
limit = data["tracks"]["limit"]
|
|
||||||
remaining_offsets = range(limit, total, limit)
|
|
||||||
fields_filter = (
|
|
||||||
"items(track(name,duration_ms,id,is_local,external_urls,external_ids,artists(name),album(images)))"
|
|
||||||
",next"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
semaphore = asyncio.Semaphore(self._playlist_concurrency)
|
async def close(self) -> None:
|
||||||
|
"""Close the underlying HTTP session to Spotify."""
|
||||||
|
await self.http.close()
|
||||||
|
|
||||||
async def fetch(offset: int) -> List[Track]:
|
async def user_from_token(self, token: str) -> User:
|
||||||
url = (
|
"""Create a user session from a token.
|
||||||
f"{request_url}/tracks?offset={offset}&limit={limit}&fields={quote(fields_filter)}"
|
|
||||||
|
.. note::
|
||||||
|
|
||||||
|
This code is equivelent to `User.from_token(client, token)`
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
token : :class:`str`
|
||||||
|
The token to attatch the user session to.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
user : :class:`spotify.User`
|
||||||
|
The user from the ID
|
||||||
|
"""
|
||||||
|
return await User.from_token(self, token)
|
||||||
|
|
||||||
|
async def get_album(self, spotify_id: str, *, market: str = "US") -> Album:
|
||||||
|
"""Retrive an album with a spotify ID.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
spotify_id : :class:`str`
|
||||||
|
The ID to search for.
|
||||||
|
market : Optional[:class:`str`]
|
||||||
|
An ISO 3166-1 alpha-2 country code
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
album : :class:`spotify.Album`
|
||||||
|
The album from the ID
|
||||||
|
"""
|
||||||
|
data = await self.http.album(to_id(spotify_id), market=market)
|
||||||
|
return Album(self, data)
|
||||||
|
|
||||||
|
async def get_artist(self, spotify_id: str) -> Artist:
|
||||||
|
"""Retrive an artist with a spotify ID.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
spotify_id : str
|
||||||
|
The ID to search for.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
artist : Artist
|
||||||
|
The artist from the ID
|
||||||
|
"""
|
||||||
|
data = await self.http.artist(to_id(spotify_id))
|
||||||
|
return Artist(self, data)
|
||||||
|
|
||||||
|
async def get_track(self, spotify_id: str) -> Track:
|
||||||
|
"""Retrive an track with a spotify ID.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
spotify_id : str
|
||||||
|
The ID to search for.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
track : Track
|
||||||
|
The track from the ID
|
||||||
|
"""
|
||||||
|
data = await self.http.track(to_id(spotify_id))
|
||||||
|
return Track(self, data)
|
||||||
|
|
||||||
|
async def get_user(self, spotify_id: str) -> User:
|
||||||
|
"""Retrive an user with a spotify ID.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
spotify_id : str
|
||||||
|
The ID to search for.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
user : User
|
||||||
|
The user from the ID
|
||||||
|
"""
|
||||||
|
data = await self.http.user(to_id(spotify_id))
|
||||||
|
return User(self, data)
|
||||||
|
|
||||||
|
# Get multiple objects
|
||||||
|
|
||||||
|
async def get_albums(self, *ids: str, market: str = "US") -> List[Album]:
|
||||||
|
"""Retrive multiple albums with a list of spotify IDs.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
ids : List[str]
|
||||||
|
the ID to look for
|
||||||
|
market : Optional[str]
|
||||||
|
An ISO 3166-1 alpha-2 country code
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
albums : List[Album]
|
||||||
|
The albums from the IDs
|
||||||
|
"""
|
||||||
|
data = await self.http.albums(
|
||||||
|
",".join(to_id(_id) for _id in ids), market=market
|
||||||
)
|
)
|
||||||
async with semaphore:
|
return list(Album(self, album) for album in data["albums"])
|
||||||
if not self.session:
|
|
||||||
raise SpotifyRequestException(
|
|
||||||
"HTTP session not initialized for Spotify client.",
|
|
||||||
)
|
|
||||||
r = await self.session.get(url, headers=self._bearer_headers)
|
|
||||||
if r.status != 200:
|
|
||||||
if self._log:
|
|
||||||
self._log.warning(
|
|
||||||
f"Skipping page offset={offset} due to {r.status} {r.reason}",
|
|
||||||
)
|
|
||||||
return []
|
|
||||||
pj: dict = await r.json(loads=json.loads)
|
|
||||||
return [
|
|
||||||
Track(item["track"])
|
|
||||||
for item in pj.get("items", [])
|
|
||||||
if item.get("track") is not None
|
|
||||||
]
|
|
||||||
|
|
||||||
# Fetch pages in rolling waves; yield promptly as soon as a wave completes.
|
async def get_artists(self, *ids: str) -> List[Artist]:
|
||||||
wave_size = self._playlist_concurrency * 2
|
"""Retrive multiple artists with a list of spotify IDs.
|
||||||
for i, offset in enumerate(remaining_offsets):
|
|
||||||
# Build wave
|
|
||||||
if i % wave_size == 0:
|
|
||||||
wave_offsets = list(
|
|
||||||
o for o in remaining_offsets if o >= offset and o < offset + wave_size
|
|
||||||
)
|
|
||||||
results = await asyncio.gather(*[fetch(o) for o in wave_offsets])
|
|
||||||
for page_tracks in results:
|
|
||||||
if not page_tracks:
|
|
||||||
continue
|
|
||||||
for j in range(0, len(page_tracks), batch_size):
|
|
||||||
yield page_tracks[j : j + batch_size]
|
|
||||||
# Skip ahead in iterator by adjusting enumerate drive (consume extras)
|
|
||||||
# Fast-forward the generator manually
|
|
||||||
for _ in range(len(wave_offsets) - 1):
|
|
||||||
try:
|
|
||||||
next(remaining_offsets) # type: ignore
|
|
||||||
except StopIteration:
|
|
||||||
break
|
|
||||||
|
|
||||||
async def get_recommendations(self, *, query: str) -> List[Track]:
|
Parameters
|
||||||
if not self._bearer_token or time.time() >= self._expiry:
|
----------
|
||||||
await self._fetch_bearer_token()
|
ids : List[:class:`str`]
|
||||||
|
The IDs to look for.
|
||||||
|
|
||||||
result = SPOTIFY_URL_REGEX.match(query)
|
Returns
|
||||||
if not result:
|
-------
|
||||||
raise InvalidSpotifyURL("The Spotify link provided is not valid.")
|
artists : List[:class:`Artist`]
|
||||||
|
The artists from the IDs
|
||||||
|
"""
|
||||||
|
data = await self.http.artists(",".join(to_id(_id) for _id in ids))
|
||||||
|
return list(Artist(self, artist) for artist in data["artists"])
|
||||||
|
|
||||||
spotify_type = result.group("type")
|
async def search( # pylint: disable=invalid-name
|
||||||
spotify_id = result.group("id")
|
self,
|
||||||
|
q: str,
|
||||||
|
*,
|
||||||
|
types: Iterable[str] = ("track", "playlist", "artist", "album"),
|
||||||
|
limit: int = 20,
|
||||||
|
offset: int = 0,
|
||||||
|
market: str = "US",
|
||||||
|
should_include_external: bool = False,
|
||||||
|
) -> SearchResults:
|
||||||
|
"""Access the spotify search functionality.
|
||||||
|
|
||||||
if not spotify_type == "track":
|
>>> results = client.search('Cadet', types=['artist'])
|
||||||
raise InvalidSpotifyURL(
|
>>> for artist in result.get('artists', []):
|
||||||
"The provided query is not a Spotify track.",
|
... if artist.name.lower() == 'cadet':
|
||||||
|
... print(repr(artist))
|
||||||
|
... break
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
q : :class:`str`
|
||||||
|
the search query
|
||||||
|
types : Optional[Iterable[`:class:`str`]]
|
||||||
|
A sequence of search types (can be any of `track`, `playlist`, `artist` or `album`) to refine the search request.
|
||||||
|
A `ValueError` may be raised if a search type is found that is not valid.
|
||||||
|
limit : Optional[:class:`int`]
|
||||||
|
The limit of search results to return when searching.
|
||||||
|
Maximum limit is 50, any larger may raise a :class:`HTTPException`
|
||||||
|
offset : Optional[:class:`int`]
|
||||||
|
The offset from where the api should start from in the search results.
|
||||||
|
market : Optional[:class:`str`]
|
||||||
|
An ISO 3166-1 alpha-2 country code. Provide this parameter if you want to apply Track Relinking.
|
||||||
|
should_include_external : :class:`bool`
|
||||||
|
If `True` is specified, the response will include any relevant audio content
|
||||||
|
that is hosted externally. By default external content is filtered out from responses.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
results : :class:`SearchResults`
|
||||||
|
The results of the search.
|
||||||
|
|
||||||
|
Raises
|
||||||
|
------
|
||||||
|
TypeError
|
||||||
|
Raised when a parameter with a bad type is passed.
|
||||||
|
ValueError
|
||||||
|
Raised when a bad search type is passed with the `types` argument.
|
||||||
|
"""
|
||||||
|
if not hasattr(types, "__iter__"):
|
||||||
|
raise TypeError("types must be an iterable.")
|
||||||
|
|
||||||
|
types_ = set(types)
|
||||||
|
|
||||||
|
if not types_.issubset(_SEARCH_TYPES):
|
||||||
|
raise ValueError(_SEARCH_TYPE_ERR % types_.difference(_SEARCH_TYPES).pop())
|
||||||
|
|
||||||
|
query_type = ",".join(tp.strip() for tp in types)
|
||||||
|
|
||||||
|
include_external: Optional[str]
|
||||||
|
if should_include_external:
|
||||||
|
include_external = "audio"
|
||||||
|
else:
|
||||||
|
include_external = None
|
||||||
|
|
||||||
|
data = await self.http.search(
|
||||||
|
q=q,
|
||||||
|
query_type=query_type,
|
||||||
|
market=market,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
include_external=include_external,
|
||||||
)
|
)
|
||||||
|
|
||||||
request_url = REQUEST_URL.format(
|
return SearchResults(
|
||||||
type="recommendation",
|
**{
|
||||||
id=f"?seed_tracks={spotify_id}",
|
key: [_TYPES[obj["type"]](self, obj) for obj in value["items"]]
|
||||||
|
for key, value in data.items()
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
if not self.session:
|
|
||||||
raise SpotifyRequestException("HTTP session not initialized for Spotify client.")
|
|
||||||
resp = await self.session.get(request_url, headers=self._bearer_headers)
|
|
||||||
if resp.status != 200:
|
|
||||||
raise SpotifyRequestException(
|
|
||||||
f"Error while fetching results: {resp.status} {resp.reason}",
|
|
||||||
)
|
|
||||||
|
|
||||||
data: dict = await resp.json(loads=json.loads)
|
|
||||||
tracks = [Track(track) for track in data["tracks"]]
|
|
||||||
|
|
||||||
return tracks
|
|
||||||
|
|
||||||
async def track_search(self, *, query: str) -> List[Track]:
|
|
||||||
if not self._bearer_token or time.time() >= self._expiry:
|
|
||||||
await self._fetch_bearer_token()
|
|
||||||
|
|
||||||
request_url = f"https://api.spotify.com/v1/search?q={quote(query)}&type=track"
|
|
||||||
|
|
||||||
if not self.session:
|
|
||||||
raise SpotifyRequestException("HTTP session not initialized for Spotify client.")
|
|
||||||
resp = await self.session.get(request_url, headers=self._bearer_headers)
|
|
||||||
if resp.status != 200:
|
|
||||||
raise SpotifyRequestException(
|
|
||||||
f"Error while fetching results: {resp.status} {resp.reason}",
|
|
||||||
)
|
|
||||||
|
|
||||||
data: dict = await resp.json(loads=json.loads)
|
|
||||||
tracks = [Track(track) for track in data["tracks"]["items"]]
|
|
||||||
|
|
||||||
return tracks
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
__all__ = ("SpotifyException", "HTTPException", "Forbidden", "NotFound")
|
||||||
|
|
||||||
|
|
||||||
|
class SpotifyException(Exception):
|
||||||
|
"""Base exception class for spotify.py."""
|
||||||
|
|
||||||
|
|
||||||
|
class HTTPException(SpotifyException):
|
||||||
|
"""A generic exception that's thrown when a HTTP operation fails."""
|
||||||
|
|
||||||
|
def __init__(self, response, message):
|
||||||
|
self.response = response
|
||||||
|
self.status = response.status
|
||||||
|
error = message.get("error")
|
||||||
|
|
||||||
|
if isinstance(error, dict):
|
||||||
|
self.text = error.get("message", "")
|
||||||
|
else:
|
||||||
|
self.text = message.get("error_description", "")
|
||||||
|
|
||||||
|
fmt = "{0.reason} (status code: {0.status})"
|
||||||
|
if self.text.strip():
|
||||||
|
fmt += ": {1}"
|
||||||
|
|
||||||
|
super().__init__(fmt.format(self.response, self.text))
|
||||||
|
|
||||||
|
|
||||||
|
class Forbidden(HTTPException):
|
||||||
|
"""An exception that's thrown when status code 403 occurs."""
|
||||||
|
|
||||||
|
|
||||||
|
class NotFound(HTTPException):
|
||||||
|
"""An exception that's thrown when status code 404 occurs."""
|
||||||
|
|
||||||
|
|
||||||
|
class BearerTokenError(HTTPException):
|
||||||
|
"""An exception that's thrown when Spotify could not provide a valid Bearer Token"""
|
||||||
|
|
||||||
|
|
||||||
|
class RateLimitedException(Exception):
|
||||||
|
"""An exception that gets thrown when a rate limit is encountered."""
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
__all__ = (
|
|
||||||
"SpotifyRequestException",
|
|
||||||
"InvalidSpotifyURL",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class SpotifyRequestException(Exception):
|
|
||||||
"""An error occurred when making a request to the Spotify API"""
|
|
||||||
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class InvalidSpotifyURL(Exception):
|
|
||||||
"""An invalid Spotify URL was passed"""
|
|
||||||
|
|
||||||
pass
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,26 @@
|
||||||
|
from .. import _clean_namespace
|
||||||
|
from . import typing
|
||||||
|
|
||||||
|
from .base import AsyncIterable, SpotifyBase, URIBase
|
||||||
|
from .common import Device, Context, Image
|
||||||
|
from .artist import Artist
|
||||||
|
from .track import Track, PlaylistTrack
|
||||||
|
from .player import Player
|
||||||
|
from .album import Album
|
||||||
|
from .library import Library
|
||||||
|
from .playlist import Playlist
|
||||||
|
from .user import User
|
||||||
|
|
||||||
|
__all__ = (
|
||||||
|
"User",
|
||||||
|
"Track",
|
||||||
|
"PlaylistTrack",
|
||||||
|
"Artist",
|
||||||
|
"Album",
|
||||||
|
"Playlist",
|
||||||
|
"Library",
|
||||||
|
"Player",
|
||||||
|
"Device",
|
||||||
|
"Context",
|
||||||
|
"Image",
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,121 @@
|
||||||
|
from functools import partial
|
||||||
|
from typing import Optional, List
|
||||||
|
|
||||||
|
from ..oauth import set_required_scopes
|
||||||
|
from . import AsyncIterable, URIBase, Image, Artist, Track
|
||||||
|
|
||||||
|
|
||||||
|
class Album(URIBase, AsyncIterable): # pylint: disable=too-many-instance-attributes
|
||||||
|
"""A Spotify Album.
|
||||||
|
|
||||||
|
Attributes
|
||||||
|
----------
|
||||||
|
artists : List[Artist]
|
||||||
|
The artists for the album.
|
||||||
|
id : str
|
||||||
|
The ID of the album.
|
||||||
|
name : str
|
||||||
|
The name of the album.
|
||||||
|
href : str
|
||||||
|
The HTTP API URL for the album.
|
||||||
|
uri : str
|
||||||
|
The URI for the album.
|
||||||
|
album_group : str
|
||||||
|
ossible values are “album”, “single”, “compilation”, “appears_on”.
|
||||||
|
Compare to album_type this field represents relationship between the artist and the album.
|
||||||
|
album_type : str
|
||||||
|
The type of the album: one of "album" , "single" , or "compilation".
|
||||||
|
release_date : str
|
||||||
|
The date the album was first released.
|
||||||
|
release_date_precision : str
|
||||||
|
The precision with which release_date value is known: year, month or day.
|
||||||
|
genres : List[str]
|
||||||
|
A list of the genres used to classify the album.
|
||||||
|
label : str
|
||||||
|
The label for the album.
|
||||||
|
popularity : int
|
||||||
|
The popularity of the album. The value will be between 0 and 100, with 100 being the most popular.
|
||||||
|
copyrights : List[Dict]
|
||||||
|
The copyright statements of the album.
|
||||||
|
markets : List[str]
|
||||||
|
The markets in which the album is available: ISO 3166-1 alpha-2 country codes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, client, data):
|
||||||
|
self.__client = client
|
||||||
|
|
||||||
|
# Simple object attributes.
|
||||||
|
self.type = data.pop("album_type", None)
|
||||||
|
self.group = data.pop("album_group", None)
|
||||||
|
self.artists = [Artist(client, artist) for artist in data.pop("artists", [])]
|
||||||
|
|
||||||
|
self.artist = self.artists[0] if self.artists else None
|
||||||
|
self.markets = data.pop("avaliable_markets", None)
|
||||||
|
self.url = data.pop("external_urls").get("spotify", None)
|
||||||
|
self.id = data.pop("id", None) # pylint: disable=invalid-name
|
||||||
|
self.name = data.pop("name", None)
|
||||||
|
self.href = data.pop("href", None)
|
||||||
|
self.uri = data.pop("uri", None)
|
||||||
|
self.release_date = data.pop("release_date", None)
|
||||||
|
self.release_date_precision = data.pop("release_date_precision", None)
|
||||||
|
self.images = [Image(**image) for image in data.pop("images", [])]
|
||||||
|
self.restrictions = data.pop("restrictions", None)
|
||||||
|
|
||||||
|
# Full object attributes
|
||||||
|
self.genres = data.pop("genres", None)
|
||||||
|
self.copyrights = data.pop("copyrights", None)
|
||||||
|
self.label = data.pop("label", None)
|
||||||
|
self.popularity = data.pop("popularity", None)
|
||||||
|
self.total_tracks = data.pop("total_tracks", None)
|
||||||
|
|
||||||
|
# AsyncIterable attrs
|
||||||
|
self.__aiter_klass__ = Track
|
||||||
|
self.__aiter_fetch__ = partial(
|
||||||
|
self.__client.http.album_tracks, self.id, limit=50
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<spotify.Album: {(self.name or self.id or self.uri)!r}>"
|
||||||
|
|
||||||
|
# Public
|
||||||
|
|
||||||
|
@set_required_scopes(None)
|
||||||
|
async def get_tracks(
|
||||||
|
self, *, limit: Optional[int] = 20, offset: Optional[int] = 0
|
||||||
|
) -> List[Track]:
|
||||||
|
"""get the albums tracks from spotify.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
limit : Optional[int]
|
||||||
|
The limit on how many tracks to retrieve for this album (default is 20).
|
||||||
|
offset : Optional[int]
|
||||||
|
The offset from where the api should start from in the tracks.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
tracks : List[Track]
|
||||||
|
The tracks of the artist.
|
||||||
|
"""
|
||||||
|
data = await self.__client.http.album_tracks(
|
||||||
|
self.id, limit=limit, offset=offset
|
||||||
|
)
|
||||||
|
return list(Track(self.__client, item, album=self) for item in data["items"])
|
||||||
|
|
||||||
|
@set_required_scopes(None)
|
||||||
|
async def get_all_tracks(
|
||||||
|
self, *, market: Optional[str] = "US"
|
||||||
|
) -> List[Track]: # pylint: disable=unused-argument
|
||||||
|
"""loads all of the albums tracks, depending on how many the album has this may be a long operation.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
market : Optional[str]
|
||||||
|
An ISO 3166-1 alpha-2 country code. Provide this parameter if you want to apply Track Relinking.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
tracks : List[:class:`spotify.Track`]
|
||||||
|
The tracks of the artist.
|
||||||
|
"""
|
||||||
|
return [track async for track in self]
|
||||||
|
|
@ -0,0 +1,186 @@
|
||||||
|
from functools import partial
|
||||||
|
from typing import Optional, List, TYPE_CHECKING
|
||||||
|
|
||||||
|
from ..oauth import set_required_scopes
|
||||||
|
from . import AsyncIterable, URIBase, Image
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
import spotify
|
||||||
|
|
||||||
|
|
||||||
|
class Artist(URIBase, AsyncIterable): # pylint: disable=too-many-instance-attributes
|
||||||
|
"""A Spotify Artist.
|
||||||
|
|
||||||
|
Attributes
|
||||||
|
----------
|
||||||
|
id : str
|
||||||
|
The Spotify ID of the artist.
|
||||||
|
uri : str
|
||||||
|
The URI of the artist.
|
||||||
|
url : str
|
||||||
|
The open.spotify URL.
|
||||||
|
href : str
|
||||||
|
A link to the Web API endpoint providing full details of the artist.
|
||||||
|
name : str
|
||||||
|
The name of the artist.
|
||||||
|
genres : List[str]
|
||||||
|
A list of the genres the artist is associated with.
|
||||||
|
For example: "Prog Rock" , "Post-Grunge". (If not yet classified, the array is empty.)
|
||||||
|
followers : Optional[int]
|
||||||
|
The total number of followers.
|
||||||
|
popularity : int
|
||||||
|
The popularity of the artist.
|
||||||
|
The value will be between 0 and 100, with 100 being the most popular.
|
||||||
|
The artist’s popularity is calculated from the popularity of all the artist’s tracks.
|
||||||
|
images : List[Image]
|
||||||
|
Images of the artist in various sizes, widest first.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, client, data):
|
||||||
|
self.__client = client
|
||||||
|
|
||||||
|
# Simplified object attributes
|
||||||
|
self.id = data.pop("id") # pylint: disable=invalid-name
|
||||||
|
self.uri = data.pop("uri")
|
||||||
|
self.url = data.pop("external_urls").get("spotify", None)
|
||||||
|
self.href = data.pop("href")
|
||||||
|
self.name = data.pop("name")
|
||||||
|
|
||||||
|
# Full object attributes
|
||||||
|
self.genres = data.pop("genres", None)
|
||||||
|
self.followers = data.pop("followers", {}).get("total", None)
|
||||||
|
self.popularity = data.pop("popularity", None)
|
||||||
|
self.images = list(Image(**image) for image in data.pop("images", []))
|
||||||
|
|
||||||
|
# AsyncIterable attrs
|
||||||
|
from .album import Album
|
||||||
|
|
||||||
|
self.__aiter_klass__ = Album
|
||||||
|
self.__aiter_fetch__ = partial(
|
||||||
|
self.__client.http.artist_albums, self.id, limit=50
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<spotify.Artist: {self.name!r}>"
|
||||||
|
|
||||||
|
# Public
|
||||||
|
|
||||||
|
@set_required_scopes(None)
|
||||||
|
async def get_albums(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
limit: Optional[int] = 20,
|
||||||
|
offset: Optional[int] = 0,
|
||||||
|
include_groups=None,
|
||||||
|
market: Optional[str] = None,
|
||||||
|
) -> List["spotify.Album"]:
|
||||||
|
"""Get the albums of a Spotify artist.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
limit : Optional[int]
|
||||||
|
The maximum number of items to return. Default: 20. Minimum: 1. Maximum: 50.
|
||||||
|
offset : Optiona[int]
|
||||||
|
The offset of which Spotify should start yielding from.
|
||||||
|
include_groups : INCLUDE_GROUPS_TP
|
||||||
|
INCLUDE_GROUPS
|
||||||
|
market : Optional[str]
|
||||||
|
An ISO 3166-1 alpha-2 country code.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
albums : List[Album]
|
||||||
|
The albums of the artist.
|
||||||
|
"""
|
||||||
|
from .album import Album
|
||||||
|
|
||||||
|
data = await self.__client.http.artist_albums(
|
||||||
|
self.id,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
include_groups=include_groups,
|
||||||
|
market=market,
|
||||||
|
)
|
||||||
|
return list(Album(self.__client, item) for item in data["items"])
|
||||||
|
|
||||||
|
@set_required_scopes(None)
|
||||||
|
async def get_all_albums(self, *, market="US") -> List["spotify.Album"]:
|
||||||
|
"""loads all of the artists albums, depending on how many the artist has this may be a long operation.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
market : Optional[str]
|
||||||
|
An ISO 3166-1 alpha-2 country code.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
albums : List[Album]
|
||||||
|
The albums of the artist.
|
||||||
|
"""
|
||||||
|
from .album import Album
|
||||||
|
|
||||||
|
albums: List[Album] = []
|
||||||
|
offset = 0
|
||||||
|
total = await self.total_albums(market=market)
|
||||||
|
|
||||||
|
while len(albums) < total:
|
||||||
|
data = await self.__client.http.artist_albums(
|
||||||
|
self.id, limit=50, offset=offset, market=market
|
||||||
|
)
|
||||||
|
|
||||||
|
offset += 50
|
||||||
|
albums += list(Album(self.__client, item) for item in data["items"])
|
||||||
|
|
||||||
|
return albums
|
||||||
|
|
||||||
|
@set_required_scopes(None)
|
||||||
|
async def total_albums(self, *, market: str = None) -> int:
|
||||||
|
"""get the total amout of tracks in the album.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
market : Optional[str]
|
||||||
|
An ISO 3166-1 alpha-2 country code.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
total : int
|
||||||
|
The total amount of albums.
|
||||||
|
"""
|
||||||
|
data = await self.__client.http.artist_albums(
|
||||||
|
self.id, limit=1, offset=0, market=market
|
||||||
|
)
|
||||||
|
return data["total"]
|
||||||
|
|
||||||
|
@set_required_scopes(None)
|
||||||
|
async def top_tracks(self, country: str = "US") -> List["spotify.Track"]:
|
||||||
|
"""Get Spotify catalog information about an artist’s top tracks by country.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
country : str
|
||||||
|
The country to search for, it defaults to 'US'.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
tracks : List[Track]
|
||||||
|
The artists top tracks.
|
||||||
|
"""
|
||||||
|
from .track import Track
|
||||||
|
|
||||||
|
top = await self.__client.http.artist_top_tracks(self.id, country=country)
|
||||||
|
return list(Track(self.__client, item) for item in top["tracks"])
|
||||||
|
|
||||||
|
@set_required_scopes(None)
|
||||||
|
async def related_artists(self) -> List["Artist"]:
|
||||||
|
"""Get Spotify catalog information about artists similar to a given artist.
|
||||||
|
|
||||||
|
Similarity is based on analysis of the Spotify community’s listening history.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
artists : List[Artist]
|
||||||
|
The artists deemed similar.
|
||||||
|
"""
|
||||||
|
related = await self.__client.http.artist_related_artists(self.id)
|
||||||
|
return list(Artist(self.__client, item) for item in related["artists"])
|
||||||
|
|
@ -0,0 +1,143 @@
|
||||||
|
from typing import Optional, Callable, Type
|
||||||
|
|
||||||
|
import spotify
|
||||||
|
|
||||||
|
|
||||||
|
class SpotifyBase:
|
||||||
|
"""The base class all Spotify models **must** derive from.
|
||||||
|
|
||||||
|
This base class is used to transparently construct spotify
|
||||||
|
models based on the :class:`spotify,Client` type.
|
||||||
|
|
||||||
|
Currently it is used to detect whether a Client is a synchronous
|
||||||
|
client and, if as such, construct and return the appropriate
|
||||||
|
synchronous model.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__slots__ = ()
|
||||||
|
|
||||||
|
def __new__(cls, client, *_, **__):
|
||||||
|
|
||||||
|
if hasattr(client, "__client_thread__"):
|
||||||
|
cls = getattr( # pylint: disable=self-cls-assignment
|
||||||
|
spotify.sync.models, cls.__name__
|
||||||
|
)
|
||||||
|
|
||||||
|
return object.__new__(cls)
|
||||||
|
|
||||||
|
async def from_href(self):
|
||||||
|
"""Get the full object from spotify with a `href` attribute.
|
||||||
|
|
||||||
|
.. note ::
|
||||||
|
|
||||||
|
This can be used to get an updated model of the object.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
model : SpotifyBase
|
||||||
|
An instance of whatever the class was before.
|
||||||
|
|
||||||
|
Raises
|
||||||
|
------
|
||||||
|
TypeError
|
||||||
|
This is raised if the model has no `href` attribute.
|
||||||
|
|
||||||
|
Additionally if the model has no `http` attribute and
|
||||||
|
the model has no way to access its client, while theoretically
|
||||||
|
impossible its a failsafe, this will be raised.
|
||||||
|
"""
|
||||||
|
if not hasattr(self, "href"):
|
||||||
|
raise TypeError(
|
||||||
|
"Spotify object has no `href` attribute, therefore cannot be retrived"
|
||||||
|
)
|
||||||
|
|
||||||
|
if hasattr(self, "http"):
|
||||||
|
return await self.http.request( # pylint: disable=no-member
|
||||||
|
("GET", self.href) # pylint: disable=no-member
|
||||||
|
)
|
||||||
|
|
||||||
|
klass = type(self)
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = getattr(self, f"_{klass.__name__}__client")
|
||||||
|
except AttributeError:
|
||||||
|
raise TypeError("Spotify object has no way to access a HTTPClient.")
|
||||||
|
else:
|
||||||
|
http = client.http # pylint: disable=no-member
|
||||||
|
|
||||||
|
data = await http.request(("GET", self.href)) # pylint: disable=no-member
|
||||||
|
|
||||||
|
return klass(client, data)
|
||||||
|
|
||||||
|
|
||||||
|
class URIBase(SpotifyBase):
|
||||||
|
"""Base class used for inheriting magic methods for models who have URIs.
|
||||||
|
|
||||||
|
This class inherits from :class:`SpotifyBase` and is used to reduce boilerplate
|
||||||
|
in spotify models by supplying a `__eq__`, `__ne__`, and `__str__` double underscore
|
||||||
|
methods.
|
||||||
|
|
||||||
|
The objects that inherit from :class:`URIBase` support equality and string casting.
|
||||||
|
|
||||||
|
- Two objects are equal if **They are strictly the same type and have the same uri**
|
||||||
|
- Casting to a string will return the uri of the object.
|
||||||
|
"""
|
||||||
|
|
||||||
|
uri = repr(None)
|
||||||
|
|
||||||
|
def __hash__(self):
|
||||||
|
return hash(self.uri) # pylint: disable=no-member
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
return (
|
||||||
|
type(self) is type(other) and self.uri == other.uri
|
||||||
|
) # pylint: disable=no-member
|
||||||
|
|
||||||
|
def __ne__(self, other):
|
||||||
|
return not self.__eq__(other)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.uri # pylint: disable=no-member
|
||||||
|
|
||||||
|
|
||||||
|
class AsyncIterable(SpotifyBase):
|
||||||
|
"""Base class intended for all models that can be asynchronously iterated over.
|
||||||
|
|
||||||
|
This class implements two magic class vars:
|
||||||
|
|
||||||
|
* `__aiter_fetch__` ~ A coroutine function that accepts a keyword argument named `option`
|
||||||
|
* `__aiter_klass__` ~ A spotify model class, essentially a type that subclasses `SpotifyBase`
|
||||||
|
|
||||||
|
Additionally the class implements `__aiter__` that will exhaust the paging
|
||||||
|
objects returned by the `__aiter_fetch__` calls and yield each data item in
|
||||||
|
said paging objects as an instance of `__aiter_klass__`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__aiter_fetch__: Optional[Callable] = None
|
||||||
|
__aiter_klass__: Optional[Type[SpotifyBase]] = None
|
||||||
|
|
||||||
|
async def __aiter__(self):
|
||||||
|
client = getattr(self, f"_{type(self).__name__}__client")
|
||||||
|
|
||||||
|
assert self.__aiter_fetch__ is not None
|
||||||
|
fetch = self.__aiter_fetch__
|
||||||
|
|
||||||
|
assert self.__aiter_klass__ is not None
|
||||||
|
klass = self.__aiter_klass__
|
||||||
|
|
||||||
|
total = None
|
||||||
|
processed = offset = 0
|
||||||
|
|
||||||
|
while total is None or processed < total:
|
||||||
|
data = await fetch(offset=offset) # pylint: disable=not-callable
|
||||||
|
|
||||||
|
if total is None:
|
||||||
|
assert "total" in data
|
||||||
|
total = data["total"]
|
||||||
|
|
||||||
|
assert "items" in data
|
||||||
|
for item in data["items"]:
|
||||||
|
processed += 1
|
||||||
|
yield klass(client, item) # pylint: disable=not-callable
|
||||||
|
|
||||||
|
offset += 50
|
||||||
|
|
@ -0,0 +1,109 @@
|
||||||
|
class Image:
|
||||||
|
"""An object representing a Spotify image resource.
|
||||||
|
|
||||||
|
Attributes
|
||||||
|
----------
|
||||||
|
height : :class:`str`
|
||||||
|
The height of the image.
|
||||||
|
width : :class:`str`
|
||||||
|
The width of the image.
|
||||||
|
url : :class:`str`
|
||||||
|
The URL of the image.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__slots__ = ("height", "width", "url")
|
||||||
|
|
||||||
|
def __init__(self, *, height: str, width: str, url: str):
|
||||||
|
self.height = height
|
||||||
|
self.width = width
|
||||||
|
self.url = url
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<spotify.Image: {self.url!r} (width: {self.width!r}, height: {self.height!r})>"
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
return type(self) is type(other) and self.url == other.url
|
||||||
|
|
||||||
|
|
||||||
|
class Context:
|
||||||
|
"""A Spotify Context.
|
||||||
|
|
||||||
|
Attributes
|
||||||
|
----------
|
||||||
|
type : str
|
||||||
|
The object type, e.g. “artist”, “playlist”, “album”.
|
||||||
|
href : str
|
||||||
|
A link to the Web API endpoint providing full details of the track.
|
||||||
|
external_urls : str
|
||||||
|
External URLs for this context.
|
||||||
|
uri : str
|
||||||
|
The Spotify URI for the context.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__slots__ = ("external_urls", "type", "href", "uri")
|
||||||
|
|
||||||
|
def __init__(self, data):
|
||||||
|
self.external_urls = data.get("external_urls")
|
||||||
|
self.type = data.get("type")
|
||||||
|
|
||||||
|
self.href = data.get("href")
|
||||||
|
self.uri = data.get("uri")
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<spotify.Context: {self.uri!r}>"
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
return type(self) is type(other) and self.uri == other.uri
|
||||||
|
|
||||||
|
|
||||||
|
class Device:
|
||||||
|
"""A Spotify Users device.
|
||||||
|
|
||||||
|
Attributes
|
||||||
|
----------
|
||||||
|
id : str
|
||||||
|
The device ID
|
||||||
|
name : int
|
||||||
|
The name of the device.
|
||||||
|
type : str
|
||||||
|
A Device type, such as “Computer”, “Smartphone” or “Speaker”.
|
||||||
|
volume : int
|
||||||
|
The current volume in percent. This may be null.
|
||||||
|
is_active : bool
|
||||||
|
if this device is the currently active device.
|
||||||
|
is_restricted : bool
|
||||||
|
Whether controlling this device is restricted.
|
||||||
|
At present if this is “true” then no Web API commands will be accepted by this device.
|
||||||
|
is_private_session : bool
|
||||||
|
If this device is currently in a private session.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__slots__ = (
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"type",
|
||||||
|
"volume",
|
||||||
|
"is_active",
|
||||||
|
"is_restricted",
|
||||||
|
"is_private_session",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, data):
|
||||||
|
self.id = data.get("id") # pylint: disable=invalid-name
|
||||||
|
self.name = data.get("name")
|
||||||
|
self.type = data.get("type")
|
||||||
|
|
||||||
|
self.volume = data.get("volume_percent")
|
||||||
|
|
||||||
|
self.is_active = data.get("is_active")
|
||||||
|
self.is_restricted = data.get("is_restricted")
|
||||||
|
self.is_private_session = data.get("is_private_session")
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
return type(self) is type(other) and self.id == other.id
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<spotify.Device: {(self.name or self.id)!r}>"
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.id
|
||||||
|
|
@ -0,0 +1,189 @@
|
||||||
|
from typing import Sequence, Union, List
|
||||||
|
|
||||||
|
from ..oauth import set_required_scopes
|
||||||
|
from . import SpotifyBase
|
||||||
|
from .track import Track
|
||||||
|
from .album import Album
|
||||||
|
|
||||||
|
|
||||||
|
class Library(SpotifyBase):
|
||||||
|
"""A Spotify Users Library.
|
||||||
|
|
||||||
|
Attributes
|
||||||
|
----------
|
||||||
|
user : :class:`Spotify.User`
|
||||||
|
The user which this library object belongs to.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, client, user):
|
||||||
|
self.user = user
|
||||||
|
self.__client = client
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<spotify.Library: {self.user!r}>"
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
return type(self) is type(other) and self.user == other.user
|
||||||
|
|
||||||
|
def __ne__(self, other):
|
||||||
|
return not self.__eq__(other)
|
||||||
|
|
||||||
|
@set_required_scopes("user-library-read")
|
||||||
|
async def contains_albums(self, *albums: Sequence[Union[str, Album]]) -> List[bool]:
|
||||||
|
"""Check if one or more albums is already saved in the current Spotify user’s ‘Your Music’ library.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
albums : Union[Album, str]
|
||||||
|
A sequence of artist objects or spotify IDs
|
||||||
|
"""
|
||||||
|
_albums = [str(obj) for obj in albums]
|
||||||
|
return await self.user.http.is_saved_album(_albums)
|
||||||
|
|
||||||
|
@set_required_scopes("user-library-read")
|
||||||
|
async def contains_tracks(self, *tracks: Sequence[Union[str, Track]]) -> List[bool]:
|
||||||
|
"""Check if one or more tracks is already saved in the current Spotify user’s ‘Your Music’ library.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
tracks : Union[Track, str]
|
||||||
|
A sequence of track objects or spotify IDs
|
||||||
|
"""
|
||||||
|
_tracks = [str(obj) for obj in tracks]
|
||||||
|
return await self.user.http.is_saved_track(_tracks)
|
||||||
|
|
||||||
|
@set_required_scopes("user-library-read")
|
||||||
|
async def get_tracks(self, *, limit=20, offset=0) -> List[Track]:
|
||||||
|
"""Get a list of the songs saved in the current Spotify user’s ‘Your Music’ library.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
limit : Optional[int]
|
||||||
|
The maximum number of items to return. Default: 20. Minimum: 1. Maximum: 50.
|
||||||
|
offset : Optional[int]
|
||||||
|
The index of the first item to return. Default: 0
|
||||||
|
"""
|
||||||
|
data = await self.user.http.saved_tracks(limit=limit, offset=offset)
|
||||||
|
|
||||||
|
return [Track(self.__client, item["track"]) for item in data["items"]]
|
||||||
|
|
||||||
|
@set_required_scopes("user-library-read")
|
||||||
|
async def get_all_tracks(self) -> List[Track]:
|
||||||
|
"""Get a list of all the songs saved in the current Spotify user’s ‘Your Music’ library.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
tracks : List[:class:`Track`]
|
||||||
|
The tracks of the artist.
|
||||||
|
"""
|
||||||
|
tracks: List[Track] = []
|
||||||
|
total = None
|
||||||
|
offset = 0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
data = await self.user.http.saved_tracks(limit=50, offset=offset)
|
||||||
|
|
||||||
|
if total is None:
|
||||||
|
total = data["total"]
|
||||||
|
|
||||||
|
offset += 50
|
||||||
|
tracks += list(
|
||||||
|
Track(self.__client, item["track"]) for item in data["items"]
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(tracks) >= total:
|
||||||
|
break
|
||||||
|
|
||||||
|
return tracks
|
||||||
|
|
||||||
|
@set_required_scopes("user-library-read")
|
||||||
|
async def get_albums(self, *, limit=20, offset=0) -> List[Album]:
|
||||||
|
"""Get a list of the albums saved in the current Spotify user’s ‘Your Music’ library.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
limit : Optional[int]
|
||||||
|
The maximum number of items to return. Default: 20. Minimum: 1. Maximum: 50.
|
||||||
|
offset : Optional[int]
|
||||||
|
The index of the first item to return. Default: 0
|
||||||
|
"""
|
||||||
|
data = await self.user.http.saved_albums(limit=limit, offset=offset)
|
||||||
|
|
||||||
|
return [Album(self.__client, item["album"]) for item in data["items"]]
|
||||||
|
|
||||||
|
@set_required_scopes("user-library-read")
|
||||||
|
async def get_all_albums(self) -> List[Album]:
|
||||||
|
"""Get a list of the albums saved in the current Spotify user’s ‘Your Music’ library.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
albums : List[:class:`Album`]
|
||||||
|
The albums.
|
||||||
|
"""
|
||||||
|
albums: List[Album] = []
|
||||||
|
total = None
|
||||||
|
offset = 0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
data = await self.user.http.saved_albums(limit=50, offset=offset)
|
||||||
|
|
||||||
|
if total is None:
|
||||||
|
total = data["total"]
|
||||||
|
|
||||||
|
offset += 50
|
||||||
|
albums += list(
|
||||||
|
Album(self.__client, item["album"]) for item in data["items"]
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(albums) >= total:
|
||||||
|
break
|
||||||
|
|
||||||
|
return albums
|
||||||
|
|
||||||
|
@set_required_scopes("user-library-modify")
|
||||||
|
async def remove_albums(self, *albums):
|
||||||
|
"""Remove one or more albums from the current user’s ‘Your Music’ library.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
albums : Sequence[Union[Album, str]]
|
||||||
|
A sequence of artist objects or spotify IDs
|
||||||
|
"""
|
||||||
|
_albums = [(obj if isinstance(obj, str) else obj.id) for obj in albums]
|
||||||
|
await self.user.http.delete_saved_albums(",".join(_albums))
|
||||||
|
|
||||||
|
@set_required_scopes("user-library-modify")
|
||||||
|
async def remove_tracks(self, *tracks):
|
||||||
|
"""Remove one or more tracks from the current user’s ‘Your Music’ library.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
tracks : Sequence[Union[Track, str]]
|
||||||
|
A sequence of track objects or spotify IDs
|
||||||
|
"""
|
||||||
|
_tracks = [(obj if isinstance(obj, str) else obj.id) for obj in tracks]
|
||||||
|
await self.user.http.delete_saved_tracks(",".join(_tracks))
|
||||||
|
|
||||||
|
@set_required_scopes("user-library-modify")
|
||||||
|
async def save_albums(self, *albums):
|
||||||
|
"""Save one or more albums to the current user’s ‘Your Music’ library.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
albums : Sequence[Union[Album, str]]
|
||||||
|
A sequence of artist objects or spotify IDs
|
||||||
|
"""
|
||||||
|
_albums = [(obj if isinstance(obj, str) else obj.id) for obj in albums]
|
||||||
|
await self.user.http.save_albums(",".join(_albums))
|
||||||
|
|
||||||
|
@set_required_scopes("user-library-modify")
|
||||||
|
async def save_tracks(self, *tracks):
|
||||||
|
"""Save one or more tracks to the current user’s ‘Your Music’ library.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
tracks : Sequence[Union[Track, str]]
|
||||||
|
A sequence of track objects or spotify IDs
|
||||||
|
"""
|
||||||
|
_tracks = [(obj if isinstance(obj, str) else obj.id) for obj in tracks]
|
||||||
|
await self.user.http.save_tracks(_tracks)
|
||||||
|
|
@ -0,0 +1,262 @@
|
||||||
|
from typing import Union, Optional, List
|
||||||
|
|
||||||
|
from ..oauth import set_required_scopes
|
||||||
|
from . import SpotifyBase, Device, Track
|
||||||
|
from .typing import SomeURIs, SomeURI
|
||||||
|
|
||||||
|
Offset = Union[int, str, Track]
|
||||||
|
SomeDevice = Union[Device, str]
|
||||||
|
|
||||||
|
|
||||||
|
class Player(SpotifyBase): # pylint: disable=too-many-instance-attributes
|
||||||
|
"""A Spotify Users current playback.
|
||||||
|
|
||||||
|
Attributes
|
||||||
|
----------
|
||||||
|
device : :class:`spotify.Device`
|
||||||
|
The device that is currently active.
|
||||||
|
repeat_state : :class:`str`
|
||||||
|
"off", "track", "context"
|
||||||
|
shuffle_state : :class:`bool`
|
||||||
|
If shuffle is on or off.
|
||||||
|
is_playing : :class:`bool`
|
||||||
|
If something is currently playing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, client, user, data):
|
||||||
|
self.__client = client
|
||||||
|
self.__user = user
|
||||||
|
|
||||||
|
self.repeat_state = data.get("repeat_state", None)
|
||||||
|
self.shuffle_state = data.pop("shuffle_state", None)
|
||||||
|
self.is_playing = data.pop("is_playing", None)
|
||||||
|
self.device = Device(data=data.pop("device", None))
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<spotify.Player: {self.user!r}>"
|
||||||
|
|
||||||
|
# Properties
|
||||||
|
|
||||||
|
@property
|
||||||
|
def user(self):
|
||||||
|
return self.__user
|
||||||
|
|
||||||
|
# Public methods
|
||||||
|
|
||||||
|
@set_required_scopes("user-modify-playback-state")
|
||||||
|
async def pause(self, *, device: Optional[SomeDevice] = None):
|
||||||
|
"""Pause playback on the user’s account.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
device : Optional[:obj:`SomeDevice`]
|
||||||
|
The Device object or id of the device this command is targeting.
|
||||||
|
If not supplied, the user’s currently active device is the target.
|
||||||
|
"""
|
||||||
|
device_id: Optional[str] = str(device) if device is not None else None
|
||||||
|
await self.user.http.pause_playback(device_id=device_id)
|
||||||
|
|
||||||
|
@set_required_scopes("user-modify-playback-state")
|
||||||
|
async def resume(self, *, device: Optional[SomeDevice] = None):
|
||||||
|
"""Resume playback on the user's account.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
device : Optional[:obj:`SomeDevice`]
|
||||||
|
The Device object or id of the device this command is targeting.
|
||||||
|
If not supplied, the user’s currently active device is the target.
|
||||||
|
"""
|
||||||
|
device_id: Optional[str] = str(device) if device is not None else None
|
||||||
|
await self.user.http.play_playback(None, device_id=device_id)
|
||||||
|
|
||||||
|
@set_required_scopes("user-modify-playback-state")
|
||||||
|
async def seek(self, pos, *, device: Optional[SomeDevice] = None):
|
||||||
|
"""Seeks to the given position in the user’s currently playing track.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
pos : int
|
||||||
|
The position in milliseconds to seek to.
|
||||||
|
Must be a positive number.
|
||||||
|
Passing in a position that is greater than the length of the track will cause the player to start playing the next song.
|
||||||
|
device : Optional[:obj:`SomeDevice`]
|
||||||
|
The Device object or id of the device this command is targeting.
|
||||||
|
If not supplied, the user’s currently active device is the target.
|
||||||
|
"""
|
||||||
|
device_id: Optional[str] = str(device) if device is not None else None
|
||||||
|
await self.user.http.seek_playback(pos, device_id=device_id)
|
||||||
|
|
||||||
|
@set_required_scopes("user-modify-playback-state")
|
||||||
|
async def set_repeat(self, state, *, device: Optional[SomeDevice] = None):
|
||||||
|
"""Set the repeat mode for the user’s playback.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
state : str
|
||||||
|
Options are repeat-track, repeat-context, and off
|
||||||
|
device : Optional[:obj:`SomeDevice`]
|
||||||
|
The Device object or id of the device this command is targeting.
|
||||||
|
If not supplied, the user’s currently active device is the target.
|
||||||
|
"""
|
||||||
|
device_id: Optional[str] = str(device) if device is not None else None
|
||||||
|
await self.user.http.repeat_playback(state, device_id=device_id)
|
||||||
|
|
||||||
|
@set_required_scopes("user-modify-playback-state")
|
||||||
|
async def set_volume(self, volume: int, *, device: Optional[SomeDevice] = None):
|
||||||
|
"""Set the volume for the user’s current playback device.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
volume : int
|
||||||
|
The volume to set. Must be a value from 0 to 100 inclusive.
|
||||||
|
device : Optional[:obj:`SomeDevice`]
|
||||||
|
The Device object or id of the device this command is targeting.
|
||||||
|
If not supplied, the user’s currently active device is the target.
|
||||||
|
"""
|
||||||
|
device_id: Optional[str] = str(device) if device is not None else None
|
||||||
|
await self.user.http.set_playback_volume(volume, device_id=device_id)
|
||||||
|
|
||||||
|
@set_required_scopes("user-modify-playback-state")
|
||||||
|
async def next(self, *, device: Optional[SomeDevice] = None):
|
||||||
|
"""Skips to next track in the user’s queue.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
device : Optional[:obj:`SomeDevice`]
|
||||||
|
The Device object or id of the device this command is targeting.
|
||||||
|
If not supplied, the user’s currently active device is the target.
|
||||||
|
"""
|
||||||
|
device_id: Optional[str] = str(device) if device is not None else None
|
||||||
|
await self.user.http.skip_next(device_id=device_id)
|
||||||
|
|
||||||
|
@set_required_scopes("user-modify-playback-state")
|
||||||
|
async def previous(self, *, device: Optional[SomeDevice] = None):
|
||||||
|
"""Skips to previous track in the user’s queue.
|
||||||
|
|
||||||
|
Note that this will ALWAYS skip to the previous track, regardless of the current track’s progress.
|
||||||
|
Returning to the start of the current track should be performed using :meth:`seek`
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
device : Optional[:obj:`SomeDevice`]
|
||||||
|
The Device object or id of the device this command is targeting.
|
||||||
|
If not supplied, the user’s currently active device is the target.
|
||||||
|
"""
|
||||||
|
device_id: Optional[str] = str(device) if device is not None else None
|
||||||
|
return await self.user.http.skip_previous(device_id=device_id)
|
||||||
|
|
||||||
|
@set_required_scopes("user-modify-playback-state")
|
||||||
|
async def enqueue(self, uri: SomeURI, device: Optional[SomeDevice] = None):
|
||||||
|
"""Add an item to the end of the user’s current playback queue.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
uri : Union[:class:`spotify.URIBase`, :class:`str`]
|
||||||
|
The uri of the item to add to the queue. Must be a track or an
|
||||||
|
episode uri.
|
||||||
|
device_id : Optional[Union[Device, :class:`str`]]
|
||||||
|
The id of the device this command is targeting. If not supplied,
|
||||||
|
the user’s currently active device is the target.
|
||||||
|
"""
|
||||||
|
device_id: Optional[str]
|
||||||
|
if device is not None:
|
||||||
|
if not isinstance(device, (Device, str)):
|
||||||
|
raise TypeError(
|
||||||
|
f"Expected `device` to either be a spotify.Device or a string. got {type(device)!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
device_id = str(device)
|
||||||
|
else:
|
||||||
|
device_id = None
|
||||||
|
|
||||||
|
await self.user.http.playback_queue(uri=str(uri), device_id=device_id)
|
||||||
|
|
||||||
|
@set_required_scopes("user-modify-playback-state")
|
||||||
|
async def play(
|
||||||
|
self,
|
||||||
|
*uris: SomeURIs,
|
||||||
|
offset: Optional[Offset] = 0,
|
||||||
|
device: Optional[SomeDevice] = None,
|
||||||
|
):
|
||||||
|
"""Start a new context or resume current playback on the user’s active device.
|
||||||
|
|
||||||
|
The method treats a single argument as a Spotify context, such as a Artist, Album and playlist objects/URI.
|
||||||
|
When called with multiple positional arguments they are interpreted as a array of Spotify Track objects/URIs.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
*uris : SomeURI
|
||||||
|
When a single argument is passed in that argument is treated
|
||||||
|
as a context (except if it is a track or track uri).
|
||||||
|
Valid contexts are: albums, artists, playlists.
|
||||||
|
Album, Artist and Playlist objects are accepted too.
|
||||||
|
Otherwise when multiple arguments are passed in they,
|
||||||
|
A sequence of Spotify Tracks or Track URIs to play.
|
||||||
|
offset : Optional[:obj:`Offset`]
|
||||||
|
Indicates from where in the context playback should start.
|
||||||
|
Only available when `context` corresponds to an album or playlist object,
|
||||||
|
or when the `uris` parameter is used. when an integer offset is zero based and can’t be negative.
|
||||||
|
device : Optional[:obj:`SomeDevice`]
|
||||||
|
The Device object or id of the device this command is targeting.
|
||||||
|
If not supplied, the user’s currently active device is the target.
|
||||||
|
"""
|
||||||
|
context_uri: Union[List[str], str]
|
||||||
|
|
||||||
|
if (
|
||||||
|
len(uris) > 1
|
||||||
|
or isinstance(uris[0], Track)
|
||||||
|
or (isinstance(uris[0], str) and "track" in uris[0])
|
||||||
|
):
|
||||||
|
# Regular uris paramter
|
||||||
|
context_uri = [str(uri) for uri in uris]
|
||||||
|
else:
|
||||||
|
# Treat it as a context URI
|
||||||
|
context_uri = str(uris[0])
|
||||||
|
|
||||||
|
device_id: Optional[str]
|
||||||
|
if device is not None:
|
||||||
|
if not isinstance(device, (Device, str)):
|
||||||
|
raise TypeError(
|
||||||
|
f"Expected `device` to either be a spotify.Device or a string. got {type(device)!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
device_id = str(device)
|
||||||
|
else:
|
||||||
|
device_id = None
|
||||||
|
|
||||||
|
await self.user.http.play_playback(
|
||||||
|
context_uri, offset=offset, device_id=device_id
|
||||||
|
)
|
||||||
|
|
||||||
|
@set_required_scopes("user-modify-playback-state")
|
||||||
|
async def shuffle(
|
||||||
|
self, state: Optional[bool] = None, *, device: Optional[SomeDevice] = None
|
||||||
|
):
|
||||||
|
"""shuffle on or off for user’s playback.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
state : Optional[bool]
|
||||||
|
if `True` then Shuffle user’s playback.
|
||||||
|
else if `False` do not shuffle user’s playback.
|
||||||
|
device : Optional[:obj:`SomeDevice`]
|
||||||
|
The Device object or id of the device this command is targeting.
|
||||||
|
If not supplied, the user’s currently active device is the target.
|
||||||
|
"""
|
||||||
|
device_id: Optional[str] = str(device) if device is not None else None
|
||||||
|
await self.user.http.shuffle_playback(state, device_id=device_id)
|
||||||
|
|
||||||
|
@set_required_scopes("user-modify-playback-state")
|
||||||
|
async def transfer(self, device: SomeDevice, ensure_playback: bool = False):
|
||||||
|
"""Transfer playback to a new device and determine if it should start playing.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
device : :obj:`SomeDevice`
|
||||||
|
The device on which playback should be started/transferred.
|
||||||
|
ensure_playback : bool
|
||||||
|
if `True` ensure playback happens on new device.
|
||||||
|
else keep the current playback state.
|
||||||
|
"""
|
||||||
|
device_id: Optional[str] = str(device) if device is not None else None
|
||||||
|
await self.user.http.transfer_player(device_id=device_id, play=ensure_playback)
|
||||||
|
|
@ -0,0 +1,525 @@
|
||||||
|
from functools import partial
|
||||||
|
from itertools import islice
|
||||||
|
from typing import List, Optional, Union, Callable, Tuple, Iterable, TYPE_CHECKING, Any, Dict, Set
|
||||||
|
|
||||||
|
from ..oauth import set_required_scopes
|
||||||
|
from ..http import HTTPUserClient, HTTPClient
|
||||||
|
from . import AsyncIterable, URIBase, Track, PlaylistTrack, Image
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
import spotify
|
||||||
|
|
||||||
|
|
||||||
|
class MutableTracks:
|
||||||
|
__slots__ = (
|
||||||
|
"playlist",
|
||||||
|
"tracks",
|
||||||
|
"was_empty",
|
||||||
|
"is_empty",
|
||||||
|
"replace_tracks",
|
||||||
|
"get_all_tracks",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, playlist: "Playlist") -> None:
|
||||||
|
self.playlist = playlist
|
||||||
|
self.tracks = tracks = getattr(playlist, "_Playlist__tracks")
|
||||||
|
|
||||||
|
if tracks is not None:
|
||||||
|
self.was_empty = self.is_empty = not tracks
|
||||||
|
|
||||||
|
self.replace_tracks = playlist.replace_tracks
|
||||||
|
self.get_all_tracks = playlist.get_all_tracks
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
if self.tracks is None:
|
||||||
|
self.tracks = tracks = list(await self.get_all_tracks())
|
||||||
|
self.was_empty = self.is_empty = not tracks
|
||||||
|
else:
|
||||||
|
tracks = list(self.tracks)
|
||||||
|
|
||||||
|
return tracks
|
||||||
|
|
||||||
|
async def __aexit__(self, typ, value, traceback):
|
||||||
|
if self.was_empty and self.is_empty:
|
||||||
|
# the tracks were empty and is still empty.
|
||||||
|
# skip the api call.
|
||||||
|
return
|
||||||
|
|
||||||
|
tracks = self.tracks
|
||||||
|
|
||||||
|
await self.replace_tracks(*tracks)
|
||||||
|
setattr(self.playlist, "_Playlist__tracks", tuple(self.tracks))
|
||||||
|
|
||||||
|
|
||||||
|
class Playlist(URIBase, AsyncIterable): # pylint: disable=too-many-instance-attributes
|
||||||
|
"""A Spotify Playlist.
|
||||||
|
|
||||||
|
Attributes
|
||||||
|
----------
|
||||||
|
collaborative : :class:`bool`
|
||||||
|
Returns true if context is not search and the owner allows other users to modify the playlist. Otherwise returns false.
|
||||||
|
description : :class:`str`
|
||||||
|
The playlist description. Only returned for modified, verified playlists, otherwise null.
|
||||||
|
url : :class:`str`
|
||||||
|
The open.spotify URL.
|
||||||
|
followers : :class:`int`
|
||||||
|
The total amount of followers
|
||||||
|
href : :class:`str`
|
||||||
|
A link to the Web API endpoint providing full details of the playlist.
|
||||||
|
id : :class:`str`
|
||||||
|
The Spotify ID for the playlist.
|
||||||
|
images : List[:class:`spotify.Image`]
|
||||||
|
Images for the playlist.
|
||||||
|
The array may be empty or contain up to three images.
|
||||||
|
The images are returned by size in descending order.
|
||||||
|
If returned, the source URL for the image ( url ) is temporary and will expire in less than a day.
|
||||||
|
name : :class:`str`
|
||||||
|
The name of the playlist.
|
||||||
|
owner : :class:`spotify.User`
|
||||||
|
The user who owns the playlist
|
||||||
|
public : :class`bool`
|
||||||
|
The playlist’s public/private status:
|
||||||
|
true the playlist is public,
|
||||||
|
false the playlist is private,
|
||||||
|
null the playlist status is not relevant.
|
||||||
|
snapshot_id : :class:`str`
|
||||||
|
The version identifier for the current playlist.
|
||||||
|
tracks : Optional[Tuple[:class:`PlaylistTrack`]]
|
||||||
|
A tuple of :class:`PlaylistTrack` objects or `None`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__slots__ = (
|
||||||
|
"collaborative",
|
||||||
|
"description",
|
||||||
|
"url",
|
||||||
|
"followers",
|
||||||
|
"href",
|
||||||
|
"id",
|
||||||
|
"images",
|
||||||
|
"name",
|
||||||
|
"owner",
|
||||||
|
"public",
|
||||||
|
"uri",
|
||||||
|
"total_tracks",
|
||||||
|
"__client",
|
||||||
|
"__http",
|
||||||
|
"__tracks",
|
||||||
|
)
|
||||||
|
|
||||||
|
__tracks: Optional[Tuple[PlaylistTrack, ...]]
|
||||||
|
__http: Union[HTTPUserClient, HTTPClient]
|
||||||
|
total_tracks: Optional[int]
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
client: "spotify.Client",
|
||||||
|
data: Union[dict, "Playlist"],
|
||||||
|
*,
|
||||||
|
http: Optional[HTTPClient] = None,
|
||||||
|
):
|
||||||
|
self.__client = client
|
||||||
|
self.__http = http or client.http
|
||||||
|
|
||||||
|
assert self.__http is not None
|
||||||
|
|
||||||
|
self.__tracks = None
|
||||||
|
self.total_tracks = None
|
||||||
|
|
||||||
|
if not isinstance(data, (Playlist, dict)):
|
||||||
|
raise TypeError("data must be a Playlist instance or a dict.")
|
||||||
|
|
||||||
|
if isinstance(data, dict):
|
||||||
|
self.__from_raw(data)
|
||||||
|
else:
|
||||||
|
for name in filter((lambda name: name[0] != "_"), Playlist.__slots__):
|
||||||
|
setattr(self, name, getattr(data, name))
|
||||||
|
|
||||||
|
# AsyncIterable attrs
|
||||||
|
self.__aiter_klass__ = PlaylistTrack
|
||||||
|
self.__aiter_fetch__ = partial(
|
||||||
|
client.http.get_playlist_tracks, self.id, limit=50
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<spotify.Playlist: {getattr(self, "name", None) or self.id}>'
|
||||||
|
|
||||||
|
def __len__(self):
|
||||||
|
return self.total_tracks
|
||||||
|
|
||||||
|
# Internals
|
||||||
|
|
||||||
|
def __from_raw(self, data: dict) -> None:
|
||||||
|
from .user import User
|
||||||
|
|
||||||
|
client = self.__client
|
||||||
|
|
||||||
|
self.id = data.pop("id") # pylint: disable=invalid-name
|
||||||
|
|
||||||
|
self.images = tuple(Image(**image) for image in data.pop("images", []))
|
||||||
|
self.owner = User(client, data=data.pop("owner"))
|
||||||
|
|
||||||
|
self.public = data.pop("public")
|
||||||
|
self.collaborative = data.pop("collaborative")
|
||||||
|
self.description = data.pop("description", None)
|
||||||
|
self.followers = data.pop("followers", {}).get("total", None)
|
||||||
|
self.href = data.pop("href")
|
||||||
|
self.name = data.pop("name")
|
||||||
|
self.url = data.pop("external_urls").get("spotify", None)
|
||||||
|
self.uri = data.pop("uri")
|
||||||
|
|
||||||
|
tracks: Optional[Tuple[PlaylistTrack, ...]] = (
|
||||||
|
tuple(PlaylistTrack(client, item) for item in data["tracks"]["items"])
|
||||||
|
if "items" in data["tracks"]
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
self.__tracks = tracks
|
||||||
|
|
||||||
|
self.total_tracks = (
|
||||||
|
data["tracks"]["total"]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Track retrieval
|
||||||
|
|
||||||
|
@set_required_scopes(None)
|
||||||
|
async def get_tracks(
|
||||||
|
self, *, limit: Optional[int] = 20, offset: Optional[int] = 0
|
||||||
|
) -> Tuple[PlaylistTrack, ...]:
|
||||||
|
"""Get a fraction of a playlists tracks.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
limit : Optional[int]
|
||||||
|
The limit on how many tracks to retrieve for this playlist (default is 20).
|
||||||
|
offset : Optional[int]
|
||||||
|
The offset from where the api should start from in the tracks.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
tracks : Tuple[PlaylistTrack]
|
||||||
|
The tracks of the playlist.
|
||||||
|
"""
|
||||||
|
data = await self.__http.get_playlist_tracks(
|
||||||
|
self.id, limit=limit, offset=offset
|
||||||
|
)
|
||||||
|
return tuple(PlaylistTrack(self.__client, item) for item in data["items"])
|
||||||
|
|
||||||
|
@set_required_scopes(None)
|
||||||
|
async def get_all_tracks(self) -> Tuple[PlaylistTrack, ...]:
|
||||||
|
"""Get all playlist tracks from the playlist.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
tracks : Tuple[:class:`PlaylistTrack`]
|
||||||
|
The playlists tracks.
|
||||||
|
"""
|
||||||
|
tracks: List[PlaylistTrack] = []
|
||||||
|
offset = 0
|
||||||
|
|
||||||
|
if self.total_tracks is None:
|
||||||
|
self.total_tracks = (
|
||||||
|
await self.__http.get_playlist_tracks(self.id, limit=1, offset=0)
|
||||||
|
)["total"]
|
||||||
|
|
||||||
|
while len(tracks) < self.total_tracks:
|
||||||
|
data = await self.__http.get_playlist_tracks(
|
||||||
|
self.id, limit=50, offset=offset
|
||||||
|
)
|
||||||
|
|
||||||
|
tracks += [PlaylistTrack(self.__client, item) for item in data["items"]]
|
||||||
|
offset += 50
|
||||||
|
|
||||||
|
self.total_tracks = len(tracks)
|
||||||
|
return tuple(tracks)
|
||||||
|
|
||||||
|
# Playlist structure modification
|
||||||
|
|
||||||
|
# Basic api wrapping
|
||||||
|
|
||||||
|
@set_required_scopes("playlist-modify-public", "playlist-modify-private")
|
||||||
|
async def add_tracks(self, *tracks) -> str:
|
||||||
|
"""Add one or more tracks to a user’s playlist.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
tracks : Iterable[Union[:class:`str`, :class:`Track`]]
|
||||||
|
Tracks to add to the playlist
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
snapshot_id : :class:`str`
|
||||||
|
The snapshot id of the playlist.
|
||||||
|
"""
|
||||||
|
data = await self.__http.add_playlist_tracks(
|
||||||
|
self.id, tracks=[str(track) for track in tracks]
|
||||||
|
)
|
||||||
|
return data["snapshot_id"]
|
||||||
|
|
||||||
|
@set_required_scopes("playlist-modify-public", "playlist-modify-private")
|
||||||
|
async def remove_tracks(
|
||||||
|
self, *tracks: Union[str, Track, Tuple[Union[str, Track], List[int]]]
|
||||||
|
):
|
||||||
|
"""Remove one or more tracks from a user’s playlist.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
tracks : Iterable[Union[:class:`str`, :class:`Track`]]
|
||||||
|
Tracks to remove from the playlist
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
snapshot_id : :class:`str`
|
||||||
|
The snapshot id of the playlist.
|
||||||
|
"""
|
||||||
|
tracks_: List[Union[str, Dict[str, Union[str, Set[int]]]]] = []
|
||||||
|
|
||||||
|
for part in tracks:
|
||||||
|
if not isinstance(part, (Track, str, tuple)):
|
||||||
|
raise TypeError(
|
||||||
|
"Track argument of tracks parameter must be a Track instance, string or a tuple of those and an iterator of positive integers."
|
||||||
|
)
|
||||||
|
|
||||||
|
if isinstance(part, (Track, str)):
|
||||||
|
tracks_.append(str(part))
|
||||||
|
continue
|
||||||
|
|
||||||
|
track, positions, = part
|
||||||
|
|
||||||
|
if not isinstance(track, (Track, str)):
|
||||||
|
raise TypeError(
|
||||||
|
"Track argument of tuple track parameter must be a Track instance or a string."
|
||||||
|
)
|
||||||
|
|
||||||
|
if not hasattr(positions, "__iter__"):
|
||||||
|
raise TypeError("Positions element of track tuple must be a iterator.")
|
||||||
|
|
||||||
|
if not all(isinstance(index, int) for index in positions):
|
||||||
|
raise TypeError("Members of the positions iterator must be integers.")
|
||||||
|
|
||||||
|
elem: Dict[str, Union[str, Set[int]]] = {"uri": str(track), "positions": set(positions)}
|
||||||
|
tracks_.append(elem)
|
||||||
|
|
||||||
|
data = await self.__http.remove_playlist_tracks(self.id, tracks=tracks_)
|
||||||
|
return data["snapshot_id"]
|
||||||
|
|
||||||
|
@set_required_scopes("playlist-modify-public", "playlist-modify-private")
|
||||||
|
async def replace_tracks(self, *tracks: Union[Track, PlaylistTrack, str]) -> None:
|
||||||
|
"""Replace all the tracks in a playlist, overwriting its existing tracks.
|
||||||
|
|
||||||
|
This powerful request can be useful for replacing tracks, re-ordering existing tracks, or clearing the playlist.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
tracks : Iterable[Union[:class:`str`, :class:`Track`]]
|
||||||
|
Tracks to place in the playlist
|
||||||
|
"""
|
||||||
|
bucket: List[str] = []
|
||||||
|
for track in tracks:
|
||||||
|
if not isinstance(track, (str, Track)):
|
||||||
|
raise TypeError(
|
||||||
|
f"tracks must be a iterable of strings or Track instances. Got {type(track)!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
bucket.append(str(track))
|
||||||
|
|
||||||
|
body: Tuple[str, ...] = tuple(bucket)
|
||||||
|
|
||||||
|
head: Tuple[str, ...]
|
||||||
|
tail: Tuple[str, ...]
|
||||||
|
head, tail = body[:100], body[100:]
|
||||||
|
|
||||||
|
if head:
|
||||||
|
await self.__http.replace_playlist_tracks(self.id, tracks=head)
|
||||||
|
|
||||||
|
while tail:
|
||||||
|
head, tail = tail[:100], tail[100:]
|
||||||
|
await self.extend(head)
|
||||||
|
|
||||||
|
@set_required_scopes("playlist-modify-public", "playlist-modify-private")
|
||||||
|
async def reorder_tracks(
|
||||||
|
self,
|
||||||
|
start: int,
|
||||||
|
insert_before: int,
|
||||||
|
length: int = 1,
|
||||||
|
*,
|
||||||
|
snapshot_id: Optional[str] = None,
|
||||||
|
) -> str:
|
||||||
|
"""Reorder a track or a group of tracks in a playlist.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
start : int
|
||||||
|
The position of the first track to be reordered.
|
||||||
|
insert_before : int
|
||||||
|
The position where the tracks should be inserted.
|
||||||
|
length : Optional[int]
|
||||||
|
The amount of tracks to be reordered. Defaults to 1 if not set.
|
||||||
|
snapshot_id : str
|
||||||
|
The playlist’s snapshot ID against which you want to make the changes.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
snapshot_id : str
|
||||||
|
The snapshot id of the playlist.
|
||||||
|
"""
|
||||||
|
data = await self.__http.reorder_playlists_tracks(
|
||||||
|
self.id, start, length, insert_before, snapshot_id=snapshot_id
|
||||||
|
)
|
||||||
|
return data["snapshot_id"]
|
||||||
|
|
||||||
|
# Library functionality.
|
||||||
|
|
||||||
|
@set_required_scopes("playlist-modify-public", "playlist-modify-private")
|
||||||
|
async def clear(self):
|
||||||
|
"""Clear the playlists tracks.
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
|
||||||
|
This method will mutate the current
|
||||||
|
playlist object, and the spotify Playlist.
|
||||||
|
|
||||||
|
.. warning::
|
||||||
|
|
||||||
|
This is a desctructive operation and can not be reversed!
|
||||||
|
"""
|
||||||
|
await self.__http.replace_playlist_tracks(self.id, tracks=[])
|
||||||
|
|
||||||
|
@set_required_scopes("playlist-modify-public", "playlist-modify-private")
|
||||||
|
async def extend(self, tracks: Union["Playlist", Iterable[Union[Track, str]]]):
|
||||||
|
"""Extend a playlists tracks with that of another playlist or a list of Track/Track URIs.
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
|
||||||
|
This method will mutate the current
|
||||||
|
playlist object, and the spotify Playlist.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
tracks : Union["Playlist", List[Union[Track, str]]]
|
||||||
|
Tracks to add to the playlist, acceptable values are:
|
||||||
|
- A :class:`spotify.Playlist` object
|
||||||
|
- A :class:`list` of :class:`spotify.Track` objects or Track URIs
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
snapshot_id : str
|
||||||
|
The snapshot id of the playlist.
|
||||||
|
"""
|
||||||
|
bucket: Iterable[Union[Track, str]]
|
||||||
|
|
||||||
|
if isinstance(tracks, Playlist):
|
||||||
|
bucket = await tracks.get_all_tracks()
|
||||||
|
|
||||||
|
elif not hasattr(tracks, "__iter__"):
|
||||||
|
raise TypeError(
|
||||||
|
f"`tracks` was an invalid type, expected any of: Playlist, Iterable[Union[Track, str]], instead got {type(tracks)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
else:
|
||||||
|
bucket = list(tracks)
|
||||||
|
|
||||||
|
gen: Iterable[str] = (str(track) for track in bucket)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
head: List[str] = list(islice(gen, 0, 100))
|
||||||
|
|
||||||
|
if not head:
|
||||||
|
break
|
||||||
|
|
||||||
|
await self.__http.add_playlist_tracks(self.id, tracks=head)
|
||||||
|
|
||||||
|
@set_required_scopes("playlist-modify-public", "playlist-modify-private")
|
||||||
|
async def insert(self, index, obj: Union[PlaylistTrack, Track]) -> None:
|
||||||
|
"""Insert an object before the index.
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
|
||||||
|
This method will mutate the current
|
||||||
|
playlist object, and the spotify Playlist.
|
||||||
|
"""
|
||||||
|
if not isinstance(obj, (PlaylistTrack, Track)):
|
||||||
|
raise TypeError(
|
||||||
|
f"Expected a PlaylistTrack or Track object instead got {obj!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
async with MutableTracks(self) as tracks:
|
||||||
|
tracks.insert(index, obj)
|
||||||
|
|
||||||
|
@set_required_scopes("playlist-modify-public", "playlist-modify-private")
|
||||||
|
async def pop(self, index: int = -1) -> PlaylistTrack:
|
||||||
|
"""Remove and return the track at the specified index.
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
|
||||||
|
This method will mutate the current
|
||||||
|
playlist object, and the spotify Playlist.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
playlist_track : :class:`PlaylistTrack`
|
||||||
|
The track that was removed.
|
||||||
|
|
||||||
|
Raises
|
||||||
|
------
|
||||||
|
IndexError
|
||||||
|
If there are no tracks or the index is out of range.
|
||||||
|
"""
|
||||||
|
async with MutableTracks(self) as tracks:
|
||||||
|
return tracks.pop(index)
|
||||||
|
|
||||||
|
@set_required_scopes("playlist-modify-public", "playlist-modify-private")
|
||||||
|
async def sort(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
key: Optional[Callable[[PlaylistTrack], bool]] = None,
|
||||||
|
reverse: Optional[bool] = False,
|
||||||
|
) -> None:
|
||||||
|
"""Stable sort the playlist in place.
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
|
||||||
|
This method will mutate the current
|
||||||
|
playlist object, and the spotify Playlist.
|
||||||
|
"""
|
||||||
|
async with MutableTracks(self) as tracks:
|
||||||
|
tracks.sort(key=key, reverse=reverse)
|
||||||
|
|
||||||
|
@set_required_scopes("playlist-modify-public", "playlist-modify-private")
|
||||||
|
async def remove(self, value: Union[PlaylistTrack, Track]) -> None:
|
||||||
|
"""Remove the first occurence of the value.
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
|
||||||
|
This method will mutate the current
|
||||||
|
playlist object, and the spotify Playlist.
|
||||||
|
|
||||||
|
Raises
|
||||||
|
-------
|
||||||
|
ValueError
|
||||||
|
If the value is not present.
|
||||||
|
"""
|
||||||
|
async with MutableTracks(self) as tracks:
|
||||||
|
tracks.remove(value)
|
||||||
|
|
||||||
|
@set_required_scopes("playlist-modify-public", "playlist-modify-private")
|
||||||
|
async def copy(self) -> "Playlist":
|
||||||
|
"""Return a shallow copy of the playlist object.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
playlist : :class:`Playlist`
|
||||||
|
The playlist object copy.
|
||||||
|
"""
|
||||||
|
return Playlist(client=self.__client, data=self, http=self.__http)
|
||||||
|
|
||||||
|
@set_required_scopes("playlist-modify-public", "playlist-modify-private")
|
||||||
|
async def reverse(self) -> None:
|
||||||
|
"""Reverse the playlist in place.
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
|
||||||
|
This method will mutate the current
|
||||||
|
playlist object, and the spotify Playlist.
|
||||||
|
"""
|
||||||
|
async with MutableTracks(self) as tracks:
|
||||||
|
tracks.reverse()
|
||||||
|
|
@ -0,0 +1,123 @@
|
||||||
|
"""Source implementation for spotify Tracks, and any other semantically relevent, implementation."""
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
from itertools import starmap
|
||||||
|
|
||||||
|
from ..oauth import set_required_scopes
|
||||||
|
from . import URIBase, Image, Artist
|
||||||
|
|
||||||
|
|
||||||
|
class Track(URIBase): # pylint: disable=too-many-instance-attributes
|
||||||
|
"""A Spotify Track object.
|
||||||
|
|
||||||
|
Attribtues
|
||||||
|
----------
|
||||||
|
id : :class:`str`
|
||||||
|
The Spotify ID for the track.
|
||||||
|
name : :class:`str`
|
||||||
|
The name of the track.
|
||||||
|
href : :class:`str`
|
||||||
|
A link to the Web API endpoint providing full details of the track.
|
||||||
|
uri : :class:`str`
|
||||||
|
The Spotify URI for the track.
|
||||||
|
duration : int
|
||||||
|
The track length in milliseconds.
|
||||||
|
explicit : bool
|
||||||
|
Whether or not the track has explicit
|
||||||
|
`True` if it does.
|
||||||
|
`False` if it does not (or unknown)
|
||||||
|
disc_number : int
|
||||||
|
The disc number (usually 1 unless the album consists of more than one disc).
|
||||||
|
track_number : int
|
||||||
|
The number of the track.
|
||||||
|
If an album has several discs, the track number is the number on the specified disc.
|
||||||
|
url : :class:`str`
|
||||||
|
The open.spotify URL for this Track
|
||||||
|
is_local : bool
|
||||||
|
Whether or not the track is from a local file.
|
||||||
|
popularity : int
|
||||||
|
POPULARITY
|
||||||
|
preview_url : :class:`str`
|
||||||
|
The preview URL for this Track.
|
||||||
|
images : List[Image]
|
||||||
|
The images of the Track.
|
||||||
|
markets : List[:class:`str`]
|
||||||
|
The available markets for the Track.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, client, data, album=None):
|
||||||
|
from .album import Album
|
||||||
|
|
||||||
|
self.__client = client
|
||||||
|
|
||||||
|
self.artists = artists = list(
|
||||||
|
Artist(client, artist) for artist in data.pop("artists", [])
|
||||||
|
)
|
||||||
|
self.artist = artists[-1] if artists else None
|
||||||
|
|
||||||
|
album_ = data.pop("album", None)
|
||||||
|
self.album = Album(client, album_) if album_ else album
|
||||||
|
|
||||||
|
self.id = data.pop("id", None) # pylint: disable=invalid-name
|
||||||
|
self.name = data.pop("name", None)
|
||||||
|
self.href = data.pop("href", None)
|
||||||
|
self.uri = data.pop("uri", None)
|
||||||
|
self.duration = data.pop("duration_ms", None)
|
||||||
|
self.explicit = data.pop("explicit", None)
|
||||||
|
self.disc_number = data.pop("disc_number", None)
|
||||||
|
self.track_number = data.pop("track_number", None)
|
||||||
|
self.url = data.pop("external_urls").get("spotify", None)
|
||||||
|
self.is_local = data.pop("is_local", None)
|
||||||
|
self.popularity = data.pop("popularity", None)
|
||||||
|
self.preview_url = data.pop("preview_url", None)
|
||||||
|
self.markets = data.pop("available_markets", [])
|
||||||
|
|
||||||
|
if "images" in data:
|
||||||
|
self.images = list(starmap(Image, data.pop("images")))
|
||||||
|
else:
|
||||||
|
self.images = self.album.images.copy() if self.album is not None else []
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<spotify.Track: {self.name!r}>"
|
||||||
|
|
||||||
|
@set_required_scopes(None)
|
||||||
|
def audio_analysis(self):
|
||||||
|
"""Get a detailed audio analysis for the track."""
|
||||||
|
return self.__client.http.track_audio_analysis(self.id)
|
||||||
|
|
||||||
|
@set_required_scopes(None)
|
||||||
|
def audio_features(self):
|
||||||
|
"""Get audio feature information for the track."""
|
||||||
|
return self.__client.http.track_audio_features(self.id)
|
||||||
|
|
||||||
|
|
||||||
|
class PlaylistTrack(Track, URIBase):
|
||||||
|
"""A Track on a Playlist.
|
||||||
|
|
||||||
|
Like a regular :class:`Track` but has some additional attributes.
|
||||||
|
|
||||||
|
Attributes
|
||||||
|
----------
|
||||||
|
added_by : :class:`str`
|
||||||
|
The Spotify user who added the track.
|
||||||
|
is_local : bool
|
||||||
|
Whether this track is a local file or not.
|
||||||
|
added_at : datetime.datetime
|
||||||
|
The datetime of when the track was added to the playlist.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__slots__ = ("added_at", "added_by", "is_local")
|
||||||
|
|
||||||
|
def __init__(self, client, data):
|
||||||
|
from .user import User
|
||||||
|
|
||||||
|
super().__init__(client, data["track"])
|
||||||
|
|
||||||
|
self.added_by = User(client, data["added_by"])
|
||||||
|
self.added_at = datetime.datetime.strptime(
|
||||||
|
data["added_at"], "%Y-%m-%dT%H:%M:%SZ"
|
||||||
|
)
|
||||||
|
self.is_local = data["is_local"]
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<spotify.PlaylistTrack: {self.name!r}>"
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
"""Type annotation aliases for other Spotify models."""
|
||||||
|
|
||||||
|
from typing import Union, Sequence
|
||||||
|
|
||||||
|
from .base import URIBase
|
||||||
|
|
||||||
|
SomeURI = Union[URIBase, str]
|
||||||
|
SomeURIs = Sequence[Union[URIBase, str]]
|
||||||
|
OneOrMoreURIs = Union[SomeURI, Sequence[SomeURI]]
|
||||||
|
|
@ -0,0 +1,562 @@
|
||||||
|
"""Source implementation for a spotify User"""
|
||||||
|
|
||||||
|
import functools
|
||||||
|
from functools import partial
|
||||||
|
from base64 import b64encode
|
||||||
|
from typing import (
|
||||||
|
Optional,
|
||||||
|
Dict,
|
||||||
|
Union,
|
||||||
|
List,
|
||||||
|
Type,
|
||||||
|
TypeVar,
|
||||||
|
TYPE_CHECKING,
|
||||||
|
)
|
||||||
|
|
||||||
|
from ..utils import to_id
|
||||||
|
from ..http import HTTPUserClient
|
||||||
|
from . import (
|
||||||
|
AsyncIterable,
|
||||||
|
URIBase,
|
||||||
|
Image,
|
||||||
|
Device,
|
||||||
|
Context,
|
||||||
|
Player,
|
||||||
|
Playlist,
|
||||||
|
Track,
|
||||||
|
Artist,
|
||||||
|
Library,
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
import spotify
|
||||||
|
|
||||||
|
T = TypeVar("T", Artist, Track) # pylint: disable=invalid-name
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_http(func):
|
||||||
|
func.__ensure_http__ = True
|
||||||
|
return func
|
||||||
|
|
||||||
|
|
||||||
|
class User(URIBase, AsyncIterable): # pylint: disable=too-many-instance-attributes
|
||||||
|
"""A Spotify User.
|
||||||
|
|
||||||
|
Attributes
|
||||||
|
----------
|
||||||
|
id : :class:`str`
|
||||||
|
The Spotify user ID for the user.
|
||||||
|
uri : :class:`str`
|
||||||
|
The Spotify URI for the user.
|
||||||
|
url : :class:`str`
|
||||||
|
The open.spotify URL.
|
||||||
|
href : :class:`str`
|
||||||
|
A link to the Web API endpoint for this user.
|
||||||
|
display_name : :class:`str`
|
||||||
|
The name displayed on the user’s profile.
|
||||||
|
`None` if not available.
|
||||||
|
followers : :class:`int`
|
||||||
|
The total number of followers.
|
||||||
|
images : List[:class:`Image`]
|
||||||
|
The user’s profile image.
|
||||||
|
email : :class:`str`
|
||||||
|
The user’s email address, as entered by the user when creating their account.
|
||||||
|
country : :class:`str`
|
||||||
|
The country of the user, as set in the user’s account profile. An ISO 3166-1 alpha-2 country code.
|
||||||
|
birthdate : :class:`str`
|
||||||
|
The user’s date-of-birth.
|
||||||
|
product : :class:`str`
|
||||||
|
The user’s Spotify subscription level: “premium”, “free”, etc.
|
||||||
|
(The subscription level “open” can be considered the same as “free”.)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, client: "spotify.Client", data: dict, **kwargs):
|
||||||
|
self.__client = self.client = client
|
||||||
|
|
||||||
|
if "http" not in kwargs:
|
||||||
|
self.library = None
|
||||||
|
self.http = client.http
|
||||||
|
else:
|
||||||
|
self.http = kwargs.pop("http")
|
||||||
|
self.library = Library(client, self)
|
||||||
|
|
||||||
|
# Public user object attributes
|
||||||
|
self.id = data.pop("id") # pylint: disable=invalid-name
|
||||||
|
self.uri = data.pop("uri")
|
||||||
|
self.url = data.pop("external_urls").get("spotify", None)
|
||||||
|
self.display_name = data.pop("display_name", None)
|
||||||
|
self.href = data.pop("href")
|
||||||
|
self.followers = data.pop("followers", {}).get("total", None)
|
||||||
|
self.images = list(Image(**image) for image in data.pop("images", []))
|
||||||
|
|
||||||
|
# Private user object attributes
|
||||||
|
self.email = data.pop("email", None)
|
||||||
|
self.country = data.pop("country", None)
|
||||||
|
self.birthdate = data.pop("birthdate", None)
|
||||||
|
self.product = data.pop("product", None)
|
||||||
|
|
||||||
|
# AsyncIterable attrs
|
||||||
|
self.__aiter_klass__ = Playlist
|
||||||
|
self.__aiter_fetch__ = partial(
|
||||||
|
self.__client.http.get_playlists, self.id, limit=50
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<spotify.User: {(self.display_name or self.id)!r}>"
|
||||||
|
|
||||||
|
def __getattr__(self, attr):
|
||||||
|
value = object.__getattribute__(self, attr)
|
||||||
|
|
||||||
|
if (
|
||||||
|
hasattr(value, "__ensure_http__")
|
||||||
|
and getattr(self, "http", None) is not None
|
||||||
|
):
|
||||||
|
|
||||||
|
@functools.wraps(value)
|
||||||
|
def _raise(*args, **kwargs):
|
||||||
|
raise AttributeError(
|
||||||
|
"User has not HTTP presence to perform API requests."
|
||||||
|
)
|
||||||
|
|
||||||
|
return _raise
|
||||||
|
return value
|
||||||
|
|
||||||
|
async def __aenter__(self) -> "User":
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, _, __, ___):
|
||||||
|
await self.http.close()
|
||||||
|
|
||||||
|
# Internals
|
||||||
|
|
||||||
|
async def _get_top(self, klass: Type[T], kwargs: dict) -> List[T]:
|
||||||
|
target = {Artist: "artists", Track: "tracks"}[klass]
|
||||||
|
data = {
|
||||||
|
key: value
|
||||||
|
for key, value in kwargs.items()
|
||||||
|
if key in ("limit", "offset", "time_range")
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = await self.http.top_artists_or_tracks(target, **data) # type: ignore
|
||||||
|
|
||||||
|
return [klass(self.__client, item) for item in resp["items"]]
|
||||||
|
|
||||||
|
### Alternate constructors
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def from_code(
|
||||||
|
cls, client: "spotify.Client", code: str, *, redirect_uri: str,
|
||||||
|
):
|
||||||
|
"""Create a :class:`User` object from an authorization code.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
client : :class:`spotify.Client`
|
||||||
|
The spotify client to associate the user with.
|
||||||
|
code : :class:`str`
|
||||||
|
The authorization code to use to further authenticate the user.
|
||||||
|
redirect_uri : :class:`str`
|
||||||
|
The rediriect URI to use in tandem with the authorization code.
|
||||||
|
"""
|
||||||
|
route = ("POST", "https://accounts.spotify.com/api/token")
|
||||||
|
payload = {
|
||||||
|
"redirect_uri": redirect_uri,
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"code": code,
|
||||||
|
}
|
||||||
|
|
||||||
|
client_id = client.http.client_id
|
||||||
|
client_secret = client.http.client_secret
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Basic {b64encode(':'.join((client_id, client_secret)).encode()).decode()}",
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
}
|
||||||
|
|
||||||
|
raw = await client.http.request(route, headers=headers, params=payload)
|
||||||
|
|
||||||
|
token = raw["access_token"]
|
||||||
|
refresh_token = raw["refresh_token"]
|
||||||
|
|
||||||
|
return await cls.from_token(client, token, refresh_token)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def from_token(
|
||||||
|
cls,
|
||||||
|
client: "spotify.Client",
|
||||||
|
token: Optional[str],
|
||||||
|
refresh_token: Optional[str] = None,
|
||||||
|
):
|
||||||
|
"""Create a :class:`User` object from an access token.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
client : :class:`spotify.Client`
|
||||||
|
The spotify client to associate the user with.
|
||||||
|
token : :class:`str`
|
||||||
|
The access token to use for http requests.
|
||||||
|
refresh_token : :class:`str`
|
||||||
|
Used to acquire new token when it expires.
|
||||||
|
"""
|
||||||
|
client_id = client.http.client_id
|
||||||
|
client_secret = client.http.client_secret
|
||||||
|
http = HTTPUserClient(client_id, client_secret, token, refresh_token)
|
||||||
|
data = await http.current_user()
|
||||||
|
return cls(client, data=data, http=http)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def from_refresh_token(cls, client: "spotify.Client", refresh_token: str):
|
||||||
|
"""Create a :class:`User` object from a refresh token.
|
||||||
|
It will poll the spotify API for a new access token and
|
||||||
|
use that to initialize the spotify user.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
client : :class:`spotify.Client`
|
||||||
|
The spotify client to associate the user with.
|
||||||
|
refresh_token: str
|
||||||
|
Used to acquire token.
|
||||||
|
"""
|
||||||
|
return await cls.from_token(client, None, refresh_token)
|
||||||
|
|
||||||
|
### Contextual methods
|
||||||
|
|
||||||
|
@ensure_http
|
||||||
|
async def currently_playing(self) -> Dict[str, Union[Track, Context, str]]:
|
||||||
|
"""Get the users currently playing track.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
context, track : Dict[str, Union[Track, Context, str]]
|
||||||
|
A tuple of the context and track.
|
||||||
|
"""
|
||||||
|
data = await self.http.currently_playing() # type: ignore
|
||||||
|
|
||||||
|
if "item" in data:
|
||||||
|
context = data.pop("context", None)
|
||||||
|
|
||||||
|
if context is not None:
|
||||||
|
data["context"] = Context(context)
|
||||||
|
else:
|
||||||
|
data["context"] = None
|
||||||
|
|
||||||
|
data["item"] = Track(self.__client, data.get("item", {}) or {})
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
@ensure_http
|
||||||
|
async def get_player(self) -> Player:
|
||||||
|
"""Get information about the users current playback.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
player : :class:`Player`
|
||||||
|
A player object representing the current playback.
|
||||||
|
"""
|
||||||
|
player = Player(self.__client, self, await self.http.current_player()) # type: ignore
|
||||||
|
return player
|
||||||
|
|
||||||
|
@ensure_http
|
||||||
|
async def get_devices(self) -> List[Device]:
|
||||||
|
"""Get information about the users avaliable devices.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
devices : List[:class:`Device`]
|
||||||
|
The devices the user has available.
|
||||||
|
"""
|
||||||
|
data = await self.http.available_devices() # type: ignore
|
||||||
|
return [Device(item) for item in data["devices"]]
|
||||||
|
|
||||||
|
@ensure_http
|
||||||
|
async def recently_played(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
limit: int = 20,
|
||||||
|
before: Optional[str] = None,
|
||||||
|
after: Optional[str] = None,
|
||||||
|
) -> List[Dict[str, Union[Track, Context, str]]]:
|
||||||
|
"""Get tracks from the current users recently played tracks.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
playlist_history : List[Dict[:class:`str`, Union[Track, Context, :class:`str`]]]
|
||||||
|
A list of playlist history object.
|
||||||
|
Each object is a dict with a timestamp, track and context field.
|
||||||
|
"""
|
||||||
|
data = await self.http.recently_played(limit=limit, before=before, after=after) # type: ignore
|
||||||
|
client = self.__client
|
||||||
|
|
||||||
|
# List[T] where T: {'track': Track, 'content': Context: 'timestamp': ISO8601}
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"played_at": track.get("played_at"),
|
||||||
|
"context": Context(track.get("context", {}) or {}),
|
||||||
|
"track": Track(client, track.get("track", {}) or {}),
|
||||||
|
}
|
||||||
|
for track in data["items"]
|
||||||
|
]
|
||||||
|
|
||||||
|
### Playlist track methods
|
||||||
|
|
||||||
|
@ensure_http
|
||||||
|
async def add_tracks(self, playlist: Union[str, Playlist], *tracks) -> str:
|
||||||
|
"""Add one or more tracks to a user’s playlist.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
playlist : Union[:class:`str`, Playlist]
|
||||||
|
The playlist to modify
|
||||||
|
tracks : Sequence[Union[:class:`str`, Track]]
|
||||||
|
Tracks to add to the playlist
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
snapshot_id : :class:`str`
|
||||||
|
The snapshot id of the playlist.
|
||||||
|
"""
|
||||||
|
data = await self.http.add_playlist_tracks( # type: ignore
|
||||||
|
to_id(str(playlist)), tracks=[str(track) for track in tracks]
|
||||||
|
)
|
||||||
|
return data["snapshot_id"]
|
||||||
|
|
||||||
|
@ensure_http
|
||||||
|
async def replace_tracks(self, playlist, *tracks) -> None:
|
||||||
|
"""Replace all the tracks in a playlist, overwriting its existing tracks.
|
||||||
|
|
||||||
|
This powerful request can be useful for replacing tracks, re-ordering existing tracks, or clearing the playlist.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
playlist : Union[:class:`str`, PLaylist]
|
||||||
|
The playlist to modify
|
||||||
|
tracks : Sequence[Union[:class:`str`, Track]]
|
||||||
|
Tracks to place in the playlist
|
||||||
|
"""
|
||||||
|
await self.http.replace_playlist_tracks( # type: ignore
|
||||||
|
to_id(str(playlist)), tracks=",".join(str(track) for track in tracks)
|
||||||
|
)
|
||||||
|
|
||||||
|
@ensure_http
|
||||||
|
async def remove_tracks(self, playlist, *tracks):
|
||||||
|
"""Remove one or more tracks from a user’s playlist.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
playlist : Union[:class:`str`, Playlist]
|
||||||
|
The playlist to modify
|
||||||
|
tracks : Sequence[Union[:class:`str`, Track]]
|
||||||
|
Tracks to remove from the playlist
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
snapshot_id : :class:`str`
|
||||||
|
The snapshot id of the playlist.
|
||||||
|
"""
|
||||||
|
data = await self.http.remove_playlist_tracks( # type: ignore
|
||||||
|
to_id(str(playlist)), tracks=(str(track) for track in tracks)
|
||||||
|
)
|
||||||
|
return data["snapshot_id"]
|
||||||
|
|
||||||
|
@ensure_http
|
||||||
|
async def reorder_tracks(
|
||||||
|
self, playlist, start, insert_before, length=1, *, snapshot_id=None
|
||||||
|
):
|
||||||
|
"""Reorder a track or a group of tracks in a playlist.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
playlist : Union[:class:`str`, Playlist]
|
||||||
|
The playlist to modify
|
||||||
|
start : int
|
||||||
|
The position of the first track to be reordered.
|
||||||
|
insert_before : int
|
||||||
|
The position where the tracks should be inserted.
|
||||||
|
length : Optional[int]
|
||||||
|
The amount of tracks to be reordered. Defaults to 1 if not set.
|
||||||
|
snapshot_id : :class:`str`
|
||||||
|
The playlist’s snapshot ID against which you want to make the changes.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
snapshot_id : :class:`str`
|
||||||
|
The snapshot id of the playlist.
|
||||||
|
"""
|
||||||
|
data = await self.http.reorder_playlists_tracks( # type: ignore
|
||||||
|
to_id(str(playlist)), start, length, insert_before, snapshot_id=snapshot_id
|
||||||
|
)
|
||||||
|
return data["snapshot_id"]
|
||||||
|
|
||||||
|
### Playlist methods
|
||||||
|
|
||||||
|
@ensure_http
|
||||||
|
async def edit_playlist(
|
||||||
|
self, playlist, *, name=None, public=None, collaborative=None, description=None
|
||||||
|
):
|
||||||
|
"""Change a playlist’s name and public/private, collaborative state and description.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
playlist : Union[:class:`str`, Playlist]
|
||||||
|
The playlist to modify
|
||||||
|
name : Optional[:class:`str`]
|
||||||
|
The new name of the playlist.
|
||||||
|
public : Optional[bool]
|
||||||
|
The public/private status of the playlist.
|
||||||
|
`True` for public, `False` for private.
|
||||||
|
collaborative : Optional[bool]
|
||||||
|
If `True`, the playlist will become collaborative and other users will be able to modify the playlist.
|
||||||
|
description : Optional[:class:`str`]
|
||||||
|
The new playlist description
|
||||||
|
"""
|
||||||
|
|
||||||
|
kwargs = {
|
||||||
|
"name": name,
|
||||||
|
"public": public,
|
||||||
|
"collaborative": collaborative,
|
||||||
|
"description": description,
|
||||||
|
}
|
||||||
|
|
||||||
|
await self.http.change_playlist_details(to_id(str(playlist)), **kwargs) # type: ignore
|
||||||
|
|
||||||
|
@ensure_http
|
||||||
|
async def create_playlist(
|
||||||
|
self, name, *, public=True, collaborative=False, description=None
|
||||||
|
):
|
||||||
|
"""Create a playlist for a Spotify user.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
name : :class:`str`
|
||||||
|
The name of the playlist.
|
||||||
|
public : Optional[bool]
|
||||||
|
The public/private status of the playlist.
|
||||||
|
`True` for public, `False` for private.
|
||||||
|
collaborative : Optional[bool]
|
||||||
|
If `True`, the playlist will become collaborative and other users will be able to modify the playlist.
|
||||||
|
description : Optional[:class:`str`]
|
||||||
|
The playlist description
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
playlist : :class:`Playlist`
|
||||||
|
The playlist that was created.
|
||||||
|
"""
|
||||||
|
data = {"name": name, "public": public, "collaborative": collaborative}
|
||||||
|
|
||||||
|
if description:
|
||||||
|
data["description"] = description
|
||||||
|
|
||||||
|
playlist_data = await self.http.create_playlist(self.id, **data) # type: ignore
|
||||||
|
return Playlist(self.__client, playlist_data, http=self.http)
|
||||||
|
|
||||||
|
@ensure_http
|
||||||
|
async def follow_playlist(
|
||||||
|
self, playlist: Union[str, Playlist], *, public: bool = True
|
||||||
|
) -> None:
|
||||||
|
"""follow a playlist
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
playlist : Union[:class:`str`, Playlist]
|
||||||
|
The playlist to modify
|
||||||
|
public : Optional[bool]
|
||||||
|
The public/private status of the playlist.
|
||||||
|
`True` for public, `False` for private.
|
||||||
|
"""
|
||||||
|
await self.http.follow_playlist(to_id(str(playlist)), public=public) # type: ignore
|
||||||
|
|
||||||
|
@ensure_http
|
||||||
|
async def get_playlists(
|
||||||
|
self, *, limit: int = 20, offset: int = 0
|
||||||
|
) -> List[Playlist]:
|
||||||
|
"""get the users playlists from spotify.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
limit : Optional[int]
|
||||||
|
The limit on how many playlists to retrieve for this user (default is 20).
|
||||||
|
offset : Optional[int]
|
||||||
|
The offset from where the api should start from in the playlists.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
playlists : List[Playlist]
|
||||||
|
A list of the users playlists.
|
||||||
|
"""
|
||||||
|
data = await self.http.get_playlists(self.id, limit=limit, offset=offset) # type: ignore
|
||||||
|
|
||||||
|
return [
|
||||||
|
Playlist(self.__client, playlist_data, http=self.http)
|
||||||
|
for playlist_data in data["items"]
|
||||||
|
]
|
||||||
|
|
||||||
|
@ensure_http
|
||||||
|
async def get_all_playlists(self) -> List[Playlist]:
|
||||||
|
"""Get all of the users playlists from spotify.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
playlists : List[:class:`Playlist`]
|
||||||
|
A list of the users playlists.
|
||||||
|
"""
|
||||||
|
playlists: List[Playlist] = []
|
||||||
|
total = None
|
||||||
|
offset = 0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
data = await self.http.get_playlists(self.id, limit=50, offset=offset) # type: ignore
|
||||||
|
|
||||||
|
if total is None:
|
||||||
|
total = data["total"]
|
||||||
|
|
||||||
|
offset += 50
|
||||||
|
playlists += [
|
||||||
|
Playlist(self.__client, playlist_data, http=self.http)
|
||||||
|
for playlist_data in data["items"]
|
||||||
|
]
|
||||||
|
|
||||||
|
if len(playlists) >= total:
|
||||||
|
break
|
||||||
|
|
||||||
|
return playlists
|
||||||
|
|
||||||
|
@ensure_http
|
||||||
|
async def top_artists(self, **data) -> List[Artist]:
|
||||||
|
"""Get the current user’s top artists based on calculated affinity.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
limit : Optional[int]
|
||||||
|
The number of entities to return. Default: 20. Minimum: 1. Maximum: 50.
|
||||||
|
offset : Optional[int]
|
||||||
|
The index of the first entity to return. Default: 0
|
||||||
|
time_range : Optional[:class:`str`]
|
||||||
|
Over what time frame the affinities are computed. (long_term, short_term, medium_term)
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
tracks : List[Artist]
|
||||||
|
The top artists for the user.
|
||||||
|
"""
|
||||||
|
return await self._get_top(Artist, data)
|
||||||
|
|
||||||
|
@ensure_http
|
||||||
|
async def top_tracks(self, **data) -> List[Track]:
|
||||||
|
"""Get the current user’s top tracks based on calculated affinity.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
limit : Optional[int]
|
||||||
|
The number of entities to return. Default: 20. Minimum: 1. Maximum: 50.
|
||||||
|
offset : Optional[int]
|
||||||
|
The index of the first entity to return. Default: 0
|
||||||
|
time_range : Optional[:class:`str`]
|
||||||
|
Over what time frame the affinities are computed. (long_term, short_term, medium_term)
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
tracks : List[Track]
|
||||||
|
The top tracks for the user.
|
||||||
|
"""
|
||||||
|
return await self._get_top(Track, data)
|
||||||
|
|
@ -0,0 +1,202 @@
|
||||||
|
from urllib.parse import quote_plus as quote
|
||||||
|
from typing import Optional, Dict, Iterable, Union, Set, Callable, Tuple, Any
|
||||||
|
|
||||||
|
VALID_SCOPES = (
|
||||||
|
# Playlists
|
||||||
|
"playlist-read-collaborative"
|
||||||
|
"playlist-modify-private"
|
||||||
|
"playlist-modify-public"
|
||||||
|
"playlist-read-private"
|
||||||
|
# Spotify Connect
|
||||||
|
"user-modify-playback-state"
|
||||||
|
"user-read-currently-playing"
|
||||||
|
"user-read-playback-state"
|
||||||
|
# Users
|
||||||
|
"user-read-private"
|
||||||
|
"user-read-email"
|
||||||
|
# Library
|
||||||
|
"user-library-modify"
|
||||||
|
"user-library-read"
|
||||||
|
# Follow
|
||||||
|
"user-follow-modify"
|
||||||
|
"user-follow-read"
|
||||||
|
# Listening History
|
||||||
|
"user-read-recently-played"
|
||||||
|
"user-top-read"
|
||||||
|
# Playback
|
||||||
|
"streaming"
|
||||||
|
"app-remote-control"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def set_required_scopes(*scopes: Optional[str]) -> Callable:
|
||||||
|
"""A decorator that lets you attach metadata to functions.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
scopes : :class:`str`
|
||||||
|
A series of scopes that are required.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
decorator : :class:`typing.Callable`
|
||||||
|
The decorator that sets the scope metadata.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def decorate(func) -> Callable:
|
||||||
|
func.__requires_spotify_scopes__ = tuple(scopes)
|
||||||
|
return func
|
||||||
|
|
||||||
|
return decorate
|
||||||
|
|
||||||
|
|
||||||
|
def get_required_scopes(func: Callable[..., Any]) -> Tuple[str, ...]:
|
||||||
|
"""Get the required scopes for a function.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
func : Callable[..., Any]
|
||||||
|
The function to inspect.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
scopes : Tuple[:class:`str`, ...]
|
||||||
|
A tuple of scopes required for a call to succeed.
|
||||||
|
"""
|
||||||
|
if not hasattr(func, "__requires_spotify_scopes__"):
|
||||||
|
raise AttributeError("Scope metadata has not been set for this object!")
|
||||||
|
return func.__requires_spotify_scopes__ # type: ignore
|
||||||
|
|
||||||
|
|
||||||
|
class OAuth2:
|
||||||
|
"""Helper object for Spotify OAuth2 operations.
|
||||||
|
|
||||||
|
At a very basic level you can you oauth2 only for authentication.
|
||||||
|
|
||||||
|
>>> oauth2 = OAuth2(client, 'some://redirect/uri')
|
||||||
|
>>> print(oauth2.url)
|
||||||
|
|
||||||
|
Working with scopes:
|
||||||
|
|
||||||
|
>>> oauth2 = OAuth2(client, 'some://redirect/uri', scopes=['user-read-currently-playing'])
|
||||||
|
>>> oauth2.set_scopes(user_read_playback_state=True)
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
client_id : :class:`str`
|
||||||
|
The client id provided by spotify for the app.
|
||||||
|
redirect_uri : :class:`str`
|
||||||
|
The URI Spotify should redirect to.
|
||||||
|
scopes : Optional[Iterable[:class:`str`], Dict[:class:`str`, :class:`bool`]]
|
||||||
|
The scopes to be requested.
|
||||||
|
state : Optional[:class:`str`]
|
||||||
|
The state used to secure sessions.
|
||||||
|
|
||||||
|
Attributes
|
||||||
|
----------
|
||||||
|
attrs : Dict[:class:`str`, :class:`str`]
|
||||||
|
The attributes used when constructing url parameters
|
||||||
|
parameters : :class:`str`
|
||||||
|
The URL parameters used
|
||||||
|
url : :class:`str`
|
||||||
|
The URL for OAuth2
|
||||||
|
"""
|
||||||
|
|
||||||
|
_BASE = "https://accounts.spotify.com/authorize/?response_type=code&{parameters}"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
client_id: str,
|
||||||
|
redirect_uri: str,
|
||||||
|
*,
|
||||||
|
scopes: Optional[Union[Iterable[str], Dict[str, bool]]] = None,
|
||||||
|
state: str = None,
|
||||||
|
):
|
||||||
|
self.client_id = client_id
|
||||||
|
self.redirect_uri = redirect_uri
|
||||||
|
self.state = state
|
||||||
|
self.__scopes: Set[str] = set()
|
||||||
|
|
||||||
|
if scopes is not None:
|
||||||
|
if not isinstance(scopes, dict) and hasattr(scopes, "__iter__"):
|
||||||
|
scopes = {scope: True for scope in scopes}
|
||||||
|
|
||||||
|
if isinstance(scopes, dict):
|
||||||
|
self.set_scopes(**scopes)
|
||||||
|
else:
|
||||||
|
raise TypeError(
|
||||||
|
f"scopes must be an iterable of strings or a dict of string to bools"
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<spotfy.OAuth2: client_id={self.client_id!r}, scope={self.scopes!r}>"
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.url
|
||||||
|
|
||||||
|
# Alternate constructors
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_client(cls, client, *args, **kwargs):
|
||||||
|
"""Construct a OAuth2 object from a `spotify.Client`."""
|
||||||
|
return cls(client.http.client_id, *args, **kwargs)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def url_only(**kwargs) -> str:
|
||||||
|
"""Construct a OAuth2 URL instead of an OAuth2 object."""
|
||||||
|
oauth = OAuth2(**kwargs)
|
||||||
|
return oauth.url
|
||||||
|
|
||||||
|
# Properties
|
||||||
|
|
||||||
|
@property
|
||||||
|
def scopes(self):
|
||||||
|
""":class:`frozenset` - A frozenset of the current scopes"""
|
||||||
|
return frozenset(self.__scopes)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def attributes(self):
|
||||||
|
"""Attributes used when constructing url parameters."""
|
||||||
|
data = {"client_id": self.client_id, "redirect_uri": quote(self.redirect_uri)}
|
||||||
|
|
||||||
|
if self.scopes:
|
||||||
|
data["scope"] = quote(" ".join(self.scopes))
|
||||||
|
|
||||||
|
if self.state is not None:
|
||||||
|
data["state"] = self.state
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
attrs = attributes
|
||||||
|
|
||||||
|
@property
|
||||||
|
def parameters(self) -> str:
|
||||||
|
""":class:`str` - The formatted url parameters that are used."""
|
||||||
|
return "&".join("{0}={1}".format(*item) for item in self.attributes.items())
|
||||||
|
|
||||||
|
@property
|
||||||
|
def url(self) -> str:
|
||||||
|
""":class:`str` - The formatted oauth url used for authorization."""
|
||||||
|
return self._BASE.format(parameters=self.parameters)
|
||||||
|
|
||||||
|
# Public api
|
||||||
|
|
||||||
|
def set_scopes(self, **scopes: Dict[str, bool]) -> None:
|
||||||
|
r"""Modify the scopes for the current oauth2 object.
|
||||||
|
|
||||||
|
Add or remove certain scopes from this oauth2 instance.
|
||||||
|
Since hypens are not allowed, replace the _ with -.
|
||||||
|
|
||||||
|
>>> oauth2.set_scopes(user_read_playback_state=True)
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
\*\*scopes: Dict[:class:`str`, :class:`bool`]
|
||||||
|
The scopes to enable or disable.
|
||||||
|
"""
|
||||||
|
for scope_name, state in scopes.items():
|
||||||
|
scope_name = scope_name.replace("_","-")
|
||||||
|
if state:
|
||||||
|
self.__scopes.add(scope_name)
|
||||||
|
else:
|
||||||
|
self.__scopes.remove(scope_name)
|
||||||
|
|
@ -1,97 +0,0 @@
|
||||||
from typing import List
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
__all__ = (
|
|
||||||
"Track",
|
|
||||||
"Playlist",
|
|
||||||
"Album",
|
|
||||||
"Artist",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Track:
|
|
||||||
"""The base class for a Spotify Track"""
|
|
||||||
|
|
||||||
def __init__(self, data: dict, image: Optional[str] = None) -> None:
|
|
||||||
self.name: str = data["name"]
|
|
||||||
self.artists: str = ", ".join(artist["name"] for artist in data["artists"])
|
|
||||||
self.length: float = data["duration_ms"]
|
|
||||||
self.id: str = data["id"]
|
|
||||||
|
|
||||||
self.isrc: Optional[str] = None
|
|
||||||
if data.get("external_ids"):
|
|
||||||
self.isrc = data["external_ids"]["isrc"]
|
|
||||||
|
|
||||||
self.image: Optional[str] = image
|
|
||||||
if data.get("album") and data["album"].get("images"):
|
|
||||||
self.image = data["album"]["images"][0]["url"]
|
|
||||||
|
|
||||||
self.uri: Optional[str] = None
|
|
||||||
if not data["is_local"]:
|
|
||||||
self.uri = data["external_urls"]["spotify"]
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
return (
|
|
||||||
f"<Pomice.spotify.Track name={self.name} artists={self.artists} "
|
|
||||||
f"length={self.length} id={self.id} isrc={self.isrc}>"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Playlist:
|
|
||||||
"""The base class for a Spotify playlist"""
|
|
||||||
|
|
||||||
def __init__(self, data: dict, tracks: List[Track]) -> None:
|
|
||||||
self.name: str = data["name"]
|
|
||||||
self.tracks = tracks
|
|
||||||
self.owner: str = data["owner"]["display_name"]
|
|
||||||
self.total_tracks: int = data["tracks"]["total"]
|
|
||||||
self.id: str = data["id"]
|
|
||||||
if data.get("images") and len(data["images"]):
|
|
||||||
self.image = data["images"][0]["url"]
|
|
||||||
else:
|
|
||||||
self.image = self.tracks[0].image
|
|
||||||
self.uri = data["external_urls"]["spotify"]
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
return (
|
|
||||||
f"<Pomice.spotify.Playlist name={self.name} owner={self.owner} id={self.id} "
|
|
||||||
f"total_tracks={self.total_tracks} tracks={self.tracks}>"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Album:
|
|
||||||
"""The base class for a Spotify album"""
|
|
||||||
|
|
||||||
def __init__(self, data: dict) -> None:
|
|
||||||
self.name: str = data["name"]
|
|
||||||
self.artists: str = ", ".join(artist["name"] for artist in data["artists"])
|
|
||||||
self.image: str = data["images"][0]["url"]
|
|
||||||
self.tracks = [Track(track, image=self.image) for track in data["tracks"]["items"]]
|
|
||||||
self.total_tracks: int = data["total_tracks"]
|
|
||||||
self.id: str = data["id"]
|
|
||||||
self.uri: str = data["external_urls"]["spotify"]
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
return (
|
|
||||||
f"<Pomice.spotify.Album name={self.name} artists={self.artists} id={self.id} "
|
|
||||||
f"total_tracks={self.total_tracks} tracks={self.tracks}>"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Artist:
|
|
||||||
"""The base class for a Spotify artist"""
|
|
||||||
|
|
||||||
def __init__(self, data: dict, tracks: dict) -> None:
|
|
||||||
self.name: str = (
|
|
||||||
# Setting that because its only playing top tracks
|
|
||||||
f"Top tracks for {data['name']}"
|
|
||||||
)
|
|
||||||
self.genres: str = ", ".join(genre for genre in data["genres"])
|
|
||||||
self.followers: int = data["followers"]["total"]
|
|
||||||
self.image: str = data["images"][0]["url"]
|
|
||||||
self.tracks = [Track(track, image=self.image) for track in tracks]
|
|
||||||
self.id: str = data["id"]
|
|
||||||
self.uri: str = data["external_urls"]["spotify"]
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
return f"<Pomice.spotify.Artist name={self.name} id={self.id} " f"tracks={self.tracks}>"
|
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
# pylint: skip-file
|
||||||
|
|
||||||
|
from spotify import *
|
||||||
|
from spotify import __all__, _types, Client
|
||||||
|
from spotify.utils import clean as _clean_namespace
|
||||||
|
|
||||||
|
from . import models
|
||||||
|
from .models import Client, Synchronous as _Sync
|
||||||
|
|
||||||
|
|
||||||
|
with _clean_namespace(locals(), "name", "base", "Mock"):
|
||||||
|
for name, base in _types.items():
|
||||||
|
|
||||||
|
class Mock(base, metaclass=_Sync): # type: ignore
|
||||||
|
__slots__ = {"__client_thread__"}
|
||||||
|
|
||||||
|
Mock.__name__ = base.__name__
|
||||||
|
Mock.__qualname__ = base.__qualname__
|
||||||
|
Mock.__doc__ = base.__doc__
|
||||||
|
|
||||||
|
locals()[name] = Mock
|
||||||
|
setattr(models, name, Mock)
|
||||||
|
|
||||||
|
Client._default_http_client = locals()[
|
||||||
|
"HTTPClient"
|
||||||
|
] # pylint: disable=protected-access
|
||||||
|
|
@ -0,0 +1,83 @@
|
||||||
|
from functools import wraps
|
||||||
|
from inspect import getmembers, iscoroutinefunction
|
||||||
|
from typing import Type, Callable, TYPE_CHECKING
|
||||||
|
|
||||||
|
from .. import Client as _Client
|
||||||
|
from .thread import EventLoopThread
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
import spotify
|
||||||
|
|
||||||
|
|
||||||
|
def _infer_initializer(base: Type, name: str) -> Callable[..., None]:
|
||||||
|
"""Infer the __init__ to use for a given :class:`typing.Type` base and :class:`str` name."""
|
||||||
|
if name in {"HTTPClient", "HTTPUserClient"}:
|
||||||
|
|
||||||
|
def initializer(self: "spotify.HTTPClient", *args, **kwargs) -> None:
|
||||||
|
base.__init__(self, *args, **kwargs)
|
||||||
|
self.__client_thread__ = kwargs["loop"].__spotify_thread__ # type: ignore
|
||||||
|
|
||||||
|
else:
|
||||||
|
assert name != "Client"
|
||||||
|
|
||||||
|
def initializer(self: "spotify.SpotifyBase", client: _Client, *args, **kwargs) -> None: # type: ignore
|
||||||
|
base.__init__(self, client, *args, **kwargs)
|
||||||
|
self.__client_thread__ = client.__client_thread__ # type: ignore
|
||||||
|
|
||||||
|
return initializer
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_coroutine_function(corofunc):
|
||||||
|
if isinstance(corofunc, classmethod):
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
@wraps(corofunc)
|
||||||
|
def wrapped(cls, client, *args, **kwargs):
|
||||||
|
assert isinstance(client, _Client)
|
||||||
|
client.__client_thread__.run_coroutine_threadsafe(
|
||||||
|
corofunc(cls, client, *args, **kwargs)
|
||||||
|
)
|
||||||
|
|
||||||
|
else:
|
||||||
|
|
||||||
|
@wraps(corofunc)
|
||||||
|
def wrapped(self, *args, **kwargs):
|
||||||
|
return self.__client_thread__.run_coroutine_threadsafe(
|
||||||
|
corofunc(self, *args, **kwargs)
|
||||||
|
)
|
||||||
|
|
||||||
|
return wrapped
|
||||||
|
|
||||||
|
|
||||||
|
class Synchronous(type):
|
||||||
|
"""Metaclass used for overloading coroutine functions on models."""
|
||||||
|
|
||||||
|
def __new__(cls, name, bases, dct):
|
||||||
|
klass = super().__new__(cls, name, bases, dct)
|
||||||
|
|
||||||
|
base = bases[0]
|
||||||
|
name = base.__name__
|
||||||
|
|
||||||
|
if name != "Client":
|
||||||
|
# Models and the HTTP classes get their __init__ overloaded.
|
||||||
|
initializer = _infer_initializer(base, name)
|
||||||
|
setattr(klass, "__init__", initializer)
|
||||||
|
|
||||||
|
for ident, obj in getmembers(base):
|
||||||
|
if not iscoroutinefunction(obj):
|
||||||
|
continue
|
||||||
|
|
||||||
|
setattr(klass, ident, _normalize_coroutine_function(obj))
|
||||||
|
|
||||||
|
return klass # type: ignore
|
||||||
|
|
||||||
|
|
||||||
|
class Client(_Client, metaclass=Synchronous):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
thread = EventLoopThread()
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
kwargs["loop"] = thread.loop # pylint: disable=protected-access
|
||||||
|
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.__thread = self.__client_thread__ = thread
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
from asyncio import new_event_loop, run_coroutine_threadsafe, set_event_loop
|
||||||
|
from threading import Thread, RLock, get_ident
|
||||||
|
from typing import Any, Coroutine
|
||||||
|
|
||||||
|
|
||||||
|
class EventLoopThread(Thread):
|
||||||
|
"""A surrogate thread that spins an asyncio event loop."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(daemon=True)
|
||||||
|
|
||||||
|
self.__lock = RLock()
|
||||||
|
self.__loop = loop = new_event_loop()
|
||||||
|
loop.__spotify_thread__ = self
|
||||||
|
|
||||||
|
# Properties
|
||||||
|
|
||||||
|
@property
|
||||||
|
def loop(self):
|
||||||
|
return self.__loop
|
||||||
|
|
||||||
|
# Overloads
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
set_event_loop(self.__loop)
|
||||||
|
self.__loop.run_forever()
|
||||||
|
|
||||||
|
# Public API
|
||||||
|
|
||||||
|
def run_coroutine_threadsafe(self, coro: Coroutine) -> Any:
|
||||||
|
"""Like :func:`asyncio.run_coroutine_threadsafe` but for this specific thread."""
|
||||||
|
|
||||||
|
# If the current thread is the same
|
||||||
|
# as the event loop Thread.
|
||||||
|
#
|
||||||
|
# then we're in the process of making
|
||||||
|
# nested calls to await other coroutines
|
||||||
|
# and should pass back the coroutine as it should be.
|
||||||
|
if get_ident() == self.ident:
|
||||||
|
return coro
|
||||||
|
|
||||||
|
# Double lock because I haven't looked
|
||||||
|
# into whether this deadlocks under whatever
|
||||||
|
# conditions, Best to play it safe.
|
||||||
|
with self.__lock:
|
||||||
|
future = run_coroutine_threadsafe(coro, self.__loop)
|
||||||
|
|
||||||
|
return future.result()
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
from re import compile as re_compile
|
||||||
|
from functools import lru_cache
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from typing import Iterable, Hashable, TypeVar, Dict, Tuple
|
||||||
|
|
||||||
|
__all__ = ("clean", "filter_items", "to_id")
|
||||||
|
|
||||||
|
_URI_RE = re_compile(r"^.*:([a-zA-Z0-9]+)$")
|
||||||
|
_OPEN_RE = re_compile(r"http[s]?:\/\/open\.spotify\.com\/(.*)\/(.*)")
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def clean(mapping: dict, *keys: Iterable[Hashable]):
|
||||||
|
"""A helper context manager that defers mutating a mapping."""
|
||||||
|
yield
|
||||||
|
for key in keys:
|
||||||
|
mapping.pop(key)
|
||||||
|
|
||||||
|
|
||||||
|
K = TypeVar("K") # pylint: disable=invalid-name
|
||||||
|
V = TypeVar("V") # pylint: disable=invalid-name
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1024)
|
||||||
|
def _cached_filter_items(data: Tuple[Tuple[K, V], ...]) -> Dict[K, V]:
|
||||||
|
data_ = {}
|
||||||
|
for key, value in data:
|
||||||
|
if value is not None:
|
||||||
|
data_[key] = value
|
||||||
|
return data_
|
||||||
|
|
||||||
|
|
||||||
|
def filter_items(data: Dict[K, V]) -> Dict[K, V]:
|
||||||
|
"""Filter the items of a dict where the value is not None."""
|
||||||
|
return _cached_filter_items((*data.items(),))
|
||||||
|
|
||||||
|
|
||||||
|
def to_id(value: str) -> str:
|
||||||
|
"""Get a spotify ID from a URI or open.spotify URL.
|
||||||
|
|
||||||
|
Paramters
|
||||||
|
---------
|
||||||
|
value : :class:`str`
|
||||||
|
The value to operate on.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
id : :class:`str`
|
||||||
|
The Spotify ID from the value.
|
||||||
|
"""
|
||||||
|
value = value.strip()
|
||||||
|
match = _URI_RE.match(value)
|
||||||
|
|
||||||
|
if match is None:
|
||||||
|
match = _OPEN_RE.match(value)
|
||||||
|
|
||||||
|
if match is None:
|
||||||
|
return value
|
||||||
|
return match.group(2)
|
||||||
|
return match.group(1)
|
||||||
288
pomice/utils.py
288
pomice/utils.py
|
|
@ -1,56 +1,41 @@
|
||||||
|
"""
|
||||||
|
The MIT License (MIT)
|
||||||
|
Copyright (c) 2015-present Rapptz
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a
|
||||||
|
copy of this software and associated documentation files (the "Software"),
|
||||||
|
to deal in the Software without restriction, including without limitation
|
||||||
|
the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
||||||
|
and/or sell copies of the Software, and to permit persons to whom the
|
||||||
|
Software is furnished to do so, subject to the following conditions:
|
||||||
|
The above copyright notice and this permission notice shall be included in
|
||||||
|
all copies or substantial portions of the Software.
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
|
||||||
|
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||||
|
DEALINGS IN THE SOFTWARE.
|
||||||
|
"""
|
||||||
|
|
||||||
import random
|
import random
|
||||||
import socket
|
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
|
||||||
from itertools import zip_longest
|
|
||||||
from timeit import default_timer as timer
|
|
||||||
from typing import Any
|
|
||||||
from typing import Callable
|
|
||||||
from typing import Dict
|
|
||||||
from typing import Iterable
|
|
||||||
from typing import NamedTuple
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from .enums import RouteIPType
|
__all__ = [
|
||||||
from .enums import RouteStrategy
|
'ExponentialBackoff',
|
||||||
|
'PomiceStats'
|
||||||
__all__ = (
|
]
|
||||||
"ExponentialBackoff",
|
|
||||||
"NodeStats",
|
|
||||||
"FailingIPBlock",
|
|
||||||
"RouteStats",
|
|
||||||
"Ping",
|
|
||||||
"LavalinkVersion",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ExponentialBackoff:
|
class ExponentialBackoff:
|
||||||
"""
|
|
||||||
The MIT License (MIT)
|
|
||||||
Copyright (c) 2015-present Rapptz
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a
|
|
||||||
copy of this software and associated documentation files (the "Software"),
|
|
||||||
to deal in the Software without restriction, including without limitation
|
|
||||||
the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
|
||||||
and/or sell copies of the Software, and to permit persons to whom the
|
|
||||||
Software is furnished to do so, subject to the following conditions:
|
|
||||||
The above copyright notice and this permission notice shall be included in
|
|
||||||
all copies or substantial portions of the Software.
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
|
|
||||||
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
||||||
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
|
||||||
DEALINGS IN THE SOFTWARE.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, base: int = 1, *, integral: bool = False) -> None:
|
def __init__(self, base: int = 1, *, integral: bool = False) -> None:
|
||||||
|
|
||||||
self._base = base
|
self._base = base
|
||||||
|
|
||||||
self._exp = 0
|
self._exp = 0
|
||||||
self._max = 10
|
self._max = 10
|
||||||
self._reset_time = base * 2**11
|
self._reset_time = base * 2 ** 11
|
||||||
self._last_invocation = time.monotonic()
|
self._last_invocation = time.monotonic()
|
||||||
|
|
||||||
rand = random.Random()
|
rand = random.Random()
|
||||||
|
|
@ -59,6 +44,7 @@ class ExponentialBackoff:
|
||||||
self._randfunc = rand.randrange if integral else rand.uniform
|
self._randfunc = rand.randrange if integral else rand.uniform
|
||||||
|
|
||||||
def delay(self) -> float:
|
def delay(self) -> float:
|
||||||
|
|
||||||
invocation = time.monotonic()
|
invocation = time.monotonic()
|
||||||
interval = invocation - self._last_invocation
|
interval = invocation - self._last_invocation
|
||||||
self._last_invocation = invocation
|
self._last_invocation = invocation
|
||||||
|
|
@ -67,212 +53,28 @@ class ExponentialBackoff:
|
||||||
self._exp = 0
|
self._exp = 0
|
||||||
|
|
||||||
self._exp = min(self._exp + 1, self._max)
|
self._exp = min(self._exp + 1, self._max)
|
||||||
return self._randfunc(0, self._base * 2**self._exp) # type: ignore
|
return self._randfunc(0, self._base * 2 ** self._exp)
|
||||||
|
|
||||||
|
|
||||||
class NodeStats:
|
class NodeStats:
|
||||||
"""The base class for the node stats object.
|
"""The base class for the node stats object. Gives critcical information on the node, which is updated every minute."""
|
||||||
Gives critical information on the node, which is updated every minute.
|
|
||||||
"""
|
|
||||||
|
|
||||||
__slots__ = (
|
|
||||||
"used",
|
|
||||||
"free",
|
|
||||||
"reservable",
|
|
||||||
"allocated",
|
|
||||||
"cpu_cores",
|
|
||||||
"cpu_system_load",
|
|
||||||
"cpu_process_load",
|
|
||||||
"players_active",
|
|
||||||
"players_total",
|
|
||||||
"uptime",
|
|
||||||
)
|
|
||||||
|
|
||||||
def __init__(self, data: Dict[str, Any]) -> None:
|
|
||||||
memory: dict = data.get("memory", {})
|
|
||||||
self.used = memory.get("used")
|
|
||||||
self.free = memory.get("free")
|
|
||||||
self.reservable = memory.get("reservable")
|
|
||||||
self.allocated = memory.get("allocated")
|
|
||||||
|
|
||||||
cpu: dict = data.get("cpu", {})
|
|
||||||
self.cpu_cores = cpu.get("cores")
|
|
||||||
self.cpu_system_load = cpu.get("systemLoad")
|
|
||||||
self.cpu_process_load = cpu.get("lavalinkLoad")
|
|
||||||
|
|
||||||
self.players_active = data.get("playingPlayers")
|
|
||||||
self.players_total = data.get("players")
|
|
||||||
self.uptime = data.get("uptime")
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
return f"<Pomice.NodeStats total_players={self.players_total!r} playing_active={self.players_active!r}>"
|
|
||||||
|
|
||||||
|
|
||||||
class FailingIPBlock:
|
|
||||||
"""
|
|
||||||
The base class for the failing IP block object from the route planner stats.
|
|
||||||
Gives critical information about any failing addresses on the block
|
|
||||||
and the time they failed.
|
|
||||||
"""
|
|
||||||
|
|
||||||
__slots__ = ("address", "failing_time")
|
|
||||||
|
|
||||||
def __init__(self, data: dict) -> None:
|
def __init__(self, data: dict) -> None:
|
||||||
self.address = data.get("address")
|
|
||||||
self.failing_time = datetime.fromtimestamp(
|
memory = data.get('memory')
|
||||||
float(data.get("failingTimestamp", 0)),
|
self.used = memory.get('used')
|
||||||
)
|
self.free = memory.get('free')
|
||||||
|
self.reservable = memory.get('reservable')
|
||||||
|
self.allocated = memory.get('allocated')
|
||||||
|
|
||||||
|
cpu = data.get('cpu')
|
||||||
|
self.cpu_cores = cpu.get('cores')
|
||||||
|
self.cpu_system_load = cpu.get('systemLoad')
|
||||||
|
self.cpu_process_load = cpu.get('lavalinkLoad')
|
||||||
|
|
||||||
|
self.players_active = data.get('playingPlayers')
|
||||||
|
self.players_total = data.get('players')
|
||||||
|
self.uptime = data.get('uptime')
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return f"<Pomice.FailingIPBlock address={self.address} failing_time={self.failing_time}>"
|
return f'<Pomice.NodeStats total_players={self.players_total} playing_active={self.players_active}>'
|
||||||
|
|
||||||
|
|
||||||
class RouteStats:
|
|
||||||
"""
|
|
||||||
The base class for the route planner stats object.
|
|
||||||
Gives critical information about the route planner strategy on the node.
|
|
||||||
"""
|
|
||||||
|
|
||||||
__slots__ = (
|
|
||||||
"strategy",
|
|
||||||
"ip_block_type",
|
|
||||||
"ip_block_size",
|
|
||||||
"failing_addresses",
|
|
||||||
"block_index",
|
|
||||||
"address_index",
|
|
||||||
)
|
|
||||||
|
|
||||||
def __init__(self, data: Dict[str, Any]) -> None:
|
|
||||||
self.strategy = RouteStrategy(data.get("class"))
|
|
||||||
|
|
||||||
details: dict = data.get("details", {})
|
|
||||||
|
|
||||||
ip_block: dict = details.get("ipBlock", {})
|
|
||||||
self.ip_block_type = RouteIPType(ip_block.get("type"))
|
|
||||||
self.ip_block_size = ip_block.get("size")
|
|
||||||
self.failing_addresses = [
|
|
||||||
FailingIPBlock(
|
|
||||||
data,
|
|
||||||
)
|
|
||||||
for data in details.get("failingAddresses", [])
|
|
||||||
]
|
|
||||||
|
|
||||||
self.block_index = details.get("blockIndex")
|
|
||||||
self.address_index = details.get("currentAddressIndex")
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
return f"<Pomice.RouteStats route_strategy={self.strategy!r} failing_addresses={len(self.failing_addresses)}>"
|
|
||||||
|
|
||||||
|
|
||||||
class Ping:
|
|
||||||
# Thanks to https://github.com/zhengxiaowai/tcping for the nice ping impl
|
|
||||||
def __init__(self, host: str, port: int, timeout: int = 5) -> None:
|
|
||||||
self.timer = self.Timer()
|
|
||||||
|
|
||||||
self._successed = 0
|
|
||||||
self._failed = 0
|
|
||||||
self._conn_time = None
|
|
||||||
self._host = host
|
|
||||||
self._port = port
|
|
||||||
self._timeout = timeout
|
|
||||||
|
|
||||||
class Socket:
|
|
||||||
def __init__(self, family: int, type_: int, timeout: Optional[float]) -> None:
|
|
||||||
s = socket.socket(family, type_)
|
|
||||||
s.settimeout(timeout)
|
|
||||||
self._s = s
|
|
||||||
|
|
||||||
def connect(self, host: str, port: int) -> None:
|
|
||||||
self._s.connect((host, port))
|
|
||||||
|
|
||||||
def shutdown(self) -> None:
|
|
||||||
self._s.shutdown(socket.SHUT_RD)
|
|
||||||
|
|
||||||
def close(self) -> None:
|
|
||||||
self._s.close()
|
|
||||||
|
|
||||||
class Timer:
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self._start: float = 0.0
|
|
||||||
self._stop: float = 0.0
|
|
||||||
|
|
||||||
def start(self) -> None:
|
|
||||||
self._start = timer()
|
|
||||||
|
|
||||||
def stop(self) -> None:
|
|
||||||
self._stop = timer()
|
|
||||||
|
|
||||||
def cost(self, funcs: Iterable[Callable], args: Any) -> float:
|
|
||||||
self.start()
|
|
||||||
for func, arg in zip_longest(funcs, args):
|
|
||||||
if arg:
|
|
||||||
func(*arg)
|
|
||||||
else:
|
|
||||||
func()
|
|
||||||
|
|
||||||
self.stop()
|
|
||||||
return self._stop - self._start
|
|
||||||
|
|
||||||
def _create_socket(self, family: int, type_: int) -> Socket:
|
|
||||||
return self.Socket(family, type_, self._timeout)
|
|
||||||
|
|
||||||
def get_ping(self) -> float:
|
|
||||||
s = self._create_socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
||||||
|
|
||||||
cost_time = self.timer.cost(
|
|
||||||
(s.connect, s.shutdown),
|
|
||||||
((self._host, self._port), None),
|
|
||||||
)
|
|
||||||
s_runtime = 1000 * (cost_time)
|
|
||||||
|
|
||||||
return s_runtime
|
|
||||||
|
|
||||||
|
|
||||||
class LavalinkVersion(NamedTuple):
|
|
||||||
major: int
|
|
||||||
minor: int
|
|
||||||
fix: int
|
|
||||||
|
|
||||||
def __eq__(self, other: object) -> bool:
|
|
||||||
if not isinstance(other, LavalinkVersion):
|
|
||||||
return False
|
|
||||||
|
|
||||||
return (
|
|
||||||
(self.major == other.major) and (self.minor == other.minor) and (self.fix == other.fix)
|
|
||||||
)
|
|
||||||
|
|
||||||
def __ne__(self, other: object) -> bool:
|
|
||||||
if not isinstance(other, LavalinkVersion):
|
|
||||||
return False
|
|
||||||
|
|
||||||
return not (self == other)
|
|
||||||
|
|
||||||
def __lt__(self, other: object) -> bool:
|
|
||||||
if not isinstance(other, LavalinkVersion):
|
|
||||||
return False
|
|
||||||
|
|
||||||
if self.major > other.major:
|
|
||||||
return False
|
|
||||||
if self.minor > other.minor:
|
|
||||||
return False
|
|
||||||
if self.fix > other.fix:
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
def __gt__(self, other: object) -> bool:
|
|
||||||
if not isinstance(other, LavalinkVersion):
|
|
||||||
return False
|
|
||||||
|
|
||||||
return not (self < other)
|
|
||||||
|
|
||||||
def __le__(self, other: object) -> bool:
|
|
||||||
if not isinstance(other, LavalinkVersion):
|
|
||||||
return False
|
|
||||||
|
|
||||||
return (self < other) or (self == other)
|
|
||||||
|
|
||||||
def __ge__(self, other: object) -> bool:
|
|
||||||
if not isinstance(other, LavalinkVersion):
|
|
||||||
return False
|
|
||||||
|
|
||||||
return (self > other) or (self == other)
|
|
||||||
|
|
|
||||||
|
|
@ -4,16 +4,3 @@ requires = [
|
||||||
"wheel"
|
"wheel"
|
||||||
]
|
]
|
||||||
build-backend = "setuptools.build_meta"
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
[tool.black]
|
|
||||||
line-length = 100
|
|
||||||
|
|
||||||
[tool.mypy]
|
|
||||||
mypy_path = "./"
|
|
||||||
files = ["pomice"]
|
|
||||||
disallow_untyped_defs = true
|
|
||||||
disallow_any_unimported = true
|
|
||||||
no_implicit_optional = true
|
|
||||||
check_untyped_defs = true
|
|
||||||
warn_unused_ignores = true
|
|
||||||
show_error_codes = true
|
|
||||||
|
|
|
||||||
61
setup.py
61
setup.py
|
|
@ -1,73 +1,32 @@
|
||||||
# type: ignore
|
|
||||||
import re
|
|
||||||
|
|
||||||
import setuptools
|
import setuptools
|
||||||
|
|
||||||
version = ""
|
|
||||||
requirements = ["aiohttp>=3.7.4,<4", "orjson", "websockets"]
|
|
||||||
with open("pomice/__init__.py") as f:
|
|
||||||
version = re.search(
|
|
||||||
r'^__version__\s*=\s*[\'"]([^\'"]*)[\'"]',
|
|
||||||
f.read(),
|
|
||||||
re.MULTILINE,
|
|
||||||
).group(1)
|
|
||||||
|
|
||||||
if not version:
|
|
||||||
raise RuntimeError("version is not set")
|
|
||||||
|
|
||||||
if version.endswith(("a", "b", "rc")):
|
|
||||||
# append version identifier based on commit count
|
|
||||||
try:
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
p = subprocess.Popen(
|
|
||||||
["git", "rev-list", "--count", "HEAD"],
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.PIPE,
|
|
||||||
)
|
|
||||||
out, err = p.communicate()
|
|
||||||
if out:
|
|
||||||
version += out.decode("utf-8").strip()
|
|
||||||
p = subprocess.Popen(
|
|
||||||
["git", "rev-parse", "--short", "HEAD"],
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.PIPE,
|
|
||||||
)
|
|
||||||
out, err = p.communicate()
|
|
||||||
if out:
|
|
||||||
version += "+g" + out.decode("utf-8").strip()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
with open("README.md") as f:
|
with open("README.md") as f:
|
||||||
readme = f.read()
|
readme = f.read()
|
||||||
|
|
||||||
setuptools.setup(
|
setuptools.setup(
|
||||||
name="pomice",
|
name="pomice",
|
||||||
author="cloudwithax",
|
author="cloudwithax",
|
||||||
version=version,
|
version="1.0.6",
|
||||||
url="https://github.com/cloudwithax/pomice",
|
url="https://github.com/cloudwithax/pomice",
|
||||||
packages=setuptools.find_packages(),
|
packages=setuptools.find_packages(),
|
||||||
license="GPL",
|
license="GPL",
|
||||||
description="The modern Lavalink wrapper designed for Discord.py",
|
description="The modern Lavalink wrapper designed for Discord.py",
|
||||||
long_description=readme,
|
long_description=readme,
|
||||||
long_description_content_type="text/markdown",
|
long_description_content_type="text/markdown",
|
||||||
package_data={"pomice": ["py.typed"]},
|
|
||||||
include_package_data=True,
|
include_package_data=True,
|
||||||
install_requires=requirements,
|
install_requires=['discord.py>=1.7.1'],
|
||||||
extra_require=None,
|
extra_require=None,
|
||||||
classifiers=[
|
classifiers=[
|
||||||
"Framework :: AsyncIO",
|
"Framework :: AsyncIO",
|
||||||
"Operating System :: OS Independent",
|
'Operating System :: OS Independent',
|
||||||
"Natural Language :: English",
|
'Natural Language :: English',
|
||||||
"Intended Audience :: Developers",
|
'Intended Audience :: Developers',
|
||||||
"Programming Language :: Python :: 3 :: Only",
|
"Programming Language :: Python :: 3 :: Only",
|
||||||
"Programming Language :: Python :: 3.8",
|
"Programming Language :: Python :: 3.8",
|
||||||
"Topic :: Software Development :: Libraries :: Python Modules",
|
'Topic :: Software Development :: Libraries :: Python Modules',
|
||||||
"Topic :: Software Development :: Libraries",
|
'Topic :: Software Development :: Libraries',
|
||||||
"Topic :: Internet",
|
"Topic :: Internet"
|
||||||
],
|
],
|
||||||
python_requires=">=3.8",
|
python_requires='>=3.8',
|
||||||
keywords=["pomice", "lavalink", "discord.py"],
|
keywords=['pomice', 'lavalink', "discord.py"],
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue