diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index ca72b0e..8de25cc 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -5,24 +5,29 @@ on: branches: - 'main' +permissions: + contents: read + packages: write + jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 + - name: Install poetry + run: pipx install poetry==1.8.2 - uses: actions/setup-python@v4 with: - python-version: 3.11 - cache: 'pip' + python-version: 3.12 + cache: 'poetry' + cache-dependency-path: poetry.lock - name: Install dependencies run: | - python -m pip install --upgrade pip wheel - pip install -r requirements.txt - - name: Install pylint - run: pip install pylint - - name: Run pylint + poetry env use 3.12 + poetry install + - name: Run precommit hooks run: | - pylint --rcfile=.pylintrc --fail-under=9.0 $(git ls-files '*.py') + poetry run pre-commit run --all-files build: uses: ./.github/workflows/build.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8873d2c..d0c1995 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -57,7 +57,7 @@ jobs: prerelease: false - name: Prune untagged images uses: actions/delete-package-versions@v4 - with: + with: package-name: 'blanco-bot' package-type: 'container' min-versions-to-keep: 3 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..6255c90 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,26 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: double-quote-string-fixer + - id: end-of-file-fixer + - id: trailing-whitespace + - id: no-commit-to-branch + args: ['--branch', 'main'] + - id: mixed-line-ending + - id: check-merge-conflict + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.3.7 + hooks: + - id: ruff + args: [ --fix ] + - id: ruff-format + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.9.0 + hooks: + - id: mypy + args: ['--config-file=mypy.ini', '--check-untyped-defs'] + additional_dependencies: + - types-pyyaml==6.0.1 + - types-redis==4.6.0.20240311 + - types-requests==2.31.0 diff --git a/.pylintrc b/.pylintrc deleted file mode 100644 index cab0349..0000000 --- a/.pylintrc +++ /dev/null @@ -1,2 +0,0 @@ -[MESSAGES CONTROL] -disable=too-many-instance-attributes,too-many-locals,too-many-return-statements,too-few-public-methods,too-many-branches diff --git a/.vscode/.gitignore b/.vscode/.gitignore index a5122bd..450a234 100644 --- a/.vscode/.gitignore +++ b/.vscode/.gitignore @@ -1 +1 @@ -launch.json \ No newline at end of file +launch.json diff --git a/.vscode/launch.json b/.vscode/launch.json index c1af0dd..fd83634 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -27,4 +27,4 @@ } } ] -} \ No newline at end of file +} diff --git a/.vscode/settings.json b/.vscode/settings.json index e8c0ee5..1ea600d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,7 +3,7 @@ "files.exclude": { "**/__pycache__": true }, - "editor.tabSize": 4, + "editor.tabSize": 2, "css.lint.unknownAtRules": "ignore", "pylint.args": [ "--disable=too-many-instance-attributes", @@ -11,5 +11,7 @@ "--disable=too-few-public-methods", "--disable=too-many-return-statements", "--disable=too-many-branches" - ] -} \ No newline at end of file + ], + "editor.formatOnSave": true, + "editor.defaultFormatter": "charliermarsh.ruff" +} diff --git a/Dockerfile b/Dockerfile index 24c4634..ab0ce57 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,46 +1,54 @@ ARG RELEASE="0.0.0-unknown" -FROM node:lts-alpine AS tailwind +FROM --platform=$BUILDPLATFORM python:3.11 AS dependencies +ARG TARGETARCH -# Compile Tailwind CSS -RUN mkdir -p /opt/build -COPY tailwind.config.js /opt/build/ -COPY server/ /opt/build/server -WORKDIR /opt/build -RUN npm install -D tailwindcss && \ - npx tailwindcss -i ./server/static/css/base.css \ - -o ./server/static/css/main.css --minify +# Install build-essential for building Python packages +RUN apt-get update && apt-get install -y build-essential curl +# Install Poetry +RUN pip install poetry==1.8.2 +ENV POETRY_NO_INTERACTION=1 \ + POETRY_VIRTUALENVS_IN_PROJECT=1 \ + POETRY_VIRTUALENVS_CREATE=1 \ + POETRY_CACHE_DIR=/tmp/poetry_cache -FROM python:3.11 AS dependencies +# Copy files +WORKDIR /app +COPY pyproject.toml poetry.lock tailwind.config.js dashboard/static/css/base.css ./ -# Install build-essential for building Python packages -RUN apt-get update && apt-get install -y build-essential +# Install dependencies +RUN --mount=type=cache,target=$POETRY_CACHE_DIR poetry install --without dev -# Install pip requirements under virtualenv -RUN python -m venv /opt/venv -ENV PATH="/opt/venv/bin:${PATH}" -COPY requirements.txt . -RUN pip install --upgrade pip wheel && pip install -r requirements.txt +# Compile Tailwind CSS +RUN echo "Downloading Tailwind CLI for ${TARGETARCH}" && \ + if [ "${TARGETARCH}" = "amd64" ]; then \ + curl -sL https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-linux-x64 -o ./tailwindcss; \ + else \ + curl -sL https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-linux-${TARGETARCH} -o ./tailwindcss; \ + fi && \ + chmod +x ./tailwindcss && \ + ./tailwindcss -i ./base.css -o ./main.css --minify FROM python:3.11-slim AS main ARG RELEASE -COPY --from=dependencies /opt/venv /opt/venv LABEL maintainer="Jared Dantis " # Copy bot files COPY . /opt/app -COPY --from=tailwind /opt/build/server/static/css/main.css /opt/app/server/static/css/main.css +COPY --from=dependencies /app/.venv /opt/venv +COPY --from=dependencies /app/main.css /opt/app/dashboard/static/css/main.css WORKDIR /opt/app # Set release -RUN sed -i "s/0.0.0-unknown/${RELEASE}/" utils/constants.py +RUN sed -i "s/0.0.0-unknown/${RELEASE}/" bot/utils/constants.py # Run bot -ENV PATH="/opt/venv/bin:${PATH}" -ENV PYTHONDONTWRITEBYTECODE=1 -ENV PYTHONUNBUFFERED=1 +ENV PATH="/opt/venv/bin:${PATH}" \ + PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 EXPOSE 8080 -CMD ["python3", "main.py"] +ENTRYPOINT ["python"] +CMD ["-m", "bot.main"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..1b5119c --- /dev/null +++ b/Makefile @@ -0,0 +1,29 @@ +MAKEFLAGS += --jobs=2 +.PHONY: install dev +all: install dev-frontend dev precommit image dev-image + +install: + poetry env use 3.11 + poetry install + poetry run pre-commit install + +dev-frontend: config.yml blanco.db + poetry run python -m bot.dev_server + +dev-backend: config.yml blanco.db + poetry run python -m bot.api.main + +dev: config.yml blanco.db + poetry run python -m bot.main + +precommit: + poetry run pre-commit run --all-files + +image: + docker buildx build -t blanco-bot . + +dev-image: config.yml blanco.db image + docker run --rm -it \ + -v $(PWD):/opt/app \ + -p 8080:8080 \ + blanco-bot diff --git a/dataclass/__init__.py b/bot/__init__.py similarity index 100% rename from dataclass/__init__.py rename to bot/__init__.py diff --git a/bot/api/depends/database.py b/bot/api/depends/database.py new file mode 100644 index 0000000..83ad74b --- /dev/null +++ b/bot/api/depends/database.py @@ -0,0 +1,33 @@ +from typing import TYPE_CHECKING + +from fastapi import HTTPException, Request +from starlette.status import HTTP_500_INTERNAL_SERVER_ERROR + +if TYPE_CHECKING: + from bot.database import Database + + +def database_dependency(request: Request) -> 'Database': + """ + FastAPI dependency to get the database object. + + Args: + request (web.Request): The request. + + Returns: + Database: The database object. + """ + + state = request.app.state + if not hasattr(state, 'database'): + raise HTTPException( + status_code=HTTP_500_INTERNAL_SERVER_ERROR, detail='No database connection' + ) + + database: 'Database' = state.database + if database is None: + raise HTTPException( + status_code=HTTP_500_INTERNAL_SERVER_ERROR, detail='No database connection' + ) + + return database diff --git a/bot/api/depends/session.py b/bot/api/depends/session.py new file mode 100644 index 0000000..9ff4817 --- /dev/null +++ b/bot/api/depends/session.py @@ -0,0 +1,48 @@ +from typing import TYPE_CHECKING + +from fastapi import HTTPException, Request +from starlette.status import HTTP_401_UNAUTHORIZED + +if TYPE_CHECKING: + from bot.api.models.session import Session + from bot.api.utils.session import SessionManager + +EXPECTED_AUTH_SCHEME = 'Bearer' +EXPECTED_AUTH_PARTS = 2 + + +def session_dependency(request: Request) -> 'Session': + """ + FastAPI dependency to get the requesting user's session object. + + Args: + request (web.Request): The request. + + Returns: + Session: The session object for the current Discord user. + """ + + authorization = request.headers.get('Authorization') + if authorization is None: + raise HTTPException( + status_code=HTTP_401_UNAUTHORIZED, detail='No authorization header' + ) + + parts = authorization.split() + if len(parts) != EXPECTED_AUTH_PARTS: + raise HTTPException( + status_code=HTTP_401_UNAUTHORIZED, detail='Invalid authorization header' + ) + + scheme, token = parts + if scheme != EXPECTED_AUTH_SCHEME: + raise HTTPException( + status_code=HTTP_401_UNAUTHORIZED, detail='Invalid authorization scheme' + ) + + session_manager: 'SessionManager' = request.app.state.session_manager + session = session_manager.decode_session(token) + if session is None: + raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail='Invalid session') + + return session diff --git a/bot/api/depends/user.py b/bot/api/depends/user.py new file mode 100644 index 0000000..6da600c --- /dev/null +++ b/bot/api/depends/user.py @@ -0,0 +1,30 @@ +from typing import TYPE_CHECKING, Optional + +from fastapi import Depends, HTTPException +from starlette.status import HTTP_401_UNAUTHORIZED + +from .database import database_dependency +from .session import session_dependency + +if TYPE_CHECKING: + from bot.api.models.session import Session + from bot.database import Database + from bot.models.oauth import OAuth + + +def user_dependency( + db: 'Database' = Depends(database_dependency), + session: 'Session' = Depends(session_dependency), +) -> 'OAuth': + """ + FastAPI dependency to get the requesting user's info. + + Returns: + OAuth: The info for the current Discord user. + """ + + user: Optional['OAuth'] = db.get_oauth('discord', session.user_id) + if user is None: + raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail='User not found') + + return user diff --git a/bot/api/extension.py b/bot/api/extension.py new file mode 100644 index 0000000..3ed286c --- /dev/null +++ b/bot/api/extension.py @@ -0,0 +1,17 @@ +""" +Nextcord extension that runs the API server for the bot +""" + +from typing import TYPE_CHECKING + +from .main import run_app + +if TYPE_CHECKING: + from bot.utils.blanco import BlancoBot + + +def setup(bot: 'BlancoBot'): + """ + Run the API server within the bot's existing event loop. + """ + run_app(bot.loop, bot.database) diff --git a/bot/api/main.py b/bot/api/main.py new file mode 100644 index 0000000..eb48849 --- /dev/null +++ b/bot/api/main.py @@ -0,0 +1,93 @@ +""" +Main module for the API server. +""" + +from asyncio import set_event_loop +from contextlib import asynccontextmanager +from logging import INFO +from typing import TYPE_CHECKING, Any, Optional + +from fastapi import FastAPI +from uvicorn import Config, Server, run +from uvicorn.config import LOGGING_CONFIG + +from bot.database import Database +from bot.utils.config import config as bot_config +from bot.utils.logger import DATE_FMT_STR, LOG_FMT_COLOR, create_logger + +from .routes.account import account_router +from .routes.callback import callback_router +from .utils.session import SessionManager + +if TYPE_CHECKING: + from asyncio import AbstractEventLoop + + +_database: Optional[Database] = None + + +@asynccontextmanager +async def lifespan(app: FastAPI): + logger = create_logger('api.lifespan') + + if _database is None: + logger.warn('Manually creating database connection') + database = Database(bot_config.db_file) + else: + logger.info('Connecting to database from FastAPI') + database = _database + + app.state.database = database + app.state.session_manager = SessionManager(database) + yield + + +app = FastAPI(lifespan=lifespan) +app.include_router(account_router) +app.include_router(callback_router) + + +@app.get('/') +async def health_check(): + return {'status': 'ok'} + + +def _get_log_config() -> dict[str, Any]: + log_config = LOGGING_CONFIG + log_config['formatters']['default']['fmt'] = LOG_FMT_COLOR[INFO] + log_config['formatters']['default']['datefmt'] = DATE_FMT_STR + log_config['formatters']['access']['fmt'] = LOG_FMT_COLOR[INFO] + + return log_config + + +def run_app(loop: 'AbstractEventLoop', db: Database): + """ + Run the API server in the bot's event loop. + """ + global _database # noqa: PLW0603 + _database = db + + set_event_loop(loop) + + config = Config( + app=app, + loop=loop, # type: ignore + host='0.0.0.0', + port=bot_config.server_port, + log_config=_get_log_config(), + ) + server = Server(config) + + loop.create_task(server.serve()) + + +if __name__ == '__main__': + run( + app='bot.api.main:app', + host='127.0.0.1', + port=bot_config.server_port, + reload=True, + reload_dirs=['bot/api'], + log_config=_get_log_config(), + ) diff --git a/bot/api/models/account.py b/bot/api/models/account.py new file mode 100644 index 0000000..64f320b --- /dev/null +++ b/bot/api/models/account.py @@ -0,0 +1,26 @@ +from typing import Optional + +from pydantic import BaseModel, Field + + +class AccountResponse(BaseModel): + username: str = Field(description="The user's username.") + spotify_logged_in: bool = Field( + description='Whether the user is logged in to Spotify.' + ) + spotify_username: Optional[str] = Field( + default=None, description="The user's Spotify username, if logged in." + ) + lastfm_logged_in: bool = Field( + description='Whether the user is logged in to Last.fm.' + ) + lastfm_username: Optional[str] = Field( + default=None, description="The user's Last.fm username, if logged in." + ) + + +class UnlinkRequest(BaseModel): + service: str = Field( + description='The service to unlink from the user account.', + examples=['spotify', 'lastfm'], + ) diff --git a/bot/api/models/oauth.py b/bot/api/models/oauth.py new file mode 100644 index 0000000..bba5d48 --- /dev/null +++ b/bot/api/models/oauth.py @@ -0,0 +1,17 @@ +from typing import Optional + +from pydantic import BaseModel, Field + + +class OAuthResponse(BaseModel): + session_id: str = Field(description='The session ID for the user.') + jwt: str = Field(description='The JSON Web Token for the user.') + + +class DiscordUser(BaseModel): + id: int = Field(description='The user ID.') + username: str = Field(description='The username.') + discriminator: str = Field(description='The discriminator.') + avatar: Optional[str] = Field( + default=None, description='The avatar hash, if the user has one.' + ) diff --git a/bot/api/models/session.py b/bot/api/models/session.py new file mode 100644 index 0000000..83e69c9 --- /dev/null +++ b/bot/api/models/session.py @@ -0,0 +1,7 @@ +from pydantic import BaseModel + + +class Session(BaseModel): + user_id: int + session_id: str + expiration_time: int diff --git a/bot/api/routes/account/__init__.py b/bot/api/routes/account/__init__.py new file mode 100644 index 0000000..c2caedd --- /dev/null +++ b/bot/api/routes/account/__init__.py @@ -0,0 +1,18 @@ +from fastapi import APIRouter + +from .delete import delete_account +from .lastfm import redirect_to_lastfm_login +from .login import redirect_to_login +from .logout import logout +from .me import get_logged_in_user +from .spotify import redirect_to_spotify_login +from .unlink import unlink_service + +account_router = APIRouter(prefix='/account', tags=['account']) +account_router.add_api_route('/delete', delete_account, methods=['GET']) +account_router.add_api_route('/lastfm', redirect_to_lastfm_login, methods=['GET']) +account_router.add_api_route('/login', redirect_to_login, methods=['GET']) +account_router.add_api_route('/logout', logout, methods=['GET']) +account_router.add_api_route('/me', get_logged_in_user, methods=['GET']) +account_router.add_api_route('/spotify', redirect_to_spotify_login, methods=['GET']) +account_router.add_api_route('/unlink', unlink_service, methods=['POST']) diff --git a/bot/api/routes/account/delete.py b/bot/api/routes/account/delete.py new file mode 100644 index 0000000..959e98f --- /dev/null +++ b/bot/api/routes/account/delete.py @@ -0,0 +1,23 @@ +from typing import TYPE_CHECKING + +from fastapi import Depends +from fastapi.responses import RedirectResponse + +from bot.api.depends.database import database_dependency +from bot.api.depends.session import session_dependency + +if TYPE_CHECKING: + from bot.api.models.session import Session + from bot.database import Database + + +async def delete_account( + db: 'Database' = Depends(database_dependency), + session: 'Session' = Depends(session_dependency), +) -> RedirectResponse: + user_id = session.user_id + db.delete_oauth('lastfm', user_id) + db.delete_oauth('spotify', user_id) + db.delete_oauth('discord', user_id) + + return RedirectResponse(url='/account/logout') diff --git a/bot/api/routes/account/lastfm.py b/bot/api/routes/account/lastfm.py new file mode 100644 index 0000000..4b06377 --- /dev/null +++ b/bot/api/routes/account/lastfm.py @@ -0,0 +1,46 @@ +from fastapi import HTTPException +from fastapi.responses import RedirectResponse +from starlette.status import HTTP_500_INTERNAL_SERVER_ERROR +from yarl import URL + +from bot.utils.config import config as bot_config + + +async def redirect_to_lastfm_login() -> RedirectResponse: + api_key = bot_config.lastfm_api_key + base_url = bot_config.base_url + + if api_key is None or base_url is None: + raise HTTPException( + status_code=HTTP_500_INTERNAL_SERVER_ERROR, + detail='Missing Last.fm API key or base URL', + ) + + url = _build_url(api_key, base_url) + response = RedirectResponse(url=str(url)) + return response + + +def _build_url(api_key: str, base_url: str) -> str: + """ + Generate a state token and build the URL for the login redirect. + + Args: + api_key: The Last.fm API key + base_url: The base URL of the bot. + + Returns: + str: The URL. + """ + + url = URL.build( + scheme='https', + host='www.last.fm', + path='/api/auth', + query={ + 'api_key': api_key, + 'cb': f'{base_url}/callback/lastfm', + }, + ) + + return str(url) diff --git a/bot/api/routes/account/login.py b/bot/api/routes/account/login.py new file mode 100644 index 0000000..74fc126 --- /dev/null +++ b/bot/api/routes/account/login.py @@ -0,0 +1,62 @@ +from secrets import token_urlsafe +from typing import Tuple + +from fastapi import HTTPException +from fastapi.responses import RedirectResponse +from starlette.status import HTTP_500_INTERNAL_SERVER_ERROR +from yarl import URL + +from bot.utils.config import config as bot_config + +DISCORD_OAUTH_SCOPES = [ + 'identify', + 'guilds', + 'email', +] + + +async def redirect_to_login() -> RedirectResponse: + oauth_id = bot_config.discord_oauth_id + base_url = bot_config.base_url + + if oauth_id is None or base_url is None: + raise HTTPException( + status_code=HTTP_500_INTERNAL_SERVER_ERROR, + detail='Missing Discord OAuth ID or base URL', + ) + + state, url = _build_url(oauth_id, base_url) + response = RedirectResponse(url=str(url)) + response.set_cookie('state', state, httponly=True, samesite='lax') + return response + + +def _build_url(oauth_id: str, base_url: str) -> Tuple[str, str]: + """ + Generate a state token and build the URL for the OAuth redirect. + + Args: + oauth_id: The Discord OAuth client ID. + base_url: The base URL of the bot. + + Returns: + Tuple[str, str]: The state token and URL. + """ + + state = token_urlsafe(16) + + url = URL.build( + scheme='https', + host='discord.com', + path='/api/oauth2/authorize', + query={ + 'client_id': oauth_id, + 'response_type': 'code', + 'scope': ' '.join(DISCORD_OAUTH_SCOPES), + 'redirect_uri': f'{base_url}/callback/discord', + 'state': state, + 'prompt': 'none', + }, + ) + + return state, str(url) diff --git a/bot/api/routes/account/logout.py b/bot/api/routes/account/logout.py new file mode 100644 index 0000000..62d3f8d --- /dev/null +++ b/bot/api/routes/account/logout.py @@ -0,0 +1,20 @@ +from typing import TYPE_CHECKING + +from fastapi import Depends, Request +from fastapi.responses import RedirectResponse + +from bot.api.depends.session import session_dependency + +if TYPE_CHECKING: + from bot.api.models.session import Session + from bot.api.utils.session import SessionManager + + +async def logout( + request: Request, + session: 'Session' = Depends(session_dependency), +) -> RedirectResponse: + session_manager: 'SessionManager' = request.app.state.session_manager + session_manager.delete_session(session.session_id) + + return RedirectResponse(url='/') diff --git a/bot/api/routes/account/me.py b/bot/api/routes/account/me.py new file mode 100644 index 0000000..64444ab --- /dev/null +++ b/bot/api/routes/account/me.py @@ -0,0 +1,38 @@ +""" +Route for getting the current user's account information. +""" + +from typing import TYPE_CHECKING, Optional + +from fastapi import Depends + +from bot.api.depends.database import database_dependency +from bot.api.depends.user import user_dependency +from bot.api.models.account import AccountResponse + +if TYPE_CHECKING: + from bot.database import Database + from bot.models.oauth import LastfmAuth, OAuth + + +async def get_logged_in_user( + user: 'OAuth' = Depends(user_dependency), + db: 'Database' = Depends(database_dependency), +) -> AccountResponse: + spotify_username = None + spotify: Optional['OAuth'] = db.get_oauth('spotify', user.user_id) + if spotify is not None: + spotify_username = spotify.username + + lastfm_username = None + lastfm: Optional['LastfmAuth'] = db.get_lastfm_credentials(user.user_id) + if lastfm is not None: + lastfm_username = lastfm.username + + return AccountResponse( + username=user.username, + spotify_logged_in=spotify is not None, + spotify_username=spotify_username, + lastfm_logged_in=lastfm is not None, + lastfm_username=lastfm_username, + ) diff --git a/bot/api/routes/account/spotify.py b/bot/api/routes/account/spotify.py new file mode 100644 index 0000000..e645f09 --- /dev/null +++ b/bot/api/routes/account/spotify.py @@ -0,0 +1,56 @@ +from secrets import token_urlsafe +from typing import Tuple + +from fastapi import HTTPException +from fastapi.responses import RedirectResponse +from starlette.status import HTTP_500_INTERNAL_SERVER_ERROR +from yarl import URL + +from bot.api.utils.constants import SPOTIFY_OAUTH_SCOPES +from bot.utils.config import config as bot_config + + +async def redirect_to_spotify_login() -> RedirectResponse: + oauth_id = bot_config.spotify_client_id + base_url = bot_config.base_url + + if oauth_id is None or base_url is None: + raise HTTPException( + status_code=HTTP_500_INTERNAL_SERVER_ERROR, + detail='Missing Spotify OAuth ID or base URL', + ) + + state, url = _build_url(oauth_id, base_url) + response = RedirectResponse(url=str(url)) + response.set_cookie('state', state, httponly=True, samesite='lax') + return response + + +def _build_url(oauth_id: str, base_url: str) -> Tuple[str, str]: + """ + Generate a state token and build the URL for the OAuth redirect. + + Args: + oauth_id: The Spotify OAuth client ID. + base_url: The base URL of the bot. + + Returns: + Tuple[str, str]: The state token and URL. + """ + + state = token_urlsafe(16) + + url = URL.build( + scheme='https', + host='accounts.spotify.com', + path='/authorize', + query={ + 'client_id': oauth_id, + 'response_type': 'code', + 'scope': ' '.join(SPOTIFY_OAUTH_SCOPES), + 'redirect_uri': f'{base_url}/callback/spotify', + 'state': state, + }, + ) + + return state, str(url) diff --git a/bot/api/routes/account/unlink.py b/bot/api/routes/account/unlink.py new file mode 100644 index 0000000..370a924 --- /dev/null +++ b/bot/api/routes/account/unlink.py @@ -0,0 +1,29 @@ +from typing import TYPE_CHECKING + +from fastapi import Depends, HTTPException +from fastapi.responses import Response +from starlette.status import HTTP_204_NO_CONTENT, HTTP_400_BAD_REQUEST + +from bot.api.depends.database import database_dependency +from bot.api.depends.user import user_dependency +from bot.api.models.account import UnlinkRequest + +if TYPE_CHECKING: + from bot.database import Database + from bot.models.oauth import OAuth + +VALID_SERVICES = ('lastfm', 'spotify') + + +async def unlink_service( + request: UnlinkRequest, + db: 'Database' = Depends(database_dependency), + user: 'OAuth' = Depends(user_dependency), +) -> Response: + service = request.service + if service not in VALID_SERVICES: + raise HTTPException(status_code=HTTP_400_BAD_REQUEST, detail='Invalid service') + + db.delete_oauth(service, user.user_id) + + return Response(status_code=HTTP_204_NO_CONTENT) diff --git a/bot/api/routes/callback/__init__.py b/bot/api/routes/callback/__init__.py new file mode 100644 index 0000000..b5ae7c6 --- /dev/null +++ b/bot/api/routes/callback/__init__.py @@ -0,0 +1,10 @@ +from fastapi import APIRouter + +from .discord import discord_callback +from .lastfm import lastfm_callback +from .spotify import spotify_callback + +callback_router = APIRouter(prefix='/callback', tags=['oauth']) +callback_router.add_api_route('/discord', discord_callback, methods=['GET']) +callback_router.add_api_route('/lastfm', lastfm_callback, methods=['GET']) +callback_router.add_api_route('/spotify', spotify_callback, methods=['GET']) diff --git a/bot/api/routes/callback/discord.py b/bot/api/routes/callback/discord.py new file mode 100644 index 0000000..4202dd3 --- /dev/null +++ b/bot/api/routes/callback/discord.py @@ -0,0 +1,163 @@ +from datetime import UTC, datetime +from typing import TYPE_CHECKING, Tuple + +from fastapi import Depends, HTTPException, Request, Response +from requests import HTTPError, Timeout, get, post +from starlette.status import HTTP_400_BAD_REQUEST, HTTP_500_INTERNAL_SERVER_ERROR + +from bot.api.depends.database import database_dependency +from bot.api.models.oauth import DiscordUser, OAuthResponse +from bot.models.oauth import OAuth +from bot.utils.config import config as bot_config +from bot.utils.constants import DISCORD_API_BASE_URL, USER_AGENT + +if TYPE_CHECKING: + from bot.api.utils.session import SessionManager + from bot.database import Database + + +async def discord_callback( + request: Request, + response: Response, + code: str, + state: str, + db: 'Database' = Depends(database_dependency), +) -> OAuthResponse: + _validate_state(request, response, state=state) + + oauth_id = bot_config.discord_oauth_id + oauth_secret = bot_config.discord_oauth_secret + base_url = bot_config.base_url + if oauth_id is None or oauth_secret is None or base_url is None: + raise HTTPException( + status_code=HTTP_500_INTERNAL_SERVER_ERROR, + detail='Missing Discord OAuth ID, secret, or base URL', + ) + + access_token, refresh_token, expiration_time = _exchange_code_for_token( + oauth_id, oauth_secret, base_url, code + ) + user = _get_user_info(access_token) + _store_user_info(db, user, access_token, refresh_token, expiration_time) + + session_manager: 'SessionManager' = request.app.state.session_manager + session_id = session_manager.create_session(user.id) + jwt = session_manager.encode_session(session_id) + + return OAuthResponse(session_id=session_id, jwt=jwt) + + +def _validate_state(request: Request, response: Response, state: str) -> str: + expected_state = request.cookies.get('state') + if expected_state is None: + raise HTTPException( + status_code=HTTP_400_BAD_REQUEST, + detail='Missing state cookie', + ) + + if state != expected_state: + raise HTTPException( + status_code=HTTP_400_BAD_REQUEST, + detail='Invalid state', + ) + + response.delete_cookie('state') + return state + + +def _exchange_code_for_token( + client_id: str, client_secret: str, base_url: str, code: str +) -> Tuple[str, str, int]: + """ + Exchange the code for an access token. + + Returns: + Tuple[str, str, int]: The access token, refresh token, + and the time at which the access token expires. + """ + + response = post( + str(DISCORD_API_BASE_URL / 'oauth2/token'), + data={ + 'client_id': client_id, + 'client_secret': client_secret, + 'grant_type': 'authorization_code', + 'code': code, + 'redirect_uri': f'{base_url}/callback/discord', + }, + headers={ + 'Content-Type': 'application/x-www-form-urlencoded', + 'User-Agent': USER_AGENT, + }, + timeout=5, + ) + + try: + response.raise_for_status() + except HTTPError as err: + raise HTTPException( + status_code=HTTP_400_BAD_REQUEST, detail=f'Error getting access token: {err}' + ) + except Timeout: + raise HTTPException( + status_code=HTTP_500_INTERNAL_SERVER_ERROR, + detail='Timed out while requesting access token', + ) + + data = response.json() + access_token = data['access_token'] + refresh_token = data['refresh_token'] + expires_in = data['expires_in'] + expiration_time = int(datetime.now(UTC).timestamp()) + expires_in + + return access_token, refresh_token, expiration_time + + +def _get_user_info(access_token: str) -> DiscordUser: + response = get( + str(DISCORD_API_BASE_URL / 'users/@me'), + headers={ + 'Authorization': f'Bearer {access_token}', + 'User-Agent': USER_AGENT, + }, + timeout=5, + ) + + try: + response.raise_for_status() + except HTTPError as err: + raise HTTPException( + status_code=HTTP_400_BAD_REQUEST, detail=f'Error getting user info: {err}' + ) + except Timeout: + raise HTTPException( + status_code=HTTP_500_INTERNAL_SERVER_ERROR, + detail='Timed out while requesting user info', + ) + + data = response.json() + return DiscordUser( + id=data['id'], + username=data['username'], + discriminator=data['discriminator'], + avatar=data.get('avatar'), + ) + + +def _store_user_info( + db: 'Database', + user: DiscordUser, + access_token: str, + refresh_token: str, + expiration_time: int, +): + db.set_oauth( + 'discord', + OAuth( + user_id=user.id, + username=user.username, + access_token=access_token, + refresh_token=refresh_token, + expires_at=expiration_time, + ), + ) diff --git a/bot/api/routes/callback/lastfm.py b/bot/api/routes/callback/lastfm.py new file mode 100644 index 0000000..a175b0c --- /dev/null +++ b/bot/api/routes/callback/lastfm.py @@ -0,0 +1,97 @@ +from hashlib import md5 +from typing import TYPE_CHECKING, Tuple + +from fastapi import Depends, HTTPException +from fastapi.responses import RedirectResponse +from requests import HTTPError, Timeout, request +from starlette.status import HTTP_400_BAD_REQUEST, HTTP_500_INTERNAL_SERVER_ERROR + +from bot.api.depends.database import database_dependency +from bot.api.depends.user import user_dependency +from bot.models.oauth import LastfmAuth, OAuth +from bot.utils.config import config as bot_config +from bot.utils.constants import LASTFM_API_BASE_URL, USER_AGENT + +if TYPE_CHECKING: + from bot.database import Database + + +async def lastfm_callback( + token: str, + db: 'Database' = Depends(database_dependency), + user: OAuth = Depends(user_dependency), +) -> RedirectResponse: + api_key = bot_config.lastfm_api_key + secret = bot_config.lastfm_shared_secret + + if api_key is None or secret is None: + raise HTTPException( + status_code=HTTP_500_INTERNAL_SERVER_ERROR, + detail='Missing Last.fm API key or shared secret', + ) + + session_key_url = _create_session_key_url(api_key, token, secret) + session_key, username = _get_session_key(session_key_url) + _store_user_info(db, user, session_key, username) + + return RedirectResponse(url='/') + + +def _create_session_key_url(api_key: str, token: str, secret: str) -> str: + signature = ''.join( + ['api_key', api_key, 'method', 'auth.getSession', 'token', token, secret] + ) + + hashed = md5(signature.encode('utf-8')).hexdigest() + + url = LASTFM_API_BASE_URL.with_query( + { + 'method': 'auth.getSession', + 'api_key': api_key, + 'token': token, + 'api_sig': hashed, + 'format': 'json', + } + ) + + return str(url) + + +def _get_session_key(url: str) -> Tuple[str, str]: + response = request( + 'GET', + url, + headers={ + 'User-Agent': USER_AGENT, + }, + timeout=5, + ) + + try: + response.raise_for_status() + except HTTPError as err: + raise HTTPException( + status_code=HTTP_400_BAD_REQUEST, + detail=f'Error getting session key: {err}', + ) + except Timeout: + raise HTTPException( + status_code=HTTP_500_INTERNAL_SERVER_ERROR, + detail='Timed out while requesting access token', + ) + + data = response.json() + session_key = data['session']['key'] + username = data['session']['name'] + + return session_key, username + + +def _store_user_info(db: 'Database', user: OAuth, session_key: str, username: str): + db.set_lastfm_credentials( + LastfmAuth( + user_id=user.user_id, + username=username, + session_key=session_key, + ), + ) diff --git a/bot/api/routes/callback/spotify.py b/bot/api/routes/callback/spotify.py new file mode 100644 index 0000000..dc86e89 --- /dev/null +++ b/bot/api/routes/callback/spotify.py @@ -0,0 +1,136 @@ +from base64 import b64encode +from datetime import UTC, datetime +from typing import TYPE_CHECKING, Tuple + +from fastapi import Depends, HTTPException, Request, Response +from fastapi.responses import RedirectResponse +from requests import HTTPError, Timeout, post +from starlette.status import HTTP_400_BAD_REQUEST, HTTP_500_INTERNAL_SERVER_ERROR + +from bot.api.depends.database import database_dependency +from bot.api.depends.user import user_dependency +from bot.models.oauth import OAuth +from bot.utils.config import config as bot_config +from bot.utils.constants import DISCORD_API_BASE_URL, USER_AGENT + +if TYPE_CHECKING: + from bot.database import Database + + +async def spotify_callback( # noqa: PLR0913 + request: Request, + response: Response, + code: str, + state: str, + db: 'Database' = Depends(database_dependency), + user: OAuth = Depends(user_dependency), +) -> RedirectResponse: + _validate_state(request, response, state=state) + + oauth_id = bot_config.spotify_client_id + oauth_secret = bot_config.spotify_client_secret + base_url = bot_config.base_url + if oauth_id is None or oauth_secret is None or base_url is None: + raise HTTPException( + status_code=HTTP_500_INTERNAL_SERVER_ERROR, + detail='Missing Spotify OAuth ID, secret, or base URL', + ) + + access_token, refresh_token, expiration_time, scopes = _exchange_code_for_token( + oauth_id, oauth_secret, base_url, code + ) + _store_user_info(db, user, access_token, refresh_token, expiration_time, scopes) + + return RedirectResponse(url=base_url) + + +def _validate_state(request: Request, response: Response, state: str) -> str: + expected_state = request.cookies.get('state') + if expected_state is None: + raise HTTPException( + status_code=HTTP_400_BAD_REQUEST, + detail='Missing state cookie', + ) + + if state != expected_state: + raise HTTPException( + status_code=HTTP_400_BAD_REQUEST, + detail='Invalid state', + ) + + response.delete_cookie('state') + return state + + +def _exchange_code_for_token( + client_id: str, client_secret: str, base_url: str, code: str +) -> Tuple[str, str, int, str]: + """ + Exchange the code for an access token. + + Returns: + Tuple[str, str, int, str]: The access token, refresh token, + the token expiration timestamp, and the list of authorized + scopes. + """ + + response = post( + str(DISCORD_API_BASE_URL / 'token'), + data={ + 'grant_type': 'authorization_code', + 'code': code, + 'redirect_uri': f'{base_url}/callback/spotify', + }, + headers={ + 'Authorization': f'Basic {b64encode(f"{client_id}:{client_secret}".encode()).decode()}', + 'Content-Type': 'application/x-www-form-urlencoded', + 'User-Agent': USER_AGENT, + }, + timeout=5, + ) + + try: + response.raise_for_status() + except HTTPError as err: + raise HTTPException( + status_code=HTTP_400_BAD_REQUEST, detail=f'Error getting access token: {err}' + ) + except Timeout: + raise HTTPException( + status_code=HTTP_500_INTERNAL_SERVER_ERROR, + detail='Timed out while requesting access token', + ) + + data = response.json() + access_token = data['access_token'] + refresh_token = data['refresh_token'] + expires_in = data['expires_in'] + scopes = data['scope'] + expiration_time = int(datetime.now(UTC).timestamp()) + expires_in + + return access_token, refresh_token, expiration_time, scopes + + +def _store_user_info( # noqa: PLR0913 + db: 'Database', + user: OAuth, + access_token: str, + refresh_token: str, + expiration_time: int, + scopes: str, +): + db.set_oauth( + 'spotify', + OAuth( + user_id=user.user_id, + username=user.username, + access_token=access_token, + refresh_token=refresh_token, + expires_at=expiration_time, + ), + ) + + db.set_spotify_scopes( + user.user_id, + scopes.split(' '), + ) diff --git a/bot/api/utils/constants.py b/bot/api/utils/constants.py new file mode 100644 index 0000000..144fc07 --- /dev/null +++ b/bot/api/utils/constants.py @@ -0,0 +1,7 @@ +SPOTIFY_OAUTH_SCOPES = [ + 'user-read-private', # Get username + 'user-read-email', # Also for username, weirdly + 'user-library-modify', # Add/remove Liked Songs + 'user-top-read', # Get top tracks, for recommendations/radio + 'playlist-read-private', # Get owned playlists +] diff --git a/bot/api/utils/session.py b/bot/api/utils/session.py new file mode 100644 index 0000000..ad28865 --- /dev/null +++ b/bot/api/utils/session.py @@ -0,0 +1,108 @@ +from datetime import UTC, datetime +from typing import TYPE_CHECKING, Dict, Optional +from uuid import uuid4 + +import jwt + +from bot.api.models.session import Session +from bot.utils.config import config as bot_config +from bot.utils.logger import create_logger + +if TYPE_CHECKING: + from bot.database import Database + from bot.models.oauth import OAuth + +_MIN_IN_SECONDS = 60 +SESSION_LIFETIME = 60 * _MIN_IN_SECONDS + + +class SessionManager: + """ + Manages user sessions. + """ + + def __init__(self, database: 'Database'): + self._database = database + self._logger = create_logger('api.session') + self._sessions: Dict[str, Session] = {} + self._secret = bot_config.jwt_secret + + def create_session(self, user_id: int) -> str: + """ + Create a new session. + + Returns: + str: The session ID. + """ + + user: Optional['OAuth'] = self._database.get_oauth('discord', user_id) + if user is None: + raise ValueError('User not found') + + session_id = str(uuid4()) + expiration_time = int(datetime.now(tz=UTC).timestamp()) + SESSION_LIFETIME + session = Session( + session_id=session_id, user_id=user_id, expiration_time=expiration_time + ) + self._sessions[session_id] = session + + return session_id + + def get_session(self, session_id: str) -> Optional[Session]: + """ + Get a session by its ID. + + Returns: + Optional[Session]: The session, if it exists. + """ + return self._sessions.get(session_id) + + def delete_session(self, session_id: str): + """ + Delete a session by its ID. + """ + if session_id in self._sessions: + del self._sessions[session_id] + + def encode_session(self, session_id: str) -> str: + """ + Encode a session into a JWT. + + Returns: + str: The JWT. + """ + + if self._secret is None: + raise ValueError('JWT secret not set') + + session = self.get_session(session_id) + if session is None: + raise ValueError('Session not found') + + return jwt.encode( + payload=session.model_dump(), + key=self._secret, + algorithm='HS256', + ) + + def decode_session(self, token: str) -> Optional[Session]: + """ + Decode a JWT into a session. + + Returns: + Optional[Session]: The session, if the token is valid. + """ + if self._secret is None: + raise ValueError('JWT secret not set') + + try: + payload = jwt.decode( + jwt=token, + key=self._secret, + algorithms=['HS256'], + ) + except jwt.PyJWTError as e: + self._logger.error(f'Error decoding JWT: {e}') + return None + + return Session(**payload) diff --git a/cogs/__init__.py b/bot/cogs/__init__.py similarity index 57% rename from cogs/__init__.py rename to bot/cogs/__init__.py index 301ebfe..661efcd 100644 --- a/cogs/__init__.py +++ b/bot/cogs/__init__.py @@ -4,18 +4,20 @@ """ from typing import TYPE_CHECKING + +from .bumps import BumpCog from .debug import DebugCog from .player import PlayerCog -from .bumps import BumpCog + if TYPE_CHECKING: - from utils.blanco import BlancoBot + from bot.utils.blanco import BlancoBot def setup(bot: 'BlancoBot'): - """ - Setup function for the cogs extension. - """ - # Add cogs - bot.add_cog(DebugCog(bot)) - bot.add_cog(PlayerCog(bot)) - bot.add_cog(BumpCog(bot)) + """ + Setup function for the cogs extension. + """ + # Add cogs + bot.add_cog(DebugCog(bot)) + bot.add_cog(PlayerCog(bot)) + bot.add_cog(BumpCog(bot)) diff --git a/bot/cogs/bumps.py b/bot/cogs/bumps.py new file mode 100644 index 0000000..482c7eb --- /dev/null +++ b/bot/cogs/bumps.py @@ -0,0 +1,229 @@ +""" +BumpCog: Cog for guild bumps. +""" + +from typing import TYPE_CHECKING + +from nextcord import Color, Interaction, Permissions, SlashOption, slash_command +from nextcord.ext.commands import Cog + +from bot.models.bump import Bump +from bot.utils.embeds import CustomEmbed, create_error_embed, create_success_embed +from bot.utils.logger import create_logger +from bot.utils.paginator import Paginator, list_chunks +from bot.utils.url import check_url + +if TYPE_CHECKING: + from bot.utils.blanco import BlancoBot + + +MAX_BUMP_METADATA_LENGTH = 32 + + +class BumpCog(Cog): + """ + Cog for guild bumps. + """ + + def __init__(self, bot: 'BlancoBot'): + """ + Constructor for BumpCog. + """ + self._bot = bot + self._logger = create_logger(self.__class__.__name__) + self._logger.info('Loaded BumpCog') + + @slash_command( + name='bump', + dm_permission=False, + default_member_permissions=Permissions(manage_guild=True), + ) + async def bump(self, itx: Interaction): + """ + Base slash command for bumps. + """ + + @bump.subcommand(name='toggle', description='Toggle the playback of bumps.') + async def bump_toggle( + self, + itx: Interaction, + toggle: bool = SlashOption( + name='toggle', description='Turn bumps on or off?', required=False + ), + ): + """ + Subcommand for toggling bumps. + """ + if itx.guild is None: + raise RuntimeError('[bump::toggle] itx.guild is None') + + if toggle is None: + enabled = self._bot.database.get_bumps_enabled(itx.guild.id) + status = ( + 'Bump playback is currently enabled.' + if enabled + else 'Bump playback is currently disabled.' + ) + return await itx.response.send_message( + embed=create_success_embed( + title='Bumps status', + body=status, + ) + ) + + self._bot.database.set_bumps_enabled(itx.guild.id, toggle) + status = ( + 'Bump playback has been enabled.' + if toggle + else 'Bump playback has been disabled.' + ) + return await itx.response.send_message( + embed=create_success_embed( + title='Bumps toggled', + body=status, + ) + ) + + @bump.subcommand(name='add', description='Add a bump.') + async def bump_add( + self, + itx: Interaction, + title: str = SlashOption(name='title', description='Title of bump.', required=True), + author: str = SlashOption( + name='author', description='Author of bump.', required=True + ), + url: str = SlashOption(name='url', description='URL to add.', required=True), + ): + """ + Subcommand for adding a bump. + """ + if itx.guild is None: + raise RuntimeError('[bump::add] itx.guild is None') + + if len(title) > MAX_BUMP_METADATA_LENGTH or len(author) > MAX_BUMP_METADATA_LENGTH: + return await itx.response.send_message( + embed=create_error_embed( + message='Titles/authors cannot exceed 32 characters in length.' + ) + ) + + if not check_url(url): + return await itx.response.send_message( + embed=create_error_embed(message='The given URL is not valid.') + ) + + bump = self._bot.database.get_bump_by_url(itx.guild.id, url) + if bump is not None: + return await itx.response.send_message( + embed=create_error_embed(message='A bump with the given URL already exists.') + ) + + self._bot.database.add_bump(itx.guild.id, url, title, author) + return await itx.response.send_message( + embed=create_success_embed( + title='Bump added', body='Bump has been successfully added to the database.' + ) + ) + + @bump.subcommand(name='remove', description='Remove a bump.') + async def bump_remove( + self, + itx: Interaction, + idx: int = SlashOption(name='index', description='Index of bump.', required=True), + ): + """ + Subcommand for removing a bump. + """ + if itx.guild is None: + raise RuntimeError('[bump::remove] itx.guild is None') + + bump = self._bot.database.get_bump(itx.guild.id, idx) + if bump is None: + return await itx.response.send_message( + embed=create_error_embed( + message='There is no bump at that index for this guild.' + ) + ) + + self._bot.database.delete_bump(itx.guild.id, idx) + return await itx.response.send_message( + embed=create_success_embed( + title='Bump removed', + body='Bump has successfully been removed from the database.', + ) + ) + + @bump.subcommand(name='list', description='List every bump.') + async def bump_list( + self, + itx: Interaction, + ): + """ + Subcommand for listing bumps. + """ + if itx.guild is None: + raise RuntimeError('[bump::list] itx.guild is None') + await itx.response.defer() + + bumps = self._bot.database.get_bumps(itx.guild.id) + if bumps is None: + return await itx.response.send_message( + embed=create_error_embed(message='This guild has no bumps.') + ) + + pages = [] + count = 1 + for _, chunk in enumerate(list_chunks(bumps)): + chunk_bumps = [] + + bump: Bump + for bump in chunk: + line = f'{bump.idx} :: [{bump.title}]({bump.url}) by {bump.author}' + chunk_bumps.append(line) + count += 1 + + embed = CustomEmbed( + title=f'Bumps for {itx.guild.name}', + description='\n'.join(chunk_bumps), + color=Color.lighter_gray(), + ) + pages.append(embed.get()) + + paginator = Paginator(itx) + return await paginator.run(pages) + + @bump.subcommand(name='interval', description='Set or get the bump interval.') + async def bump_interval( + self, + itx: Interaction, + interval: int = SlashOption( + name='interval', + description='The new interval bumps will play at', + required=False, + min_value=1, + max_value=60, + ), + ): + """ + Subcommand for changing/checking the bump interval. + """ + + if itx.guild is None: + raise RuntimeError('[bump::interval] itx.guild is None') + + if interval is None: + curr_interval = self._bot.database.get_bump_interval(itx.guild.id) + return await itx.response.send_message( + embed=create_success_embed( + title='Current Interval', + body=f'A bump will play once at least every {curr_interval} minute(s).', + ) + ) + + self._bot.database.set_bump_interval(itx.guild.id, interval) + return await itx.response.send_message( + embed=create_success_embed( + title='Interval Changed', + body=f'The bump interval has been set to {interval} minute(s).', + ) + ) diff --git a/bot/cogs/debug/__init__.py b/bot/cogs/debug/__init__.py new file mode 100644 index 0000000..f6f979d --- /dev/null +++ b/bot/cogs/debug/__init__.py @@ -0,0 +1,151 @@ +""" +DebugCog: Cog for debugging commands. +""" + +from typing import TYPE_CHECKING + +from nextcord import Color, Interaction, PartialMessageable, SlashOption, slash_command +from nextcord.ext import application_checks +from nextcord.ext.commands import Cog + +from bot.models.custom_embed import CustomEmbed +from bot.utils.embeds import create_success_embed +from bot.utils.logger import create_logger +from bot.utils.paginator import Paginator + +if TYPE_CHECKING: + from bot.utils.blanco import BlancoBot + +STATS_FORMAT = """ +```asciidoc +Uptime :: {uptime} +Players :: {playing_player_count} playing ({player_count} total) +CPU :: {system_load:.2f}% (Lavalink {lavalink_load:.2f}%) +Memory :: {used:.0f} MiB used + {free:.0f} MiB free + {allocated:.0f} MiB allocated + {reservable:.0f} MiB reservable +``` +""" + + +class DebugCog(Cog): + """ + Cog for debugging commands. + """ + + def __init__(self, bot: 'BlancoBot'): + """ + Constructor for DebugCog. + """ + self._bot = bot + self._logger = create_logger(self.__class__.__name__) + self._logger.info('Loaded DebugCog') + + @slash_command(name='announce') + @application_checks.is_owner() + async def announce( + self, + itx: Interaction, + message: str = SlashOption(description='The message to announce.', required=True), + ): + """ + Posts an announcement to the system channel in all guilds. + If there is no system channel, attempt to send to the last channel + used by the bot for now playing embeds. + """ + await itx.response.defer() + + # Create announcement embed + embed = CustomEmbed( + color=Color.yellow(), + title=':warning: Announcement', + description=message, + footer='From the bot owner', + timestamp_now=True, + ).get() + + # Send announcement to all guilds + for guild in self._bot.guilds: + # Get system channel + system_channel = guild.system_channel + if system_channel is None: + # Attempt to get status channel + system_channel = self._bot.get_status_channel(guild.id) + + if system_channel is None or ( + not isinstance(system_channel, PartialMessageable) + and not system_channel.permissions_for(guild.me).send_messages + ): + self._logger.error('No suitable announcement channel saved for %s', guild.name) + else: + # Send message + await system_channel.send(embed=embed) + self._logger.info('Sent announcement to %s', guild.name) + + await itx.followup.send(embed=create_success_embed('Announced!'), ephemeral=True) + + @slash_command(name='reload') + @application_checks.is_owner() + async def reload(self, itx: Interaction): + """ + Reloads all bot.cogs. + """ + # Reload cogs + self._bot.unload_extension('cogs') + self._bot.load_extension('cogs') + + # Resync commands + await self._bot.sync_all_application_commands() + + await itx.response.send_message( + embed=create_success_embed('Reloaded extensions!'), ephemeral=True + ) + + @slash_command(name='stats') + async def stats(self, itx: Interaction): + """ + Shows bot statistics. + """ + await itx.response.defer() + + pages = [] + nodes = self._bot.pool.nodes + for node in nodes: + stats = node.stats + + if stats is not None: + # Adapted from @ooliver1/mafic test bot + pages.append( + CustomEmbed( + color=Color.purple(), + title=f':bar_chart:|Stats for node `{node.label}`', + description='No statistics available' + if stats is None + else STATS_FORMAT.format( + uptime=stats.uptime, + used=stats.memory.used / 1024 / 1024, + free=stats.memory.free / 1024 / 1024, + allocated=stats.memory.allocated / 1024 / 1024, + reservable=stats.memory.reservable / 1024 / 1024, + system_load=stats.cpu.system_load * 100, + lavalink_load=stats.cpu.lavalink_load * 100, + player_count=stats.player_count, + playing_player_count=stats.playing_player_count, + ), + footer=f'{len(nodes)} total node(s)', + ).get() + ) + else: + pages.append( + CustomEmbed( + color=Color.red(), + title=f':bar_chart:|Stats for node `{node.label}`', + description='No statistics available', + footer=f'{len(nodes)} total node(s)', + ).get() + ) + + # Run paginator + paginator = Paginator(itx) + return await paginator.run(pages) diff --git a/bot/cogs/player/__init__.py b/bot/cogs/player/__init__.py new file mode 100644 index 0000000..6740e5b --- /dev/null +++ b/bot/cogs/player/__init__.py @@ -0,0 +1,750 @@ +""" +PlayerCog: Cog for controlling the music player. +""" + +from asyncio import TimeoutError as AsyncioTimeoutError +from itertools import islice +from typing import TYPE_CHECKING, Any, Generator, List, Optional + +from mafic import PlayerNotConnected +from nextcord import ( + Color, + Forbidden, + Guild, + HTTPException, + Interaction, + Member, + SlashOption, + VoiceState, + slash_command, +) +from nextcord.abc import Messageable +from nextcord.ext import application_checks +from nextcord.ext.commands import Cog +from requests import HTTPError, codes + +from bot.models.custom_embed import CustomEmbed +from bot.utils.constants import RELEASE, SPOTIFY_403_ERR_MSG +from bot.utils.embeds import create_error_embed, create_success_embed +from bot.utils.exceptions import ( + BlancoException, + EmptyQueueError, + EndOfQueueError, + JockeyError, + JockeyException, + SpotifyNoResultsError, +) +from bot.utils.logger import create_logger +from bot.utils.paginator import Paginator +from bot.utils.player_checks import check_mutual_voice +from bot.views.spotify_dropdown import SpotifyDropdownView + +from .jockey import Jockey + +if TYPE_CHECKING: + from bot.models.queue_item import QueueItem + from bot.utils.blanco import BlancoBot + + +QUEUE_LINE_LENGTH = 50 + + +def list_chunks(data: List[Any]) -> Generator[List[Any], Any, Any]: + """ + Yield 10-element chunks of a list. Used for pagination. + """ + for i in range(0, len(data), 10): + yield list(islice(data, i, i + 10)) + + +class PlayerCog(Cog): + """ + Cog for creating, controlling, and destroying music players for guilds. + """ + + def __init__(self, bot: 'BlancoBot'): + """ + Constructor for PlayerCog. + """ + self._bot = bot + self._logger = create_logger(self.__class__.__name__) + + # Initialize Lavalink client instance + if not bot.pool_initialized: + bot.loop.create_task(bot.init_pool()) + + self._logger.info('Loaded PlayerCog') + + @Cog.listener() + async def on_voice_state_update( + self, member: Member, before: VoiceState, after: VoiceState + ): + """ + Called every time the voice state of a member changes. + In this cog, we use it to check if the bot is left alone in a voice channel, + or if the bot has been server-undeafened. + """ + # Get the player for this guild from cache + jockey: Jockey = member.guild.voice_client # type: ignore + if jockey is not None: + # Stop playing if we're left alone + if ( + hasattr(jockey.channel, 'members') + and len(jockey.channel.members) == 1 # type: ignore + and jockey.channel.members[0].id == member.guild.me.id # type: ignore + and after.channel is None + ): + return self._disconnect(jockey=jockey, reason='You left me alone :(') + + # Did we get server undeafened? + if member.id == member.guild.me.id and before.deaf and not after.deaf: + await self._deafen( + member.guild.me, was_deafened=True, channel=jockey.status_channel + ) + + async def _get_jockey(self, itx: Interaction) -> Jockey: + """ + Gets the Jockey instance for the specified guild. + """ + jockey: Jockey = itx.guild.voice_client # type: ignore + if jockey is None: + if not itx.response.is_done(): + await itx.followup.send(embed=create_error_embed('Not connected to voice')) + raise RuntimeError('Attempted to access nonexistent jockey') + + return jockey + + async def _deafen( + self, + bot_user: Member, + was_deafened: bool = False, + channel: Optional[Messageable] = None, + ): + """ + Attempt to deafen the bot user. + + :param bot_user: The bot user to deafen. Should be an instance of nextcord.Member. + :param was_deafened: Whether the bot user was previously deafened. + :param channel: The Messageable channel to send the error message to. + """ + # Check if we're already deafened + if not was_deafened and bot_user.voice is not None and bot_user.voice.deaf: + return + + if bot_user.guild_permissions.deafen_members: + try: + await bot_user.edit(deafen=True) + except Forbidden: + pass + + # Send message + if channel is not None and hasattr(channel, 'send'): + err = 'Please server deafen me.' + if was_deafened: + err = 'Please do not undeafen me.' + + try: + await channel.send( + embed=create_error_embed( + message=f'{err} Deafening helps save server resources.' + ) + ) + except (Forbidden, HTTPException): + self._logger.error( + 'Unable to send deafen message in guild %d', bot_user.guild.id + ) + + async def _disconnect( + self, + jockey: Optional[Jockey] = None, + itx: Optional[Interaction] = None, + reason: Optional[str] = None, + ): + # Destroy jockey instance + if jockey is None: + if itx is None: + raise ValueError('[player::_disconnect] Either jockey or itx must be specified') + jockey = await self._get_jockey(itx) + + try: + await jockey.stop() + except PlayerNotConnected: + self._logger.warning('Attempted to disconnect disconnected Jockey') + await jockey.disconnect() + + # Send disconnection message + embed = CustomEmbed( + title=':wave:|Disconnected from voice', + description=reason, + footer=f'Blanco release {RELEASE}', + ).get() + + # Try to send disconnection message + try: + if itx is not None: + await itx.followup.send(embed=embed) + else: + guild_id = jockey.guild.id + channel = self._bot.get_status_channel(guild_id) + if channel is not None: + await channel.send(embed=embed) + except (Forbidden, HTTPException): + self._logger.error( + 'Unable to send disconnect message in guild %d', jockey.guild.id + ) + + # Dispatch disconnect event + self._bot.dispatch('jockey_disconnect', jockey) + + @slash_command(name='jump') + @application_checks.check(check_mutual_voice) + async def jump( + self, + itx: Interaction, + position: int = SlashOption(description='Position to jump to', required=True), + ): + """ + Jumps to the specified position in the queue. + """ + jockey = await self._get_jockey(itx) + + # First check if the value is within range + if position < 1 or position > len(jockey.queue): + await itx.response.send_message( + f'Specify a number from 1 to {str(len(jockey.queue))}.', ephemeral=True + ) + return + + # Dispatch to jockey + await itx.response.defer() + try: + await jockey.skip(index=position - 1, auto=False) + except JockeyError as err: + await itx.followup.send(embed=create_error_embed(str(err))) + else: + await itx.followup.send( + embed=create_success_embed(f'Jumped to track {str(position)}') + ) + + @slash_command(name='loop') + @application_checks.check(check_mutual_voice) + async def loop(self, itx: Interaction): + """ + Loops the current track. + """ + jockey = await self._get_jockey(itx) + if not jockey.queue_manager.is_looping_one: + jockey.queue_manager.is_looping_one = True + + # Update now playing message + await jockey.update_now_playing() + + return await itx.response.send_message( + embed=create_success_embed('Looping current track') + ) + + @slash_command(name='loopall') + @application_checks.check(check_mutual_voice) + async def loopall(self, itx: Interaction): + """ + Loops the whole queue. + """ + jockey = await self._get_jockey(itx) + if not jockey.queue_manager.is_looping_all: + jockey.queue_manager.is_looping_all = True + + # Update now playing message + await jockey.update_now_playing() + + return await itx.response.send_message( + embed=create_success_embed('Looping entire queue') + ) + + @slash_command(name='nowplaying') + @application_checks.check(check_mutual_voice) + async def now_playing(self, itx: Interaction): + """ + Displays the currently playing track. + """ + await itx.response.defer(ephemeral=True) + jockey = await self._get_jockey(itx) + embed = jockey.now_playing() + await itx.followup.send(embed=embed) + + @slash_command(name='pause') + @application_checks.check(check_mutual_voice) + async def pause(self, itx: Interaction, quiet: bool = False): + """ + Pauses the current track. + """ + if not quiet: + await itx.response.defer() + + # Dispatch to jockey + jockey = await self._get_jockey(itx) + await jockey.pause() + + if not quiet: + await itx.followup.send(embed=create_success_embed('Paused'), delete_after=5.0) + + @slash_command(name='play') + @application_checks.check(check_mutual_voice) + async def play( + self, + itx: Interaction, + query: str = SlashOption(description='Query string or URL', required=True), + ): + """ + Play a song from a search query or a URL. + If you want to unpause a paused player, use /unpause instead. + """ + if ( + not isinstance(itx.user, Member) + or not itx.user.voice + or not itx.user.voice.channel + or not isinstance(itx.guild, Guild) + ): + raise BlancoException( + 'Connect to a server voice channel to use this command.', + ephemeral=True, + ) + + # Set status channel + guild_id = itx.guild.id + channel = itx.channel + if not isinstance(channel, Messageable): + raise BlancoException('[player::play] itx.channel is not Messageable') + self._bot.set_status_channel(guild_id, channel) + + # Check if Lavalink is ready + if not self._bot.pool_initialized or len(self._bot.pool.nodes) == 0: + raise BlancoException('No Lavalink nodes available. Try again later.') + + # Connect to voice + await itx.response.defer() + voice_channel = itx.user.voice.channel + if itx.guild.voice_client is None: + try: + await voice_channel.connect(cls=Jockey) # type: ignore + await voice_channel.guild.change_voice_state( + channel=voice_channel, self_deaf=True + ) + await self._deafen(itx.guild.me, channel=channel) + except AsyncioTimeoutError: + raise BlancoException('Timed out while connecting to voice. Try again later.') + + # Dispatch to jockey + jockey = await self._get_jockey(itx) + try: + track_name = await jockey.play_impl(query, itx.user.id) + except JockeyError as err: + # Disconnect if we're not playing anything + if not jockey.playing: + await self._disconnect(itx=itx, reason=f'Error: `{err}`') + + raise BlancoException(err) from err + except JockeyException as exc: + raise BlancoException(exc) from exc + + body = [f'{track_name}\n'] + + # Add Last.fm integration promo if enabled + assert self._bot.config is not None + if ( + self._bot.config.base_url is not None + and self._bot.config.lastfm_api_key is not None + and self._bot.config.lastfm_shared_secret is not None + ): + # Check if the user has connected their Last.fm account + if self._bot.database.get_lastfm_credentials(itx.user.id) is not None: + body.append(f':handshake: {itx.user.mention} is scrobbling to Last.fm!') + body.append( + f':sparkles: [Link Last.fm]({self._bot.config.base_url}) to scrobble as you listen' + ) + + # Update now playing message + await jockey.update_now_playing() + + embed = create_success_embed( + title='Added to queue', + body='\n'.join(body), + ) + await itx.followup.send(embed=embed.set_footer(text=f'Blanco release {RELEASE}')) + + @slash_command(name='playlists') + async def playlist(self, itx: Interaction): + """ + Pick a Spotify playlist from your library to play. + """ + if itx.user is None: + return None + await itx.response.defer() + + # Get Spotify client + try: + spotify = self._bot.get_spotify_client(itx.user.id) + if spotify is None: + raise ValueError('You are not connected to Spotify.') + except ValueError as err: + return await itx.followup.send( + embed=create_error_embed(err.args[0]), ephemeral=True + ) + + # Get the user's playlists + try: + playlists = spotify.get_user_playlists() + except HTTPError as err: + if err.response is not None and err.response.status_code == codes.forbidden: + return await itx.followup.send( + embed=create_error_embed( + message=SPOTIFY_403_ERR_MSG.format('get your playlists') + ), + ephemeral=True, + ) + raise + if len(playlists) == 0: + return await itx.followup.send( + embed=create_error_embed(message='You have no playlists.'), + ephemeral=True, + ) + + # Create dropdown + view = SpotifyDropdownView(self._bot, playlists, itx.user.id, 'playlist') + await itx.followup.send( + embed=create_success_embed( + title='Pick a playlist', + body='Select a playlist from the dropdown below.', + ), + view=view, + delete_after=60.0, + ) + + @slash_command(name='previous') + @application_checks.check(check_mutual_voice) + async def previous(self, itx: Interaction): + """ + Skip to the previous song. + """ + # Dispatch to jockey + await itx.response.defer() + jockey = await self._get_jockey(itx) + try: + await jockey.skip(forward=False, auto=False) + except EndOfQueueError as err: + embed = create_error_embed(f'Unable to rewind: {err.args[0]}') + await itx.followup.send(embed=embed) + + @slash_command(name='queue') + @application_checks.check(check_mutual_voice) + async def queue(self, itx: Interaction): + """ + Displays the current queue. + """ + if itx.guild is None: + raise RuntimeError('[player::queue] itx.guild is None') + await itx.response.defer() + + # Get jockey + jockey = await self._get_jockey(itx) + if len(jockey.queue) == 0: + await itx.followup.send(embed=create_error_embed('Queue is empty')) + return None + + # Show loop status + embed_header = [f'{len(jockey.queue)} total'] + if jockey.queue_manager.is_looping_all: + embed_header.append(':repeat: Looping entire queue (`/unloopall` to disable)') + + # Show shuffle status + queue = jockey.queue_manager.shuffled_queue + current = jockey.queue_manager.current_shuffled_index + if jockey.queue_manager.is_shuffling: + embed_header.append( + ':twisted_rightwards_arrows: Shuffling queue (`/unshuffle` to disable)' + ) + + # Show queue in chunks of 10 per page + pages = [] + homepage = 0 + count = 1 + prefix_len = len(str(len(jockey.queue))) + for i, chunk in enumerate(list_chunks(queue)): + chunk_tracks = [] + + # Create page content + track: 'QueueItem' + for track in chunk: + title, artist = track.get_details() + + # Pad index with spaces if necessary + index = str(count) + while len(index) < prefix_len: + index = ' ' + index + + # Is this the current track? + line_prefix = ' ' + if count - 1 == current: + line_prefix = '> ' + homepage = i + + # Create item line + line_prefix = '> ' if count - 1 == current else ' ' + line = f'{line_prefix} {index} :: {title} - {artist}' + + # Truncate line if necessary + if len(line) > QUEUE_LINE_LENGTH: + line = line[:47] + '...' + else: + line = f'{line:50.50}' + chunk_tracks.append(line) + count += 1 + + # Create page + tracks = '\n'.join(chunk_tracks) + embed_body = embed_header + [f'```asciidoc\n{tracks}```'] + embed = CustomEmbed( + title=f'Queue for {itx.guild.name}', + description='\n'.join(embed_body), + color=Color.lighter_gray(), + ) + pages.append(embed.get()) + + # Run paginator + paginator = Paginator(itx) + return await paginator.run(pages, start=homepage) + + @slash_command(name='remove') + @application_checks.check(check_mutual_voice) + async def remove( + self, + itx: Interaction, + position: int = SlashOption(description='Position to remove', required=True), + ): + """ + Remove a track from queue. + """ + jockey = await self._get_jockey(itx) + if position < 1 or position > jockey.queue_size: + return await itx.response.send_message( + embed=create_error_embed( + message=f'Specify a number from 1 to {str(jockey.queue_size)}.' + ), + ephemeral=True, + ) + if position - 1 == jockey.queue_manager.current_index: + return await itx.response.send_message( + embed=create_error_embed( + message='You cannot remove the currently playing track.' + ), + ephemeral=True, + ) + + # Dispatch to jockey + await itx.response.defer() + title, artist = await jockey.remove(index=position - 1) + await itx.followup.send( + embed=create_success_embed( + title='Removed from queue', body=f'**{title}**\n{artist}' + ) + ) + + # Update now playing message + await jockey.update_now_playing() + + @slash_command(name='search') + async def search( + self, + itx: Interaction, + search_type: str = SlashOption( + description='Search type', + required=True, + choices=['track', 'playlist', 'album', 'artist'], + ), + query: str = SlashOption(description='Query string', required=True), + ): + """ + Search Spotify's catalog for tracks to play. + """ + if itx.user is None: + return None + await itx.response.defer() + + # Search catalog + try: + results = self._bot.spotify.search(query, search_type) + except SpotifyNoResultsError: + return await itx.followup.send( + embed=create_error_embed(message=f'No results found for `{query}`.'), + ephemeral=True, + ) + + # Create dropdown + view = SpotifyDropdownView(self._bot, results, itx.user.id, search_type) + await itx.followup.send( + embed=create_success_embed( + title=f'Results for `{query}`', + body='Select a result to play from the dropdown below.', + ), + view=view, + delete_after=60.0, + ) + + @slash_command(name='shuffle') + @application_checks.check(check_mutual_voice) + async def shuffle(self, itx: Interaction, quiet: bool = False): + """ + Shuffle the current playlist. + If you want to unshuffle the current queue, use /unshuffle instead. + """ + if not quiet: + await itx.response.defer() + + # Dispatch to jockey + jockey = await self._get_jockey(itx) + try: + jockey.queue_manager.shuffle() + except EmptyQueueError as err: + if not quiet: + await itx.followup.send(embed=create_error_embed(str(err.args[0]))) + else: + # Update now playing message + await jockey.update_now_playing() + + if not quiet: + await itx.followup.send( + embed=create_success_embed(f'{len(jockey.queue)} tracks shuffled') + ) + + @slash_command(name='skip') + @application_checks.check(check_mutual_voice) + async def skip(self, itx: Interaction): + """ + Skip the current song. + """ + # Dispatch to jockey + await itx.response.defer(ephemeral=True) + jockey = await self._get_jockey(itx) + try: + await jockey.skip(auto=False) + except EndOfQueueError as err: + embed = create_error_embed(f'Unable to skip: {err.args[0]}') + await itx.followup.send(embed=embed) + + @slash_command(name='stop') + @application_checks.check(check_mutual_voice) + async def stop(self, itx: Interaction): + """ + Stops the current song and disconnects from voice. + """ + if not isinstance(itx.user, Member): + raise RuntimeError('[player::stop] itx.user is not a Member') + await itx.response.defer() + await self._disconnect(itx=itx, reason=f'Stopped by <@{itx.user.id}>') + + @slash_command(name='unloop') + @application_checks.check(check_mutual_voice) + async def unloop(self, itx: Interaction): + """ + Stops looping the current track. + """ + # Dispatch to jockey + jockey = await self._get_jockey(itx) + if jockey.queue_manager.is_looping_one: + jockey.queue_manager.is_looping_one = False + + # Update now playing message + await jockey.update_now_playing() + + return await itx.response.send_message( + embed=create_success_embed('Not looping current track') + ) + + @slash_command(name='unloopall') + @application_checks.check(check_mutual_voice) + async def unloopall(self, itx: Interaction): + """ + Stops looping the whole queue. + """ + # Dispatch to jockey + jockey = await self._get_jockey(itx) + if jockey.queue_manager.is_looping_all: + jockey.queue_manager.is_looping_all = False + + # Update now playing message + await jockey.update_now_playing() + + return await itx.response.send_message( + embed=create_success_embed('Not looping entire queue') + ) + + @slash_command(name='unpause') + @application_checks.check(check_mutual_voice) + async def unpause(self, itx: Interaction, quiet: bool = False): + """ + Unpauses the current track. + """ + if not quiet: + await itx.response.defer() + + # Dispatch to jockey + jockey = await self._get_jockey(itx) + await jockey.resume() + + if not quiet: + await itx.followup.send(embed=create_success_embed('Unpaused'), delete_after=5.0) + + @slash_command(name='unshuffle') + @application_checks.check(check_mutual_voice) + async def unshuffle(self, itx: Interaction, quiet: bool = False): + """ + Unshuffle the current playlist. + """ + if not quiet: + await itx.response.defer() + + # Dispatch to jockey + jockey = await self._get_jockey(itx) + if jockey.queue_manager.is_shuffling: + jockey.queue_manager.unshuffle() + if not quiet: + return await itx.followup.send(embed=create_success_embed('Unshuffled')) + + # Update now playing message + await jockey.update_now_playing() + + if not quiet: + return await itx.followup.send( + embed=create_error_embed('Current queue is not shuffled') + ) + + @slash_command(name='volume') + @application_checks.check(check_mutual_voice) + async def volume( + self, + itx: Interaction, + volume: Optional[int] = SlashOption( + description='Volume level. Leave empty to print current volume.', + required=False, + min_value=0, + max_value=1000, + ), + ): + """ + Sets the volume level. + """ + jockey = await self._get_jockey(itx) + + # Is the volume argument empty? + if not volume: + # Print current volume + return await itx.response.send_message( + f'The volume is set to {jockey.volume}.', ephemeral=True + ) + + # Dispatch to jockey + await itx.response.defer() + await jockey.set_volume(volume) + await itx.followup.send(embed=create_success_embed(f'Volume set to {volume}')) + + # Update now playing message + await jockey.update_now_playing() diff --git a/bot/cogs/player/helpers/lavalink_search.py b/bot/cogs/player/helpers/lavalink_search.py new file mode 100644 index 0000000..b278b72 --- /dev/null +++ b/bot/cogs/player/helpers/lavalink_search.py @@ -0,0 +1,193 @@ +""" +Lavalink search helpers, which augment the basic search endpoint +with fuzzy search and exclusion of non-official track versions +(remixes, etc.) that the user didn't specifically ask for. +""" + +from typing import TYPE_CHECKING, List, Optional + +from mafic import Playlist, SearchType, TrackLoadException + +from bot.models.lavalink_result import LavalinkResult +from bot.utils.constants import BLACKLIST +from bot.utils.exceptions import LavalinkSearchError +from bot.utils.fuzzy import check_similarity + +if TYPE_CHECKING: + from mafic import Node, Track + + +def filter_results(query: str, search_results: List['Track']) -> List[LavalinkResult]: + """ + Filters search results by removing karaoke, live, instrumental etc versions. + """ + results = [] + + for result in search_results: + if not result.length: + # Can't play a track with no duration + continue + + # Skip karaoke, live, instrumental etc versions + # if the original query did not ask for it + valid = True + for word in BLACKLIST: + if word in result.title.lower() and word not in query.lower(): + valid = False + break + + if valid: + results.append(parse_result(result)) + + return results + + +def parse_result(result: 'Track') -> LavalinkResult: + """ + Parses a Lavalink track result into a LavalinkResult object. + """ + parsed = LavalinkResult( + title=result.title, + author=result.author, + duration_ms=result.length, + artwork_url=result.artwork_url, + lavalink_track=result, + ) + if result.uri is not None: + parsed.url = result.uri + + return parsed + + +async def get_deezer_matches( + node: 'Node', + query: str, + desired_duration_ms: Optional[int] = None, + auto_filter: bool = False, +) -> List[LavalinkResult]: + """ + Gets Deezer tracks from Lavalink, and returns a list of LavalinkResult objects. + + :param node: The Lavalink node to use. + :param query: The query to search for. + :param desired_duration_ms: The desired duration of the track, in milliseconds. + :param automatic: Whether to automatically filter results. + """ + return await search_lavalink( + node, + query, + search_type=SearchType.DEEZER_SEARCH.value, + desired_duration_ms=desired_duration_ms, + auto_filter=auto_filter, + ) + + +async def get_deezer_track(node: 'Node', isrc: str) -> LavalinkResult: + """ + Gets a single Deezer track from Lavalink, and returns a LavalinkResult object. + + :param node: The Lavalink node to use. + :param isrc: The ISRC to search for. + """ + results = await search_lavalink( + node, isrc, search_type=SearchType.DEEZER_ISRC.value, auto_filter=False + ) + return results[0] + + +async def get_soundcloud_matches( + node: 'Node', + query: str, + desired_duration_ms: Optional[int] = None, + auto_filter: bool = False, +) -> List[LavalinkResult]: + """ + Gets SoundCloud tracks from Lavalink, and returns a list of LavalinkResult objects. + + :param node: The Lavalink node to use. + :param query: The query to search for. + :param desired_duration_ms: The desired duration of the track, in milliseconds. + :param automatic: Whether to automatically filter results. + """ + return await search_lavalink( + node, + query, + search_type=SearchType.SOUNDCLOUD.value, + desired_duration_ms=desired_duration_ms, + auto_filter=auto_filter, + ) + + +async def get_youtube_matches( + node: 'Node', + query: str, + desired_duration_ms: Optional[int] = None, + auto_filter: bool = False, +) -> List[LavalinkResult]: + """ + Gets YouTube tracks from Lavalink, and returns a list of LavalinkResult objects. + + :param node: The Lavalink node to use. + :param query: The query to search for. + :param desired_duration_ms: The desired duration of the track, in milliseconds. + :param automatic: Whether to automatically filter results. + """ + return await search_lavalink( + node, + query, + search_type=SearchType.YOUTUBE.value, + desired_duration_ms=desired_duration_ms, + auto_filter=auto_filter, + ) + + +async def search_lavalink( + node: 'Node', + query: str, + search_type: str = SearchType.YOUTUBE.value, + desired_duration_ms: Optional[int] = None, + auto_filter: bool = False, +) -> List[LavalinkResult]: + """ + Generic search function for Lavalink that returns a list of LavalinkResult objects. + + :param node: The Lavalink node to use. + :param query: The query to search for. + :param search_type: The search type to use. See mafic.SearchType. + :param desired_duration_ms: The desired duration of the track, in milliseconds. + :param automatic: Whether to automatically filter results. + """ + try: + search = await node.fetch_tracks(query, search_type=search_type) + except TrackLoadException as exc: + raise LavalinkSearchError( + query, reason=f"Could not get tracks for `{query}': {exc.cause}" + ) from exc + + if isinstance(search, Playlist) and len(search.tracks) == 0: + raise LavalinkSearchError(query, reason='Playlist is empty') + if (isinstance(search, list) and len(search) == 0) or search is None: + raise LavalinkSearchError(query, reason='No results found') + + search_results = search if isinstance(search, list) else search.tracks + if auto_filter: + results = filter_results(query, search_results) + else: + results = [parse_result(result) for result in search_results] + + # Are there valid results? + if len(results) == 0: + raise LavalinkSearchError(query, reason='No valid results found') + + # Sort by descending similarity + if desired_duration_ms is not None: + results.sort( + key=lambda x: ( + 1 - check_similarity(query, x.title), + abs(x.duration_ms - desired_duration_ms), + ) + ) + else: + results.sort(key=lambda x: 1 - check_similarity(query, x.title)) + + return results diff --git a/bot/cogs/player/helpers/lavalink_track.py b/bot/cogs/player/helpers/lavalink_track.py new file mode 100644 index 0000000..4f3001c --- /dev/null +++ b/bot/cogs/player/helpers/lavalink_track.py @@ -0,0 +1,276 @@ +""" +Lavalink track helpers, which take care of finding a matching +playable Lavalink track for a QueueItem, caching it, and +invalidating it when necessary. +""" + +from typing import TYPE_CHECKING, List, Optional, Tuple + +from mafic import SearchType + +from bot.database.redis import REDIS +from bot.utils.constants import CONFIDENCE_THRESHOLD +from bot.utils.exceptions import LavalinkSearchError +from bot.utils.fuzzy import rank_results +from bot.utils.logger import create_logger +from bot.utils.musicbrainz import annotate_track + +from .lavalink_search import get_deezer_matches, get_deezer_track, get_youtube_matches + +if TYPE_CHECKING: + from mafic import Node, Track + + from bot.models.lavalink_result import LavalinkResult + from bot.models.queue_item import QueueItem + + from .lavalink_search import LavalinkSearchError + +LOGGER = create_logger('track_finder') + + +async def find_lavalink_track( + node: 'Node', + item: 'QueueItem', + /, + deezer_enabled: bool = False, + in_place: bool = False, + lookup_mbid: bool = False, +) -> 'Track': + """ + Finds a matching playable Lavalink track for a QueueItem. + + :param node: The Lavalink node to use for searching. Must be an instance of mafic.Node. + :param item: The QueueItem to find a track for. + :param deezer_enabled: Whether to use Deezer for searching. + :param in_place: Whether to modify the QueueItem in place. + :param lookup_mbid: Whether to look up the MBID for the track. + """ + results: List['LavalinkResult'] = [] + + cached, redis_key, redis_key_type = _get_cached_track(item) + if cached is not None: + LOGGER.info('Found cached Lavalink track for Spotify ID %s', item.spotify_id) + track = await node.decode_track(cached) + if in_place: + item.lavalink_track = track + + return track + + if item.isrc is None or lookup_mbid: + annotate_track(item) + + if item.isrc is not None: + if deezer_enabled: + await _append_deezer_results_for_isrc( + results=results, + node=node, + isrc=item.isrc, + title=item.title, + ) + + await _append_youtube_results_for_isrc( + results=results, + node=node, + isrc=item.isrc, + title=item.title, + duration_ms=item.duration, + ) + else: + LOGGER.warning( + "`%s' has no ISRC. Scrobbling might fail for this track.", item.title + ) + item.is_imperfect = True + + # Fallback to metadata search + if len(results) == 0: + query = f'{item.title} {item.artist}' + LOGGER.warning( + "No matches for ISRC %s `%s'. Falling back to metadata search.", + item.isrc, + item.title, + ) + + if deezer_enabled: + await _append_deezer_results_for_metadata( + results=results, + node=node, + query=query, + title=item.title, + duration_ms=item.duration, + ) + + await _append_youtube_results_for_metadata( + results=results, + node=node, + query=query, + title=item.title, + duration_ms=item.duration, + ) + + if len(results) == 0: + raise LavalinkSearchError('No results found') + + lavalink_track = results[0].lavalink_track + if in_place: + item.lavalink_track = lavalink_track + _set_cached_track(lavalink_track.id, key=redis_key, key_type=redis_key_type) + + return lavalink_track + + +def invalidate_cached_track(item: 'QueueItem'): + """ + Removes a cached Lavalink track from Redis. + + :param item: The QueueItem to invalidate the track for. + """ + if REDIS is None: + return + + redis_key, redis_key_type = _determine_cache_key(item) + + # Invalidate cached Lavalink track + if redis_key is not None and redis_key_type is not None: + REDIS.invalidate_lavalink_track(redis_key, key_type=redis_key_type) + else: + LOGGER.warning("Could not invalidate cached track for `%s': no key", item.title) + + +def _get_cached_track( + item: 'QueueItem', +) -> Tuple[Optional[str], Optional[str], Optional[str]]: + """ + Gets a cached Lavalink track from Redis. + + :param item: The QueueItem to get the cached track for. + """ + + redis_key, redis_key_type = _determine_cache_key(item) + cached = None + + if REDIS is not None and redis_key is not None and redis_key_type is not None: + cached = REDIS.get_lavalink_track(redis_key, key_type=redis_key_type) + + return cached, redis_key, redis_key_type + + +def _set_cached_track( + lavalink_track: str, + key: Optional[str] = None, + key_type: Optional[str] = None, +): + """ + Caches a Lavalink track in Redis. + + :param lavalink_track: The Lavalink track to cache. + :param key: The key to cache the track under. + :param key_type: The type of key to cache the track under. + """ + if REDIS is not None and key_type is not None and key is not None: + REDIS.set_lavalink_track(key, lavalink_track, key_type=key_type) + + +def _determine_cache_key(item: 'QueueItem') -> Tuple[Optional[str], Optional[str]]: + """ + Determines the Redis key and key type for caching a Lavalink track. + + :param item: The QueueItem to determine the cache key for. + """ + + redis_key = None + redis_key_type = None + + if item.spotify_id is not None: + redis_key = item.spotify_id + redis_key_type = 'spotify_id' + elif item.isrc is not None: + redis_key = item.isrc + redis_key_type = 'isrc' + + return redis_key, redis_key_type + + +async def _append_deezer_results_for_isrc( + results: List['LavalinkResult'], + node: 'Node', + isrc: str, + title: Optional[str] = None, +) -> List['LavalinkResult']: + try: + result = await get_deezer_track(node, isrc) + except LavalinkSearchError: + LOGGER.warning("No Deezer match for ISRC %s `%s'", isrc, title) + else: + results.append(result) + LOGGER.debug("Matched ISRC %s `%s' on Deezer", isrc, title) + + return results + + +async def _append_deezer_results_for_metadata( + results: List['LavalinkResult'], + node: 'Node', + query: str, + title: Optional[str] = None, + duration_ms: Optional[int] = None, +): + try: + dz_results = await get_deezer_matches( + node, query, desired_duration_ms=duration_ms, auto_filter=True + ) + except LavalinkSearchError: + LOGGER.warning("No Deezer results for `%s'", title) + else: + ranked = rank_results(query, dz_results, SearchType.DEEZER_SEARCH) + if ranked[0][1] >= CONFIDENCE_THRESHOLD: + LOGGER.warning( + "Using Deezer result `%s' (%s) for `%s'", + ranked[0][0].title, + ranked[0][0].lavalink_track.identifier, + title, + ) + results.append(ranked[0][0]) + else: + LOGGER.warning("No similar Deezer results for `%s'", title) + + +async def _append_youtube_results_for_isrc( + results: List['LavalinkResult'], + node: 'Node', + isrc: str, + title: Optional[str] = None, + duration_ms: Optional[int] = None, +): + if len(results) > 0: + return + + try: + results.extend( + await get_youtube_matches(node, f'"{isrc}"', desired_duration_ms=duration_ms) + ) + except LavalinkSearchError: + LOGGER.warning("No YouTube match for ISRC %s `%s'", isrc, title) + else: + LOGGER.debug("Matched ISRC %s `%s' on YouTube", isrc, title) + + +async def _append_youtube_results_for_metadata( + results: List['LavalinkResult'], + node: 'Node', + query: str, + title: Optional[str] = None, + duration_ms: Optional[int] = None, +): + try: + yt_results = await get_youtube_matches(node, query, desired_duration_ms=duration_ms) + except LavalinkSearchError: + LOGGER.warning("No YouTube results for `%s'", title) + else: + ranked = rank_results(query, yt_results, SearchType.YOUTUBE) + LOGGER.warning( + "Using YouTube result `%s' (%s) for `%s'", + ranked[0][0].title, + ranked[0][0].lavalink_track.identifier, + title, + ) + results.append(ranked[0][0]) diff --git a/bot/cogs/player/helpers/parsers.py b/bot/cogs/player/helpers/parsers.py new file mode 100644 index 0000000..a265791 --- /dev/null +++ b/bot/cogs/player/helpers/parsers.py @@ -0,0 +1,266 @@ +""" +Playback query parsers for the Player cog. +""" + +from typing import TYPE_CHECKING, List + +from mafic import SearchType +from requests.status_codes import codes +from spotipy.exceptions import SpotifyException + +from bot.models.queue_item import QueueItem +from bot.utils.constants import CONFIDENCE_THRESHOLD +from bot.utils.exceptions import ( + JockeyException, + LavalinkInvalidIdentifierError, + SpotifyNoResultsError, +) +from bot.utils.fuzzy import rank_results +from bot.utils.logger import create_logger +from bot.utils.spotify_client import Spotify +from bot.utils.url import ( + check_sc_url, + check_spotify_url, + check_url, + check_youtube_playlist_url, + check_youtube_url, + check_ytmusic_playlist_url, + check_ytmusic_url, + get_spinfo_from_url, + get_ytid_from_url, + get_ytlistid_from_url, +) + +from .lavalink_search import ( + get_soundcloud_matches, + get_youtube_matches, +) + +if TYPE_CHECKING: + from mafic import Node + +from bot.models.spotify import SpotifyTrack + +LOGGER = create_logger('jockey_helpers') + + +async def parse_query( + node: 'Node', spotify: Spotify, query: str, requester: int +) -> List[QueueItem]: + """ + Parse a query and return a list of QueueItems. + + :param node: The Lavalink node to use for searching. Must be an instance of mafic.Node. + :param spotify: The Spotify client to use for searching. See utils/spotify_client.py. + :param query: The query to parse. Can be plain language or a URL. + :param requester: The ID of the user who requested the track. + """ + query_is_url = check_url(query) + if query_is_url: + if check_spotify_url(query): + # Query is a Spotify URL. + return await parse_spotify_query(spotify, query, requester) + if check_youtube_url(query) or check_ytmusic_url(query): + # Query is a YouTube URL. + return await parse_youtube_query(node, query, requester) + if check_youtube_playlist_url(query) or check_ytmusic_playlist_url(query): + # Query is a YouTube playlist URL. + return await parse_youtube_playlist(node, query, requester) + if check_sc_url(query): + # Query is a SoundCloud URL. + return await parse_sc_query(node, query, requester) + + # Direct URL playback is deprecated + raise JockeyException('Direct playback from unsupported URLs is deprecated') + + # Attempt to look for a matching track on Spotify + try: + results = spotify.search_track(query, limit=10) + except SpotifyNoResultsError: + pass + else: + # Return top result if it's good enough + ranked = rank_results(query, results, SearchType.SPOTIFY_SEARCH) + if ranked[0][1] >= CONFIDENCE_THRESHOLD: + track = ranked[0][0] + return [ + QueueItem( + requester=requester, + title=track.title, + artist=track.artist, + author=track.author, + album=track.album, + spotify_id=track.spotify_id, + duration=track.duration_ms, + artwork=track.artwork, + isrc=track.isrc, + ) + ] + + # Get matching tracks from YouTube + results = await get_youtube_matches(node, query, auto_filter=False) + + # Return top result + ranked = rank_results(query, results, SearchType.YOUTUBE) + result = ranked[0][0] + return [ + QueueItem( + title=result.title, + artist=result.author, + artwork=result.artwork_url, + duration=result.duration_ms, + requester=requester, + url=result.url, + lavalink_track=result.lavalink_track, + ) + ] + + +async def parse_sc_query(node: 'Node', query: str, requester: int) -> List[QueueItem]: + """ + Parse a SoundCloud query and return a list of QueueItems. + See parse_query() for more information. + """ + try: + # Get results with Lavalink + tracks = await get_soundcloud_matches(node, query) + except Exception as exc: + raise LavalinkInvalidIdentifierError( + f'Entity {query} is private, nonexistent, or has no stream URL' + ) from exc + + return [ + QueueItem( + requester=requester, + title=track.title, + artist=track.author, + artwork=track.artwork_url, + duration=track.duration_ms, + url=track.url, + lavalink_track=track.lavalink_track, + ) + for track in tracks + ] + + +async def parse_spotify_query( + spotify: Spotify, query: str, requester: int +) -> List[QueueItem]: + """ + Parse a Spotify query and return a list of QueueItems. + See parse_query() for more information. + """ + # Get artwork for Spotify album/playlist + sp_type, sp_id = get_spinfo_from_url(query) + + new_tracks = [] + track_queue: List['SpotifyTrack'] + try: + if sp_type == 'track': + # Get track details from Spotify + track_queue = [spotify.get_track(sp_id)] + elif sp_type == 'artist': + # Get top tracks from Spotify + track_queue = spotify.get_artist_top_tracks(sp_id) + else: + # Get playlist or album tracks from Spotify + track_queue = spotify.get_tracks(sp_type, sp_id)[2] + except SpotifyException as exc: + if exc.http_status == codes.not_found: + # No tracks. + raise SpotifyNoResultsError( + f'The {sp_type} does not exist or is private.' + ) from exc + + raise SpotifyNoResultsError( + f'An error occurred while fetching the playlist: {exc.msg}' + ) from exc + + if len(track_queue) < 1: + if sp_type == 'track': + # No tracks. + raise SpotifyNoResultsError('Track does not exist or is private.') + raise SpotifyNoResultsError(f'{sp_type} does not have any public tracks.') + + # At least one track. + for track in track_queue: + new_tracks.append( + QueueItem( + requester=requester, + title=track.title, + artist=track.artist, + author=track.author, + album=track.album, + spotify_id=track.spotify_id, + duration=track.duration_ms, + artwork=track.artwork, + isrc=track.isrc, + ) + ) + + return new_tracks + + +async def parse_youtube_playlist( + node: 'Node', query: str, requester: int +) -> List[QueueItem]: + """ + Parse a YouTube playlist query and return a list of QueueItems. + See parse_query() for more information. + """ + try: + # Get playlist tracks from YouTube + playlist_id = get_ytlistid_from_url(query) + tracks = await get_youtube_matches( + node, f'https://youtube.com/playlist?list={playlist_id}' + ) + except Exception as exc: + # No tracks. + raise LavalinkInvalidIdentifierError( + query, 'Playlist is empty, private, or nonexistent' + ) from exc + + return [ + QueueItem( + requester=requester, + title=track.title, + artist=track.author, + artwork=track.artwork_url, + duration=track.duration_ms, + url=track.url, + lavalink_track=track.lavalink_track, + ) + for track in tracks + ] + + +async def parse_youtube_query( + node: 'Node', query: str, requester: int +) -> List[QueueItem]: + """ + Parse a non-playlist YouTube query and return a list of QueueItems. + See parse_query() for more information. + """ + # Is it a video? + try: + video_id = get_ytid_from_url(query) + + # Get the video's details + video = await get_youtube_matches(node, video_id) + return [ + QueueItem( + title=video[0].title, + artist=video[0].author, + artwork=video[0].artwork_url, + requester=requester, + duration=video[0].duration_ms, + url=video[0].url, + lavalink_track=video[0].lavalink_track, + ) + ] + except LavalinkInvalidIdentifierError: + raise + except Exception as exc: + raise LavalinkInvalidIdentifierError( + query, 'Only YouTube video and playlist URLs are supported.' + ) from exc diff --git a/bot/cogs/player/helpers/queue_manager.py b/bot/cogs/player/helpers/queue_manager.py new file mode 100644 index 0000000..6f58e69 --- /dev/null +++ b/bot/cogs/player/helpers/queue_manager.py @@ -0,0 +1,379 @@ +""" +Queue manager class for the player cog. +""" + +from random import shuffle +from typing import TYPE_CHECKING, List, Tuple + +from bot.models.queue_item import QueueItem +from bot.utils.exceptions import EmptyQueueError, EndOfQueueError +from bot.utils.logger import create_logger + +if TYPE_CHECKING: + from bot.database import Database + + +class QueueManager: + """ + Queue manager for Blanco's Jockey. + """ + + def __init__(self, guild_id: int, database: 'Database', /): + self._guild_id = guild_id + self._queue: List[QueueItem] = [] + self._shuf_i: List[int] = [] + + # Restore loop preferences from bot.database + self._db = database + self._loop_one = database.get_loop(guild_id) + self._loop_all = database.get_loop_all(guild_id) + + # The current track index. + # Even if the queue is shuffled, this must ALWAYS + # correspond to an element in self._queue, not self._shuf_i. + self._i = -1 + + # Logger + self._logger = create_logger(self.__class__.__name__) + self._logger.info('Initialized queue manager for guild %d', guild_id) + + @property + def queue(self) -> List[QueueItem]: + """ + Returns the queue. + """ + return self._queue + + @property + def shuffled_queue(self) -> List[QueueItem]: + """ + Returns the queue, shuffled. + """ + if not self.is_shuffling: + return self.queue + return [self.queue[i] for i in self._shuf_i] + + @property + def is_shuffling(self) -> bool: + """ + Returns whether the queue is shuffled. + """ + return len(self._shuf_i) > 0 + + @property + def is_looping_one(self) -> bool: + """ + Returns whether the queue is looping the current track. + """ + return self._loop_one + + @is_looping_one.setter + def is_looping_one(self, value: bool): + """ + Sets whether the queue is looping the current track. + """ + self._loop_one = value + self._db.set_loop(self._guild_id, value) + + @property + def is_looping_all(self) -> bool: + """ + Returns whether the queue is looping all tracks. + """ + return self._loop_all + + @is_looping_all.setter + def is_looping_all(self, value: bool): + """ + Sets whether the queue is looping all tracks. + """ + self._loop_all = value + self._db.set_loop_all(self._guild_id, value) + + @property + def size(self) -> int: + """ + Returns the size of the queue. + """ + return len(self.queue) + + @property + def current(self) -> QueueItem: + """ + Returns the current track in the queue. + + Raises: + EmptyQueueError: If the queue is empty. + """ + if self.size == 0: + raise EmptyQueueError + + return self.queue[self.current_index] + + @property + def current_index(self) -> int: + """ + Returns the current track index, NOT accounting for shuffling. + This is the index of the current track in self._queue. + """ + return self._i + + @current_index.setter + def current_index(self, i: int): + """ + Sets the current track index. + + Args: + i: The new current track index. Must be adjusted for shuffling, + i.e., i must correspond to an element in self._queue, + not self._shuf_i. + """ + self._i = i + + @property + def current_shuffled_index(self) -> int: + """ + Returns the current track index, accounting for shuffling. + This is the index of the current track in self._shuf_i. + """ + if not self.is_shuffling: + return self.current_index + return self._shuf_i.index(self.current_index) + + @property + def next_track(self) -> Tuple[int, QueueItem]: + """ + Returns a tuple containing the index of the next track in the queue + and the track itself. + + Raises: + EmptyQueueError: If the queue is empty. + EndOfQueueError: If the last track in the queue is reached. + """ + if self.size == 0: + raise EmptyQueueError + + try: + i = self.calc_next_index() + track = self.queue[i] + except EndOfQueueError as err: + raise EndOfQueueError('No next track in queue.') from err + + return i, track + + @property + def previous_track(self) -> Tuple[int, QueueItem]: + """ + Returns a tuple containing the index of the previous track in the queue + and the track itself. + + Raises: + EmptyQueueError: If the queue is empty. + EndOfQueueError: If the first track in the queue is reached. + """ + if self.size == 0: + raise EmptyQueueError + + try: + i = self.calc_next_index(delta=-1) + track = self.queue[i] + except EndOfQueueError as err: + raise EndOfQueueError('No previous track in queue.') from err + + return i, track + + def calc_next_index(self, *, delta: int = 1) -> int: + """ + Calculate the next track index, accounting for shuffling and + looping a single track. + + Args: + delta: How far ahead or back to seek the next index. + + Returns: + The next track index in self._queue. + + Raises: + EndOfQueueError: If one of the ends of the queue is reached, + and the queue is not looping all tracks. + """ + forward = delta > 0 + + # Return the current index if the queue is looping a single track. + next_i = self.current_index + if self.is_looping_one: + return next_i + + # If we're shuffling, we need to use self._shuf_i to calculate the next index. + # Otherwise, we can just use the current index. + if self.is_shuffling: + next_i = self._shuf_i.index(next_i) + + # Calculate the next index. + next_i += delta + if (next_i >= self.size and forward) or (next_i < 0 and not forward): + if self.is_looping_all: + next_i = 0 if forward else self.size - 1 + else: + raise EndOfQueueError + + # If we're shuffling, we need to convert the next index back to + # an index in self._queue. + if self.is_shuffling: + next_i = self._shuf_i[next_i] + return next_i + + def skip(self) -> QueueItem: + """ + Returns the next track in the queue and adjusts the current + track index. + + Raises: + EmptyQueueError: If the queue is empty. + EndOfQueueError: If the last track in the queue is reached. + """ + i, track = self.next_track + self._i = i + return track + + def rewind(self) -> QueueItem: + """ + Returns the previous track in the queue and adjusts the current + track index. + + Raises: + EmptyQueueError: If the queue is empty. + EndOfQueueError: If the first track in the queue is reached. + """ + i, track = self.previous_track + self._i = i + return track + + def shuffle(self): + """ + Shuffles the queue non-destructively by generating a random + permutation of indices. Each call to shuffle() will generate + a different permutation, with the current track always at + the beginning. + + Raises: + EmptyQueueError: If the queue is empty. + """ + if self.size == 0: + raise EmptyQueueError + + # Shuffle everything except the current track. + indices = [i for i in range(self.size) if i != self.current_index] + shuffle(indices) + + # Prepend the current track index to the shuffle index list. + self._shuf_i = [self.current_index] + indices + + def unshuffle(self): + """ + Unshuffles the queue by clearing the shuffle index list. + """ + self._shuf_i = [] + + def extend(self, items: List[QueueItem]): + """ + Appends multiple items to the end of the queue. + + Args: + items: The QueueItems to append. + """ + new_queue = self.size == 0 + + # Append the items to the queue. + self.queue.extend(items) + if self.is_shuffling: + self._shuf_i.extend(list(range(self.size - len(items), self.size))) + + # Update index + if new_queue: + self.current_index = 0 + + def insert(self, item: QueueItem, /, index: int): + """ + Inserts an item in the queue at a specified index. + + Args: + item: The QueueItem to insert. + index: The index at which to insert the item. + + Raises: + EmptyQueueError: If the queue is empty and we are trying + to insert past index zero. Use enqueue() instead. + IndexError: If the index is out of range. + """ + if self.size == 0 and index != 0: + raise EmptyQueueError + if not 0 <= index <= self.size: + raise IndexError(f'Index {index} out of range.') + + if self.is_shuffling: + # If we're shuffling, insert the item at the end of self._queue, + # then insert the new index at the specified index in self._shuf_i. + self.queue.append(item) + self._shuf_i.insert(index, self.size - 1) + else: + # Otherwise, just insert the item at the specified index in self._queue. + self.queue.insert(index, item) + + def move(self, source_i: int, dest_i: int, /): + """ + Moves a queue item from one index to another. + + Args: + source_i: The index of the item to move. + dest_i: The index to move the item to. + + Raises: + EmptyQueueError: If the queue is empty. + IndexError: If either index is out of range, or if the + source and destination indices are the same, or if + the source index is the current track index. + """ + if self.size == 0: + raise EmptyQueueError + if not 0 <= source_i < self.size: + raise IndexError(f'Source index {source_i} out of range.') + if not 0 <= dest_i < self.size: + raise IndexError(f'Destination index {dest_i} out of range.') + if source_i == dest_i: + raise IndexError('Source and destination indices are the same.') + if source_i == self.current_index: + raise IndexError('Cannot move the current track.') + + self.insert(self.remove(source_i), dest_i) + + def remove(self, index: int, /) -> QueueItem: + """ + Removes an element at the given index and returns the element. + + Raises: + EmptyQueueError: If the queue is empty. + IndexError: If the index is out of range. + """ + if self.size == 0: + raise EmptyQueueError + if not 0 <= index < self.size: + raise IndexError(f'Index {index} out of range.') + + # Adjust the index if we're shuffling. + adjusted_index = index + if self.is_shuffling: + # Remove the index from self._shuf_i. + adjusted_index = self._shuf_i.pop(index) + + # Adjust the indices in self._shuf_i. + for i, j in enumerate(self._shuf_i): + if j > index: + self._shuf_i[i] -= 1 + + # If we're removing the current track, adjust the current track index. + if adjusted_index == self.current_index: + self._i = self.calc_next_index() + + # Remove the element from self._queue. + return self.queue.pop(adjusted_index) diff --git a/bot/cogs/player/helpers/scrobble_handler.py b/bot/cogs/player/helpers/scrobble_handler.py new file mode 100644 index 0000000..c20e0c2 --- /dev/null +++ b/bot/cogs/player/helpers/scrobble_handler.py @@ -0,0 +1,89 @@ +from time import time +from typing import TYPE_CHECKING + +from nextcord import VoiceChannel + +from bot.utils.exceptions import BlancoException +from bot.utils.musicbrainz import annotate_track + +if TYPE_CHECKING: + from nextcord.abc import Connectable + + from bot.models.queue_item import QueueItem + from bot.utils.blanco import BlancoBot + + +_SEC_IN_MSEC = 1000 +_MIN_IN_SEC = 60 +MIN_TRACK_LENGTH_MSEC = 30 * _SEC_IN_MSEC +MIN_ELAPSED_MSEC = 4 * _MIN_IN_SEC * _SEC_IN_MSEC + + +class ScrobbleHandler: + """ + Scrobbler class for scrobbling tracks to Last.fm. + """ + + def __init__(self, bot: 'BlancoBot', channel: 'Connectable'): + self._bot = bot + self._channel = channel + + def scrobble(self, track: 'QueueItem'): + try: + self._validate_config() + length = self._validate_track_length(track) + self._validate_elapsed(track, length) + self._ensure_annotations(track) + + self._scrobble_for_humans(track) + except AssertionError as e: + raise BlancoException(f"Cannot scrobble `{track.title}': {e}") + + def _validate_config(self): + assert self._bot.config is not None, 'Config is not loaded.' + if not self._bot.config.lastfm_enabled: + raise BlancoException('Last.fm is not enabled.') + + def _validate_track_length(self, track: 'QueueItem') -> int: + """ + Validate the length of the track to be scrobbled. + + Args: + track (QueueItem): The track to be scrobbled. + + Returns: + int: The length of the track in milliseconds. + """ + length = track.duration + if track.lavalink_track is not None: + length = track.lavalink_track.length + + assert length is not None, 'Cannot scrobble track with no duration.' + assert length >= MIN_TRACK_LENGTH_MSEC, 'Track is too short to scrobble.' + return length + + def _validate_elapsed(self, track: 'QueueItem', duration: int): + now = int(time()) + start_time = track.start_time or now + elapsed_ms = (now - start_time) * _SEC_IN_MSEC + assert elapsed_ms >= min( + duration // 2, MIN_ELAPSED_MSEC + ), 'Not enough time elapsed.' + + def _ensure_annotations(self, track: 'QueueItem'): + annotate_track(track) + + has_mbid = track.mbid is not None + has_isrc = track.isrc is not None + assert has_mbid or has_isrc, 'No MusicBrainz ID or ISRC found.' + + def _scrobble_for_humans(self, track: 'QueueItem'): + assert isinstance(self._channel, VoiceChannel), 'Not in a voice channel.' + + human_members = [m for m in self._channel.members if not m.bot] + assert len(human_members) > 0, 'No human members in the voice channel.' + + for human in human_members: + scrobbler = self._bot.get_scrobbler(human.id) + if scrobbler is not None: + scrobbler.scrobble(track) diff --git a/bot/cogs/player/jockey.py b/bot/cogs/player/jockey.py new file mode 100644 index 0000000..09813ab --- /dev/null +++ b/bot/cogs/player/jockey.py @@ -0,0 +1,686 @@ +""" +Music player class for Blanco. Subclass of mafic.Player. +""" + +from asyncio import get_event_loop, sleep +from time import time +from typing import TYPE_CHECKING, List, Optional, Tuple + +from mafic import Player, PlayerNotConnected +from nextcord import ( + Colour, + Forbidden, + HTTPException, + Message, + NotFound, + StageChannel, + VoiceChannel, +) + +from bot.models.custom_embed import CustomEmbed +from bot.utils.constants import UNPAUSE_THRESHOLD +from bot.utils.embeds import create_error_embed +from bot.utils.exceptions import ( + BlancoException, + BumpError, + BumpNotEnabledError, + BumpNotReadyError, + EndOfQueueError, + JockeyError, + JockeyException, + LavalinkSearchError, + SpotifyNoResultsError, +) +from bot.utils.time import human_readable_time +from bot.views.now_playing import NowPlayingView + +from .helpers.lavalink_track import find_lavalink_track, invalidate_cached_track +from .helpers.parsers import parse_query +from .helpers.queue_manager import QueueManager +from .helpers.scrobble_handler import ScrobbleHandler + +if TYPE_CHECKING: + from mafic import Track + from nextcord import Embed + from nextcord.abc import Connectable, Messageable + + from bot.models.queue_item import QueueItem + from bot.utils.blanco import BlancoBot + + +MAX_PLAYER_CONNECT_WAIT_SEC = 10 +MIN_TRACK_LENGTH_FOR_SCROBBLE_MSEC = 30000 + + +class Jockey(Player['BlancoBot']): + """ + Class that handles music playback for a single guild. + Contains all the methods for music playback, along with a + local instance of an in-memory database for fast queueing. + """ + + def __init__(self, client: 'BlancoBot', channel: 'Connectable'): + super().__init__(client, channel) + self._bot = client + + if not isinstance(channel, StageChannel) and not isinstance(channel, VoiceChannel): + raise TypeError(f'Channel must be a voice channel, not {type(channel)}') + + # Scrobble handler + self._scrobbler = ScrobbleHandler(client, channel) + + # Database + self._db = client.database + client.database.init_guild(channel.guild.id) + + # Pause timestamp + self._pause_ts: Optional[int] = None + + # Queue + self._queue_mgr = QueueManager(channel.guild.id, client.database) + + # Volume + self._volume = client.database.get_volume(channel.guild.id) + + # Logger + self._logger = client.jockey_logger + self._logger.info("Using node `%s' for %s", self.node.label, channel.guild.name) + + @property + def playing(self) -> bool: + """ + Returns whether the player is currently playing a track. + """ + return self.current is not None + + @property + def queue(self) -> List['QueueItem']: + """ + Returns the player queue. + """ + return self._queue_mgr.queue + + @property + def queue_manager(self) -> QueueManager: + """ + Returns the queue manager for the player. + """ + return self._queue_mgr + + @property + def queue_size(self) -> int: + """ + Returns the player queue size. + """ + return self._queue_mgr.size + + @property + def status_channel(self) -> 'Messageable': + """ + Returns the status channel for the player. + """ + channel = self._bot.get_status_channel(self.guild.id) + if channel is None: + raise ValueError('Status channel has not been set') + return channel + + @property + def volume(self) -> int: + """ + Returns the player volume. + """ + return self._volume + + @volume.setter + def volume(self, value: int): + """ + Sets the player volume and saves it to the database. + """ + self._volume = value + self._db.set_volume(self.guild.id, value) + + async def _edit_np_controls(self, show_controls: bool = True): + """ + Edits the now playing message to show or hide controls. + """ + view = None + if show_controls: + view = NowPlayingView(self._bot, self) + + np_msg = await self._get_now_playing() + if isinstance(np_msg, Message): + try: + await np_msg.edit(view=view) + except (HTTPException, Forbidden) as exc: + self._logger.warning( + 'Could not edit now playing message for %s: %s', + self.guild.name, + exc, + ) + + async def _enqueue(self, index: int, auto: bool = True): + """ + Attempt to enqueue a track, for use with the skip() method. + + :param index: The index of the track to enqueue. + :param auto: Whether this is an automatic enqueue, i.e. not part of a user's command. + """ + try: + track = self._queue_mgr.queue[index] + await self._play(track) + except PlayerNotConnected: + if not auto: + await self.status_channel.send( + embed=create_error_embed('Attempted to skip while disconnected') + ) + raise JockeyError('Player is not connected') + except JockeyError as err: + self._logger.error('Failed to enqueue track: %s', err) + raise + + # Scrobble if possible + await self._scrobble(self._queue_mgr.current) + + # Update queue index + self._queue_mgr.current_index = index + + async def _get_now_playing(self) -> Optional[Message]: + np_msg_id = self._db.get_now_playing(self.guild.id) + if np_msg_id != -1: + try: + np_msg = await self.status_channel.fetch_message(np_msg_id) + return np_msg + except (Forbidden, HTTPException, NotFound) as exc: + self._logger.warning( + 'Failed to fetch now playing message for %s: %s', + self.guild.name, + exc, + ) + + return None + + async def _play(self, item: 'QueueItem', position: Optional[int] = None): + if item.lavalink_track is None: + try: + assert self._bot.config is not None + deezer_enabled = self._bot.config.lavalink_nodes[self.node.label].deezer + item.lavalink_track = await find_lavalink_track( + self.node, item, deezer_enabled=deezer_enabled + ) + except LavalinkSearchError as err: + self._logger.critical("Failed to play `%s'.", item.title) + raise JockeyError(err.args[0]) from err + + # Play track + has_retried = False + while True: + try: + await self.play( + item.lavalink_track, + volume=self.volume, + start_time=position, + replace=True, + pause=False, + ) + except PlayerNotConnected as err: + # If we've already retried, give up + if has_retried: + raise JockeyError(err.args[0]) from err + + # Wait until we're connected + wait_time = 0.0 + self._logger.warning( + "PlayerNotConnected raised while trying to play `%s', retrying...", + item.title, + ) + while not self.connected: + if wait_time >= MAX_PLAYER_CONNECT_WAIT_SEC: + raise JockeyError('Timeout while waiting for player to connect') from err + + # Print wait message only once + if wait_time == 0.0: + self._logger.debug('Waiting 10 sec for player to connect...') + await sleep(0.1) + wait_time += 0.1 + + # Remove cached Lavalink track and try again + invalidate_cached_track(item) + has_retried = True + else: + # Clear pause timestamp for new track + if position is None: + self._pause_ts = None + + break + + # Save start time for scrobbling + item.start_time = int(time()) + + async def _scrobble(self, item: 'QueueItem'): + """ + Scrobbles a track in a separate thread. + + :param item: The track to scrobble. + """ + loop = get_event_loop() + loop.create_task(self._scrobble_impl(item)) + + async def _scrobble_impl(self, item: 'QueueItem'): + """ + Wraps the scrobble method for logging purposes. + """ + try: + self._scrobbler.scrobble(item) + except BlancoException as e: + self._logger.warning(e) + + async def disconnect(self, *, force: bool = False): + """ + Removes the controls from Now Playing, then disconnects. + """ + # Get now playing message + np_msg = await self._get_now_playing() + if np_msg is not None: + try: + await np_msg.edit(view=None) + except (HTTPException, Forbidden): + self._logger.warning( + 'Failed to remove now playing message for %s', self.guild.name + ) + + # Disconnect + await super().disconnect(force=force) + + def now_playing(self, current: Optional['Track'] = None) -> 'Embed': + """ + Returns information about the currently playing track. + + :return: An instance of nextcord.Embed + """ + if current is None: + if self.current is None: + raise EndOfQueueError('No track is currently playing') + current = self.current + + # Construct Spotify URL if it exists + track = self._queue_mgr.current + uri = current.uri + if track.spotify_id is not None: + uri = f'https://open.spotify.com/track/{track.spotify_id}' + + # Get track duration + duration_ms = track.duration + if track.lavalink_track is not None: + duration_ms = track.lavalink_track.length + + # Build track duration string + duration = '' + if duration_ms is not None: + duration = human_readable_time(duration_ms) + + # Display complete artists if available + artist = track.artist if track.author is None else track.author + if artist is None: + artist = 'Unknown artist' + + # Display type of track + is_stream = False + if track.lavalink_track is not None: + is_stream = track.lavalink_track.stream + + # Build footer + footer = f'Track {self._queue_mgr.current_shuffled_index + 1} of {self.queue_size}' + if self._queue_mgr.is_shuffling: + footer += ' 🔀' + if self._queue_mgr.is_looping_one: + footer += ' 🔂' + if self._queue_mgr.is_looping_all: + footer += ' 🔁' + footer += f' • Volume {self.volume}%' + + imperfect_msg = ':warning: Playing the [**closest match**]({})' + embed = CustomEmbed( + title='Now streaming' if is_stream else 'Now playing', + description=[ + f'[**{track.title}**]({uri})', + artist, + duration if not is_stream else '', + f'\nrequested by <@{track.requester}>', + imperfect_msg.format(current.uri) if track.is_imperfect else '', + ], + footer=footer, + color=Colour.teal(), + thumbnail_url=track.artwork, + ) + return embed.get() + + async def on_load_failed(self, failed_source: 'Track'): + """ + Called when a track fails to load. + Sends an error message to the status channel + and skips to the next track in queue. + + :param failed_track: The track that failed to load. Must be an instance of mafic.Track. + """ + # Get current track and its index + failed_track = self._queue_mgr.current + index = self._queue_mgr.current_shuffled_index + 1 + queue_size = self._queue_mgr.size + + # Send error embed + embed = CustomEmbed( + color=Colour.red(), + title=':warning:|Failed to load track', + description=[ + 'This could be due to a temporary issue with the source,', + 'a bot outage, or the track may be unavailable for playback.', + 'You can try playing the track again later.', + ], + fields=[ + ['Track', f'`{failed_track.title}`\n{failed_track.artist}'], + ['Position in queue', f'{index} of {queue_size}'], + ['Playback source', f'`{failed_source.title}`\n{failed_source.author}'], + ['Playback URL', f'[{failed_source.source}]({failed_source.uri})'], + ], + footer='Skipping to next track...', + ) + await self.status_channel.send(embed=embed.get()) + + # Skip to next track + await self.skip() + + async def pause(self, pause: bool = True): + """ + Pauses the player and stores the time at which playback was paused. + + The timestamp is necessary because Lavalink 4.0.0 (beta) does not + properly resume tracks when they are paused for an extended period, + causing the track to skip to the next one in the queue after a few + seconds of resumed playback. + + :param pause: Whether to pause or resume playback. + """ + await super().pause(pause=pause) + + # Store pause timestamp + self._pause_ts = int(time()) + + async def play_bump(self): + """ + Check and attempt to play a bump if it's been long enough. + """ + + enabled = self._db.get_bumps_enabled(self.guild.id) + if not enabled: + raise BumpNotEnabledError + + interval = self._db.get_bump_interval(self.guild.id) * 60 + last_bump = self._db.get_last_bump(self.guild.id) + + if last_bump == 0: + self._db.set_last_bump(self.guild.id) + raise BumpNotReadyError + + if int(time()) - last_bump < interval: + raise BumpNotReadyError + + bump = self._db.get_random_bump(self.guild.id) + if bump is None: + raise BumpError('Guild has no bumps.') + + requester = self._bot.user.id if self._bot.user is not None else self.guild.me.id + + try: + tracks = await parse_query(self.node, self._bot.spotify, bump.url, requester) + except (JockeyException, SpotifyNoResultsError): + raise + + if len(tracks) == 0: + raise BumpError('Unable to parse bump URL into tracks.') + + await self._play(tracks[0]) + self._db.set_last_bump(self.guild.id) + + async def play_impl(self, query: str, requester: int) -> str: + """ + Adds an item to the player queue and begins playback if necessary. + + :param query: The query to play. + :param requester: The ID of the user who requested the track. + :return: A string containing the name of the track that was added. + """ + # Get results for query + try: + new_tracks = await parse_query(self.node, self._bot.spotify, query, requester) + except JockeyException: + raise + except SpotifyNoResultsError as err: + raise JockeyError(err.args[0]) from err + except Exception as exc: + if self.playing: + raise JockeyException(str(exc)) from exc + raise JockeyError(str(exc)) from exc + + # Add new tracks to queue + old_size = self._queue_mgr.size + self._queue_mgr.extend(new_tracks) + + # Get info for first track + first = new_tracks[0] + first_name = ( + f'**{first.title}**\n{first.artist}' if first.title is not None else query + ) + + # Are we beginning a new queue or is the player idle? + if not self.playing: + # We are! Play the first new track. + old_index = self._queue_mgr.current_index + self._queue_mgr.current_index = old_size + + try: + await self._play(new_tracks[0]) + except (JockeyError, PlayerNotConnected) as err: + # Remove enqueued tracks + for _ in range(old_size, self._queue_mgr.size): + self._queue_mgr.remove(old_size) + + # Restore old index + self._queue_mgr.current_index = old_index + + raise JockeyError(f'Failed to play "{first.title}"') from err + + # Send embed + return first_name if len(new_tracks) == 1 else f'{len(new_tracks)} item(s)' + + async def remove(self, index: int) -> Tuple[str | None, str | None]: + """ + Removes a track from the queue. + """ + # Remove track from queue + removed_track = self._queue_mgr.remove(index) + + # Return removed track details + return removed_track.title, removed_track.artist + + async def resume(self): + """ + Resumes the player from a paused state. + + If the player was paused for an extended period, the current track + will be re-enqueued and played from the last position to work around + a bug in Lavalink 4.0.0 (beta). + """ + # Check if we were paused for too long or if reenqueuing is disabled + assert self._bot.config is not None + if not self._bot.config.reenqueue_paused or ( + self._pause_ts is None or int(time()) - self._pause_ts < UNPAUSE_THRESHOLD + ): + await super().resume() + return + + # We were paused for too long, re-enqueue the current track + # and play from a little bit before the last position + last_pos = max(self.position - 10, 0) + self._pause_ts = None + self._logger.debug( + 'Unpaused beyond %d sec threshold, re-enqueueing', UNPAUSE_THRESHOLD + ) + await self._play(self._queue_mgr.current, last_pos) + + async def set_volume(self, volume: int, /): + """ + Sets the player volume. + """ + await super().set_volume(volume) + self.volume = volume + + async def skip(self, *, forward: bool = True, index: int = -1, auto: bool = True): # noqa: PLR0912 + """ + Skips the current track and plays the next one in the queue. + + :param forward: Whether to skip forward or backward. + :param index: The index of the track to skip to. + :param auto: Whether this is an automatic skip, i.e. not part of a user's command. + This is True when the player skips to the next track automatically, + such as when the current track ends. + """ + # It takes a while for the player to skip, + # so let's remove the player controls while we wait + # to prevent the user from spamming them. + await self._edit_np_controls(show_controls=False) + + try: + await self.play_bump() + return + except (JockeyException, SpotifyNoResultsError) as err: + self._logger.error('Error parsing bump into track: %s', err) + except BumpError as err: + self._logger.error('Error playing bump: %s', err) + except BumpNotEnabledError: + self._logger.debug('Bumps are not enabled in this guild.') + except BumpNotReadyError: + self._logger.debug('Not ready to play a bump yet.') + + # If index is specified, use that instead + if index != -1: + try: + await self._enqueue(index, auto=auto) + except JockeyError: + await self._edit_np_controls(show_controls=True) + await self.status_channel.send( + embed=create_error_embed(f'Unable to skip to index {index}') + ) + raise + + return + + # Check if we're looping the current track + if auto and self._queue_mgr.is_looping_one: + # Re-enqueue the current track + try: + await self._enqueue(self._queue_mgr.current_index, auto=auto) + except JockeyError as err: + await self._edit_np_controls(show_controls=True) + await self.status_channel.send( + embed=create_error_embed(f'Unable to loop track: {err}') + ) + + return + + # Try to enqueue the next playable track + delta = 1 if forward else -1 + while True: + # Get next index + try: + next_i = self._queue_mgr.calc_next_index(delta=delta) + except EndOfQueueError: + # We've reached the end of the queue and looping is disabled + return + + # Get details of next track for logging + next_track = self._queue_mgr.queue[next_i] + next_title = next_track.title if next_track.title is not None else 'Unknown track' + next_artist = ( + next_track.artist if next_track.artist is not None else 'Unknown artist' + ) + + # Try to enqueue the next track + try: + await self._enqueue(next_i, auto=auto) + except JockeyError as err: + await self._edit_np_controls(show_controls=True) + delta += 1 if forward else -1 + + await self.status_channel.send( + embed=CustomEmbed( + color=Colour.red(), + title=':warning:|Failed to skip to track', + description='It might be unavailable temporarily ' + 'or restricted to specific regions.\n', + fields=[ + ['Track', f'`{next_title}`\n{next_artist}'], + ['Position in queue', f'{next_i + 1} of {self.queue_size}'], + ['Error', f'```{err}```'], + ], + footer='Skipping to next track...' if auto else None, + ).get() + ) + else: + break + + async def update_now_playing(self): + """ + Update the existing Now Playing view with current information. + """ + # Get now playing message + np_msg = await self._get_now_playing() + if np_msg is None: + return + + # Edit message + try: + await np_msg.edit(embed=self.now_playing()) + except (HTTPException, Forbidden) as exc: + # Ignore 404 + if not isinstance(exc, NotFound): + self._logger.warning( + 'Failed to edit now playing message for %s: %s', + self.guild.name, + exc, + ) + + async def play_bump(self): + """ + Check and attempt to play a bump if it's been long enough. + """ + + enabled = self._db.get_bumps_enabled(self.guild.id) + if not enabled: + raise BumpNotEnabledError + + interval = self._db.get_bump_interval(self.guild.id) * 60 + last_bump = self._db.get_last_bump(self.guild.id) + + if last_bump == 0: + self._db.set_last_bump(self.guild.id) + raise BumpNotReadyError + + if int(time()) - last_bump < interval: + raise BumpNotReadyError + + bump = self._db.get_random_bump(self.guild.id) + if bump is None: + raise BumpError('Guild has no bumps.') + + requester = self._bot.user.id if self._bot.user is not None else self.guild.me.id + + try: + tracks = await parse_query(self.node, self._bot.spotify, bump.url, requester) + except (JockeyException, SpotifyNoResultsError): + raise + + if len(tracks) == 0: + raise BumpError('Unable to parse bump URL into tracks.') + + await self._play(tracks[0]) + self._db.set_last_bump(self.guild.id) diff --git a/bot/database/__init__.py b/bot/database/__init__.py new file mode 100644 index 0000000..37ddf6b --- /dev/null +++ b/bot/database/__init__.py @@ -0,0 +1,380 @@ +""" +Database module for Blanco. Interfaces with the bot's SQLite database. +""" + +import sqlite3 as sql +from time import time +from typing import List, Optional + +from bot.models.bump import Bump +from bot.models.oauth import LastfmAuth, OAuth +from bot.utils.logger import create_logger + +from .migrations import run_migrations + + +class Database: + """ + Class for handling connections to the bot's SQLite DB. + """ + + def __init__(self, db_filename: str): + self._con = sql.connect(db_filename, check_same_thread=False) + self._cur = self._con.cursor() + self._logger = create_logger(self.__class__.__name__) + + # Run migrations + self._logger.info('Connected to database %s, running migrations...', db_filename) + run_migrations(self._logger, self._con) + + def init_guild(self, guild_id: int): + """ + Initialize a guild in the database if it hasn't been yet. + """ + self._cur.execute( + f'INSERT OR IGNORE INTO player_settings (guild_id) VALUES ({guild_id})' + ) + self._con.commit() + + def get_volume(self, guild_id: int) -> int: + """ + Get the volume for a guild. + """ + self._cur.execute(f'SELECT volume FROM player_settings WHERE guild_id = {guild_id}') + return self._cur.fetchone()[0] + + def set_volume(self, guild_id: int, volume: int): + """ + Set the volume for a guild. + """ + self._cur.execute( + f'UPDATE player_settings SET volume = {volume} WHERE guild_id = {guild_id}' + ) + self._con.commit() + + def get_loop(self, guild_id: int) -> bool: + """ + Get the loop setting for a guild. + """ + self._cur.execute(f'SELECT loop FROM player_settings WHERE guild_id = {guild_id}') + return self._cur.fetchone()[0] == 1 + + def set_loop(self, guild_id: int, loop: bool): + """ + Set the loop setting for a guild. + """ + self._cur.execute( + f'UPDATE player_settings SET loop = {int(loop)} WHERE guild_id = {guild_id}' + ) + self._con.commit() + + def get_loop_all(self, guild_id: int) -> bool: + """ + Get the whole-queue loop setting for a guild. + """ + self._cur.execute( + f'SELECT loop_all FROM player_settings WHERE guild_id = {guild_id}' + ) + return self._cur.fetchone()[0] == 1 + + def set_loop_all(self, guild_id: int, loop: bool): + """ + Set the whole-queue loop setting for a guild. + """ + self._cur.execute( + f'UPDATE player_settings SET loop_all = {int(loop)} WHERE guild_id = {guild_id}' + ) + self._con.commit() + + def get_now_playing(self, guild_id: int) -> int: + """ + Get the last now playing message ID for a guild. + """ + self._cur.execute( + f'SELECT last_np_msg FROM player_settings WHERE guild_id = {guild_id}' + ) + return self._cur.fetchone()[0] + + def set_now_playing(self, guild_id: int, msg_id: int): + """ + Set the last now playing message ID for a guild. + """ + self._cur.execute( + f'UPDATE player_settings SET last_np_msg = {msg_id} WHERE guild_id = {guild_id}' + ) + self._con.commit() + + def get_status_channel(self, guild_id: int) -> int: + """ + Get the status channel for a guild. + """ + self._cur.execute( + f'SELECT status_channel FROM player_settings WHERE guild_id = {guild_id}' + ) + return self._cur.fetchone()[0] + + def set_status_channel(self, guild_id: int, channel_id: int): + """ + Set the status channel for a guild. + """ + self._cur.execute( + f'UPDATE player_settings SET status_channel = {channel_id} WHERE guild_id = {guild_id}' + ) + self._con.commit() + + def get_session_id(self, node_id: str) -> str: + """ + Get the session ID for a Lavalink node. + """ + self._cur.execute(f'SELECT session_id FROM lavalink WHERE node_id = "{node_id}"') + return self._cur.fetchone()[0] + + def set_session_id(self, node_id: str, session_id: str): + """ + Set the session ID for a Lavalink node. + """ + self._cur.execute( + f"""INSERT OR REPLACE INTO lavalink ( + node_id, + session_id + ) VALUES ("{node_id}", "{session_id}")""" + ) + self._con.commit() + + def set_oauth(self, provider: str, credentials: OAuth): + """ + Save OAuth2 data for a user. + + :param provider: The provider to save the data for. Can be either 'discord' or 'spotify'. + :param credentials: The OAuth2 credentials to save. + """ + self._cur.execute(f""" + INSERT OR REPLACE INTO {provider}_oauth ( + user_id, + username, + access_token, + refresh_token, + expires_at + ) VALUES ( + {credentials.user_id}, + "{credentials.username}", + "{credentials.access_token}", + "{credentials.refresh_token}", + {credentials.expires_at} + ) + """) + self._con.commit() + + def get_oauth(self, provider: str, user_id: int) -> Optional[OAuth]: + """ + Get OAuth2 data for a user from the database. + + :param provider: The provider to get credentials for. Can be either 'discord' or 'spotify'. + :param user_id: The user ID to get credentials for + """ + self._cur.execute(f'SELECT * FROM {provider}_oauth WHERE user_id = {user_id}') + row = self._cur.fetchone() + if row is None: + return None + return OAuth( + user_id=row[0], + username=row[1], + access_token=row[2], + refresh_token=row[3], + expires_at=row[4], + ) + + def set_lastfm_credentials(self, credentials: LastfmAuth): + """ + Save Last.fm credentials for a user. + """ + self._cur.execute(f""" + INSERT OR REPLACE INTO lastfm_oauth ( + user_id, + username, + session_key + ) VALUES ( + {credentials.user_id}, + "{credentials.username}", + "{credentials.session_key}" + ) + """) + self._con.commit() + + def get_lastfm_credentials(self, user_id: int) -> Optional[LastfmAuth]: + """ + Get Last.fm credentials for a user. + """ + self._cur.execute(f'SELECT * FROM lastfm_oauth WHERE user_id = {user_id}') + row = self._cur.fetchone() + if row is None: + return None + return LastfmAuth(*row) + + def delete_oauth(self, provider: str, user_id: int): + """ + Delete OAuth2 data for a user from the database. + """ + self._cur.execute(f'DELETE FROM {provider}_oauth WHERE user_id = {user_id}') + self._con.commit() + + def set_spotify_scopes(self, user_id: int, scopes: List[str]): + """ + Set the Spotify scopes for a user. + """ + self._cur.execute(f""" + UPDATE spotify_oauth SET scopes = "{','.join(scopes)}" WHERE user_id = {user_id} + """) + self._con.commit() + + def get_spotify_scopes(self, user_id: int) -> List[str]: + """ + Get the Spotify scopes for a user. + """ + self._cur.execute(f'SELECT scopes FROM spotify_oauth WHERE user_id = {user_id}') + return self._cur.fetchone()[0].split(',') + + def set_last_bump(self, guild_id: int): + """ + Set the last bump for a guild. + """ + seconds = int(time()) + self._cur.execute( + f'UPDATE player_settings SET last_bump = {seconds} WHERE guild_id = {guild_id}' + ) + self._con.commit() + + def get_last_bump(self, guild_id: int) -> int: + """ + Get the last bump for a guild. + """ + self._cur.execute( + f'SELECT last_bump FROM player_settings WHERE guild_id = {guild_id}' + ) + return self._cur.fetchone()[0] + + def set_bumps_enabled(self, guild_id: int, enabled: bool): + """ + Set whether bumps are enabled for a guild. + """ + self._cur.execute( + f'UPDATE player_settings SET bumps_enabled = {int(enabled)} WHERE guild_id = {guild_id}' + ) + self._con.commit() + + def get_bumps_enabled(self, guild_id: int) -> bool: + """ + Get whether bumps are enabled for a guild. + """ + self._cur.execute( + f'SELECT bumps_enabled FROM player_settings WHERE guild_id = {guild_id}' + ) + return self._cur.fetchone()[0] == 1 + + def set_bump_interval(self, guild_id: int, interval: int): + """ + Set the bump interval for a guild. + """ + self._cur.execute( + f'UPDATE player_settings SET bump_interval = {interval} WHERE guild_id = {guild_id}' + ) + self._con.commit() + + def get_bump_interval(self, guild_id: int) -> int: + """ + Get the bump interval for a guild. + """ + self._cur.execute( + f'SELECT bump_interval FROM player_settings WHERE guild_id = {guild_id}' + ) + return self._cur.fetchone()[0] + + def add_bump(self, guild_id: int, url: str, title: str, author: str): + """ + Set a bump for a guild. + """ + self._cur.execute(f'SELECT MAX(idx) FROM bumps WHERE guild_id = {guild_id}') + idx = self._cur.fetchone()[0] + if idx is None: + idx = 0 + idx += 1 + self._cur.execute(f""" + INSERT INTO bumps ( + guild_id, + idx, + url, + title, + author + ) VALUES ( + {guild_id}, + {idx}, + "{url}", + "{title}", + "{author}" + ) + """) + self._con.commit() + + def get_bumps(self, guild_id: int) -> Optional[List[Bump]]: + """ + Get every bump for a guild. + """ + self._cur.execute(f"""SELECT idx, guild_id, url, title, author + FROM bumps WHERE guild_id = {guild_id}""") + rows = self._cur.fetchall() + if len(rows) == 0: + return None + + return [ + Bump(idx=row[0], guild_id=row[1], url=row[2], title=row[3], author=row[4]) + for row in rows + ] + + def get_bump(self, guild_id: int, idx: int) -> Optional[Bump]: + """ + Get a guild bump by its index. + """ + self._cur.execute( + f"""SELECT idx, guild_id, url, title, author FROM bumps + WHERE guild_id = {guild_id} AND idx = {idx} + """ + ) + row = self._cur.fetchone() + if row is None: + return None + return Bump(idx=row[0], guild_id=row[1], url=row[2], title=row[3], author=row[4]) + + def get_bump_by_url(self, guild_id: int, url: str) -> Optional[Bump]: + """ + Get a guild bump by its URL. + """ + self._cur.execute( + f"""SELECT idx, guild_id, url, title, author FROM bumps + WHERE guild_id = {guild_id} AND url = "{url}" + """ + ) + row = self._cur.fetchone() + if row is None: + return None + return Bump(idx=row[0], guild_id=row[1], url=row[2], title=row[3], author=row[4]) + + def get_random_bump(self, guild_id: int) -> Optional[Bump]: + """ + Get a random guild bump. + """ + self._cur.execute( + f"""SELECT idx, guild_id, url, title, author FROM bumps WHERE + guild_id = {guild_id} ORDER BY RANDOM() LIMIT 1 + """ + ) + row = self._cur.fetchone() + if row is None: + return None + return Bump(idx=row[0], guild_id=row[1], url=row[2], title=row[3], author=row[4]) + + def delete_bump(self, guild_id: int, idx: int): + """ + Delete a guild bump by its index. + """ + self._cur.execute(f'DELETE FROM bumps WHERE guild_id = {guild_id} AND idx = {idx}') + self._con.commit() diff --git a/database/migrations/0000-create.py b/bot/database/migrations/0000-create.py similarity index 76% rename from database/migrations/0000-create.py rename to bot/database/migrations/0000-create.py index 21d3a8e..0d5bb86 100644 --- a/database/migrations/0000-create.py +++ b/bot/database/migrations/0000-create.py @@ -6,20 +6,20 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from sqlite3 import Connection + from sqlite3 import Connection def run(con: 'Connection'): - """ - Run the migration. - """ - cur = con.cursor() - cur.execute(''' + """ + Run the migration. + """ + cur = con.cursor() + cur.execute(""" CREATE TABLE IF NOT EXISTS player_settings ( guild_id INTEGER PRIMARY KEY NOT NULL, volume INTEGER NOT NULL DEFAULT 100, loop INTEGER NOT NULL DEFAULT 0, last_np_msg INTEGER NOT NULL DEFAULT -1 ) - ''') - con.commit() + """) + con.commit() diff --git a/database/migrations/0001-lavalink-sessionid.py b/bot/database/migrations/0001-lavalink-sessionid.py similarity index 73% rename from database/migrations/0001-lavalink-sessionid.py rename to bot/database/migrations/0001-lavalink-sessionid.py index 4e24b36..3c7bab3 100644 --- a/database/migrations/0001-lavalink-sessionid.py +++ b/bot/database/migrations/0001-lavalink-sessionid.py @@ -7,17 +7,18 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from sqlite3 import Connection + from sqlite3 import Connection + def run(con: 'Connection'): - """ - Run the migration. - """ - cur = con.cursor() - cur.execute(''' + """ + Run the migration. + """ + cur = con.cursor() + cur.execute(""" CREATE TABLE IF NOT EXISTS lavalink ( node_id TEXT PRIMARY KEY NOT NULL, session_id TEXT NOT NULL ) - ''') - con.commit() + """) + con.commit() diff --git a/bot/database/migrations/0002-statuschannel.py b/bot/database/migrations/0002-statuschannel.py new file mode 100644 index 0000000..e1f180a --- /dev/null +++ b/bot/database/migrations/0002-statuschannel.py @@ -0,0 +1,28 @@ +""" +Add a column to the player_settings table to store the status channel ID. +""" +# pylint: disable=invalid-name + +from sqlite3 import OperationalError +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from sqlite3 import Connection + + +def run(con: 'Connection'): + """ + Run the migration. + """ + cur = con.cursor() + + # There's no built-in way to check if a column exists in SQLite, + # so we just try to add it and ignore the error if it already exists. + try: + cur.execute(""" + ALTER TABLE player_settings ADD COLUMN status_channel INTEGER NOT NULL DEFAULT -1 + """) + except OperationalError: + pass + + con.commit() diff --git a/database/migrations/0003-oauth.py b/bot/database/migrations/0003-oauth.py similarity index 82% rename from database/migrations/0003-oauth.py rename to bot/database/migrations/0003-oauth.py index 985dbb1..1849cd3 100644 --- a/database/migrations/0003-oauth.py +++ b/bot/database/migrations/0003-oauth.py @@ -6,14 +6,15 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from sqlite3 import Connection + from sqlite3 import Connection + def run(con: 'Connection'): - """ - Run the migration. - """ - cur = con.cursor() - cur.execute(''' + """ + Run the migration. + """ + cur = con.cursor() + cur.execute(""" CREATE TABLE IF NOT EXISTS discord_oauth ( user_id INTEGER PRIMARY KEY NOT NULL, username TEXT NOT NULL, @@ -21,8 +22,8 @@ def run(con: 'Connection'): refresh_token TEXT NOT NULL, expires_at INTEGER NOT NULL ) - ''') - cur.execute(''' + """) + cur.execute(""" CREATE TABLE IF NOT EXISTS spotify_oauth ( user_id INTEGER PRIMARY KEY NOT NULL, username TEXT NOT NULL, @@ -31,12 +32,12 @@ def run(con: 'Connection'): expires_at INTEGER NOT NULL, scopes TEXT NOT NULL DEFAULT '' ) - ''') - cur.execute(''' + """) + cur.execute(""" CREATE TABLE IF NOT EXISTS lastfm_oauth ( user_id INTEGER PRIMARY KEY NOT NULL, username TEXT NOT NULL, session_key TEXT NOT NULL ) - ''') - con.commit() + """) + con.commit() diff --git a/bot/database/migrations/0004-loop-all.py b/bot/database/migrations/0004-loop-all.py new file mode 100644 index 0000000..6a7d4de --- /dev/null +++ b/bot/database/migrations/0004-loop-all.py @@ -0,0 +1,28 @@ +""" +Add a column to the player_settings table to store the whole-queue repeat preference per guild. +""" +# pylint: disable=invalid-name + +from sqlite3 import OperationalError +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from sqlite3 import Connection + + +def run(con: 'Connection'): + """ + Run the migration. + """ + cur = con.cursor() + + # There's no built-in way to check if a column exists in SQLite, + # so we just try to add it and ignore the error if it already exists. + try: + cur.execute(""" + ALTER TABLE player_settings ADD COLUMN loop_all INTEGER NOT NULL DEFAULT 0 + """) + except OperationalError: + pass + + con.commit() diff --git a/database/migrations/0005-bumps.py b/bot/database/migrations/0005-bumps.py similarity index 65% rename from database/migrations/0005-bumps.py rename to bot/database/migrations/0005-bumps.py index 3b1f53d..41bde9f 100644 --- a/database/migrations/0005-bumps.py +++ b/bot/database/migrations/0005-bumps.py @@ -8,16 +8,16 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from sqlite3 import Connection + from sqlite3 import Connection def run(con: 'Connection'): - """ - Run the migration. - """ - cur = con.cursor() + """ + Run the migration. + """ + cur = con.cursor() - cur.execute(''' + cur.execute(""" CREATE TABLE IF NOT EXISTS bumps ( id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, idx INTEGER NOT NULL, @@ -28,33 +28,33 @@ def run(con: 'Connection'): UNIQUE(guild_id, idx) ) - ''') + """) - con.commit() + con.commit() - try: - cur.execute(''' + try: + cur.execute(""" ALTER TABLE player_settings ADD COLUMN bump_interval INTEGER NOT NULL DEFAULT 20 - ''') + """) - con.commit() - except OperationalError: - pass + con.commit() + except OperationalError: + pass - try: - cur.execute(''' + try: + cur.execute(""" ALTER TABLE player_settings ADD COLUMN last_bump INTEGER NOT NULL DEFAULT 0 - ''') + """) - con.commit() - except OperationalError: - pass + con.commit() + except OperationalError: + pass - try: - cur.execute(''' + try: + cur.execute(""" ALTER TABLE player_settings ADD COLUMN bumps_enabled INTEGER NOT NULL DEFAULT 0 - ''') + """) - con.commit() - except OperationalError: - pass + con.commit() + except OperationalError: + pass diff --git a/bot/database/migrations/__init__.py b/bot/database/migrations/__init__.py new file mode 100644 index 0000000..2d0a1e6 --- /dev/null +++ b/bot/database/migrations/__init__.py @@ -0,0 +1,33 @@ +""" +Database migrations module for Blanco. +Handles automatic adjustment of the SQLite database schema +across updates of the bot. +""" + +from importlib import import_module +from os import listdir, path +from sqlite3 import OperationalError +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from logging import Logger + from sqlite3 import Connection + + +def run_migrations(logger: 'Logger', con: 'Connection'): + """ + Run all migrations on Blanco's database. + + :param con: The Connection instance to the SQLite database. + """ + for file in sorted(listdir(path.dirname(__file__))): + if file != path.basename(__file__) and file.endswith('.py'): + logger.debug('Running migration: %s', file) + migration = import_module(f'bot.database.migrations.{file[:-3]}') + + try: + migration.run(con) + except OperationalError as err: + logger.error('Error running migration %s: %s', file, err) + logger.critical('Aborting migrations.') + raise RuntimeError('Error running migrations.') from err diff --git a/bot/database/redis.py b/bot/database/redis.py new file mode 100644 index 0000000..d5a2c68 --- /dev/null +++ b/bot/database/redis.py @@ -0,0 +1,172 @@ +""" +Redis client that takes care of caching MusicBrainz and Spotify lookups. +""" + +from typing import Optional + +import redis + +from bot.models.spotify import SpotifyTrack +from bot.utils.config import REDIS_HOST, REDIS_PASSWORD, REDIS_PORT +from bot.utils.logger import create_logger + + +class RedisClient: + """ + Redis client that takes care of caching MusicBrainz and Spotify lookups. + """ + + def __init__(self, host: str, port: int, password: Optional[str] = None): + self._client = redis.StrictRedis( + host=host, + port=port, + password=password, + encoding='utf-8', + decode_responses=True, + ) + + # Logger + self._logger = create_logger(self.__class__.__name__) + self._logger.debug('Attempting to connect to Redis server...') + + # Test connection + try: + self._client.ping() + except redis.ConnectionError as err: + self._logger.critical( + 'Could not connect to Redis server. Check your configuration.' + ) + raise RuntimeError('Could not connect to Redis server.') from err + + self._logger.info( + 'Connected to Redis server. Enable debug logging to see cache hits.' + ) + + def set_lavalink_track(self, key: str, value: str, *, key_type: str): + """ + Save an encoded Lavalink track. + + :param key: The key to save the track under. + :param value: The encoded track. + :param key_type: The type of key to save the track under, e.g. 'isrc' or 'spotify_id'. + """ + self._logger.debug('Caching Lavalink track for %s:%s', key_type, key) + self._client.set(f'lavalink:{key_type}:{key}', value) + + def get_lavalink_track(self, key: str, *, key_type: str) -> Optional[str]: + """ + Get an encoded Lavalink track. + + :param key: The key to get the track from. + :param key_type: The type of key to get the track from, e.g. 'isrc' or 'spotify_id'. + """ + if not self._client.exists(f'lavalink:{key_type}:{key}'): + return None + + self._logger.debug('Got cached Lavalink track for %s:%s', key_type, key) + return self._client.get(f'lavalink:{key_type}:{key}') # type: ignore + + def invalidate_lavalink_track(self, key: str, *, key_type: str): + """ + Removes a cached Lavalink track. + + :param key: The key to remove the track for. + :param key_type: The type of key to remove the track for, e.g. 'isrc' or 'spotify_id'. + """ + self._logger.debug('Invalidating Lavalink track for %s:%s', key_type, key) + if self._client.exists(f'lavalink:{key_type}:{key}'): + self._client.delete(f'lavalink:{key_type}:{key}') + + def set_spotify_track(self, spotify_id: str, track: 'SpotifyTrack'): + """ + Save a Spotify track. + """ + self._logger.debug('Caching info for Spotify track %s', spotify_id) + self._client.hmset( + f'spotify:{spotify_id}', + { + 'title': track.title, + 'artist': track.artist, + 'author': track.author, + 'duration_ms': track.duration_ms, + 'artwork': track.artwork if track.artwork is not None else '', + 'album': track.album if track.album is not None else '', + 'isrc': track.isrc if track.isrc is not None else '', + }, + ) + + # Remove standalone ISRC cache + if self._client.exists(f'isrc:{spotify_id}'): + self._client.delete(f'isrc:{spotify_id}') + + def get_spotify_track(self, spotify_id: str) -> Optional['SpotifyTrack']: + """ + Get a Spotify track. + """ + track = self._client.hgetall(f'spotify:{spotify_id}') + + if not track: + return None + + self._logger.debug('Got cached info for Spotify track %s', spotify_id) + return SpotifyTrack( + title=track['title'], # type: ignore + artist=track['artist'], # type: ignore + author=track['author'], # type: ignore + duration_ms=int(track['duration_ms']), # type: ignore + artwork=track['artwork'] if track['artwork'] else None, # type: ignore + album=track['album'] if track['album'] else None, # type: ignore + isrc=track['isrc'] if track['isrc'] else None, # type: ignore + spotify_id=spotify_id, + ) + + def set_mbid(self, spotify_id: str, mbid: str): + """ + Save a MusicBrainz ID for a Spotify track. + """ + self._logger.debug('Caching MusicBrainz ID for Spotify track %s', spotify_id) + self._client.set(f'mbid:{spotify_id}', mbid) + + def get_mbid(self, spotify_id: str) -> Optional[str]: + """ + Get a MusicBrainz ID for a Spotify track. + """ + if not self._client.exists(f'mbid:{spotify_id}'): + return None + + self._logger.debug('Got cached MusicBrainz ID for Spotify track %s', spotify_id) + return self._client.get(f'mbid:{spotify_id}') # type: ignore + + def set_isrc(self, spotify_id: str, isrc: str): + """ + Save an ISRC for a Spotify track. + """ + # Check if there is a Spotify track with this ID + if self._client.exists(f'spotify:{spotify_id}'): + # Update ISRC in Spotify track + self._logger.debug('Updating cached ISRC for Spotify track %s', spotify_id) + self._client.hset(f'spotify:{spotify_id}', 'isrc', isrc) + + self._logger.debug('Caching ISRC for Spotify track %s', spotify_id) + self._client.set(f'isrc:{spotify_id}', isrc) + + def get_isrc(self, spotify_id: str) -> Optional[str]: + """ + Get an ISRC for a Spotify track. + """ + # Check if there is a Spotify track with this ID + if self._client.exists(f'spotify:{spotify_id}'): + # Return ISRC from Spotify track + self._logger.debug('Got cached ISRC for Spotify track %s', spotify_id) + return self._client.hget(f'spotify:{spotify_id}', 'isrc') # type: ignore + + if not self._client.exists(f'isrc:{spotify_id}'): + return None + + self._logger.debug('Got cached ISRC for Spotify track %s', spotify_id) + return self._client.get(f'isrc:{spotify_id}') # type: ignore + + +REDIS = None +if REDIS_HOST is not None and REDIS_PORT != -1: + REDIS = RedisClient(REDIS_HOST, REDIS_PORT, REDIS_PASSWORD) diff --git a/bot/main.py b/bot/main.py new file mode 100644 index 0000000..ff53189 --- /dev/null +++ b/bot/main.py @@ -0,0 +1,77 @@ +""" +Main bot file. +""" + +from nextcord import Intents + +from bot.utils.blanco import BlancoBot +from bot.utils.config import ( + REDIS_HOST, + REDIS_PASSWORD, + REDIS_PORT, + SENTRY_DSN, + SENTRY_ENV, + config, +) +from bot.utils.constants import RELEASE +from bot.utils.logger import create_logger + +if __name__ == '__main__': + logger = create_logger('main') + + # Print parsed config + if config.debug_enabled: + logger.debug('Parsed configuration:') + logger.debug(' Database file: %s', config.db_file) + logger.debug(' Discord token: %s...', config.discord_token[:3]) + logger.debug(' Spotify client ID: %s...', config.spotify_client_id[:3]) + logger.debug(' Spotify client secret: %s...', config.spotify_client_secret[:3]) + logger.debug(' Match ahead: %s', 'enabled' if config.match_ahead else 'disabled') + + if SENTRY_DSN is not None and SENTRY_ENV is not None: + logger.debug(' Sentry DSN: %s...', SENTRY_DSN[:10]) + logger.debug(' Sentry environment: %s', SENTRY_ENV) + else: + logger.debug(' Sentry integration disabled') + + if REDIS_HOST is not None and REDIS_PORT != -1: + logger.debug(' Redis host: %s', REDIS_HOST) + logger.debug(' Redis port: %d', REDIS_PORT) + if REDIS_PASSWORD is not None: + logger.debug(' Redis password: %s...', REDIS_PASSWORD[:3]) + else: + logger.debug(' Redis integration disabled') + + if config.lastfm_enabled: + assert ( + config.lastfm_api_key is not None and config.lastfm_shared_secret is not None + ) + logger.debug(' Last.fm API key: %s...', config.lastfm_api_key[:3]) + logger.debug(' Last.fm shared secret: %s...', config.lastfm_shared_secret[:3]) + else: + logger.debug(' Last.fm integration disabled') + + logger.debug(' Webserver: %s', 'enabled' if config.enable_server else 'disabled') + if config.enable_server: + assert config.discord_oauth_secret is not None + logger.debug(' - Listening on port %d', config.server_port) + logger.debug(' - Base URL: %s', config.base_url) + logger.debug(' - OAuth ID: %s...', str(config.discord_oauth_id)[:3]) + logger.debug(' - OAuth secret: %s...', config.discord_oauth_secret[:3]) + + logger.debug(' Lavalink nodes:') + for node in config.lavalink_nodes.values(): + logger.debug(' - %s (%s:%d)', node.id, node.host, node.port) + logger.debug(' Secure: %s', 'yes' if node.secure else 'no') + logger.debug(' Supports Deezer: %s', 'yes' if node.deezer else 'no') + logger.debug(' Regions: %s', ', '.join(node.regions)) + + # Create bot instance + intents = Intents.default() + intents.members = True + client = BlancoBot(intents=intents, default_guild_ids=config.debug_guild_ids) + client.init_config(config) + + # Run client + logger.info('Blanco release %s booting up...', RELEASE) + client.run(config.discord_token) diff --git a/bot/models/bump.py b/bot/models/bump.py new file mode 100644 index 0000000..f41a14c --- /dev/null +++ b/bot/models/bump.py @@ -0,0 +1,18 @@ +""" +Dataclass for guild bumps. +""" + +from dataclasses import dataclass + + +@dataclass +class Bump: + """ + Dataclass for guild bumps. + """ + + idx: int + guild_id: int + url: str + title: str + author: str diff --git a/bot/models/config.py b/bot/models/config.py new file mode 100644 index 0000000..6d24b61 --- /dev/null +++ b/bot/models/config.py @@ -0,0 +1,83 @@ +""" +Dataclasses for storing Blanco's configuration objects. +""" + +from dataclasses import dataclass +from typing import Dict, List, Optional + + +@dataclass +class LavalinkNode: + """ + Dataclass for storing Lavalink node information. + """ + + id: str # pylint: disable=invalid-name + password: str + host: str + port: int + regions: List[str] + secure: bool = False + deezer: bool = False + + # Type checking + def __post_init__(self): + # Check if host, password, and label are strings + if not isinstance(self.host, str): + raise TypeError('server must be a string') + if not isinstance(self.password, str): + raise TypeError('password must be a string') + if not isinstance(self.id, str): + raise TypeError('id must be a string') + + # Check if port is an int + if not isinstance(self.port, int): + raise TypeError('port must be an int') + + # Check if ssl is a bool + if not isinstance(self.secure, bool): + raise TypeError('ssl must be a bool') + + # Check if deezer is a bool + if not isinstance(self.deezer, bool): + raise TypeError('deezer must be a bool') + + # Check if regions is a list + if not isinstance(self.regions, list): + raise TypeError('regions must be a list') + + +@dataclass +class Config: + """ + Dataclass for storing Blanco's configuration. + """ + + # Required + db_file: str + discord_token: str + spotify_client_id: str + spotify_client_secret: str + lavalink_nodes: Dict[str, LavalinkNode] + enable_server: bool + + # Optional + server_port: int = 8080 + base_url: Optional[str] = None + discord_oauth_id: Optional[str] = None + discord_oauth_secret: Optional[str] = None + jwt_secret: Optional[str] = None + lastfm_api_key: Optional[str] = None + lastfm_shared_secret: Optional[str] = None + match_ahead: bool = False + debug_enabled: bool = False + debug_guild_ids: Optional[List[int]] = None + reenqueue_paused: bool = False + + # Convenience + @property + def lastfm_enabled(self) -> bool: + """ + Returns whether Last.fm is enabled. + """ + return self.lastfm_api_key is not None and self.lastfm_shared_secret is not None diff --git a/bot/models/custom_embed.py b/bot/models/custom_embed.py new file mode 100644 index 0000000..4db8658 --- /dev/null +++ b/bot/models/custom_embed.py @@ -0,0 +1,80 @@ +""" +Dataclass for an instance of nextcord.Embed with convenience fields +for the timestamp, multiline description, etc. +""" + +from dataclasses import dataclass, field +from datetime import datetime +from typing import List, Optional, Union + +from nextcord import Colour, Embed + + +@dataclass +class CustomEmbed: + """ + Dataclass for an instance of nextcord.Embed with convenience fields + for the timestamp, multiline description, etc. + """ + + # All optional + title: Optional[str] = None + color: Colour = Colour.og_blurple() + description: Optional[Union[str, List[str]]] = None + fields: List[List[str]] = field(default_factory=list) + inline_fields: bool = False + thumbnail_url: Optional[str] = None + image_url: Optional[str] = None + + # Header and footer + header: Optional[str] = None + header_url: Optional[str] = None + header_icon_url: Optional[str] = None + footer: Optional[str] = None + footer_icon_url: Optional[str] = None + timestamp_now: bool = False + + # Create embed + def __post_init__(self): + # Can't specify header/footer icons without header/footer names + if self.header is None and self.header_icon_url is not None: + raise ValueError("Can't specify header icon without header text.") + if self.footer is None and self.footer_icon_url is not None: + raise ValueError("Can't specify footer icon without footer text.") + + # Create embed object + description = self.description + if isinstance(self.description, list): + description = '\n'.join(list(filter(None, self.description))) + embed = Embed(title=self.title, description=description, color=self.color) + + # Set embed parts + if self.header is not None: + embed.set_author(name=self.header) + if self.thumbnail_url is not None and self.thumbnail_url != '': + embed.set_thumbnail(url=self.thumbnail_url) + if self.image_url is not None: + embed.set_image(url=self.image_url) + if self.header is not None: + embed.set_author( + name=self.header, url=self.header_url, icon_url=self.header_icon_url + ) + if self.footer is not None: + embed.set_footer(text=self.footer, icon_url=self.footer_icon_url) + if len(self.fields) > 0: + for f in self.fields: # pylint: disable=invalid-name + embed.add_field(name=f[0], value=f[1], inline=self.inline_fields) + + # Save embed + self.embed = embed + + # Get embed object + def get(self) -> Embed: + """ + Get the resulting nextcord.Embed object. + """ + # Add timestamp to embed + if self.timestamp_now: + self.embed.timestamp = datetime.now() + + return self.embed diff --git a/bot/models/lavalink_result.py b/bot/models/lavalink_result.py new file mode 100644 index 0000000..d5932e3 --- /dev/null +++ b/bot/models/lavalink_result.py @@ -0,0 +1,23 @@ +""" +Dataclass for storing Lavalink search results. +""" + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Optional + +if TYPE_CHECKING: + from mafic import Track + + +@dataclass +class LavalinkResult: + """ + Dataclass for storing Lavalink search results. + """ + + title: str + author: str + duration_ms: int + lavalink_track: 'Track' + artwork_url: Optional[str] = None + url: Optional[str] = None diff --git a/bot/models/oauth.py b/bot/models/oauth.py new file mode 100644 index 0000000..109fbbe --- /dev/null +++ b/bot/models/oauth.py @@ -0,0 +1,29 @@ +""" +Dataclasses for storing authentication data for Discord, Last.fm, Spotify, etc. +""" + +from dataclasses import dataclass + + +@dataclass +class OAuth: + """ + Dataclass for storing authentication data for Discord, Spotify, etc. + """ + + user_id: int + username: str + access_token: str + refresh_token: str + expires_at: int + + +@dataclass +class LastfmAuth: + """ + Dataclass for storing authentication data for Last.fm. + """ + + user_id: int + username: str + session_key: str diff --git a/bot/models/queue_item.py b/bot/models/queue_item.py new file mode 100644 index 0000000..f5efa67 --- /dev/null +++ b/bot/models/queue_item.py @@ -0,0 +1,73 @@ +""" +Dataclass for storing a track in the player queue. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Optional, Tuple + +if TYPE_CHECKING: + from mafic import Track + + +@dataclass +class QueueItem: + """ + Dataclass for storing a track in the player queue. + """ + + # Who requested the track (required) + requester: int + + # The Spotify ID for the track, if any + spotify_id: Optional[str] = None + + # The MusicBrainz ID for the track, if any + mbid: Optional[str] = None + + # International Standard Recording Code (ISRC) + isrc: Optional[str] = None + + # Direct track URL + url: Optional[str] = None + + # Album artwork + artwork: Optional[str] = None + + # Track details + title: Optional[str] = None + artist: Optional[str] = None # First artist + author: Optional[str] = None # All artists, separated by ', ' + album: Optional[str] = None + duration: Optional[int] = 0 # milliseconds + lavalink_track: Optional['Track'] = None + + # Imperfect match - True when ISRC is present but no match found on YouTube + is_imperfect: Optional[bool] = False + + # If annotate_track() was called on this track + is_annotated: Optional[bool] = False + + # When the track started playing + start_time: Optional[int] = None + + # Get title and artist + def get_details(self) -> Tuple[str, str]: + """ + Get a string of the form `title - artist` for the track. + """ + if self.title is not None: + title = self.title + if self.artist is not None: + artist = self.artist + else: + artist = 'Unknown artist' + elif self.url is not None: + title = self.url + artist = '(direct link)' + else: + title = 'Unknown title' + artist = 'Unknown query' + + return title, artist diff --git a/bot/models/spotify.py b/bot/models/spotify.py new file mode 100644 index 0000000..e0ced08 --- /dev/null +++ b/bot/models/spotify.py @@ -0,0 +1,33 @@ +""" +Dataclass for storing a Spotify track entity. +""" + +from dataclasses import dataclass +from typing import Optional + + +@dataclass +class SpotifyResult: + """ + Dataclass for storing a Spotify catalogue search result. + """ + + name: str + description: str + spotify_id: str + + +@dataclass +class SpotifyTrack: + """ + Dataclass for storing a Spotify track entity. + """ + + title: str + artist: str # First artist + author: str # All artists, separated by ', ' + spotify_id: str + duration_ms: int + artwork: Optional[str] = None + album: Optional[str] = None + isrc: Optional[str] = None diff --git a/bot/utils/blanco.py b/bot/utils/blanco.py new file mode 100644 index 0000000..9bc16f4 --- /dev/null +++ b/bot/utils/blanco.py @@ -0,0 +1,523 @@ +""" +Custom bot class for Blanco. +""" + +from asyncio import get_event_loop +from sqlite3 import OperationalError +from typing import TYPE_CHECKING, Dict, List, Optional, Union + +from aiohttp.client_exceptions import ClientConnectorError +from mafic import EndReason, NodePool, VoiceRegion +from nextcord import ( + Activity, + ActivityType, + Forbidden, + HTTPException, + Interaction, + MessageFlags, + NotFound, + PartialMessageable, + StageChannel, + TextChannel, + Thread, + VoiceChannel, +) +from nextcord.ext.commands import Bot, ExtensionNotLoaded + +from bot.cogs.player.helpers.lavalink_track import find_lavalink_track +from bot.database import Database +from bot.views.now_playing import NowPlayingView + +from .embeds import create_error_embed +from .exceptions import EndOfQueueError, LavalinkSearchError +from .logger import create_logger +from .scrobbler import Scrobbler +from .spotify_client import Spotify +from .spotify_private import PrivateSpotify + +if TYPE_CHECKING: + from asyncio import Task + from logging import Logger + + from mafic import Node, TrackEndEvent, TrackStartEvent + + from bot.cogs.player.jockey import Jockey +from bot.models.config import Config + +StatusChannel = Union[ + PartialMessageable, VoiceChannel, TextChannel, StageChannel, Thread +] + + +# Match-ahead wrapper for finding a Lavalink track with exception handling +async def match_ahead(logger: 'Logger', *args, **kwargs): + """ + Wrapper for find_lavalink_track with exception handling. + """ + try: + return await find_lavalink_track(*args, **kwargs) + except LavalinkSearchError: + logger.warning('Failed to match track ahead') + + # No need to do anything special, the user will see the causes + # when Blanco tries to play the track for real + return None + + +class BlancoBot(Bot): + """ + Custom bot class for Blanco. + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._config: Optional['Config'] = None + self._db: Optional[Database] = None + + # Status channels + self._status_channels: Dict[int, 'StatusChannel'] = {} + + # Spotify client + self._spotify_client: Optional[Spotify] = None + + # Lavalink + self._pool = NodePool(self) + self._pool_initialized = False + + # Loggers + self._logger = create_logger(self.__class__.__name__) + self._jockey_logger = create_logger('jockey') + + # Scrobblers and private Spotify clients per user + self._scrobblers: Dict[int, 'Scrobbler'] = {} + self._scrobbler_logger = create_logger('scrobbler') + self._spotify_clients: Dict[int, PrivateSpotify] = {} + + # Annotator tasks + self._tasks: Dict[int, List['Task']] = {} + + @property + def config(self) -> Optional['Config']: + """ + Gets the bot's config. + """ + return self._config + + @property + def debug(self) -> bool: + """ + Gets whether debug mode is enabled. + """ + if self._config is None or self._config.debug_guild_ids is None: + return False + return self._config.debug_enabled and len(self._config.debug_guild_ids) > 0 + + @property + def database(self) -> Database: + """ + Gets the bot's database. + """ + if self._db is None: + raise RuntimeError('Database has not been initialized') + return self._db + + @property + def jockey_logger(self) -> 'Logger': + """ + Gets the bot's reusable logger for all Jockey instances. + """ + return self._jockey_logger + + @property + def pool(self) -> NodePool: + """ + Gets the bot's Lavalink node pool. + """ + return self._pool + + @property + def pool_initialized(self) -> bool: + """ + Gets whether the Lavalink node pool has been initialized. + """ + return self._pool_initialized + + @property + def spotify(self) -> Spotify: + """ + Gets the bot's Spotify client. See utils/spotify_client.py. + """ + if self._spotify_client is None: + raise RuntimeError('Spotify client has not been initialized') + return self._spotify_client + + ################### + # Event listeners # + ################### + + async def on_ready(self): + """ + Called when the bot is ready. + """ + if self._config is None: + raise RuntimeError('Received on_ready event before config was initialized') + + self._logger.info('Logged in as %s', self.user) + + # Try to unload cogs first if the bot was restarted + try: + self.unload_extension('bot.cogs') + except ExtensionNotLoaded: + pass + self.load_extension('bot.cogs') + + # Load server extension if server is enabled + if self._config.enable_server: + # Try to unload server first if the bot was restarted + try: + self.unload_extension('bot.api.extension') + except ExtensionNotLoaded: + pass + self._logger.info('Starting web server...') + self.load_extension('bot.api.extension') + elif self._config.base_url is not None: + self._logger.warning( + 'Server is disabled, but base URL is set to %s', + self._config.base_url, + ) + + if self.debug: + self._logger.warning('Debug mode enabled') + await self.change_presence( + activity=Activity(name='/play (debug)', type=ActivityType.listening) + ) + + # Sync commands with debug guilds + if self._config is not None and self._config.debug_guild_ids is not None: + for guild in self._config.debug_guild_ids: + self._logger.info('Syncing commands for debug guild %d', guild) + await self.sync_application_commands(guild_id=guild) + self._logger.info( + 'Synced commands for %d guild(s)!', + len(self._config.debug_guild_ids), + ) + else: + await self.change_presence( + activity=Activity(name='/play', type=ActivityType.listening) + ) + + # Sync commands + self._logger.info('Syncing global commands...') + await self.sync_application_commands() + self._logger.info('Synced commands!') + + async def on_application_command_error(self, itx: Interaction, error: Exception): + """ + Called when an error occurs while processing an interaction. + """ + embed = create_error_embed(str(error)) + + # Check if we can reply to this interaction + try: + if itx.response.is_done(): + if isinstance(itx.channel, PartialMessageable): + await itx.channel.send(embed=embed) + else: + await itx.response.send_message(embed=embed) + except NotFound: + self._logger.warning( + 'Error 404 while sending error msg for interaction %d', itx.id + ) + + async def on_jockey_disconnect(self, jockey: 'Jockey'): + """ + Called when a player disconnects from voice. + """ + self._logger.debug('Jockey disconnected from voice in %s', jockey.guild.name) + + # Clear tasks for this guild + if jockey.guild.id in self._tasks: + for task in self._tasks[jockey.guild.id]: + task.cancel() + del self._tasks[jockey.guild.id] + + async def on_node_ready(self, node: 'Node'): + """ + Called when a Lavalink node is connected and ready. + """ + self._logger.info("Connected to Lavalink node `%s'", node.label) + + # Store session ID in database + if node.session_id is not None: + try: + old_id = self.database.get_session_id(node.label) + except (OperationalError, TypeError): + old_id = None + + if old_id is not None and old_id != node.session_id: + self._logger.debug( + "Replacing old session ID `%s' for node `%s'", old_id, node.label + ) + self.database.set_session_id(node.label, node.session_id) + + async def on_track_start(self, event: 'TrackStartEvent[Jockey]'): + """ + Called when a track starts playing. + """ + guild = event.player.guild + self._logger.info("Started playing `%s' in %s", event.track.title, guild.name) + + # Send now playing embed + try: + await self.send_now_playing(event) + except EndOfQueueError: + self._logger.warning('Got track_start event for idle player in %s', guild.name) + return + + # Get queue manager and node + q_mgr = event.player.queue_manager + node = event.player.node + + # Check if Deezer is enabled for this node + assert self._config is not None + deezer_enabled = self._config.lavalink_nodes[node.label].deezer + + # Prefetch the next track in the background + if self._config.match_ahead: + try: + _, next_track = q_mgr.next_track + except EndOfQueueError: + return + if next_track.lavalink_track is not None: + return + + self._logger.debug("Matching next track `%s' in the background", next_track.title) + task = get_event_loop().create_task( + match_ahead( + self._logger, + node, + next_track, + deezer_enabled=deezer_enabled, + in_place=True, + lookup_mbid=self._config.lastfm_enabled, + ) + ) + + # Store task so it can be cancelled if the player disconnects + if guild.id not in self._tasks: + self._tasks[guild.id] = [] + task.add_done_callback(lambda _: self._tasks[guild.id].remove(task)) + self._tasks[guild.id].append(task) + + async def on_track_end(self, event: 'TrackEndEvent[Jockey]'): + """ + Called when a track ends. + """ + if event.reason == EndReason.REPLACED: + self._logger.warning( + "Skipped `%s' in %s", event.track.title, event.player.guild.name + ) + elif event.reason == EndReason.FINISHED: + # Play next track in queue + self._logger.info( + "Finished playing `%s' in %s", + event.track.title, + event.player.guild.name, + ) + await event.player.skip() + elif event.reason == EndReason.STOPPED: + self._logger.info('Stopped player in %s', event.player.guild.name) + elif event.reason == EndReason.LOAD_FAILED: + self._logger.critical( + "Failed to load `%s' in %s", event.track.title, event.player.guild.name + ) + + # Call load failed hook + await event.player.on_load_failed(event.track) + else: + self._logger.error( + "Unhandled %s in %s for `%s'", + event.reason, + event.player.guild.name, + event.track.title, + ) + + ##################### + # Utility functions # + ##################### + + def get_scrobbler(self, user_id: int) -> Optional['Scrobbler']: + """ + Gets a Last.fm scrobbler instance for the specified user. + """ + assert self._config is not None and self._db is not None + + # Check if user is authenticated with Last.fm + creds = self._db.get_lastfm_credentials(user_id) + if creds is None: + if user_id in self._scrobblers: + # User must have unlinked their account, so delete the cached scrobbler + del self._scrobblers[user_id] + + return None + + # Check if a scrobbler already exists + if user_id not in self._scrobblers: + # Create scrobbler + self._scrobblers[user_id] = Scrobbler(self._config, creds, self._scrobbler_logger) + + return self._scrobblers[user_id] + + def get_spotify_client(self, user_id: int) -> Optional[PrivateSpotify]: + """ + Gets a Spotify client instance for the specified user. + """ + assert self._config is not None and self._db is not None + + # Try to get credentials + creds = self._db.get_oauth('spotify', user_id) + if creds is None: + # Check if there is a cached client for this user + if user_id in self._spotify_clients: + # User must have unlinked their account, so delete the cached client + del self._spotify_clients[user_id] + + raise ValueError( + f'Please link your Spotify account [here.]({self._config.base_url})' + ) + + # Check if a client already exists + if user_id not in self._spotify_clients: + self._spotify_clients[user_id] = PrivateSpotify( + config=self._config, database=self._db, credentials=creds + ) + self._logger.debug('Created Spotify client for user %d', user_id) + + return self._spotify_clients[user_id] + + def set_status_channel(self, guild_id: int, channel: 'StatusChannel'): + """ + Sets the status channel for the specified guild, which is used to send + now playing messages and announcements. + """ + # If channel is None, remove the status channel + if channel is None: + del self._status_channels[guild_id] + + self._status_channels[guild_id] = channel + self.database.set_status_channel(guild_id, -1 if channel is None else channel.id) + + def get_status_channel(self, guild_id: int) -> Optional['StatusChannel']: + """ + Gets the status channel for the specified guild. + """ + # Check if status channel is cached + if guild_id in self._status_channels: + return self._status_channels[guild_id] + + # Get status channel ID from bot.database + channel_id = -1 + try: + channel_id = self.database.get_status_channel(guild_id) + except OperationalError: + self._logger.warning( + 'Failed to get status channel ID for guild %d from bot.database', guild_id + ) + + # Get status channel from ID + if channel_id != -1: + channel = self.get_channel(channel_id) + if channel is None: + self._logger.error('Failed to get status channel for guild %d', guild_id) + elif not isinstance(channel, (TextChannel, StageChannel, Thread)): + self._logger.error('Status channel for guild %d is not Messageable', guild_id) + else: + self._status_channels[guild_id] = channel + return channel + + return None + + def init_config(self, config: 'Config'): + """ + Initialize the bot with a config. + """ + self._config = config + self._db = Database(config.db_file) + self._spotify_client = Spotify( + client_id=config.spotify_client_id, + client_secret=config.spotify_client_secret, + ) + + async def init_pool(self): + """ + Initialize the Lavalink node pool. + """ + if self._config is None: + raise RuntimeError('Cannot initialize Lavalink without a config') + nodes = self._config.lavalink_nodes + + # Add local node + for node in nodes.values(): + # Try to match regions against enum + regions = [] + for region in node.regions: + regions.append(VoiceRegion(region)) + + # Get session ID from bot.database + try: + session_id = self.database.get_session_id(node.id) + except (OperationalError, TypeError): + session_id = None + self._logger.debug("No session ID for node `%s'", node.id) + else: + self._logger.debug("Using session ID `%s' for node `%s'", session_id, node.id) + + try: + await self._pool.create_node( + host=node.host, + port=node.port, + password=node.password, + regions=regions, + resuming_session_id=session_id, + label=node.id, + secure=node.secure, + ) + except ClientConnectorError: + self._logger.error("Lavalink node `%s' refused connection", node.id) + + # Check if we have any nodes + if len(self._pool.nodes) == 0: + self._logger.critical('No Lavalink nodes available') + + self._pool_initialized = True + + async def send_now_playing(self, event: 'TrackStartEvent[Jockey]'): + """ + Send a now playing message for the specified track start event. + """ + guild_id = event.player.guild.id + channel = self.get_status_channel(guild_id) + if channel is None: + raise ValueError(f'Status channel has not been set for guild {guild_id}') + + # Delete last now playing message, if it exists + last_msg_id = self.database.get_now_playing(guild_id) + if last_msg_id != -1: + try: + last_msg = await channel.fetch_message(last_msg_id) + await last_msg.delete() + except (Forbidden, HTTPException, NotFound): + pass + + # Send now playing embed + current_track = event.player.queue_manager.current + embed = event.player.now_playing(event.track) + view = NowPlayingView(self, event.player, current_track.spotify_id) + + # Send message silently + flags = MessageFlags() + flags.suppress_notifications = True # pylint: disable=assigning-non-slot + msg = await channel.send(embed=embed, view=view, flags=flags) + + # Save now playing message ID + self.database.set_now_playing(guild_id, msg.id) diff --git a/bot/utils/config.py b/bot/utils/config.py new file mode 100644 index 0000000..8c7330e --- /dev/null +++ b/bot/utils/config.py @@ -0,0 +1,202 @@ +""" +Configuration parser. + +This module parses the configuration file and environment variables and +provides a single object with the synthesized configuration values, +where the environment variables take precedence over the config file. +""" + +from os import environ +from os.path import isfile +from typing import Dict + +from yaml import safe_load + +from bot.models.config import Config, LavalinkNode + +DATABASE_FILE = None +DISCORD_TOKEN = None +SPOTIFY_CLIENT_ID = None +SPOTIFY_CLIENT_SECRET = None +MATCH_AHEAD = False +ENABLE_SERVER = False +SERVER_PORT = 8080 +SERVER_BASE_URL = None +SERVER_JWT_SECRET = None +DISCORD_OAUTH_ID = None +DISCORD_OAUTH_SECRET = None +LASTFM_API_KEY = None +LASTFM_SHARED_SECRET = None +LAVALINK_NODES: Dict[str, LavalinkNode] = {} +SENTRY_DSN = None +SENTRY_ENV = None +REDIS_HOST = None +REDIS_PORT = -1 +REDIS_PASSWORD = None +DEBUG_ENABLED = False +DEBUG_GUILDS = None +REENQUEUE_PAUSED = False + +# Parse config file if it exists +if isfile('config.yml'): + with open('config.yml', encoding='UTF-8') as f: + try: + config_file = safe_load(f) + except Exception as e: + raise ValueError(f'Error parsing config.yml: {e}') from e + + # Get config values + try: + # Read config from config.yml + DATABASE_FILE = config_file['bot']['database'] + DISCORD_TOKEN = config_file['bot']['discord_token'] + SPOTIFY_CLIENT_ID = config_file['spotify']['client_id'] + SPOTIFY_CLIENT_SECRET = config_file['spotify']['client_secret'] + + # Parse Lavalink nodes from config.yml + for node in config_file['lavalink']: + lavalink_node = LavalinkNode( + id=node['id'], + password=node['password'], + host=node['server'], + port=node['port'], + regions=node['regions'], + secure=node.get('secure', False), + ) + + # Add optional config values + if 'deezer' in node: + lavalink_node.deezer = node['deezer'] + + LAVALINK_NODES[node['id']] = lavalink_node + + # Add optional config values + MATCH_AHEAD = config_file['bot'].get('match_ahead', False) + REENQUEUE_PAUSED = config_file['bot'].get('reenqueue_paused', False) + if 'server' in config_file: + ENABLE_SERVER = config_file['server']['enabled'] + SERVER_PORT = config_file['server'].get('port', 8080) + SERVER_BASE_URL = config_file['server'].get('base_url', None) + SERVER_JWT_SECRET = config_file['server'].get('jwt_secret', None) + DISCORD_OAUTH_ID = config_file['server'].get('oauth_id', None) + DISCORD_OAUTH_SECRET = config_file['server'].get('oauth_secret', None) + if 'lastfm' in config_file: + LASTFM_API_KEY = config_file['lastfm']['api_key'] + LASTFM_SHARED_SECRET = config_file['lastfm']['shared_secret'] + if 'debug' in config_file['bot']: + DEBUG_ENABLED = config_file['bot']['debug']['enabled'] + DEBUG_GUILDS = config_file['bot']['debug']['guild_ids'] + if 'sentry' in config_file: + SENTRY_DSN = config_file['sentry']['dsn'] + SENTRY_ENV = config_file['sentry']['environment'] + if 'redis' in config_file: + REDIS_HOST = config_file['redis']['host'] + REDIS_PORT = config_file['redis']['port'] + REDIS_PASSWORD = config_file['redis']['password'] + except KeyError as e: + raise RuntimeError(f'Config missing from config.yml: {e.args[0]}') from e + + +# Override config from environment variables +DATABASE_FILE = environ.get('BLANCO_DB_FILE', DATABASE_FILE) +DISCORD_TOKEN = environ.get('BLANCO_TOKEN', DISCORD_TOKEN) +LASTFM_API_KEY = environ.get('BLANCO_LASTFM_KEY', LASTFM_API_KEY) +LASTFM_SHARED_SECRET = environ.get('BLANCO_LASTFM_SECRET', LASTFM_SHARED_SECRET) +SPOTIFY_CLIENT_ID = environ.get('BLANCO_SPOTIFY_ID', SPOTIFY_CLIENT_ID) +SPOTIFY_CLIENT_SECRET = environ.get('BLANCO_SPOTIFY_SECRET', SPOTIFY_CLIENT_SECRET) +SENTRY_DSN = environ.get('BLANCO_SENTRY_DSN', SENTRY_DSN) +SENTRY_ENV = environ.get('BLANCO_SENTRY_ENV', SENTRY_ENV) +REDIS_HOST = environ.get('BLANCO_REDIS_HOST', REDIS_HOST) +REDIS_PORT = int(environ.get('BLANCO_REDIS_PORT', REDIS_PORT)) +REDIS_PASSWORD = environ.get('BLANCO_REDIS_PASSWORD', REDIS_PASSWORD) +if 'BLANCO_REENQUEUE_PAUSED' in environ: + REENQUEUE_PAUSED = environ['BLANCO_REENQUEUE_PAUSED'].lower() == 'true' +if 'BLANCO_MATCH_AHEAD' in environ: + MATCH_AHEAD = environ['BLANCO_MATCH_AHEAD'].lower() == 'true' +if 'BLANCO_DEBUG' in environ: + DEBUG_ENABLED = environ['BLANCO_DEBUG'].lower() == 'true' + DEBUG_GUILDS = [int(id) for id in environ['BLANCO_DEBUG_GUILDS'].split(',')] +if 'BLANCO_ENABLE_SERVER' in environ: + ENABLE_SERVER = environ['BLANCO_ENABLE_SERVER'].lower() == 'true' + SERVER_PORT = int(environ.get('BLANCO_SERVER_PORT', SERVER_PORT)) + SERVER_BASE_URL = environ.get('BLANCO_BASE_URL', SERVER_BASE_URL) + SERVER_JWT_SECRET = environ.get('BLANCO_JWT_SECRET', SERVER_JWT_SECRET) + DISCORD_OAUTH_ID = environ.get('BLANCO_OAUTH_ID', DISCORD_OAUTH_ID) + DISCORD_OAUTH_SECRET = environ.get('BLANCO_OAUTH_SECRET', DISCORD_OAUTH_SECRET) + +# Parse Lavalink nodes from environment variables +i = 1 +while True: + try: + credentials, host = environ[f'BLANCO_NODE_{i}'].split('@') + node_id, password = credentials.split(':') + server, port = host.split(':') + regions = environ[f'BLANCO_NODE_{i}_REGIONS'].split(',') + secure = environ.get(f'BLANCO_NODE_{i}_SECURE', 'false').lower() == 'true' + deezer = environ.get(f'BLANCO_NODE_{i}_DEEZER', 'false').lower() == 'true' + except KeyError as e: + missing_key = e.args[0] + if missing_key == f'BLANCO_NODE_{i}': + if len(LAVALINK_NODES) == 0: + raise ValueError('No Lavalink nodes specified') from e + break + + if missing_key == f'BLANCO_NODE_{i}_REGIONS': + raise ValueError(f'No regions specified for Lavalink node {i}') from e + + break + else: + # Add node to list + LAVALINK_NODES[node_id] = LavalinkNode( + id=node_id, + password=password, + host=server, + port=int(port), + regions=regions, + secure=secure, + deezer=deezer, + ) + + i += 1 + + +# Final checks +if DATABASE_FILE is None: + raise ValueError('No database file specified') +if DISCORD_TOKEN is None: + raise ValueError('No Discord token specified') +if SPOTIFY_CLIENT_ID is None: + raise ValueError('No Spotify client ID specified') +if SPOTIFY_CLIENT_SECRET is None: + raise ValueError('No Spotify client secret specified') +if ENABLE_SERVER and ( + DISCORD_OAUTH_ID is None + or DISCORD_OAUTH_SECRET is None + or SERVER_BASE_URL is None + or SERVER_JWT_SECRET is None +): + raise ValueError( + 'Discord OAuth ID, secret, base URL, and JWT secret must be specified to enable server' + ) + + +# Create config object +config = Config( + db_file=DATABASE_FILE, + discord_token=DISCORD_TOKEN, + spotify_client_id=SPOTIFY_CLIENT_ID, + spotify_client_secret=SPOTIFY_CLIENT_SECRET, + lavalink_nodes=LAVALINK_NODES, + debug_enabled=DEBUG_ENABLED, + debug_guild_ids=DEBUG_GUILDS, + enable_server=ENABLE_SERVER, + match_ahead=MATCH_AHEAD, + server_port=SERVER_PORT, + base_url=SERVER_BASE_URL, + discord_oauth_id=DISCORD_OAUTH_ID, + discord_oauth_secret=DISCORD_OAUTH_SECRET, + jwt_secret=SERVER_JWT_SECRET, + lastfm_api_key=LASTFM_API_KEY, + lastfm_shared_secret=LASTFM_SHARED_SECRET, + reenqueue_paused=REENQUEUE_PAUSED, +) diff --git a/utils/constants.py b/bot/utils/constants.py similarity index 56% rename from utils/constants.py rename to bot/utils/constants.py index 7bb386e..aef3ee0 100644 --- a/utils/constants.py +++ b/bot/utils/constants.py @@ -4,60 +4,45 @@ from yarl import URL -RELEASE = '0.0.0-unknown' # This is replaced by the release tag during CI/CD +RELEASE = '0.0.0-unknown' # This is replaced by the release tag during CI/CD USER_AGENT = f'blanco-bot/{RELEASE} ( https://blanco.dantis.me )' -DISCORD_API_BASE_URL = URL.build( - scheme='https', - host='discord.com', - path='/api/v10' -) +DISCORD_API_BASE_URL = URL.build(scheme='https', host='discord.com', path='/api/v10') LASTFM_API_BASE_URL = URL.build( - scheme='https', - host='ws.audioscrobbler.com', - path='/2.0' + scheme='https', host='ws.audioscrobbler.com', path='/2.0' ) MUSICBRAINZ_API_BASE_URL = URL.build( - scheme='https', - host='musicbrainz.org', - path='/ws/2' + scheme='https', host='musicbrainz.org', path='/ws/2' ) SPOTIFY_ACCOUNTS_BASE_URL = URL.build( - scheme='https', - host='accounts.spotify.com', - path='/api' + scheme='https', host='accounts.spotify.com', path='/api' ) -SPOTIFY_API_BASE_URL = URL.build( - scheme='https', - host='api.spotify.com', - path='/v1' -) +SPOTIFY_API_BASE_URL = URL.build(scheme='https', host='api.spotify.com', path='/v1') # Results with these words in the title will be filtered out # unless the user's query also contains these words. BLACKLIST = ( - '3d' - '8d', - 'cover', - 'instrumental', - 'karaoke', - 'live', - 'loop', - 'mashup', - 'minus one', - 'performance', - 'piano', - 'remix', - 'rendition', - 'reverb', - 'slowed', - 'sped', - 'speed' + '3d' '8d', + 'cover', + 'instrumental', + 'karaoke', + 'live', + 'loop', + 'mashup', + 'minus one', + 'performance', + 'piano', + 'remix', + 'rendition', + 'reverb', + 'slowed', + 'sped', + 'speed', ) # A top search result below this threshold will not be considered for playback @@ -68,17 +53,19 @@ # A MusicBrainz recording whose duration exceeds this threshold will not be # considered for scrobbling and Blanco will either only scrobble if the track # has an ISRC or not at all. -DURATION_THRESHOLD = 10 * 1000 # 10 seconds +DURATION_THRESHOLD = 10 * 1000 # 10 seconds # Unpausing the player after this many seconds will cause Blanco # to re-enqueue the track and restart playback at the last known position # to work around a bug in the Lavalink unpausing logic. -UNPAUSE_THRESHOLD = 60 # 1 minute +UNPAUSE_THRESHOLD = 60 # 1 minute -SPOTIFY_403_ERR_MSG = ''.join([ +SPOTIFY_403_ERR_MSG = ''.join( + [ '**Error 403** encountered while trying to {}.\n', 'This is likely because this instance of Blanco uses Spotify API credentials ', 'that are in **development mode.** ', - 'See [this page](https://github.com/jareddantis-bots/blanco-bot/wiki/Prerequisites#a-note-on-development-mode) ', # pylint: disable=line-too-long - 'for more information.' -]) + 'See [this page](https://github.com/jareddantis-bots/blanco-bot/wiki/Prerequisites#a-note-on-development-mode) ', # pylint: disable=line-too-long + 'for more information.', + ] +) diff --git a/bot/utils/embeds.py b/bot/utils/embeds.py new file mode 100644 index 0000000..2b0e36e --- /dev/null +++ b/bot/utils/embeds.py @@ -0,0 +1,36 @@ +""" +Success and error embeds for the bot. +""" + +from typing import Optional + +from nextcord import Colour, Embed + +from bot.models.custom_embed import CustomEmbed + + +def create_error_embed(message: str) -> Embed: + """ + Create an error embed. + """ + embed = CustomEmbed(color=Colour.red(), title=':x:|Error', description=message) + return embed.get() + + +def create_success_embed( + title: Optional[str] = None, body: Optional[str] = None +) -> Embed: + """ + Create a success embed. + """ + if body is None: + if title is None: + raise ValueError('Either title or body must be specified') + + body = title + title = 'Success' + + embed = CustomEmbed( + color=Colour.green(), title=f':white_check_mark:|{title}', description=body + ) + return embed.get() diff --git a/bot/utils/exceptions.py b/bot/utils/exceptions.py new file mode 100644 index 0000000..2526cbf --- /dev/null +++ b/bot/utils/exceptions.py @@ -0,0 +1,123 @@ +""" +Custom exceptions for Blanco +""" + +from typing import Optional, Union + + +class BlancoException(Exception): + """ + Custom exception class for Blanco. + + Args: + - ephemeral (bool): Whether the error message should be ephemeral. + """ + + def __init__(self, message: Union[str, Exception], ephemeral: bool = False): + self.ephemeral = ephemeral + + if isinstance(message, Exception): + self.message = str(message) + else: + self.message = message + + super().__init__(self.message) + + def __str__(self) -> str: + return self.message + + +class EmptyQueueError(BlancoException): + """ + Raised when the queue is empty. + """ + + def __init__(self): + self.message = 'The queue is empty.' + super().__init__(self.message) + + +class EndOfQueueError(BlancoException): + """ + Raised when the end of the queue is reached. + """ + + def __init__(self, message: Optional[str] = None): + self.message = message or 'End of queue reached.' + super().__init__(self.message) + + +class JockeyError(BlancoException): + """ + Raised when an error warrants disconnection from the voice channel. + """ + + +class JockeyException(BlancoException): + """ + Raised when an error does not warrant disconnection from the voice channel. + """ + + +class LavalinkInvalidIdentifierError(BlancoException): + """ + Raised when an invalid identifier is passed to Lavalink. + """ + + def __init__(self, url, reason=None): + self.message = f'Error encountered while processing "{url}": `{reason}`' + super().__init__(self.message) + + +class LavalinkSearchError(BlancoException): + """ + Raised when Lavalink fails to search for a query. + """ + + def __init__(self, query, reason=None): + self.message = f'Could not search for "{query}" on YouTube. Reason: {reason}' + super().__init__(self.message) + + +class SpotifyInvalidURLError(BlancoException): + """ + Raised when an invalid Spotify link or URI is passed. + """ + + def __init__(self, url): + self.message = f'Invalid Spotify link or URI: {url}' + super().__init__(self.message) + + +class SpotifyNoResultsError(BlancoException): + """ + Raised when no results are found for a Spotify query. + """ + + def __init__(self, query): + self.message = f'No results found for "{query}" on Spotify.' + super().__init__(self.message) + + +class VoiceCommandError(BlancoException): + """ + Raised when a command that requires a voice channel is invoked outside of one. + """ + + +class BumpError(Exception): + """ + Raised when encountering an error while playing a bump. + """ + + +class BumpNotReadyError(Exception): + """ + Raised when it hasn't been long enough between bumps. + """ + + +class BumpNotEnabledError(Exception): + """ + Raised when bumps are not enabled in a guild. + """ diff --git a/bot/utils/fuzzy.py b/bot/utils/fuzzy.py new file mode 100644 index 0000000..e530ab2 --- /dev/null +++ b/bot/utils/fuzzy.py @@ -0,0 +1,105 @@ +""" +Utilities for fuzzy string matching. +""" + +from difflib import get_close_matches +from typing import List, Tuple, TypeVar + +from mafic import SearchType +from thefuzz import fuzz + +from .logger import create_logger + +LOGGER = create_logger('fuzzy') +T = TypeVar('T') + + +def check_similarity(actual: str, candidate: str) -> float: + """ + Checks the similarity between two strings. Meant for comparing + song titles and artists with search results. + + :param actual: The actual string. + :param candidate: The candidate string, i.e. from a search result. + :return: A float from 0 to 1, where 1 is a perfect match. + """ + actual_words = set(actual.lower().split(' ')) + candidate_words = set(candidate.lower().split(' ')) + intersection = actual_words.intersection(candidate_words) + difference = actual_words.difference(candidate_words) + + # Get words not in intersection + for word in difference: + # Look for close matches + close_matches = get_close_matches(word, candidate_words, cutoff=0.9) + if len(close_matches) > 0: + intersection.add(close_matches[0]) + + return len(intersection) / len(actual_words) + + +def check_similarity_weighted(actual: str, candidate: str, candidate_rank: int) -> int: + """ + Checks the similarity between two strings using a weighted average + of a given similarity score and the results of multiple fuzzy string + matching algorithms. Meant for refining search results that are + already ranked. + + :param actual: The actual string. + :param candidate: The candidate string, i.e. from a search result. + :param candidate_rank: The rank of the candidate, from 0 to 100. + :return: An integer from 0 to 100, where 100 is the closest match. + """ + naive = check_similarity(actual, candidate) * 100 + tsr = fuzz.token_set_ratio(actual, candidate) + tsor = fuzz.token_sort_ratio(actual, candidate) + ptsr = fuzz.partial_token_sort_ratio(actual, candidate) + + return int( + (naive * 0.7) + + (tsr * 0.12) + + (candidate_rank * 0.08) + + (tsor * 0.06) + + (ptsr * 0.04) + ) + + +def rank_results( + query: str, results: List[T], result_type: SearchType +) -> List[Tuple[T, int]]: + """ + Ranks search results based on similarity to a fuzzy query. + + :param query: The query to check against. + :param results: The results to rank. Can be mafic.Track, dataclass.SpotifyTrack, + or any object with a title and author string attribute. + :param result_type: The type of result. See ResultType. + :return: A list of tuples containing the result and its similarity to the query. + """ + # Rank results + similarities = [ + check_similarity_weighted( + query, + f'{result.title} {result.author}', # type: ignore + int(100 * (0.8**i)), + ) + for i, result in enumerate(results) + ] + ranked = sorted(zip(results, similarities), key=lambda x: x[1], reverse=True) + + # Print confidences for debugging + type_name = 'YouTube' + if result_type == SearchType.SPOTIFY_SEARCH: + type_name = 'Spotify' + elif result_type == SearchType.DEEZER_SEARCH: + type_name = 'Deezer' + LOGGER.debug('%s results and confidences for "%s":', type_name, query) + for result, confidence in ranked: + LOGGER.debug( + ' %3d %-20s %-25s', + confidence, + result.author[:20], # type: ignore + result.title[:25], # type: ignore + ) + + return ranked diff --git a/bot/utils/logger.py b/bot/utils/logger.py new file mode 100644 index 0000000..e003dd8 --- /dev/null +++ b/bot/utils/logger.py @@ -0,0 +1,93 @@ +""" +Custom logger module that supports ANSI color codes. +""" + +import logging +from typing import Optional + +import sentry_sdk +from sentry_sdk.integrations.logging import EventHandler + +from .config import DEBUG_ENABLED, SENTRY_DSN, SENTRY_ENV +from .constants import RELEASE + +# Log line format +DATE_FMT_STR = '%Y-%m-%d %H:%M:%S' +LOG_FMT_STR = ( + '{0}%(asctime)s {1}[%(levelname)s]{2} %(message)s (%(filename)s:%(lineno)d)' +) + +# ANSI terminal colors (for logging) +ANSI_BLUE = '\x1b[36;20m' +ANSI_GREEN = '\x1b[32;20m' +ANSI_GREY = '\x1b[37;1m' +ANSI_RED = '\x1b[31;20m' +ANSI_RED_BOLD = '\x1b[41;1m' +ANSI_YELLOW = '\x1b[33;20m' +ANSI_RESET = '\x1b[0m' +LOG_FMT_COLOR = { + logging.DEBUG: LOG_FMT_STR.format(ANSI_GREY, ANSI_GREEN, ANSI_RESET), + logging.INFO: LOG_FMT_STR.format(ANSI_GREY, ANSI_BLUE, ANSI_RESET), + logging.WARNING: LOG_FMT_STR.format(ANSI_GREY, ANSI_YELLOW, ANSI_RESET), + logging.ERROR: LOG_FMT_STR.format(ANSI_GREY, ANSI_RED, ANSI_RESET), + logging.CRITICAL: LOG_FMT_STR.format(ANSI_RED_BOLD, ANSI_RED_BOLD, ANSI_RESET), +} + + +# Initialize sentry +if SENTRY_DSN is not None and SENTRY_ENV is not None: + sentry_sdk.init( + dsn=SENTRY_DSN, environment=SENTRY_ENV, release=RELEASE, traces_sample_rate=1.0 + ) + + +class ColorFormatter(logging.Formatter): + """ + Custom logging formatter that supports ANSI color codes. + + Adapted from https://stackoverflow.com/a/384125 + """ + + def __init__(self, fmt: Optional[str] = None, datefmt: Optional[str] = None): + logging.Formatter.__init__(self, fmt, datefmt) + + def format(self, record: logging.LogRecord): + log_fmt = LOG_FMT_COLOR.get(record.levelno) + formatter = logging.Formatter(fmt=log_fmt, datefmt=DATE_FMT_STR) + return formatter.format(record) + + +def create_logger(name: str) -> logging.Logger: + """ + Creates a logger with the given name and returns it. + + :param name: Name of the logger + :return: Logger object + """ + logger = logging.getLogger(name) + if logger.hasHandlers(): + logger.handlers.clear() + + # Set level + level = logging.DEBUG if DEBUG_ENABLED else logging.INFO + logger.setLevel(level) + + # Set level names + logging.addLevelName(logging.DEBUG, 'DBUG') + logging.addLevelName(logging.INFO, 'INFO') + logging.addLevelName(logging.WARNING, 'WARN') + logging.addLevelName(logging.ERROR, 'ERR!') + logging.addLevelName(logging.CRITICAL, 'CRIT') + + # Add color formatter + color_handler = logging.StreamHandler() + color_handler.setFormatter(ColorFormatter()) + logger.addHandler(color_handler) + + # Add Sentry handler + if SENTRY_DSN is not None and SENTRY_ENV is not None: + sentry_handler = EventHandler() + sentry_handler.setLevel(logging.ERROR) + logger.addHandler(sentry_handler) + + return logger diff --git a/bot/utils/musicbrainz.py b/bot/utils/musicbrainz.py new file mode 100644 index 0000000..d910e44 --- /dev/null +++ b/bot/utils/musicbrainz.py @@ -0,0 +1,241 @@ +""" +Utility functions for interfacing with the MusicBrainz API. +""" + +from typing import TYPE_CHECKING, Optional, Tuple + +from ratelimit import limits, sleep_and_retry +from requests import HTTPError, Timeout, get +from requests.status_codes import codes + +from bot.database.redis import REDIS + +from .constants import DURATION_THRESHOLD, MUSICBRAINZ_API_BASE_URL, USER_AGENT +from .fuzzy import check_similarity_weighted +from .logger import create_logger + +if TYPE_CHECKING: + from bot.models.queue_item import QueueItem + + +LOGGER = create_logger('musicbrainz') + + +@limits(calls=25, period=1) +@sleep_and_retry +def annotate_track( # noqa: PLR0912 + track: 'QueueItem', *, in_place: bool = True +) -> Optional[Tuple[str | None, str | None]]: + """ + Annotates a track with MusicBrainz ID and ISRC if they are not already present. + + Can be called up to 25 times per second, and will sleep and retry if this limit + is exceeded. This is because MusicBrainz has a rate limit of 50 requests per second, + but we need to make at most two requests per track (one to search for the track by ISRC, + and one to search for it by title and artist if the ISRC search fails). + + TODO: Refactor to have fewer branches. + + :param track: The track to annotate. Must be an instance of + dataclass.queue_item.QueueItem. + :param in_place: Whether to modify the track in place. If False, a tuple containing + the MusicBrainz ID and ISRC will be returned instead. + """ + # Check if track has already been annotated + if track.is_annotated: + return track.mbid, track.isrc + + # Check if information is already cached + mbid = track.mbid + isrc = track.isrc + mbid_cached = False + isrc_cached = False + if REDIS is not None: + # Check for cached MusicBrainz ID + if mbid is None and track.spotify_id is not None: + mbid = REDIS.get_mbid(track.spotify_id) + if mbid is not None: + mbid_cached = True + + # Check for cached ISRC + if isrc is None and track.spotify_id is not None: + isrc = REDIS.get_isrc(track.spotify_id) + if isrc is not None: + isrc_cached = True + + # Lookup MusicBrainz ID and ISRC if not cached + if mbid is None: + if isrc is not None: + LOGGER.info("Looking up MusicBrainz ID for `%s'", track.title) + try: + mbid = mb_lookup_isrc(track) + except HTTPError as err: + if err.response is not None and err.response.status_code == codes.not_found: + mbid, isrc = mb_lookup(track) + else: + raise + else: + LOGGER.info("Looking up MusicBrainz ID and ISRC for `%s'", track.title) + mbid, isrc = mb_lookup(track) + + # Log MusicBrainz ID if found + if track.mbid is None and mbid is not None: + if in_place: + track.mbid = mbid + if REDIS is not None and track.spotify_id is not None: + REDIS.set_mbid(track.spotify_id, mbid) + + LOGGER.info( + "Found %sMusicBrainz ID `%s' for `%s'", + 'cached ' if mbid_cached else '', + track.mbid, + track.title, + ) + + # Log ISRC if found + if track.isrc is None and isrc is not None: + if in_place: + track.isrc = isrc + if REDIS is not None and track.spotify_id is not None: + REDIS.set_isrc(track.spotify_id, isrc) + + LOGGER.info( + "Found %sISRC `%s' for `%s'", + 'cached ' if isrc_cached else '', + isrc, + track.title, + ) + + if in_place: + # Signal that the track has been annotated + track.is_annotated = True + + return mbid, isrc + + +def mb_lookup(track: 'QueueItem') -> Tuple[str | None, str | None]: + """ + Looks up a track on MusicBrainz and returns a tuple containing + a matching MusicBrainz ID and ISRC, if available. + """ + # Build MusicBrainz query + assert track.title is not None and track.artist is not None + query = f'recording:{track.title} && artist:{track.artist}' + if track.album is not None: + query += f' && release:{track.album}' + + # Perform search + response = get( + str(MUSICBRAINZ_API_BASE_URL / 'recording'), + headers={'User-Agent': USER_AGENT, 'Accept': 'application/json'}, + params={'query': query, 'limit': '10', 'inc': 'isrcs', 'fmt': 'json'}, + timeout=5.0, + ) + try: + response.raise_for_status() + except HTTPError as err: + LOGGER.error( + "Error %d looking up track `%s' on MusicBrainz.\n%s", + err.response.status_code if err.response is not None else -1, + track.title, + err, + ) + raise + except Timeout: + LOGGER.warning("Timed out while looking up track `%s' on MusicBrainz", track.title) + return None, None + + # Parse response + parsed = response.json() + if len(parsed['recordings']) == 0: + LOGGER.error("No results found for track `%s' on MusicBrainz", track.title) + return None, None + + # Filter by duration difference + results = [ + result + for result in parsed['recordings'] + if 'length' in result + and abs(track.duration - result['length']) < DURATION_THRESHOLD + ] + if len(results) == 0: + LOGGER.error("No results found for track `%s' on MusicBrainz", track.title) + return None, None + + # Sort remaining results by similarity and ISRC presence + query = f'{track.title} {track.artist}' + best_match = results[0] + if len(results) > 1: + similarities = [ + check_similarity_weighted( + query, + f"{result['title']} {result['artist-credit'][0]['name']}", + result['score'], + ) + for result in results + ] + isrc_presence = [ + 'isrcs' in result and len(result['isrcs']) > 0 for result in results + ] + ranked = sorted( + zip(results, similarities, isrc_presence), + key=lambda x: (x[1], x[2]), + reverse=True, + ) + best_match = ranked[0][0] + + # Print confidences for debugging + LOGGER.debug('MusicBrainz results and confidences for "%s":', query) + for result, confidence, has_isrc in ranked: + LOGGER.debug( + ' %3d %-20s %-20s isrc=%s', + confidence, + result['artist-credit'][0]['name'][:20], + result['title'][:20], + has_isrc, + ) + + # Extract ID and ISRC + mbid = best_match['id'] + isrc = None + if 'isrcs' in best_match and len(best_match['isrcs']) > 0: + isrc = best_match['isrcs'][0] + + return mbid, isrc + + +def mb_lookup_isrc(track: 'QueueItem') -> Optional[str]: + """ + Looks up a track by its ISRC on MusicBrainz and returns a MusicBrainz ID. + """ + assert track.isrc is not None + response = get( + str(MUSICBRAINZ_API_BASE_URL / 'isrc' / track.isrc.upper()), + headers={'User-Agent': USER_AGENT, 'Accept': 'application/json'}, + params={'fmt': 'json'}, + timeout=5.0, + ) + + try: + response.raise_for_status() + except HTTPError: + LOGGER.error("ISRC %s (`%s') is not on MusicBrainz", track.isrc, track.title) + raise + except Timeout: + LOGGER.warning( + "Timed out while looking up track `%s' (%s) on MusicBrainz", + track.title, + track.isrc, + ) + return None + + parsed = response.json() + if len(parsed['recordings']) == 0: + LOGGER.error( + "No results found for track `%s' (%s) on MusicBrainz", + track.title, + track.isrc, + ) + return None + + return parsed['recordings'][0]['id'] diff --git a/bot/utils/paginator.py b/bot/utils/paginator.py new file mode 100644 index 0000000..90b50e6 --- /dev/null +++ b/bot/utils/paginator.py @@ -0,0 +1,128 @@ +""" +Paginator class for sending embeds with controls to change pages. + +Based on https://github.com/toxicrecker/DiscordUtils/blob/master/DiscordUtils/Pagination.py +but with support for custom home page and adapted for Interaction responses. +""" + +from asyncio import sleep +from itertools import islice +from typing import TYPE_CHECKING, Any, Callable, Generator, List, Optional + +from nextcord import Embed, Forbidden, HTTPException, Interaction + +from bot.views.paginator import PaginatorView + +if TYPE_CHECKING: + from nextcord import Message + + +def list_chunks(data: List[Any]) -> Generator[List[Any], Any, Any]: + """ + Yield 10-element chunks of a list. Used for pagination. + """ + for i in range(0, len(data), 10): + yield list(islice(data, i, i + 10)) + + +class Paginator: + """ + Paginator class for sending embeds with controls to change pages. + """ + + def __init__(self, itx: Interaction): + self.current = 0 + self.embeds: List[Embed] = [] + self.home = 0 + self.itx = itx + self.msg: Optional['Message'] = None + self.original_timeout = 0 + self.timeout = 0 + + async def run( + self, + embeds: List[Embed], + start: int = 0, + timeout: int = 0, + callback: Optional[Callable[[int], None]] = None, + ): + """ + Sends the given embeds and adds controls to change pages if there's more than one. + """ + # If there's only one page, just send it as is + if len(embeds) == 1: + msg = await self.itx.followup.send(embed=embeds[0], wait=True) + if callback is not None: + callback(msg.id) + return None + + timeout = timeout if timeout > 0 else 60 + self.original_timeout = timeout + self.timeout = timeout + + # Add footer and timestamp to every embed + for i, embed in enumerate(embeds): + embed.timestamp = self.itx.created_at + embed.set_footer(text=f'Page {i + 1} of {len(embeds)}') + + # Send initial embed and call callback with message ID + self.home = start + self.current = start + self.embeds = embeds + msg = await self.itx.followup.send( + embed=self.embeds[start], view=PaginatorView(self), wait=True + ) + self.msg = await msg.channel.fetch_message(msg.id) + if callback is not None: + callback(msg.id) + + # Remove controls if inactive for more than timeout amount + while True: + await sleep(1) + self.timeout -= 1 + if self.timeout <= 0: + return await self.msg.edit(view=None) + + async def _switch_page(self, new_page: int) -> Optional['Message']: + if self.msg is None: + return None + + self.current = new_page + try: + msg = await self.msg.edit(embed=self.embeds[self.current]) + except (Forbidden, HTTPException): + return None + + self.timeout = self.original_timeout + return msg + + async def first_page(self): + """ + Switches to the first page. + """ + await self._switch_page(0) + + async def previous_page(self): + """ + Switches to the previous page. + """ + await self._switch_page(self.current - 1) + + async def home_page(self): + """ + Switches to the home page, which is the first page by default, + but can be changed with the `start` parameter in `Paginator.run()`. + """ + await self._switch_page(self.home) + + async def next_page(self): + """ + Switches to the next page. + """ + await self._switch_page(self.current + 1) + + async def last_page(self): + """ + Switches to the last page. + """ + await self._switch_page(len(self.embeds) - 1) diff --git a/bot/utils/player_checks.py b/bot/utils/player_checks.py new file mode 100644 index 0000000..18d2243 --- /dev/null +++ b/bot/utils/player_checks.py @@ -0,0 +1,66 @@ +""" +Check functions for the music player. These are called before +the player is instantiated, and are used to check if the bot +can connect and play music in a channel. +""" + +from typing import TYPE_CHECKING + +from nextcord import Interaction, Member + +from bot.utils.exceptions import VoiceCommandError + +if TYPE_CHECKING: + from bot.cogs.player.jockey import Jockey + + +def check_mutual_voice(itx: Interaction, slash: bool = True) -> bool: + """ + This check ensures that the bot and command author are in the same voice channel. + + :param itx: The interaction object. + :param slash: Whether this check is being called as part of a slash command. See now_playing.py. + """ + + # Check that the user is in a voice channel in the first place. + if itx.guild is not None and isinstance(itx.user, Member): + if not itx.user.voice or not itx.user.voice.channel: + raise VoiceCommandError('Join a voice channel first.') + else: + # Not allowed in DMs + raise VoiceCommandError('You can only use this command in a server.') + + if itx.application_command is None and not slash: + raise VoiceCommandError('Abnormal invocation of command. Please try again.') + + player: 'Jockey' = itx.guild.voice_client # type: ignore + if player is None and slash: + assert itx.application_command is not None + if itx.application_command.name == 'play': + # The /play command causes the bot to connect to voice, + # so we don't have to worry about the rest of the checks here. + return True + raise VoiceCommandError('Please `/play` something first before using this command.') + + voice_channel = itx.user.voice.channel + if not player.is_connected(): + # Bot needs to already be in voice channel to pause, unpause, skip etc. + if itx.application_command is not None and itx.application_command.name != 'play': + raise VoiceCommandError("I'm not connected to voice.") + + # Bot needs to have permissions to connect to voice. + permissions = voice_channel.permissions_for(itx.guild.me) + if not permissions.connect or not permissions.speak: + raise VoiceCommandError( + 'I need the `CONNECT` and `SPEAK` permissions to play music.' + ) + + # Bot needs to connect to a channel that isn't full. + if voice_channel.user_limit and voice_channel.user_limit <= len( + voice_channel.members + ): + raise VoiceCommandError('Your voice channel is full.') + elif int(player.channel.id) != voice_channel.id: # type: ignore + raise VoiceCommandError('You need to be in my voice channel.') + + return True diff --git a/bot/utils/scrobbler.py b/bot/utils/scrobbler.py new file mode 100644 index 0000000..c385d76 --- /dev/null +++ b/bot/utils/scrobbler.py @@ -0,0 +1,75 @@ +""" +Last.fm scrobbling client. +""" + +from datetime import datetime +from time import mktime +from typing import TYPE_CHECKING + +import pylast + +if TYPE_CHECKING: + from logging import Logger + +from bot.models.config import Config +from bot.models.oauth import LastfmAuth +from bot.models.queue_item import QueueItem + + +class Scrobbler: + """ + Scrobbler class for scrobbling songs to Last.fm. + Meant for single use, i.e., one instance per user per listening session. + """ + + def __init__(self, config: 'Config', creds: 'LastfmAuth', logger: 'Logger'): + if config.lastfm_api_key is None or config.lastfm_shared_secret is None: + raise ValueError('Last.fm API key and/or shared secret not set.') + self._user_id = creds.user_id + + # Create Network object + self._net = pylast.LastFMNetwork( + api_key=config.lastfm_api_key, api_secret=config.lastfm_shared_secret + ) + + # Set session key + self._net.session_key = creds.session_key + + # Logger + self._logger = logger + self._logger.debug('Created scrobbler for user %d', creds.user_id) + + def scrobble(self, track: 'QueueItem'): + """ + Scrobbles a QueueItem from the music player. + """ + timestamp = track.start_time + if timestamp is None: + timestamp = int(mktime(datetime.now().timetuple())) + + duration = None + if track.duration is not None: + duration = track.duration // 1000 + + # Warn if MBID is not set + if track.mbid is None: + self._logger.warning( + "MBID not set for track `%s'; scrobble might not be accurate.", + track.title, + ) + + try: + self._net.scrobble( + artist=track.artist, + title=track.title, + timestamp=timestamp, + duration=duration, + mbid=track.mbid, + ) + except pylast.PyLastError as err: + self._logger.error( + "Error scrobbling `%s' for user %d: %s", track.title, self._user_id, err + ) + raise + + self._logger.debug("Scrobbled `%s' for user %d", track.title, self._user_id) diff --git a/bot/utils/spotify_client.py b/bot/utils/spotify_client.py new file mode 100644 index 0000000..e070db9 --- /dev/null +++ b/bot/utils/spotify_client.py @@ -0,0 +1,365 @@ +""" +Wrapper for the spotipy Spotify client which supports pagination by default. +""" + +from typing import Any, Dict, List, Optional, Tuple + +import spotipy +from requests.exceptions import ConnectionError as RequestsConnectionError +from tenacity import ( + RetryCallState, + retry, + retry_if_exception_type, + stop_after_attempt, + wait_fixed, + wait_random, +) + +from bot.database.redis import REDIS +from bot.models.spotify import SpotifyResult, SpotifyTrack + +from .constants import BLACKLIST +from .exceptions import SpotifyInvalidURLError, SpotifyNoResultsError +from .logger import create_logger +from .time import human_readable_time + +# Retry logger +RETRY_LOGGER = create_logger('spotify_retry') + + +def log_call(retry_state: RetryCallState) -> None: + """ + Logs an API call + """ + RETRY_LOGGER.debug( + 'Calling Spotify API: %s(%s, %s)', + getattr(retry_state.fn, '__name__', repr(retry_state.fn)), + retry_state.args, + retry_state.kwargs, + ) + + +def log_failure(retry_state: RetryCallState) -> None: + """ + Logs a retry attempt. + """ + func_name = getattr(retry_state.fn, '__name__', repr(retry_state.fn)) + + # Log outcome + if retry_state.outcome is not None: + RETRY_LOGGER.debug('%s() failed: %s', func_name, retry_state.outcome) + RETRY_LOGGER.debug(' Exception: %s', retry_state.outcome.exception()) + RETRY_LOGGER.debug(' Args: %s', retry_state.args) + RETRY_LOGGER.debug(' Kwargs: %s', retry_state.kwargs) + + RETRY_LOGGER.warning( + 'Retrying %s(), attempt %s', func_name, retry_state.attempt_number + ) + + +def extract_track_info( + track_obj: Dict[str, Any], + artwork: Optional[str] = None, + album_name: Optional[str] = None, +) -> SpotifyTrack: + """ + Extracts track information from the Spotify API and returns a SpotifyTrack object. + """ + if 'track' in track_obj.keys(): + # Nested track (playlist track object) + track_obj = track_obj['track'] + + # Extract ISRC if present + isrc = None + if 'external_ids' in track_obj.keys(): + if 'isrc' in track_obj['external_ids'].keys(): + isrc = track_obj['external_ids']['isrc'].upper().replace('-', '') + + # Extract album artwork if present + if 'album' in track_obj.keys(): + album_name = track_obj['album']['name'] + if 'images' in track_obj['album'].keys(): + if len(track_obj['album']['images']) > 0: + artwork = track_obj['album']['images'][0]['url'] + + return SpotifyTrack( + title=track_obj['name'], + artist=track_obj['artists'][0]['name'], + author=', '.join([x['name'] for x in track_obj['artists']]), + album=album_name, + spotify_id=track_obj['id'], + duration_ms=int(track_obj['duration_ms']), + artwork=artwork, + isrc=isrc, + ) + + +class Spotify: + """ + Wrapper for the spotipy Spotify client which supports pagination by default. + """ + + def __init__(self, client_id: str, client_secret: str): + self._client = spotipy.Spotify( + auth_manager=spotipy.oauth2.SpotifyClientCredentials( + client_id=client_id, client_secret=client_secret + ) + ) + + @property + def client(self): + """ + Returns the internal spotipy client. + """ + return self._client + + def __get_art(self, art: List[Dict[str, str]], default='') -> str: + """ + Returns the first image URL from a list of artwork images, + or a specified default if the list is empty. + """ + if len(art) == 0: + return default + return art[0]['url'] + + @retry( + retry=retry_if_exception_type(RequestsConnectionError), + stop=stop_after_attempt(3), + wait=wait_fixed(1) + wait_random(0, 2), + before=log_call, + before_sleep=log_failure, + ) + def get_artist_top_tracks(self, artist_id: str) -> List[SpotifyTrack]: + """ + Returns a list of SpotifyTrack objects for a given artist's + top 10 tracks. + """ + response = self._client.artist_top_tracks(artist_id) + if response is None: + raise SpotifyInvalidURLError(f'spotify:artist:{artist_id}') + + return [extract_track_info(track) for track in response['tracks']] + + @retry( + retry=retry_if_exception_type(RequestsConnectionError), + stop=stop_after_attempt(3), + wait=wait_fixed(1) + wait_random(0, 2), + before=log_call, + before_sleep=log_failure, + ) + def get_track_art(self, track_id: str) -> str: + """ + Returns the track artwork for a given track ID. + """ + result = self._client.track(track_id) + if result is None: + raise SpotifyInvalidURLError(f'spotify:track:{track_id}') + return self.__get_art(result['album']['images']) + + @retry( + retry=retry_if_exception_type(RequestsConnectionError), + stop=stop_after_attempt(3), + wait=wait_fixed(1) + wait_random(0, 2), + before=log_call, + before_sleep=log_failure, + ) + def get_track(self, track_id: str) -> SpotifyTrack: + """ + Returns a SpotifyTrack object for a given track ID. + """ + # Check cache + if REDIS is not None: + cached_track = REDIS.get_spotify_track(track_id) + if cached_track is not None: + return cached_track + + result = self._client.track(track_id) + if result is None: + raise SpotifyInvalidURLError(f'spotify:track:{track_id}') + + # Save to cache + if REDIS is not None: + REDIS.set_spotify_track(track_id, extract_track_info(result)) + + return extract_track_info(result) + + @retry( + retry=retry_if_exception_type(RequestsConnectionError), + stop=stop_after_attempt(3), + wait=wait_fixed(1) + wait_random(0, 2), + before=log_call, + before_sleep=log_failure, + ) + def get_tracks( + self, list_type: str, list_id: str + ) -> Tuple[str, str, List[SpotifyTrack]]: + """ + Returns a list of SpotifyTrack objects for a given album or playlist ID. + May take a long time to complete if the list is large. + """ + offset = 0 + tracks = [] + + # Get list name and author + list_artwork = None + if list_type == 'album': + album_info = self._client.album(list_id) + if album_info is None: + raise SpotifyInvalidURLError(f'spotify:{list_type}:{list_id}') + + list_artwork = album_info['images'][0]['url'] + list_name = album_info['name'] + list_author = album_info['artists'][0]['name'] + elif list_type == 'playlist': + playlist_info = self._client.playlist(list_id, fields='name,owner.display_name') + if playlist_info is None: + raise SpotifyInvalidURLError(f'spotify:{list_type}:{list_id}') + + list_name = playlist_info['name'] + list_author = playlist_info['owner']['display_name'] + else: + raise SpotifyInvalidURLError(f'spotify:{list_type}:{list_id}') + + # Get tracks + while True: + if list_type == 'album': + response = self._client.album_tracks(list_id, offset=offset) + else: + fields = ','.join( + [ + 'items.track.name', + 'items.track.artists', + 'items.track.album', + 'items.track.id', + 'items.track.duration_ms', + 'items.track.external_ids.isrc', + ] + ) + response = self._client.playlist_items( + list_id, offset=offset, fields=fields, additional_types=['track'] + ) + + if response is None: + raise SpotifyInvalidURLError(f'spotify:{list_type}:{list_id}') + if len(response['items']) == 0: + break + + tracks.extend(response['items']) + offset = offset + len(response['items']) + + if list_type == 'playlist': + return ( + list_name, + list_author, + [extract_track_info(x) for x in tracks if x['track'] is not None], + ) + return ( + list_name, + list_author, + [extract_track_info(x, list_artwork, album_name=list_name) for x in tracks], + ) + + @retry( + retry=retry_if_exception_type(RequestsConnectionError), + stop=stop_after_attempt(3), + wait=wait_fixed(1) + wait_random(0, 2), + before=log_call, + before_sleep=log_failure, + ) + def search_track(self, query, limit: int = 1) -> List[SpotifyTrack]: + """ + Searches Spotify for a given query and returns a list of SpotifyTrack objects. + + :param query: The name of a track to search for. + :param limit: The maximum number of results to return. + """ + response = self._client.search(query, limit=20, type='track') + if response is None or len(response['tracks']['items']) == 0: + raise SpotifyNoResultsError(query) + + # Filter out tracks with blacklisted words not in the original query + results = [] + for result in response['tracks']['items']: + for word in BLACKLIST: + if word in result['name'].lower() and word not in query.lower(): + break + else: + results.append(extract_track_info(result)) + + return results[:limit] + + @retry( + retry=retry_if_exception_type(RequestsConnectionError), + stop=stop_after_attempt(3), + wait=wait_fixed(1) + wait_random(0, 2), + before=log_call, + before_sleep=log_failure, + ) + def search(self, query: str, search_type: str) -> List[SpotifyResult]: + """ + Searches Spotify for a given artist, album, or playlist, + and returns a list of SpotifyResult objects. + + If you want to search for tracks specifically, use search_track(), + as that will yield a list of SpotifyTrack objects instead of SpotifyResults. + + :param query: The artist/album/playlist to search for. + :param search_type: The type of entity to search for. + Must be one of 'artist', 'album', 'playlist', or 'track'. + """ + if search_type not in ('artist', 'album', 'playlist', 'track'): + raise ValueError(f'Invalid search type: {search_type}') + + response = self._client.search(query, limit=10, type=search_type) + if response is None or len(response[f'{search_type}s']['items']) == 0: + raise SpotifyNoResultsError(query) + + # Parse results + items = response[f'{search_type}s']['items'] + if search_type == 'artist': + # Sort artists by followers + items = sorted(items, key=lambda x: x['followers']['total'], reverse=True) + results = [ + SpotifyResult( + name=entity['name'], + description=f"{entity['followers']['total']} followers", + spotify_id=entity['id'], + ) + for entity in items + ] + elif search_type == 'album': + # Include artist name, track count, and release date in album results + results = [ + SpotifyResult( + name=entity['name'], + description=f"{entity['artists'][0]['name']} " + f"({entity['total_tracks']} tracks, " + f"released {entity['release_date']})", + spotify_id=entity['id'], + ) + for entity in items + ] + elif search_type == 'playlist': + # Include author name and track count in playlist results + results = [ + SpotifyResult( + name=entity['name'], + description=f"{entity['owner']['display_name']} " + f"({entity['tracks']['total']} tracks)", + spotify_id=entity['id'], + ) + for entity in items + ] + else: + # Include artist name and release date in track results + results = [ + SpotifyResult( + name=f"{entity['name']} ({human_readable_time(entity['duration_ms'])})", + description=f"{entity['artists'][0]['name']} - " + f"{entity['album']['name']} ", + spotify_id=entity['id'], + ) + for entity in items + ] + + return results diff --git a/bot/utils/spotify_private.py b/bot/utils/spotify_private.py new file mode 100644 index 0000000..538c3ac --- /dev/null +++ b/bot/utils/spotify_private.py @@ -0,0 +1,170 @@ +""" +Custom Spotify client designed to work with predefined credentials +obtained using the Authorization Code Flow. Used for instances where +the user has already authorized the application and wants to access +their data through Blanco. +""" + +from base64 import b64encode +from time import time +from typing import TYPE_CHECKING, List + +import requests +from requests import HTTPError, Timeout + +from bot.models.oauth import OAuth +from bot.models.spotify import SpotifyResult + +from .constants import SPOTIFY_ACCOUNTS_BASE_URL, SPOTIFY_API_BASE_URL, USER_AGENT +from .logger import create_logger + +if TYPE_CHECKING: + from bot.database import Database +from bot.models.config import Config + + +class PrivateSpotify: + """ + Custom Spotify client designed to work with predefined credentials + obtained using the Authorization Code Flow. Used for instances where + the user has already authorized the application and wants to access + their data through Blanco. + """ + + def __init__(self, config: 'Config', database: 'Database', credentials: 'OAuth'): + self._client_id = config.spotify_client_id + self._client_secret = config.spotify_client_secret + self._credentials = credentials + self._db = database + self._logger = create_logger(self.__class__.__name__) + + def _refresh_token(self): + """ + Refresh the access token for a user. + """ + auth_token = b64encode(f'{self._client_id}:{self._client_secret}'.encode()).decode() + response = requests.post( + str(SPOTIFY_ACCOUNTS_BASE_URL / 'token'), + headers={ + 'Authorization': f'Basic {auth_token}', + }, + data={ + 'grant_type': 'refresh_token', + 'refresh_token': self._credentials.refresh_token, + }, + timeout=10, + ) + + try: + response.raise_for_status() + except HTTPError as err: + self._logger.error( + 'Error refreshing Spotify access token for user %d: %s', + self._credentials.user_id, + err, + ) + raise + except Timeout: + self._logger.error( + 'Timed out while refreshing Spotify access token for user %d', + self._credentials.user_id, + ) + + # Delete the user's credentials from the database + self._db.delete_oauth('spotify', self._credentials.user_id) + raise + + # Update the credentials + parsed = response.json() + new_credentials = OAuth( + user_id=self._credentials.user_id, + username=self._credentials.username, + access_token=parsed['access_token'], + refresh_token=self._credentials.refresh_token, + expires_at=int(time() + parsed['expires_in']), + ) + self._db.set_oauth('spotify', new_credentials) + self._db.set_spotify_scopes(self._credentials.user_id, parsed['scope'].split(' ')) + self._credentials = new_credentials + + def _ensure_auth(self): + """ + Makes sure that the credentials are up to date. + """ + if self._credentials.expires_at < time() + 60: + # Refresh token + self._logger.debug( + 'Refreshing Spotify token for user %d', self._credentials.user_id + ) + self._refresh_token() + + def get_user_playlists(self) -> List[SpotifyResult]: + """ + Gets a list of 25 of the user's playlists. + """ + self._ensure_auth() + response = requests.get( + str(SPOTIFY_API_BASE_URL / 'me' / 'playlists'), + headers={ + 'Authorization': f'Bearer {self._credentials.access_token}', + 'User-Agent': USER_AGENT, + }, + params={'limit': 25}, + timeout=10, + ) + + try: + response.raise_for_status() + except HTTPError as err: + self._logger.error( + 'Error %d getting Spotify playlists for user %d.\n%s', + err.response.status_code if err.response is not None else -1, + self._credentials.user_id, + err, + ) + raise + except Timeout: + self._logger.error( + 'Timed out while getting Spotify playlists for user %d', + self._credentials.user_id, + ) + return [] + + parsed = response.json() + return [ + SpotifyResult( + name=playlist['name'], + description=f"{playlist['tracks']['total']} tracks", + spotify_id=playlist['id'], + ) + for playlist in parsed['items'] + ] + + def save_track(self, spotify_id: str): + """ + Adds a track to the user's Liked Songs. + """ + self._ensure_auth() + response = requests.put( + str(SPOTIFY_API_BASE_URL / 'me' / 'tracks'), + headers={ + 'Authorization': f'Bearer {self._credentials.access_token}', + 'User-Agent': USER_AGENT, + }, + params={'ids': spotify_id}, + timeout=10, + ) + + try: + response.raise_for_status() + except HTTPError as err: + self._logger.error( + 'Error %d while trying to Like track %s.\n%s', + err.response.status_code if err.response is not None else -1, + spotify_id, + err, + ) + raise + except Timeout: + self._logger.error('Timed out while liking track %s', spotify_id) + raise diff --git a/bot/utils/time.py b/bot/utils/time.py new file mode 100644 index 0000000..0f46487 --- /dev/null +++ b/bot/utils/time.py @@ -0,0 +1,46 @@ +""" +Utility methods for converting between human and machine readable time formats. +""" + +from math import floor +from typing import Tuple, Union + +NUM_COLON_DELIMITED_SEGMENTS = 3 + + +def get_time_components(msec: Union[int, float]) -> Tuple[int, int, int]: + """ + Decompose milliseconds into a tuple of hours, minutes, and seconds. + """ + minute, sec = divmod(msec / 1000, 60) + hour, minute = divmod(minute, 60) + return floor(hour), floor(minute), floor(sec) + + +def human_readable_time(msec: Union[int, float]) -> str: + """ + Turn milliseconds into a human readable time string. + """ + hour, minute, sec = get_time_components(msec) + string = '' + if hour > 0: + string += f'{hour} hr' + if minute > 0: + string += f' {minute} min' + if sec > 0: + string += f' {sec} sec' + + return string.strip() + + +def machine_readable_time(colon_delimited_time: str) -> int: + """ + Parse colon delimited time (e.g. "1:30:00") into milliseconds. + """ + time_segments = colon_delimited_time.split(':') + sec = int(time_segments[-1]) + minute = int(time_segments[-2]) + hour = ( + int(time_segments[0]) if len(time_segments) == NUM_COLON_DELIMITED_SEGMENTS else 0 + ) + return hour * 3600000 + minute * 60000 + sec * 1000 diff --git a/bot/utils/url.py b/bot/utils/url.py new file mode 100644 index 0000000..11a8344 --- /dev/null +++ b/bot/utils/url.py @@ -0,0 +1,191 @@ +""" +String methods for validating and parsing URLs. +""" + +import re +from urllib.parse import parse_qs, urlparse + +import validators + +from .exceptions import LavalinkInvalidIdentifierError, SpotifyInvalidURLError + +MIN_SPOTIFY_URL_SEGMENTS = 2 +NUM_SC_TRACK_URL_SEGMENTS = 2 + + +def check_contains_ytlistid(url: str) -> bool: + """ + Checks if the URL is a YouTube URL with a 'list' query parameter. + """ + if not check_youtube_url(url): + return False + + parsed_url = urlparse(url) + query = parse_qs(parsed_url.query) + return 'list' in query and len(query['list']) > 0 + + +def check_url(url: str) -> bool: + """ + Checks if the URL is a valid URL. + """ + return validators.domain(url) or validators.url(url) # type: ignore + + +def check_sc_url(url: str) -> bool: + """ + Checks if the URL is a valid SoundCloud URL. + """ + url_regex = r'(^http(s)?://)?(soundcloud\.com|snd\.sc)/(.*)$' + return re.match(url_regex, url) is not None + + +def check_spotify_url(url: str) -> bool: + """ + Checks if the URL is a valid Spotify URL. + """ + url_regex = r'(https?://open\.)*spotify(\.com)*[/:]+(track|artist|album|playlist)[/:]+[A-Za-z0-9]+' # pylint: disable=line-too-long + return re.match(url_regex, url) is not None + + +def check_twitch_url(url: str) -> bool: + """ + Checks if the URL is a valid Twitch URL. + """ + url_regex = r'(^http(s)?://)?((www|en-es|en-gb|secure|beta|ro|www-origin|en-ca|fr-ca|lt|zh-tw|he|id|ca|mk|lv|ma|tl|hi|ar|bg|vi|th)\.)?twitch.tv/(?!directory|p|user/legal|admin|login|signup|jobs)(?P\w+)' # pylint: disable=line-too-long + return re.match(url_regex, url) is not None + + +def check_youtube_url(url: str) -> bool: + """ + Checks if the URL is a valid YouTube URL. + """ + url_regex = r'(?:https?://)?(?:youtu\.be/|(?:www\.|m\.)?youtube\.com/(?:watch|v|embed)(?:\.php)?(?:\?.*v=|/))([a-zA-Z0-9_-]+)' # pylint: disable=line-too-long + return re.match(url_regex, url) is not None + + +def check_youtube_playlist_url(url: str) -> bool: + """ + Checks if the URL is a valid YouTube playlist URL. + """ + url_regex = r'(?:https?://)?(?:www\.)?youtube\.com/playlist\?list=([a-zA-Z0-9_-]+)' + return re.match(url_regex, url) is not None + + +def check_ytmusic_url(url: str) -> bool: + """ + Checks if the URL is a valid YouTube Music URL. + """ + url_regex = r'(?:https?://)?music\.youtube\.com/(?:watch|v|embed)(?:\.php)?(?:\?.*v=|/)([a-zA-Z0-9_-]+)' # pylint: disable=line-too-long + return re.match(url_regex, url) is not None + + +def check_ytmusic_playlist_url(url: str) -> bool: + """ + Checks if the URL is a valid YouTube Music playlist URL. + """ + url_regex = r'(?:https?://)?music\.youtube\.com/playlist\?list=([a-zA-Z0-9_-]+)' + return re.match(url_regex, url) is not None + + +def get_sctype_from_url(url: str) -> bool: + """ + Determine SoundCloud entity type from URL. + + Returns + ------- + True if URL is a SoundCloud track, False if URL is a SoundCloud playlist. + """ + if url.startswith(('soundcloud', 'www')): + url = 'http://' + url + + query = urlparse(url) + path = [x for x in query.path.split('/') if x] + if len(path) == 1: + raise LavalinkInvalidIdentifierError( + url, reason='SoundCloud URL does not point to a track or set.' + ) + if len(path) == NUM_SC_TRACK_URL_SEGMENTS and path[1] != 'sets': + return True + if path[1] == 'sets': + return False + raise LavalinkInvalidIdentifierError(url, reason='Unrecognized SoundCloud URL.') + + +def get_spinfo_from_url(url: str) -> tuple[str, str]: + """ + Gets the Spotify type and ID from a Spotify URL. + Must be a URL that Blanco can play, i.e. a track, album, or playlist. + + :returns: A tuple containing the type and ID of the Spotify entity. + """ + if not check_spotify_url(url): + raise SpotifyInvalidURLError(url) + + parsed_path = [] + if re.match(r'^https?://open\.spotify\.com', url): + # We are dealing with a link + parsed_url = urlparse(url) + parsed_path = parsed_url.path.split('/')[1:] + elif re.match(r'^spotify:[a-z]', url): + # We are dealing with a Spotify URI + parsed_path = url.split(':')[1:] + if len(parsed_path) < MIN_SPOTIFY_URL_SEGMENTS or parsed_path[0] not in ( + 'track', + 'album', + 'playlist', + 'artist', + ): + raise SpotifyInvalidURLError(url) + + return parsed_path[0], parsed_path[1] + + +def get_ytid_from_url(url: str, id_type: str = 'v') -> str: + """ + Gets the YouTube ID from a YouTube URL. + """ + # https://gist.github.com/kmonsoor/2a1afba4ee127cce50a0 + if url.startswith(('youtu', 'www')): + url = 'http://' + url + + query = urlparse(url) + if query.hostname is None: + raise LavalinkInvalidIdentifierError(url, reason='Not a valid YouTube URL') + + if 'youtube' in query.hostname: + if re.match(r'^/watch', query.path): + if len(query.query): + return parse_qs(query.query)[id_type][0] + return query.path.split('/')[2] + if query.path.startswith(('/embed/', '/v/')): + return query.path.split('/')[2] + elif 'youtu.be' in query.hostname: + return query.path[1:] + + raise LavalinkInvalidIdentifierError( + url, reason='Could not get video ID from YouTube URL' + ) + + +def get_ytlistid_from_url(url: str, force_extract: bool = False) -> str: + """ + Gets the YouTube playlist ID from a YouTube URL. + """ + if url.startswith(('youtu', 'www')): + url = 'http://' + url + + query = urlparse(url) + if query.hostname is None: + raise LavalinkInvalidIdentifierError(url, reason='Not a valid YouTube URL') + + if 'youtube' in query.hostname: + if re.match(r'^/playlist', query.path) or force_extract: + if len(query.query): + return parse_qs(query.query)['list'][0] + else: + raise ValueError('Not a YouTube playlist URL') + + raise LavalinkInvalidIdentifierError( + url, reason='Could not get playlist ID from YouTube URL' + ) diff --git a/bot/views/now_playing.py b/bot/views/now_playing.py new file mode 100644 index 0000000..a5b5e6b --- /dev/null +++ b/bot/views/now_playing.py @@ -0,0 +1,220 @@ +""" +Now Playing view for the player. +""" + +from typing import TYPE_CHECKING, Optional + +from nextcord import ButtonStyle +from nextcord.ui import Button, View, button +from requests.exceptions import HTTPError, Timeout +from requests.status_codes import codes + +from bot.utils.constants import SPOTIFY_403_ERR_MSG +from bot.utils.embeds import create_error_embed, create_success_embed +from bot.utils.exceptions import VoiceCommandError +from bot.utils.player_checks import check_mutual_voice + +if TYPE_CHECKING: + from nextcord import Interaction + + from bot.cogs.player import PlayerCog + from bot.cogs.player.jockey import Jockey + from bot.utils.blanco import BlancoBot + + +class ShuffleButton(Button): + """ + Shuffle button for the Now Playing view. + """ + + def __init__(self, init_state: bool = False): + """ + Initialize the shuffle button. + + :param init_state: Initial state of the shuffle button. + True if the queue is shuffled, False otherwise. + """ + super().__init__( + style=ButtonStyle.grey, label='Unshuffle' if init_state else 'Shuffle' + ) + + async def callback(self, interaction: 'Interaction'): + """ + Toggle shuffle on the current queue. + """ + assert self.view is not None + view: NowPlayingView = self.view + + if await view.check_mutual_voice(interaction): + status = view.player.queue_manager.is_shuffling + self.label = 'Shuffle' if status else 'Unshuffle' + await interaction.response.edit_message(view=view) + + # Shuffle or unshuffle + if status: + return await view.cog.unshuffle(interaction, quiet=True) + return await view.cog.shuffle(interaction, quiet=True) + + +class NowPlayingView(View): + """ + View for the Now Playing message, which contains buttons for interacting + with the player. + """ + + def __init__( + self, bot: 'BlancoBot', player: 'Jockey', spotify_id: Optional[str] = None + ): + super().__init__(timeout=None) + self._bot = bot + self._cog: 'PlayerCog' = bot.get_cog('PlayerCog') # type: ignore + if self._cog is None: + raise ValueError('PlayerCog not found') + + self._spotify_id = spotify_id + self._player = player + + # Add shuffle button + self.add_item(ShuffleButton(player.queue_manager.is_shuffling)) + + @property + def cog(self) -> 'PlayerCog': + """ + Return the PlayerCog that this View was created by. + """ + return self._cog + + @property + def player(self) -> 'Jockey': + """ + Return the player that this View is bound to. + """ + return self._player + + async def check_mutual_voice(self, interaction: 'Interaction') -> bool: + """ + Check if the user is in the same voice channel as the bot. + """ + try: + _ = check_mutual_voice(interaction) + except VoiceCommandError as err: + await interaction.response.send_message(err.args[0], ephemeral=True) + return False + + return True + + @button(label='📋', style=ButtonStyle.green) + async def queue(self, _: 'Button', interaction: 'Interaction'): + """ + Display the current queue. + """ + if await self.check_mutual_voice(interaction): + return await self._cog.queue(interaction) + + @button(label='⏮️', style=ButtonStyle.grey) + async def skip_backward(self, _: 'Button', interaction: 'Interaction'): + """ + Skip to the previous track. + """ + if await self.check_mutual_voice(interaction): + return await self._cog.previous(interaction) + + @button(label='⏸️', style=ButtonStyle.blurple) + async def toggle_pause(self, btn: 'Button', interaction: 'Interaction'): + """ + Toggle pause on the current track. + """ + if await self.check_mutual_voice(interaction): + if self._player.paused: + btn.label = '⏸️' + await interaction.response.edit_message(view=self) + return await self._cog.unpause(interaction, quiet=True) + + btn.label = '▶️' + await interaction.response.edit_message(view=self) + return await self._cog.pause(interaction, quiet=True) + + @button(label='⏭️', style=ButtonStyle.grey) + async def skip_forward(self, _: 'Button', interaction: 'Interaction'): + """ + Skip to the next track. + """ + if await self.check_mutual_voice(interaction): + return await self._cog.skip(interaction) + + @button(label='⏹️', style=ButtonStyle.red) + async def stop_player(self, _: 'Button', interaction: 'Interaction'): + """ + Stop the player. + """ + if await self.check_mutual_voice(interaction): + return await self._cog.stop(interaction) + + @button(label='Like on Spotify', style=ButtonStyle.grey) + async def like(self, _: 'Button', interaction: 'Interaction'): + """ + Like the current track on Spotify. + """ + if not interaction.user: + return None + + await interaction.response.defer(ephemeral=True) + if self._spotify_id is None: + return await interaction.followup.send( + embed=create_error_embed('This track does not have a Spotify ID.'), + ephemeral=True, + ) + + # Get Spotify client + try: + spotify = self._bot.get_spotify_client(interaction.user.id) + if spotify is None: + raise ValueError('Spotify client not initialized') + except ValueError as err: + return await interaction.followup.send(err.args[0]) + + # Save track + try: + spotify.save_track(self._spotify_id) + except HTTPError as err: + if err.response is not None: + if err.response.status_code == codes.forbidden: + message = SPOTIFY_403_ERR_MSG.format('Like this track') + else: + message = ''.join( + [ + f'**Error {err.response.status_code}** while trying to Like this track.', + 'Please try again later.\n', + f'```\n{err}```', + ] + ) + else: + message = ''.join( + [ + 'Error while trying to Like this track.', + 'Please try again later.\n', + f'```\n{err}```', + ] + ) + + return await interaction.followup.send( + embed=create_error_embed(message), ephemeral=True + ) + except Timeout as err: + return await interaction.followup.send( + embed=create_error_embed( + '\n'.join( + [ + 'Timed out while trying to Like this track.', + 'Please try again later.\n', + f'```{err}```', + ] + ) + ), + ephemeral=True, + ) + + # Send response + return await interaction.followup.send( + embed=create_success_embed('Added to your Liked Songs.'), ephemeral=True + ) diff --git a/bot/views/paginator.py b/bot/views/paginator.py new file mode 100644 index 0000000..8e2cb86 --- /dev/null +++ b/bot/views/paginator.py @@ -0,0 +1,57 @@ +""" +View for the Paginator. See utils/paginator.py for more information. +""" + +from typing import TYPE_CHECKING + +from nextcord import ButtonStyle +from nextcord.ui import View, button + +if TYPE_CHECKING: + from nextcord import Interaction + from nextcord.ui import Button + + +class PaginatorView(View): + """ + Controls for the Paginator. See utils/paginator.py for more information. + """ + + def __init__(self, paginator, timeout: int = 60): + super().__init__(timeout=None) + self.paginator = paginator + + @button(label='⏮️', style=ButtonStyle.grey) + async def first_page(self, _b: 'Button', _i: 'Interaction'): + """ + Go to the first page. + """ + return await self.paginator.first_page() + + @button(label='⏪', style=ButtonStyle.grey) + async def previous_page(self, _b: 'Button', _i: 'Interaction'): + """ + Go to the previous page. + """ + return await self.paginator.previous_page() + + @button(label='🏠', style=ButtonStyle.grey) + async def home_page(self, _b: 'Button', _i: 'Interaction'): + """ + Go to the home page. + """ + return await self.paginator.home_page() + + @button(label='⏩', style=ButtonStyle.grey) + async def next_page(self, _b: 'Button', _i: 'Interaction'): + """ + Go to the next page. + """ + return await self.paginator.next_page() + + @button(label='⏭️', style=ButtonStyle.grey) + async def last_page(self, _b: 'Button', _i: 'Interaction'): + """ + Go to the last page. + """ + return await self.paginator.last_page() diff --git a/bot/views/spotify_dropdown.py b/bot/views/spotify_dropdown.py new file mode 100644 index 0000000..3c4aaf1 --- /dev/null +++ b/bot/views/spotify_dropdown.py @@ -0,0 +1,114 @@ +""" +View for the `/playlists` command, which contains a dropdown menu for selecting +a Spotify playlist. +""" + +from typing import TYPE_CHECKING, List + +from nextcord import Colour, SelectOption +from nextcord.ui import Select, View + +from bot.models.custom_embed import CustomEmbed + +if TYPE_CHECKING: + from nextcord import Interaction + + from bot.cogs.player import PlayerCog + from bot.models.spotify import SpotifyResult + from bot.utils.blanco import BlancoBot + + +MAX_LINE_LENGTH = 100 + + +class SpotifyDropdown(Select): + """ + Dropdown menu for selecting a Spotify entity. + """ + + def __init__( + self, + bot: 'BlancoBot', + choices: List['SpotifyResult'], + user_id: int, + entity_type: str, + ): + self._cog: 'PlayerCog' = bot.get_cog('PlayerCog') # type: ignore + self._user_id = user_id + self._choices = {x.spotify_id: x.name for x in choices} + self._type = entity_type + + # Create options + options = [] + for choice in choices: + # Truncate names to 100 characters + choice_name = choice.name + if len(choice_name) > MAX_LINE_LENGTH: + choice_name = choice_name[:97] + '...' + elif len(choice_name) == 0: + # Some playlists have empty names, for example: + # https://open.spotify.com/playlist/6HlbMZPay5jlI7KWA0Mwyu + choice_name = '(no name)' + + # Truncate descriptions to 100 characters + choice_desc = choice.description + if len(choice_desc) > MAX_LINE_LENGTH: + choice_desc = choice_desc[:97] + '...' + + options.append( + SelectOption( + label=choice_name, description=choice_desc, value=choice.spotify_id + ) + ) + + super().__init__( + placeholder=f'Choose {entity_type}...', + options=options, + min_values=1, + max_values=1, + ) + + async def callback(self, interaction: 'Interaction'): + """ + Callback for the dropdown menu. Calls the `/play` command with the + selected entity. + """ + # Ignore if the user isn't the one who invoked the command + if not interaction.user or interaction.user.id != self._user_id: + return + + # Edit message + entity_id = self.values[0] + entity_url = f'https://open.spotify.com/{self._type}/{entity_id}' + if interaction.message: + embed = CustomEmbed( + color=Colour.yellow(), + title=':hourglass:|Loading...', + description=f'Selected {self._type} [{self._choices[entity_id]}]({entity_url}).', + ) + await interaction.message.edit(embed=embed.get(), view=None) + + # Call the `/play` command with the entity URL + await self._cog.play(interaction, query=entity_url) + + # Delete message + if interaction.message: + await interaction.message.delete() + + +class SpotifyDropdownView(View): + """ + View for the `/playlists` command, which contains a dropdown menu for selecting + a Spotify entity. + """ + + def __init__( + self, + bot: 'BlancoBot', + playlists: List['SpotifyResult'], + user_id: int, + entity_type: str, + ): + super().__init__(timeout=None) + + self.add_item(SpotifyDropdown(bot, playlists, user_id, entity_type)) diff --git a/cogs/bumps.py b/cogs/bumps.py deleted file mode 100644 index b06b39c..0000000 --- a/cogs/bumps.py +++ /dev/null @@ -1,228 +0,0 @@ -""" -BumpCog: Cog for guild bumps. -""" - -from typing import TYPE_CHECKING - -from nextcord import (Color, Permissions, Interaction, SlashOption, slash_command) -from nextcord.ext.commands import Cog - -from dataclass.bump import Bump - -from utils.url import check_url -from utils.embeds import CustomEmbed, create_error_embed, create_success_embed -from utils.logger import create_logger -from utils.paginator import Paginator, list_chunks - -if TYPE_CHECKING: - from utils.blanco import BlancoBot - - -class BumpCog(Cog): - """ - Cog for guild bumps. - """ - def __init__(self, bot: 'BlancoBot'): - """ - Constructor for BumpCog. - """ - self._bot = bot - self._logger = create_logger(self.__class__.__name__) - self._logger.info('Loaded BumpCog') - - @slash_command( - name='bump', - dm_permission=False, - default_member_permissions=Permissions(manage_guild=True) - ) - async def bump(self, itx: Interaction): - """ - Base slash command for bumps. - """ - - - @bump.subcommand(name='toggle', description='Toggle the playback of bumps.') - async def bump_toggle( - self, - itx: Interaction, - toggle: bool = SlashOption( - name='toggle', - description='Turn bumps on or off?', - required=False - ) - ): - """ - Subcommand for toggling bumps. - """ - if itx.guild is None: - raise RuntimeError('[bump::toggle] itx.guild is None') - - if toggle is None: - enabled = self._bot.database.get_bumps_enabled(itx.guild.id) - status = "Bump playback is currently enabled." if enabled \ - else "Bump playback is currently disabled." - return await itx.response.send_message( - embed=create_success_embed( - title="Bumps status", - body=status, - ) - ) - - self._bot.database.set_bumps_enabled(itx.guild.id, toggle) - status = "Bump playback has been enabled." if toggle \ - else "Bump playback has been disabled." - return await itx.response.send_message( - embed=create_success_embed( - title="Bumps toggled", - body=status, - ) - ) - - @bump.subcommand(name='add', description='Add a bump.') - async def bump_add( - self, - itx: Interaction, - title: str = SlashOption(name='title', description='Title of bump.', required=True), - author: str = SlashOption(name='author', description='Author of bump.', required=True), - url: str = SlashOption(name='url', description='URL to add.', required=True), - ): - """ - Subcommand for adding a bump. - """ - if itx.guild is None: - raise RuntimeError('[bump::add] itx.guild is None') - - if len(title) > 32 or len(author) > 32: - return await itx.response.send_message( - embed=create_error_embed( - message='Titles/authors cannot exceed 32 characters in length.' - ) - ) - - if not check_url(url): - return await itx.response.send_message( - embed=create_error_embed( - message='The given URL is not valid.' - ) - ) - - bump = self._bot.database.get_bump_by_url(itx.guild.id, url) - if bump is not None: - return await itx.response.send_message( - embed=create_error_embed( - message='A bump with the given URL already exists.' - ) - ) - - self._bot.database.add_bump(itx.guild.id, url, title, author) - return await itx.response.send_message( - embed=create_success_embed( - title='Bump added', - body='Bump has been successfully added to the database.' - ) - ) - - @bump.subcommand(name='remove', description='Remove a bump.') - async def bump_remove( - self, - itx: Interaction, - idx: int = SlashOption(name='index', description='Index of bump.', required=True) - ): - """ - Subcommand for removing a bump. - """ - if itx.guild is None: - raise RuntimeError('[bump::remove] itx.guild is None') - - bump = self._bot.database.get_bump(itx.guild.id, idx) - if bump is None: - return await itx.response.send_message( - embed=create_error_embed( - message='There is no bump at that index for this guild.' - ) - ) - - self._bot.database.delete_bump(itx.guild.id, idx) - return await itx.response.send_message( - embed=create_success_embed( - title='Bump removed', - body='Bump has successfully been removed from the database.' - ) - ) - - @bump.subcommand(name='list', description='List every bump.') - async def bump_list( - self, - itx: Interaction, - ): - """ - Subcommand for listing bumps. - """ - if itx.guild is None: - raise RuntimeError('[bump::list] itx.guild is None') - await itx.response.defer() - - bumps = self._bot.database.get_bumps(itx.guild.id) - if bumps is None: - return await itx.response.send_message( - embed=create_error_embed( - message='This guild has no bumps.' - ) - ) - - pages = [] - count = 1 - for _, chunk in enumerate(list_chunks(bumps)): - chunk_bumps = [] - - bump: Bump - for bump in chunk: - line = f'{bump.idx} :: [{bump.title}]({bump.url}) by {bump.author}' - chunk_bumps.append(line) - count += 1 - - embed = CustomEmbed( - title=f'Bumps for {itx.guild.name}', - description='\n'.join(chunk_bumps), - color=Color.lighter_gray() - ) - pages.append(embed.get()) - - paginator = Paginator(itx) - return await paginator.run(pages) - - @bump.subcommand(name='interval', description='Set or get the bump interval.') - async def bump_interval( - self, - itx: Interaction, - interval: int = SlashOption( - name='interval', - description='The new interval bumps will play at', - required=False, - min_value=1, - max_value=60 - ) - ): - """ - Subcommand for changing/checking the bump interval. - """ - - if itx.guild is None: - raise RuntimeError('[bump::interval] itx.guild is None') - - if interval is None: - curr_interval = self._bot.database.get_bump_interval(itx.guild.id) - return await itx.response.send_message( - embed=create_success_embed( - title='Current Interval', - body=f'A bump will play once at least every {curr_interval} minute(s).' - ) - ) - - self._bot.database.set_bump_interval(itx.guild.id, interval) - return await itx.response.send_message( - embed=create_success_embed( - title='Interval Changed', - body=f'The bump interval has been set to {interval} minute(s).' - ) - ) diff --git a/cogs/debug.py b/cogs/debug.py deleted file mode 100644 index e1d439d..0000000 --- a/cogs/debug.py +++ /dev/null @@ -1,145 +0,0 @@ -""" -DebugCog: Cog for debugging commands. -""" - -from typing import TYPE_CHECKING - -from nextcord import (Color, Interaction, PartialMessageable, SlashOption, - slash_command) -from nextcord.ext import application_checks -from nextcord.ext.commands import Cog - -from dataclass.custom_embed import CustomEmbed -from utils.embeds import create_success_embed -from utils.logger import create_logger -from utils.paginator import Paginator - -if TYPE_CHECKING: - from utils.blanco import BlancoBot - -STATS_FORMAT = """ -```asciidoc -Uptime :: {uptime} -Players :: {playing_player_count} playing ({player_count} total) -CPU :: {system_load:.2f}% (Lavalink {lavalink_load:.2f}%) -Memory :: {used:.0f} MiB used - {free:.0f} MiB free - {allocated:.0f} MiB allocated - {reservable:.0f} MiB reservable -``` -""" - -class DebugCog(Cog): - """ - Cog for debugging commands. - """ - def __init__(self, bot: 'BlancoBot'): - """ - Constructor for DebugCog. - """ - self._bot = bot - self._logger = create_logger(self.__class__.__name__) - self._logger.info('Loaded DebugCog') - - @slash_command(name='announce') - @application_checks.is_owner() - async def announce( - self, - itx: Interaction, - message: str = SlashOption(description='The message to announce.', required=True) - ): - """ - Posts an announcement to the system channel in all guilds. - If there is no system channel, attempt to send to the last channel - used by the bot for now playing embeds. - """ - await itx.response.defer() - - # Create announcement embed - embed = CustomEmbed( - color=Color.yellow(), - title=':warning: Announcement', - description=message, - footer='From the bot owner', - timestamp_now=True - ).get() - - # Send announcement to all guilds - for guild in self._bot.guilds: - # Get system channel - system_channel = guild.system_channel - if system_channel is None: - # Attempt to get status channel - system_channel = self._bot.get_status_channel(guild.id) - - if system_channel is None or ( - not isinstance(system_channel, PartialMessageable) and - not system_channel.permissions_for(guild.me).send_messages - ): - self._logger.error('No suitable announcement channel saved for %s', guild.name) - else: - # Send message - await system_channel.send(embed=embed) - self._logger.info('Sent announcement to %s', guild.name) - - await itx.followup.send(embed=create_success_embed('Announced!'), ephemeral=True) - - @slash_command(name='reload') - @application_checks.is_owner() - async def reload(self, itx: Interaction): - """ - Reloads all cogs. - """ - # Reload cogs - self._bot.unload_extension('cogs') - self._bot.load_extension('cogs') - - # Resync commands - await self._bot.sync_all_application_commands() - - await itx.response.send_message( - embed=create_success_embed('Reloaded extensions!'), - ephemeral=True - ) - - @slash_command(name='stats') - async def stats(self, itx: Interaction): - """ - Shows bot statistics. - """ - await itx.response.defer() - - pages = [] - nodes = self._bot.pool.nodes - for node in nodes: - stats = node.stats - - if stats is not None: - # Adapted from @ooliver1/mafic test bot - pages.append(CustomEmbed( - color=Color.purple(), - title=f':bar_chart:|Stats for node `{node.label}`', - description='No statistics available' if stats is None else STATS_FORMAT.format( - uptime=stats.uptime, - used=stats.memory.used / 1024 / 1024, - free=stats.memory.free / 1024 / 1024, - allocated=stats.memory.allocated / 1024 / 1024, - reservable=stats.memory.reservable / 1024 / 1024, - system_load=stats.cpu.system_load * 100, - lavalink_load=stats.cpu.lavalink_load * 100, - player_count=stats.player_count, - playing_player_count=stats.playing_player_count - ), - footer=f'{len(nodes)} total node(s)' - ).get()) - else: - pages.append(CustomEmbed( - color=Color.red(), - title=f':bar_chart:|Stats for node `{node.label}`', - description='No statistics available', - footer=f'{len(nodes)} total node(s)' - ).get()) - - # Run paginator - paginator = Paginator(itx) - return await paginator.run(pages) diff --git a/cogs/player/__init__.py b/cogs/player/__init__.py deleted file mode 100644 index 496aca3..0000000 --- a/cogs/player/__init__.py +++ /dev/null @@ -1,692 +0,0 @@ -""" -PlayerCog: Cog for controlling the music player. -""" - -from asyncio import TimeoutError as AsyncioTimeoutError -from typing import TYPE_CHECKING, Any, Generator, List, Optional - -from mafic import PlayerNotConnected -from nextcord import (Color, Forbidden, Guild, HTTPException, Interaction, - Member, SlashOption, VoiceState, slash_command) -from nextcord.abc import Messageable -from nextcord.ext import application_checks -from nextcord.ext.commands import Cog -from requests import HTTPError - -from dataclass.custom_embed import CustomEmbed -from utils.constants import RELEASE, SPOTIFY_403_ERR_MSG -from utils.embeds import create_error_embed, create_success_embed -from utils.exceptions import (EmptyQueueError, EndOfQueueError, JockeyError, - JockeyException, SpotifyNoResultsError) -from utils.logger import create_logger -from utils.paginator import Paginator, list_chunks -from utils.player_checks import check_mutual_voice -from views.spotify_dropdown import SpotifyDropdownView - -from .jockey import Jockey - -if TYPE_CHECKING: - from dataclass.queue_item import QueueItem - from utils.blanco import BlancoBot - - -class PlayerCog(Cog): - """ - Cog for creating, controlling, and destroying music players for guilds. - """ - def __init__(self, bot: 'BlancoBot'): - """ - Constructor for PlayerCog. - """ - self._bot = bot - self._logger = create_logger(self.__class__.__name__) - - # Initialize Lavalink client instance - if not bot.pool_initialized: - bot.loop.create_task(bot.init_pool()) - - self._logger.info('Loaded PlayerCog') - - @Cog.listener() - async def on_voice_state_update(self, member: Member, before: VoiceState, after: VoiceState): - """ - Called every time the voice state of a member changes. - In this cog, we use it to check if the bot is left alone in a voice channel, - or if the bot has been server-undeafened. - """ - # Get the player for this guild from cache - jockey: Jockey = member.guild.voice_client # type: ignore - if jockey is not None: - # Stop playing if we're left alone - if (hasattr(jockey.channel, 'members') and - len(jockey.channel.members) == 1 and # type: ignore - jockey.channel.members[0].id == member.guild.me.id and # type: ignore - after.channel is None): - return await self._disconnect(jockey=jockey, reason='You left me alone :(') - - # Did we get server undeafened? - if member.id == member.guild.me.id and before.deaf and not after.deaf: - await self._deafen( - member.guild.me, - was_deafened=True, - channel=jockey.status_channel - ) - - async def _get_jockey(self, itx: Interaction) -> Jockey: - """ - Gets the Jockey instance for the specified guild. - """ - jockey: Jockey = itx.guild.voice_client # type: ignore - if jockey is None: - if not itx.response.is_done(): - await itx.followup.send(embed=create_error_embed('Not connected to voice')) - raise RuntimeError('Attempted to access nonexistent jockey') - - return jockey - - async def _deafen( - self, - bot_user: Member, - was_deafened: bool = False, - channel: Optional[Messageable] = None - ): - """ - Attempt to deafen the bot user. - - :param bot_user: The bot user to deafen. Should be an instance of nextcord.Member. - :param was_deafened: Whether the bot user was previously deafened. - :param channel: The Messageable channel to send the error message to. - """ - # Check if we're already deafened - if not was_deafened and bot_user.voice is not None and bot_user.voice.deaf: - return - - if bot_user.guild_permissions.deafen_members: - try: - await bot_user.edit(deafen=True) - except Forbidden: - pass - - # Send message - if channel is not None and hasattr(channel, 'send'): - err = 'Please server deafen me.' - if was_deafened: - err = 'Please do not undeafen me.' - - try: - await channel.send(embed=create_error_embed( - message=f'{err} Deafening helps save server resources.' - )) - except (Forbidden, HTTPException): - self._logger.error('Unable to send deafen message in guild %d', bot_user.guild.id) - - async def _disconnect( - self, - jockey: Optional[Jockey] = None, - itx: Optional[Interaction] = None, - reason: Optional[str] = None - ): - # Destroy jockey instance - if jockey is None: - if itx is None: - raise ValueError('[player::_disconnect] Either jockey or itx must be specified') - jockey = await self._get_jockey(itx) - - try: - await jockey.stop() - except PlayerNotConnected: - self._logger.warning('Attempted to disconnect disconnected Jockey') - await jockey.disconnect() - - # Send disconnection message - embed = CustomEmbed( - title=':wave:|Disconnected from voice', - description=reason, - footer=f'Blanco release {RELEASE}' - ).get() - - # Try to send disconnection message - try: - if itx is not None: - await itx.followup.send(embed=embed) - else: - guild_id = jockey.guild.id - channel = self._bot.get_status_channel(guild_id) - if channel is not None: - await channel.send(embed=embed) - except (Forbidden, HTTPException): - self._logger.error('Unable to send disconnect message in guild %d', jockey.guild.id) - - # Dispatch disconnect event - self._bot.dispatch('jockey_disconnect', jockey) - - @slash_command(name='jump') - @application_checks.check(check_mutual_voice) - async def jump( - self, - itx: Interaction, - position: int = SlashOption(description='Position to jump to', required=True) - ): - """ - Jumps to the specified position in the queue. - """ - jockey = await self._get_jockey(itx) - - # First check if the value is within range - if position < 1 or position > len(jockey.queue): - await itx.response.send_message( - f'Specify a number from 1 to {str(len(jockey.queue))}.', - ephemeral=True - ) - return - - # Dispatch to jockey - await itx.response.defer() - try: - await jockey.skip(index=position - 1, auto=False) - except JockeyError as err: - await itx.followup.send(embed=create_error_embed(str(err))) - else: - await itx.followup.send(embed=create_success_embed(f'Jumped to track {str(position)}')) - - @slash_command(name='loop') - @application_checks.check(check_mutual_voice) - async def loop(self, itx: Interaction): - """ - Loops the current track. - """ - jockey = await self._get_jockey(itx) - if not jockey.queue_manager.is_looping_one: - jockey.queue_manager.is_looping_one = True - - # Update now playing message - await jockey.update_now_playing() - - return await itx.response.send_message(embed=create_success_embed('Looping current track')) - - @slash_command(name='loopall') - @application_checks.check(check_mutual_voice) - async def loopall(self, itx: Interaction): - """ - Loops the whole queue. - """ - jockey = await self._get_jockey(itx) - if not jockey.queue_manager.is_looping_all: - jockey.queue_manager.is_looping_all = True - - # Update now playing message - await jockey.update_now_playing() - - return await itx.response.send_message(embed=create_success_embed('Looping entire queue')) - - @slash_command(name='nowplaying') - @application_checks.check(check_mutual_voice) - async def now_playing(self, itx: Interaction): - """ - Displays the currently playing track. - """ - await itx.response.defer(ephemeral=True) - jockey = await self._get_jockey(itx) - embed = jockey.now_playing() - await itx.followup.send(embed=embed) - - @slash_command(name='pause') - @application_checks.check(check_mutual_voice) - async def pause(self, itx: Interaction, quiet: bool = False): - """ - Pauses the current track. - """ - if not quiet: - await itx.response.defer() - - # Dispatch to jockey - jockey = await self._get_jockey(itx) - await jockey.pause() - - if not quiet: - await itx.followup.send(embed=create_success_embed('Paused'), delete_after=5.0) - - @slash_command(name='play') - @application_checks.check(check_mutual_voice) - async def play( - self, - itx: Interaction, - query: str = SlashOption(description='Query string or URL', required=True) - ): - """ - Play a song from a search query or a URL. - If you want to unpause a paused player, use /unpause instead. - """ - if (not isinstance(itx.user, Member) or not itx.user.voice or - not itx.user.voice.channel or not isinstance(itx.guild, Guild)): - return await itx.response.send_message(embed=create_error_embed( - message='Connect to a server voice channel to use this command.' - ), ephemeral=True) - - # Set status channel - guild_id = itx.guild.id - channel = itx.channel - if not isinstance(channel, Messageable): - raise RuntimeError('[player::play] itx.channel is not Messageable') - self._bot.set_status_channel(guild_id, channel) - - # Check if Lavalink is ready - if not self._bot.pool_initialized or len(self._bot.pool.nodes) == 0: - return await itx.response.send_message(embed=create_error_embed( - message='No Lavalink nodes available. Try again later.' - )) - - # Connect to voice - await itx.response.defer() - voice_channel = itx.user.voice.channel - if itx.guild.voice_client is None: - try: - await voice_channel.connect(cls=Jockey) # type: ignore - await voice_channel.guild.change_voice_state( - channel=voice_channel, - self_deaf=True - ) - await self._deafen(itx.guild.me, channel=channel) - except AsyncioTimeoutError: - return await itx.followup.send(embed=create_error_embed( - message='Timed out while connecting to voice. Try again later.' - )) - - # Dispatch to jockey - jockey = await self._get_jockey(itx) - try: - track_name = await jockey.play_impl(query, itx.user.id) - except JockeyError as err: - # Disconnect if we're not playing anything - if not jockey.playing: - return await self._disconnect(itx=itx, reason=f'Error: `{err}`') - - return await itx.followup.send(embed=create_error_embed(str(err))) - except JockeyException as exc: - return await itx.followup.send(embed=create_error_embed(str(exc))) - - body = [f'{track_name}\n'] - - # Add Last.fm integration promo if enabled - assert self._bot.config is not None - if (self._bot.config.base_url is not None and - self._bot.config.lastfm_api_key is not None and - self._bot.config.lastfm_shared_secret is not None): - # Check if the user has connected their Last.fm account - if self._bot.database.get_lastfm_credentials(itx.user.id) is not None: - body.append(f':handshake: {itx.user.mention} is scrobbling to Last.fm!') - body.append( - f':sparkles: [Link Last.fm]({self._bot.config.base_url}) to scrobble as you listen' - ) - - # Update now playing message - await jockey.update_now_playing() - - embed = create_success_embed( - title='Added to queue', - body='\n'.join(body), - ) - return await itx.followup.send(embed=embed.set_footer(text=f'Blanco release {RELEASE}')) - - @slash_command(name='playlists') - async def playlist(self, itx: Interaction): - """ - Pick a Spotify playlist from your library to play. - """ - if itx.user is None: - return - await itx.response.defer() - - # Get Spotify client - try: - spotify = self._bot.get_spotify_client(itx.user.id) - if spotify is None: - raise ValueError('You are not connected to Spotify.') - except ValueError as err: - return await itx.followup.send( - embed=create_error_embed(err.args[0]), - ephemeral=True - ) - - # Get the user's playlists - try: - playlists = spotify.get_user_playlists() - except HTTPError as err: - if err.response is not None and err.response.status_code == 403: - return await itx.followup.send(embed=create_error_embed( - message=SPOTIFY_403_ERR_MSG.format('get your playlists') - ), ephemeral=True) - raise - if len(playlists) == 0: - return await itx.followup.send(embed=create_error_embed( - message='You have no playlists.' - ), ephemeral=True) - - # Create dropdown - view = SpotifyDropdownView(self._bot, playlists, itx.user.id, 'playlist') - await itx.followup.send(embed=create_success_embed( - title='Pick a playlist', - body='Select a playlist from the dropdown below.' - ), view=view, delete_after=60.0) - - @slash_command(name='previous') - @application_checks.check(check_mutual_voice) - async def previous(self, itx: Interaction): - """ - Skip to the previous song. - """ - # Dispatch to jockey - await itx.response.defer() - jockey = await self._get_jockey(itx) - try: - await jockey.skip(forward=False, auto=False) - except EndOfQueueError as err: - embed = create_error_embed(f'Unable to rewind: {err.args[0]}') - await itx.followup.send(embed=embed) - - @slash_command(name='queue') - @application_checks.check(check_mutual_voice) - async def queue(self, itx: Interaction): - """ - Displays the current queue. - """ - if itx.guild is None: - raise RuntimeError('[player::queue] itx.guild is None') - await itx.response.defer() - - # Get jockey - jockey = await self._get_jockey(itx) - if len(jockey.queue) == 0: - await itx.followup.send(embed=create_error_embed('Queue is empty')) - return - - # Show loop status - embed_header = [f'{len(jockey.queue)} total'] - if jockey.queue_manager.is_looping_all: - embed_header.append(':repeat: Looping entire queue (`/unloopall` to disable)') - - # Show shuffle status - queue = jockey.queue_manager.shuffled_queue - current = jockey.queue_manager.current_shuffled_index - if jockey.queue_manager.is_shuffling: - embed_header.append( - ':twisted_rightwards_arrows: Shuffling queue (`/unshuffle` to disable)' - ) - - # Show queue in chunks of 10 per page - pages = [] - homepage = 0 - count = 1 - prefix_len = len(str(len(jockey.queue))) - for i, chunk in enumerate(list_chunks(queue)): - chunk_tracks = [] - - # Create page content - track: 'QueueItem' - for track in chunk: - title, artist = track.get_details() - - # Pad index with spaces if necessary - index = str(count) - while len(index) < prefix_len: - index = ' ' + index - - # Is this the current track? - line_prefix = ' ' - if count - 1 == current: - line_prefix = '> ' - homepage = i - - # Create item line - line_prefix = '> ' if count - 1 == current else ' ' - line = f'{line_prefix} {index} :: {title} - {artist}' - - # Truncate line if necessary - if len(line) > 50: - line = line[:47] + '...' - else: - line = f'{line:50.50}' - chunk_tracks.append(line) - count += 1 - - # Create page - tracks = '\n'.join(chunk_tracks) - embed_body = embed_header + [f'```asciidoc\n{tracks}```'] - embed = CustomEmbed( - title=f'Queue for {itx.guild.name}', - description='\n'.join(embed_body), - color=Color.lighter_gray() - ) - pages.append(embed.get()) - - # Run paginator - paginator = Paginator(itx) - return await paginator.run(pages, start=homepage) - - @slash_command(name='remove') - @application_checks.check(check_mutual_voice) - async def remove( - self, - itx: Interaction, - position: int = SlashOption( - description='Position to remove', - required=True - ) - ): - """ - Remove a track from queue. - """ - jockey = await self._get_jockey(itx) - if position < 1 or position > jockey.queue_size: - return await itx.response.send_message(embed=create_error_embed( - message=f'Specify a number from 1 to {str(jockey.queue_size)}.' - ), ephemeral=True) - if position - 1 == jockey.queue_manager.current_index: - return await itx.response.send_message(embed=create_error_embed( - message='You cannot remove the currently playing track.' - ), ephemeral=True) - - # Dispatch to jockey - await itx.response.defer() - title, artist = await jockey.remove(index=position - 1) - await itx.followup.send(embed=create_success_embed( - title='Removed from queue', - body=f'**{title}**\n{artist}' - )) - - # Update now playing message - await jockey.update_now_playing() - - @slash_command(name='search') - async def search( - self, - itx: Interaction, - search_type: str = SlashOption( - description='Search type', - required=True, - choices=['track', 'playlist', 'album', 'artist'] - ), - query: str = SlashOption(description='Query string', required=True) - ): - """ - Search Spotify's catalog for tracks to play. - """ - if itx.user is None: - return - await itx.response.defer() - - # Search catalog - try: - results = self._bot.spotify.search(query, search_type) - except SpotifyNoResultsError: - return await itx.followup.send(embed=create_error_embed( - message=f'No results found for `{query}`.' - ), ephemeral=True) - - # Create dropdown - view = SpotifyDropdownView(self._bot, results, itx.user.id, search_type) - await itx.followup.send(embed=create_success_embed( - title=f'Results for `{query}`', - body='Select a result to play from the dropdown below.' - ), view=view, delete_after=60.0) - - @slash_command(name='shuffle') - @application_checks.check(check_mutual_voice) - async def shuffle(self, itx: Interaction, quiet: bool = False): - """ - Shuffle the current playlist. - If you want to unshuffle the current queue, use /unshuffle instead. - """ - if not quiet: - await itx.response.defer() - - # Dispatch to jockey - jockey = await self._get_jockey(itx) - try: - jockey.queue_manager.shuffle() - except EmptyQueueError as err: - if not quiet: - await itx.followup.send(embed=create_error_embed(str(err.args[0]))) - else: - # Update now playing message - await jockey.update_now_playing() - - if not quiet: - await itx.followup.send( - embed=create_success_embed(f'{len(jockey.queue)} tracks shuffled') - ) - - @slash_command(name='skip') - @application_checks.check(check_mutual_voice) - async def skip(self, itx: Interaction): - """ - Skip the current song. - """ - # Dispatch to jockey - await itx.response.defer(ephemeral=True) - jockey = await self._get_jockey(itx) - try: - await jockey.skip(auto=False) - except EndOfQueueError as err: - embed = create_error_embed(f'Unable to skip: {err.args[0]}') - await itx.followup.send(embed=embed) - - @slash_command(name='stop') - @application_checks.check(check_mutual_voice) - async def stop(self, itx: Interaction): - """ - Stops the current song and disconnects from voice. - """ - if not isinstance(itx.user, Member): - raise RuntimeError('[player::stop] itx.user is not a Member') - await itx.response.defer() - await self._disconnect(itx=itx, reason=f'Stopped by <@{itx.user.id}>') - - @slash_command(name='unloop') - @application_checks.check(check_mutual_voice) - async def unloop(self, itx: Interaction): - """ - Stops looping the current track. - """ - # Dispatch to jockey - jockey = await self._get_jockey(itx) - if jockey.queue_manager.is_looping_one: - jockey.queue_manager.is_looping_one = False - - # Update now playing message - await jockey.update_now_playing() - - return await itx.response.send_message( - embed=create_success_embed('Not looping current track') - ) - - @slash_command(name='unloopall') - @application_checks.check(check_mutual_voice) - async def unloopall(self, itx: Interaction): - """ - Stops looping the whole queue. - """ - # Dispatch to jockey - jockey = await self._get_jockey(itx) - if jockey.queue_manager.is_looping_all: - jockey.queue_manager.is_looping_all = False - - # Update now playing message - await jockey.update_now_playing() - - return await itx.response.send_message( - embed=create_success_embed('Not looping entire queue') - ) - - @slash_command(name='unpause') - @application_checks.check(check_mutual_voice) - async def unpause(self, itx: Interaction, quiet: bool = False): - """ - Unpauses the current track. - """ - if not quiet: - await itx.response.defer() - - # Dispatch to jockey - jockey = await self._get_jockey(itx) - await jockey.resume() - - if not quiet: - await itx.followup.send(embed=create_success_embed('Unpaused'), delete_after=5.0) - - @slash_command(name='unshuffle') - @application_checks.check(check_mutual_voice) - async def unshuffle(self, itx: Interaction, quiet: bool = False): - """ - Unshuffle the current playlist. - """ - if not quiet: - await itx.response.defer() - - # Dispatch to jockey - jockey = await self._get_jockey(itx) - if jockey.queue_manager.is_shuffling: - jockey.queue_manager.unshuffle() - if not quiet: - return await itx.followup.send(embed=create_success_embed('Unshuffled')) - - # Update now playing message - await jockey.update_now_playing() - - if not quiet: - return await itx.followup.send( - embed=create_error_embed('Current queue is not shuffled') - ) - - @slash_command(name='volume') - @application_checks.check(check_mutual_voice) - async def volume( - self, - itx: Interaction, - volume: Optional[int] = SlashOption( - description='Volume level. Leave empty to print current volume.', - required=False, - min_value=0, - max_value=1000 - ) - ): - """ - Sets the volume level. - """ - jockey = await self._get_jockey(itx) - - # Is the volume argument empty? - if not volume: - # Print current volume - return await itx.response.send_message( - f'The volume is set to {jockey.volume}.', - ephemeral=True - ) - - # Dispatch to jockey - await itx.response.defer() - await jockey.set_volume(volume) - await itx.followup.send(embed=create_success_embed(f'Volume set to {volume}')) - - # Update now playing message - await jockey.update_now_playing() diff --git a/cogs/player/jockey.py b/cogs/player/jockey.py deleted file mode 100644 index f8aebfc..0000000 --- a/cogs/player/jockey.py +++ /dev/null @@ -1,683 +0,0 @@ -""" -Music player class for Blanco. Subclass of mafic.Player. -""" - -from asyncio import get_event_loop, sleep -from time import time -from typing import TYPE_CHECKING, List, Optional, Tuple - -from mafic import Player, PlayerNotConnected -from nextcord import (Colour, Forbidden, HTTPException, Message, NotFound, - StageChannel, VoiceChannel) - -from dataclass.custom_embed import CustomEmbed -from utils.constants import UNPAUSE_THRESHOLD -from utils.embeds import create_error_embed -from utils.exceptions import (EndOfQueueError, JockeyError, JockeyException, - LavalinkSearchError, SpotifyNoResultsError, - BumpError, BumpNotReadyError, BumpNotEnabledError) -from utils.musicbrainz import annotate_track -from utils.time import human_readable_time -from views.now_playing import NowPlayingView - -from .jockey_helpers import (find_lavalink_track, invalidate_lavalink_track, - parse_query) -from .queue import QueueManager - -if TYPE_CHECKING: - from mafic import Track - from nextcord import Embed - from nextcord.abc import Connectable, Messageable - - from dataclass.queue_item import QueueItem - from utils.blanco import BlancoBot - - -class Jockey(Player['BlancoBot']): - """ - Class that handles music playback for a single guild. - Contains all the methods for music playback, along with a - local instance of an in-memory database for fast queueing. - """ - - def __init__(self, client: 'BlancoBot', channel: 'Connectable'): - super().__init__(client, channel) - self._bot = client - - if not isinstance(channel, StageChannel) and not isinstance(channel, VoiceChannel): - raise TypeError(f'Channel must be a voice channel, not {type(channel)}') - - # Database - self._db = client.database - client.database.init_guild(channel.guild.id) - - # Pause timestamp - self._pause_ts: Optional[int] = None - - # Queue - self._queue_mgr = QueueManager(channel.guild.id, client.database) - - # Volume - self._volume = client.database.get_volume(channel.guild.id) - - # Logger - self._logger = client.jockey_logger - self._logger.info( - 'Using node `%s\' for %s', - self.node.label, - channel.guild.name - ) - - @property - def playing(self) -> bool: - """ - Returns whether the player is currently playing a track. - """ - return self.current is not None - - @property - def queue(self) -> List['QueueItem']: - """ - Returns the player queue. - """ - return self._queue_mgr.queue - - @property - def queue_manager(self) -> QueueManager: - """ - Returns the queue manager for the player. - """ - return self._queue_mgr - - @property - def queue_size(self) -> int: - """ - Returns the player queue size. - """ - return self._queue_mgr.size - - @property - def status_channel(self) -> 'Messageable': - """ - Returns the status channel for the player. - """ - channel = self._bot.get_status_channel(self.guild.id) - if channel is None: - raise ValueError('Status channel has not been set') - return channel - - @property - def volume(self) -> int: - """ - Returns the player volume. - """ - return self._volume - - @volume.setter - def volume(self, value: int): - """ - Sets the player volume and saves it to the database. - """ - self._volume = value - self._db.set_volume(self.guild.id, value) - - async def _edit_np_controls(self, show_controls: bool = True): - """ - Edits the now playing message to show or hide controls. - """ - view = None - if show_controls: - view = NowPlayingView(self._bot, self) - - np_msg = await self._get_now_playing() - if isinstance(np_msg, Message): - try: - await np_msg.edit(view=view) - except (HTTPException, Forbidden) as exc: - self._logger.warning( - 'Could not edit now playing message for %s: %s', - self.guild.name, - exc - ) - - async def _enqueue(self, index: int, auto: bool = True): - """ - Attempt to enqueue a track, for use with the skip() method. - - :param index: The index of the track to enqueue. - :param auto: Whether this is an automatic enqueue, i.e. not part of a user's command. - """ - try: - track = self._queue_mgr.queue[index] - await self._play(track) - except PlayerNotConnected: - if not auto: - await self.status_channel.send(embed=create_error_embed( - 'Attempted to skip while disconnected' - )) - raise JockeyError('Player is not connected') - except JockeyError as err: - self._logger.error('Failed to enqueue track: %s', err) - raise - - # Scrobble if possible - await self._scrobble(self._queue_mgr.current) - - # Update queue index - self._queue_mgr.current_index = index - - async def _get_now_playing(self) -> Optional[Message]: - np_msg_id = self._db.get_now_playing(self.guild.id) - if np_msg_id != -1: - try: - np_msg = await self.status_channel.fetch_message(np_msg_id) - return np_msg - except (Forbidden, HTTPException, NotFound) as exc: - self._logger.warning( - 'Failed to fetch now playing message for %s: %s', - self.guild.name, - exc - ) - - return None - - async def _play(self, item: 'QueueItem', position: Optional[int] = None): - if item.lavalink_track is None: - try: - assert self._bot.config is not None - deezer_enabled = self._bot.config.lavalink_nodes[self.node.label].deezer - item.lavalink_track = await find_lavalink_track( - self.node, - item, - deezer_enabled=deezer_enabled - ) - except LavalinkSearchError as err: - self._logger.critical('Failed to play `%s\'.', item.title) - raise JockeyError(err.args[0]) from err - - # Play track - has_retried = False - while True: - try: - await self.play( - item.lavalink_track, - volume=self.volume, - start_time=position, - replace=True, - pause=False - ) - except PlayerNotConnected as err: - # If we've already retried, give up - if has_retried: - raise JockeyError(err.args[0]) from err - - # Wait until we're connected - wait_time = 0 - self._logger.warning( - 'PlayerNotConnected raised while trying to play `%s\', retrying...', - item.title - ) - while not self.connected: - if wait_time >= 10: - raise JockeyError('Timeout while waiting for player to connect') from err - - # Print wait message only once - if wait_time == 0: - self._logger.debug('Waiting 10 sec for player to connect...') - await sleep(0.1) - wait_time += 0.1 - - # Remove cached Lavalink track and try again - invalidate_lavalink_track(item) - has_retried = True - else: - # Clear pause timestamp for new track - if position is None: - self._pause_ts = None - - break - - # Save start time for scrobbling - item.start_time = int(time()) - - async def _scrobble(self, item: 'QueueItem'): - """ - Scrobbles a track in a separate thread. - - :param item: The track to scrobble. - """ - get_event_loop().create_task(self._scrobble_impl(item)) - - async def _scrobble_impl(self, item: 'QueueItem'): - """ - Scrobbles a track for all users in the channel who have - linked their Last.fm accounts. - - Called by _scrobble() in a separate thread. - - :param item: The track to scrobble. - """ - if not isinstance(self.channel, VoiceChannel): - return - - # Check if scrobbling is enabled - assert self._bot.config is not None - if not self._bot.config.lastfm_enabled: - return - - # Check if track can be scrobbled - time_now = int(time()) - try: - duration = item.duration - if item.lavalink_track is not None: - duration = item.lavalink_track.length - - if item.start_time is not None and duration is not None: - # Check if track is longer than 30 seconds - if duration < 30000: - raise ValueError('Track is too short') - - # Check if enough time has passed (1/2 duration or 4 min, whichever is less) - elapsed_ms = (time_now - item.start_time) * 1000 - if elapsed_ms < min(duration // 2, 240000): - raise ValueError('Not enough time has passed') - else: - # Default to current time for timestamp - item.start_time = time_now - except ValueError as err: - self._logger.warning('Failed to scrobble `%s\': %s', item.title, err.args[0]) - return - - # Lookup MusicBrainz ID if needed - if item.mbid is None: - annotate_track(item) - - # Don't scrobble with no MBID and ISRC, - # as the track probably isn't on Last.fm - if item.mbid is None and item.isrc is None: - self._logger.warning( - 'Not scrobbling `%s\': no MusicBrainz ID or ISRC', - item.title - ) - return - - # Scrobble for every user - for member in self.channel.members: - if not member.bot: - scrobbler = self._bot.get_scrobbler(member.id) - if scrobbler is not None: - scrobbler.scrobble(item) - - async def disconnect(self, *, force: bool = False): - """ - Removes the controls from Now Playing, then disconnects. - """ - # Get now playing message - np_msg = await self._get_now_playing() - if np_msg is not None: - try: - await np_msg.edit(view=None) - except (HTTPException, Forbidden): - self._logger.warning( - 'Failed to remove now playing message for %s', - self.guild.name - ) - - # Disconnect - await super().disconnect(force=force) - - def now_playing(self, current: Optional['Track'] = None) -> 'Embed': - """ - Returns information about the currently playing track. - - :return: An instance of nextcord.Embed - """ - if current is None: - if self.current is None: - raise EndOfQueueError('No track is currently playing') - current = self.current - - # Construct Spotify URL if it exists - track = self._queue_mgr.current - uri = current.uri - if track.spotify_id is not None: - uri = f'https://open.spotify.com/track/{track.spotify_id}' - - # Get track duration - duration_ms = track.duration - if track.lavalink_track is not None: - duration_ms = track.lavalink_track.length - - # Build track duration string - duration = '' - if duration_ms is not None: - duration = human_readable_time(duration_ms) - - # Display complete artists if available - artist = track.artist if track.author is None else track.author - if artist is None: - artist = 'Unknown artist' - - # Display type of track - is_stream = False - if track.lavalink_track is not None: - is_stream = track.lavalink_track.stream - - # Build footer - footer = f'Track {self._queue_mgr.current_shuffled_index + 1} of {self.queue_size}' - if self._queue_mgr.is_shuffling: - footer += ' 🔀' - if self._queue_mgr.is_looping_one: - footer += ' 🔂' - if self._queue_mgr.is_looping_all: - footer += ' 🔁' - footer += f' • Volume {self.volume}%' - - imperfect_msg = ':warning: Playing the [**closest match**]({})' - embed = CustomEmbed( - title='Now streaming' if is_stream else 'Now playing', - description=[ - f'[**{track.title}**]({uri})', - artist, - duration if not is_stream else '', - f'\nrequested by <@{track.requester}>', - imperfect_msg.format(current.uri) if track.is_imperfect else '' - ], - footer=footer, - color=Colour.teal(), - thumbnail_url=track.artwork - ) - return embed.get() - - async def on_load_failed(self, failed_source: 'Track'): - """ - Called when a track fails to load. - Sends an error message to the status channel - and skips to the next track in queue. - - :param failed_track: The track that failed to load. Must be an instance of mafic.Track. - """ - # Get current track and its index - failed_track = self._queue_mgr.current - index = self._queue_mgr.current_shuffled_index + 1 - queue_size = self._queue_mgr.size - - # Send error embed - embed = CustomEmbed( - color=Colour.red(), - title=':warning:|Failed to load track', - description=[ - 'This could be due to a temporary issue with the source,', - 'a bot outage, or the track may be unavailable for playback.', - 'You can try playing the track again later.' - ], - fields=[ - ['Track', f'`{failed_track.title}`\n{failed_track.artist}'], - ['Position in queue', f'{index} of {queue_size}'], - ['Playback source', f'`{failed_source.title}`\n{failed_source.author}'], - ['Playback URL', f'[{failed_source.source}]({failed_source.uri})'], - ], - footer='Skipping to next track...', - ) - await self.status_channel.send(embed=embed.get()) - - # Skip to next track - await self.skip() - - async def pause(self, pause: bool = True): - """ - Pauses the player and stores the time at which playback was paused. - - The timestamp is necessary because Lavalink 4.0.0 (beta) does not - properly resume tracks when they are paused for an extended period, - causing the track to skip to the next one in the queue after a few - seconds of resumed playback. - - :param pause: Whether to pause or resume playback. - """ - await super().pause(pause=pause) - - # Store pause timestamp - self._pause_ts = int(time()) - - async def play_impl(self, query: str, requester: int) -> str: - """ - Adds an item to the player queue and begins playback if necessary. - - :param query: The query to play. - :param requester: The ID of the user who requested the track. - :return: A string containing the name of the track that was added. - """ - # Get results for query - try: - new_tracks = await parse_query( - self.node, - self._bot.spotify, - query, - requester - ) - except JockeyException: - raise - except SpotifyNoResultsError as err: - raise JockeyError(err.args[0]) from err - except Exception as exc: - if self.playing: - raise JockeyException(str(exc)) from exc - raise JockeyError(str(exc)) from exc - - # Add new tracks to queue - old_size = self._queue_mgr.size - self._queue_mgr.extend(new_tracks) - - # Get info for first track - first = new_tracks[0] - first_name = f'**{first.title}**\n{first.artist}' if first.title is not None else query - - # Are we beginning a new queue or is the player idle? - if not self.playing: - # We are! Play the first new track. - old_index = self._queue_mgr.current_index - self._queue_mgr.current_index = old_size - - try: - await self._play(new_tracks[0]) - except (JockeyError, PlayerNotConnected) as err: - # Remove enqueued tracks - for _ in range(old_size, self._queue_mgr.size): - self._queue_mgr.remove(old_size) - - # Restore old index - self._queue_mgr.current_index = old_index - - raise JockeyError(f'Failed to play "{first.title}"') from err - - # Send embed - return first_name if len(new_tracks) == 1 else f'{len(new_tracks)} item(s)' - - async def remove(self, index: int) -> Tuple[str | None, str | None]: - """ - Removes a track from the queue. - """ - # Remove track from queue - removed_track = self._queue_mgr.remove(index) - - # Return removed track details - return removed_track.title, removed_track.artist - - async def resume(self): - """ - Resumes the player from a paused state. - - If the player was paused for an extended period, the current track - will be re-enqueued and played from the last position to work around - a bug in Lavalink 4.0.0 (beta). - """ - # Check if we were paused for too long or if reenqueuing is disabled - assert self._bot.config is not None - if not self._bot.config.reenqueue_paused or ( - self._pause_ts is None or int(time()) - self._pause_ts < UNPAUSE_THRESHOLD): - await super().resume() - return - - # We were paused for too long, re-enqueue the current track - # and play from a little bit before the last position - last_pos = max(self.position - 10, 0) - self._pause_ts = None - self._logger.debug('Unpaused beyond %d sec threshold, re-enqueueing', UNPAUSE_THRESHOLD) - await self._play(self._queue_mgr.current, last_pos) - - async def set_volume(self, volume: int, /): - """ - Sets the player volume. - """ - await super().set_volume(volume) - self.volume = volume - - async def skip(self, *, forward: bool = True, index: int = -1, auto: bool = True): - """ - Skips the current track and plays the next one in the queue. - - :param forward: Whether to skip forward or backward. - :param index: The index of the track to skip to. - :param auto: Whether this is an automatic skip, i.e. not part of a user's command. - This is True when the player skips to the next track automatically, - such as when the current track ends. - """ - # It takes a while for the player to skip, - # so let's remove the player controls while we wait - # to prevent the user from spamming them. - await self._edit_np_controls(show_controls=False) - - try: - await self.play_bump() - return - except (JockeyException, SpotifyNoResultsError) as err: - self._logger.error('Error parsing bump into track: %s', err) - except BumpError as err: - self._logger.error('Error playing bump: %s', err) - except BumpNotEnabledError: - self._logger.debug('Bumps are not enabled in this guild.') - except BumpNotReadyError: - self._logger.debug('Not ready to play a bump yet.') - - # If index is specified, use that instead - if index != -1: - try: - await self._enqueue(index, auto=auto) - except JockeyError: - await self._edit_np_controls(show_controls=True) - await self.status_channel.send(embed=create_error_embed( - f'Unable to skip to index {index}' - )) - raise - - return - - # Is this autoskipping? - if auto: - # Check if we're looping the current track - if self._queue_mgr.is_looping_one: - # Re-enqueue the current track - try: - await self._enqueue(self._queue_mgr.current_index, auto=auto) - except JockeyError as err: - await self._edit_np_controls(show_controls=True) - await self.status_channel.send(embed=create_error_embed( - f'Unable to loop track: {err}' - )) - - return - - # Try to enqueue the next playable track - delta = 1 if forward else -1 - while True: - # Get next index - try: - next_i = self._queue_mgr.calc_next_index(delta=delta) - except EndOfQueueError: - # We've reached the end of the queue and looping is disabled - return - - # Get details of next track for logging - next_track = self._queue_mgr.queue[next_i] - next_title = next_track.title if next_track.title is not None else 'Unknown track' - next_artist = next_track.artist if next_track.artist is not None else 'Unknown artist' - - # Try to enqueue the next track - try: - await self._enqueue(next_i, auto=auto) - except JockeyError as err: - await self._edit_np_controls(show_controls=True) - delta += 1 if forward else -1 - - await self.status_channel.send(embed=CustomEmbed( - color=Colour.red(), - title=':warning:|Failed to skip to track', - description='It might be unavailable temporarily ' - 'or restricted to specific regions.\n', - fields=[ - ['Track', f'`{next_title}`\n{next_artist}'], - ['Position in queue', f'{next_i + 1} of {self.queue_size}'], - ['Error', f'```{err}```'] - ], - footer='Skipping to next track...' if auto else None - ).get()) - else: - break - - async def update_now_playing(self): - """ - Update the existing Now Playing view with current information. - """ - # Get now playing message - np_msg = await self._get_now_playing() - if np_msg is None: - return - - # Edit message - try: - await np_msg.edit(embed=self.now_playing()) - except (HTTPException, Forbidden) as exc: - # Ignore 404 - if not isinstance(exc, NotFound): - self._logger.warning( - 'Failed to edit now playing message for %s: %s', - self.guild.name, - exc - ) - - async def play_bump(self): - """ - Check and attempt to play a bump if it's been long enough. - """ - - enabled = self._db.get_bumps_enabled(self.guild.id) - if not enabled: - raise BumpNotEnabledError - - interval = self._db.get_bump_interval(self.guild.id) * 60 - last_bump = self._db.get_last_bump(self.guild.id) - - if last_bump == 0: - self._db.set_last_bump(self.guild.id) - raise BumpNotReadyError - - if int(time()) - last_bump < interval: - raise BumpNotReadyError - - bump = self._db.get_random_bump(self.guild.id) - if bump is None: - raise BumpError('Guild has no bumps.') - - requester = self._bot.user.id if self._bot.user is not None else self.guild.me.id - - try: - tracks = await parse_query(self.node, self._bot.spotify, bump.url, requester) - except (JockeyException, SpotifyNoResultsError): - raise - - if len(tracks) == 0: - raise BumpError('Unable to parse bump URL into tracks.') - - await self._play(tracks[0]) - self._db.set_last_bump(self.guild.id) diff --git a/cogs/player/jockey_helpers.py b/cogs/player/jockey_helpers.py deleted file mode 100644 index 3bebf34..0000000 --- a/cogs/player/jockey_helpers.py +++ /dev/null @@ -1,500 +0,0 @@ -""" -Helper functions for the music player. -""" - -from typing import TYPE_CHECKING, List, Tuple, TypeVar - -from mafic import SearchType -from spotipy.exceptions import SpotifyException - -from database.redis import REDIS -from dataclass.queue_item import QueueItem -from utils.constants import CONFIDENCE_THRESHOLD -from utils.exceptions import (JockeyException, LavalinkInvalidIdentifierError, - LavalinkSearchError, SpotifyNoResultsError) -from utils.fuzzy import check_similarity_weighted -from utils.logger import create_logger -from utils.musicbrainz import annotate_track -from utils.spotify_client import Spotify -from utils.url import (check_sc_url, check_spotify_url, check_url, - check_youtube_playlist_url, check_youtube_url, - check_ytmusic_playlist_url, check_ytmusic_url, - get_spinfo_from_url, get_ytid_from_url, - get_ytlistid_from_url) - -from .lavalink_client import (get_deezer_matches, get_deezer_track, - get_soundcloud_matches, get_youtube_matches) - -if TYPE_CHECKING: - from mafic import Node, Track - - from dataclass.spotify import SpotifyTrack - - -LOGGER = create_logger('jockey_helpers') -T = TypeVar('T') - - -def rank_results( - query: str, - results: List[T], - result_type: SearchType -) -> List[Tuple[T, int]]: - """ - Ranks search results based on similarity to a fuzzy query. - - :param query: The query to check against. - :param results: The results to rank. Can be mafic.Track, dataclass.SpotifyTrack, - or any object with a title and author string attribute. - :param result_type: The type of result. See ResultType. - :return: A list of tuples containing the result and its similarity to the query. - """ - # Rank results - similarities = [ - check_similarity_weighted( - query, - f'{result.title} {result.author}', # type: ignore - int(100 * (0.8 ** i)) - ) - for i, result in enumerate(results) - ] - ranked = sorted(zip(results, similarities), key=lambda x: x[1], reverse=True) - - # Print confidences for debugging - type_name = 'YouTube' - if result_type == SearchType.SPOTIFY_SEARCH: - type_name = 'Spotify' - elif result_type == SearchType.DEEZER_SEARCH: - type_name = 'Deezer' - LOGGER.debug('%s results and confidences for "%s":', type_name, query) - for result, confidence in ranked: - LOGGER.debug( - ' %3d %-20s %-25s', - confidence, - result.author[:20], # type: ignore - result.title[:25] # type: ignore - ) - - return ranked - - -async def find_lavalink_track( # pylint: disable=too-many-statements - node: 'Node', - item: QueueItem, - /, - deezer_enabled: bool = False, - in_place: bool = False, - lookup_mbid: bool = False -) -> 'Track': - """ - Finds a matching playable Lavalink track for a QueueItem. - - :param node: The Lavalink node to use for searching. Must be an instance of mafic.Node. - :param item: The QueueItem to find a track for. - :param deezer_enabled: Whether to use Deezer for searching. - :param in_place: Whether to modify the QueueItem in place. - :param lookup_mbid: Whether to look up the MBID for the track. - """ - results = [] - - # Check Redis if enabled - redis_key = None - redis_key_type = None - if REDIS is not None: - # Determine key type - if item.spotify_id is not None: - redis_key = item.spotify_id - redis_key_type = 'spotify_id' - elif item.isrc is not None: - redis_key = item.isrc - redis_key_type = 'isrc' - - # Get cached Lavalink track - if redis_key is not None and redis_key_type is not None: - encoded = REDIS.get_lavalink_track(redis_key, key_type=redis_key_type) - if encoded is not None: - LOGGER.info( - 'Found cached Lavalink track for Spotify ID %s', - item.spotify_id - ) - if in_place: - item.lavalink_track = await node.decode_track(encoded) - - return await node.decode_track(encoded) - - # Annotate track with ISRC and/or MBID - if item.isrc is None or lookup_mbid: - annotate_track(item) - - # Use ISRC if present - if item.isrc is not None: - # Try to match ISRC on Deezer if enabled - if deezer_enabled: - try: - result = await get_deezer_track(node, item.isrc) - except LavalinkSearchError: - LOGGER.warning( - 'No Deezer match for ISRC %s `%s\'', - item.isrc, - item.title - ) - else: - results.append(result) - LOGGER.debug( - 'Matched ISRC %s `%s\' on Deezer', - item.isrc, - item.title - ) - - # Try to match ISRC on YouTube - if len(results) == 0: - try: - results = await get_youtube_matches( - node, - f'"{item.isrc}"', - desired_duration_ms=item.duration - ) - except LavalinkSearchError: - LOGGER.warning( - 'No YouTube match for ISRC %s `%s\'', - item.isrc, - item.title - ) - else: - LOGGER.debug( - 'Matched ISRC %s `%s\' on YouTube', - item.isrc, - item.title - ) - else: - LOGGER.warning( - '`%s\' has no ISRC. Scrobbling might fail for this track.', - item.title - ) - item.is_imperfect = True - - # Fallback to metadata search - if len(results) == 0: - query = f'{item.title} {item.artist}' - - if item.isrc is not None: - LOGGER.warning( - 'No ISRC match for `%s\'. Falling back to metadata search.', - item.title - ) - - # Try to match on Deezer if enabled - if deezer_enabled: - try: - dz_results = await get_deezer_matches( - node, - query, - desired_duration_ms=item.duration, - auto_filter=True - ) - except LavalinkSearchError: - LOGGER.warning( - 'No Deezer results for `%s\'', - item.title - ) - else: - # Use top result if it's good enough - ranked = rank_results( - query, - dz_results, - SearchType.DEEZER_SEARCH - ) - if ranked[0][1] >= CONFIDENCE_THRESHOLD: - LOGGER.warning( - 'Using Deezer result `%s\' (%s) for `%s\'', - ranked[0][0].title, - ranked[0][0].lavalink_track.identifier, - item.title - ) - results.append(ranked[0][0]) - else: - LOGGER.warning( - 'No similar Deezer results for `%s\'', - item.title - ) - - if len(results) == 0: - try: - yt_results = await get_youtube_matches( - node, - query, - desired_duration_ms=item.duration - ) - except LavalinkSearchError as err: - LOGGER.error(err.message) - raise - - # Use top result - ranked = rank_results( - query, - yt_results, - SearchType.YOUTUBE - ) - LOGGER.warning( - 'Using YouTube result `%s\' (%s) for `%s\'', - ranked[0][0].title, - ranked[0][0].lavalink_track.identifier, - item.title - ) - results.append(ranked[0][0]) - - # Save Lavalink result - lavalink_track = results[0].lavalink_track - if in_place: - item.lavalink_track = lavalink_track - - # Save data to Redis if enabled - if REDIS is not None and redis_key_type is not None and redis_key is not None: - # Save Lavalink track - REDIS.set_lavalink_track( - redis_key, - lavalink_track.id, - key_type=redis_key_type - ) - - return lavalink_track - - -def invalidate_lavalink_track(item: QueueItem): - """ - Removes a cached Lavalink track from Redis. - - :param item: The QueueItem to invalidate the track for. - """ - if REDIS is None: - return - - # Determine key type - redis_key = None - redis_key_type = None - if item.spotify_id is not None: - redis_key = item.spotify_id - redis_key_type = 'spotify_id' - elif item.isrc is not None: - redis_key = item.isrc - redis_key_type = 'isrc' - - # Invalidate cached Lavalink track - if redis_key is not None and redis_key_type is not None: - REDIS.invalidate_lavalink_track( - redis_key, - key_type=redis_key_type - ) - else: - LOGGER.warning( - 'Could not invalidate cached track for `%s\': no key', - item.title - ) - - -async def parse_query( - node: 'Node', - spotify: Spotify, - query: str, - requester: int -) -> List[QueueItem]: - """ - Parse a query and return a list of QueueItems. - - :param node: The Lavalink node to use for searching. Must be an instance of mafic.Node. - :param spotify: The Spotify client to use for searching. See utils/spotify_client.py. - :param query: The query to parse. Can be plain language or a URL. - :param requester: The ID of the user who requested the track. - """ - query_is_url = check_url(query) - if query_is_url: - if check_spotify_url(query): - # Query is a Spotify URL. - return await parse_spotify_query(spotify, query, requester) - if check_youtube_url(query) or check_ytmusic_url(query): - # Query is a YouTube URL. - return await parse_youtube_query(node, query, requester) - if check_youtube_playlist_url(query) or check_ytmusic_playlist_url(query): - # Query is a YouTube playlist URL. - return await parse_youtube_playlist(node, query, requester) - if check_sc_url(query): - # Query is a SoundCloud URL. - return await parse_sc_query(node, query, requester) - - # Direct URL playback is deprecated - raise JockeyException('Direct playback from unsupported URLs is deprecated') - - # Attempt to look for a matching track on Spotify - try: - results = spotify.search_track(query, limit=10) - except SpotifyNoResultsError: - pass - else: - # Return top result if it's good enough - ranked = rank_results(query, results, SearchType.SPOTIFY_SEARCH) - if ranked[0][1] >= CONFIDENCE_THRESHOLD: - track = ranked[0][0] - return [QueueItem( - requester=requester, - title=track.title, - artist=track.artist, - author=track.author, - album=track.album, - spotify_id=track.spotify_id, - duration=track.duration_ms, - artwork=track.artwork, - isrc=track.isrc - )] - - # Get matching tracks from YouTube - results = await get_youtube_matches(node, query, auto_filter=False) - - # Return top result - ranked = rank_results(query, results, SearchType.YOUTUBE) - result = ranked[0][0] - return [QueueItem( - title=result.title, - artist=result.author, - artwork=result.artwork_url, - duration=result.duration_ms, - requester=requester, - url=result.url, - lavalink_track=result.lavalink_track - )] - - -async def parse_sc_query(node: 'Node', query: str, requester: int) -> List[QueueItem]: - """ - Parse a SoundCloud query and return a list of QueueItems. - See parse_query() for more information. - """ - try: - # Get results with Lavalink - tracks = await get_soundcloud_matches(node, query) - except Exception as exc: - raise LavalinkInvalidIdentifierError( - f'Entity {query} is private, nonexistent, or has no stream URL' - ) from exc - - return [QueueItem( - requester=requester, - title=track.title, - artist=track.author, - artwork=track.artwork_url, - duration=track.duration_ms, - url=track.url, - lavalink_track=track.lavalink_track - ) for track in tracks] - - -async def parse_spotify_query(spotify: Spotify, query: str, requester: int) -> List[QueueItem]: - """ - Parse a Spotify query and return a list of QueueItems. - See parse_query() for more information. - """ - # Get artwork for Spotify album/playlist - sp_type, sp_id = get_spinfo_from_url(query) - - new_tracks = [] - track_queue: List['SpotifyTrack'] - try: - if sp_type == 'track': - # Get track details from Spotify - track_queue = [spotify.get_track(sp_id)] - elif sp_type == 'artist': - # Get top tracks from Spotify - track_queue = spotify.get_artist_top_tracks(sp_id) - else: - # Get playlist or album tracks from Spotify - track_queue = spotify.get_tracks(sp_type, sp_id)[2] - except SpotifyException as exc: - if exc.http_status == 404: - # No tracks. - raise SpotifyNoResultsError( - f'The {sp_type} does not exist or is private.' - ) from exc - - raise SpotifyNoResultsError( - f'An error occurred while fetching the playlist: {exc.msg}' - ) from exc - - if len(track_queue) < 1: - if sp_type == 'track': - # No tracks. - raise SpotifyNoResultsError('Track does not exist or is private.') - raise SpotifyNoResultsError(f'{sp_type} does not have any public tracks.') - - # At least one track. - for track in track_queue: - new_tracks.append(QueueItem( - requester=requester, - title=track.title, - artist=track.artist, - author=track.author, - album=track.album, - spotify_id=track.spotify_id, - duration=track.duration_ms, - artwork=track.artwork, - isrc=track.isrc - )) - - return new_tracks - - -async def parse_youtube_playlist(node: 'Node', query: str, requester: int) -> List[QueueItem]: - """ - Parse a YouTube playlist query and return a list of QueueItems. - See parse_query() for more information. - """ - try: - # Get playlist tracks from YouTube - playlist_id = get_ytlistid_from_url(query) - tracks = await get_youtube_matches( - node, - f'https://youtube.com/playlist?list={playlist_id}' - ) - except Exception as exc: - # No tracks. - raise LavalinkInvalidIdentifierError( - query, - 'Playlist is empty, private, or nonexistent' - ) from exc - - return [QueueItem( - requester=requester, - title=track.title, - artist=track.author, - artwork=track.artwork_url, - duration=track.duration_ms, - url=track.url, - lavalink_track=track.lavalink_track - ) for track in tracks] - - -async def parse_youtube_query(node: 'Node', query: str, requester: int) -> List[QueueItem]: - """ - Parse a non-playlist YouTube query and return a list of QueueItems. - See parse_query() for more information. - """ - # Is it a video? - try: - video_id = get_ytid_from_url(query) - - # Get the video's details - video = await get_youtube_matches(node, video_id) - return [QueueItem( - title=video[0].title, - artist=video[0].author, - artwork=video[0].artwork_url, - requester=requester, - duration=video[0].duration_ms, - url=video[0].url, - lavalink_track=video[0].lavalink_track - )] - except LavalinkInvalidIdentifierError: - raise - except Exception as exc: - raise LavalinkInvalidIdentifierError( - query, - 'Only YouTube video and playlist URLs are supported.' - ) from exc diff --git a/cogs/player/lavalink_client.py b/cogs/player/lavalink_client.py deleted file mode 100644 index 7891731..0000000 --- a/cogs/player/lavalink_client.py +++ /dev/null @@ -1,195 +0,0 @@ -""" -Lavalink search helpers, which augment the basic search endpoint -with fuzzy search and exclusion of non-official track versions -(remixes, etc.) that the user didn't specifically ask for. -""" - -from typing import TYPE_CHECKING, List, Optional - -from mafic import Playlist, SearchType, TrackLoadException - -from dataclass.lavalink_result import LavalinkResult -from utils.constants import BLACKLIST -from utils.exceptions import LavalinkSearchError -from utils.fuzzy import check_similarity - -if TYPE_CHECKING: - from mafic import Node, Track - - -def filter_results(query: str, search_results: List['Track']) -> List[LavalinkResult]: - """ - Filters search results by removing karaoke, live, instrumental etc versions. - """ - results = [] - - for result in search_results: - if not result.length: - # Can't play a track with no duration - continue - - # Skip karaoke, live, instrumental etc versions - # if the original query did not ask for it - valid = True - for word in BLACKLIST: - if word in result.title.lower() and word not in query.lower(): - valid = False - break - - if valid: - results.append(parse_result(result)) - - return results - - -def parse_result(result: 'Track') -> LavalinkResult: - """ - Parses a Lavalink track result into a LavalinkResult object. - """ - parsed = LavalinkResult( - title=result.title, - author=result.author, - duration_ms=result.length, - artwork_url=result.artwork_url, - lavalink_track=result - ) - if result.uri is not None: - parsed.url = result.uri - - return parsed - - -async def get_deezer_matches( - node: 'Node', - query: str, - desired_duration_ms: Optional[int] = None, - auto_filter: bool = False -) -> List[LavalinkResult]: - """ - Gets Deezer tracks from Lavalink, and returns a list of LavalinkResult objects. - - :param node: The Lavalink node to use. - :param query: The query to search for. - :param desired_duration_ms: The desired duration of the track, in milliseconds. - :param automatic: Whether to automatically filter results. - """ - return await search_lavalink( - node, - query, - search_type=SearchType.DEEZER_SEARCH.value, - desired_duration_ms=desired_duration_ms, - auto_filter=auto_filter - ) - - -async def get_deezer_track(node: 'Node', isrc: str) -> LavalinkResult: - """ - Gets a single Deezer track from Lavalink, and returns a LavalinkResult object. - - :param node: The Lavalink node to use. - :param isrc: The ISRC to search for. - """ - results = await search_lavalink( - node, - isrc, - search_type=SearchType.DEEZER_ISRC.value, - auto_filter=False - ) - return results[0] - - -async def get_soundcloud_matches( - node: 'Node', - query: str, - desired_duration_ms: Optional[int] = None, - auto_filter: bool = False -) -> List[LavalinkResult]: - """ - Gets SoundCloud tracks from Lavalink, and returns a list of LavalinkResult objects. - - :param node: The Lavalink node to use. - :param query: The query to search for. - :param desired_duration_ms: The desired duration of the track, in milliseconds. - :param automatic: Whether to automatically filter results. - """ - return await search_lavalink( - node, - query, - search_type=SearchType.SOUNDCLOUD.value, - desired_duration_ms=desired_duration_ms, - auto_filter=auto_filter - ) - - -async def get_youtube_matches( - node: 'Node', - query: str, - desired_duration_ms: Optional[int] = None, - auto_filter: bool = False -) -> List[LavalinkResult]: - """ - Gets YouTube tracks from Lavalink, and returns a list of LavalinkResult objects. - - :param node: The Lavalink node to use. - :param query: The query to search for. - :param desired_duration_ms: The desired duration of the track, in milliseconds. - :param automatic: Whether to automatically filter results. - """ - return await search_lavalink( - node, - query, - search_type=SearchType.YOUTUBE.value, - desired_duration_ms=desired_duration_ms, - auto_filter=auto_filter - ) - - -async def search_lavalink( - node: 'Node', - query: str, - search_type: str = SearchType.YOUTUBE.value, - desired_duration_ms: Optional[int] = None, - auto_filter: bool = False -) -> List[LavalinkResult]: - """ - Generic search function for Lavalink that returns a list of LavalinkResult objects. - - :param node: The Lavalink node to use. - :param query: The query to search for. - :param search_type: The search type to use. See mafic.SearchType. - :param desired_duration_ms: The desired duration of the track, in milliseconds. - :param automatic: Whether to automatically filter results. - """ - try: - search = await node.fetch_tracks(query, search_type=search_type) - except TrackLoadException as exc: - raise LavalinkSearchError( - query, - reason=f'Could not get tracks for `{query}\': {exc.cause}' - ) from exc - - if isinstance(search, Playlist) and len(search.tracks) == 0: - raise LavalinkSearchError(query, reason='Playlist is empty') - if (isinstance(search, list) and len(search) == 0) or search is None: - raise LavalinkSearchError(query, reason='No results found') - - search_results = search if isinstance(search, list) else search.tracks - if auto_filter: - results = filter_results(query, search_results) - else: - results = [parse_result(result) for result in search_results] - - # Are there valid results? - if len(results) == 0: - raise LavalinkSearchError(query, reason='No valid results found') - - # Sort by descending similarity - if desired_duration_ms is not None: - results.sort( - key=lambda x: (1 - check_similarity(query, x.title), - abs(x.duration_ms - desired_duration_ms)) - ) - else: - results.sort(key=lambda x: 1 - check_similarity(query, x.title)) - - return results diff --git a/cogs/player/queue.py b/cogs/player/queue.py deleted file mode 100644 index 49594ac..0000000 --- a/cogs/player/queue.py +++ /dev/null @@ -1,378 +0,0 @@ -""" -Queue manager class for the player cog. -""" - -from random import shuffle -from typing import TYPE_CHECKING, List, Tuple - -from dataclass.queue_item import QueueItem -from utils.exceptions import EmptyQueueError, EndOfQueueError -from utils.logger import create_logger - -if TYPE_CHECKING: - from database import Database - - -class QueueManager: - """ - Queue manager for Blanco's Jockey. - """ - def __init__(self, guild_id: int, database: 'Database', /): - self._guild_id = guild_id - self._queue: List[QueueItem] = [] - self._shuf_i: List[int] = [] - - # Restore loop preferences from database - self._db = database - self._loop_one = database.get_loop(guild_id) - self._loop_all = database.get_loop_all(guild_id) - - # The current track index. - # Even if the queue is shuffled, this must ALWAYS - # correspond to an element in self._queue, not self._shuf_i. - self._i = -1 - - # Logger - self._logger = create_logger(self.__class__.__name__) - self._logger.info('Initialized queue manager for guild %d', guild_id) - - @property - def queue(self) -> List[QueueItem]: - """ - Returns the queue. - """ - return self._queue - - @property - def shuffled_queue(self) -> List[QueueItem]: - """ - Returns the queue, shuffled. - """ - if not self.is_shuffling: - return self.queue - return [self.queue[i] for i in self._shuf_i] - - @property - def is_shuffling(self) -> bool: - """ - Returns whether the queue is shuffled. - """ - return len(self._shuf_i) > 0 - - @property - def is_looping_one(self) -> bool: - """ - Returns whether the queue is looping the current track. - """ - return self._loop_one - - @is_looping_one.setter - def is_looping_one(self, value: bool): - """ - Sets whether the queue is looping the current track. - """ - self._loop_one = value - self._db.set_loop(self._guild_id, value) - - @property - def is_looping_all(self) -> bool: - """ - Returns whether the queue is looping all tracks. - """ - return self._loop_all - - @is_looping_all.setter - def is_looping_all(self, value: bool): - """ - Sets whether the queue is looping all tracks. - """ - self._loop_all = value - self._db.set_loop_all(self._guild_id, value) - - @property - def size(self) -> int: - """ - Returns the size of the queue. - """ - return len(self.queue) - - @property - def current(self) -> QueueItem: - """ - Returns the current track in the queue. - - Raises: - EmptyQueueError: If the queue is empty. - """ - if self.size == 0: - raise EmptyQueueError - - return self.queue[self.current_index] - - @property - def current_index(self) -> int: - """ - Returns the current track index, NOT accounting for shuffling. - This is the index of the current track in self._queue. - """ - return self._i - - @property - def current_shuffled_index(self) -> int: - """ - Returns the current track index, accounting for shuffling. - This is the index of the current track in self._shuf_i. - """ - if not self.is_shuffling: - return self.current_index - return self._shuf_i.index(self.current_index) - - @current_index.setter - def current_index(self, i: int): - """ - Sets the current track index. - - Args: - i: The new current track index. Must be adjusted for shuffling, - i.e., i must correspond to an element in self._queue, - not self._shuf_i. - """ - self._i = i - - @property - def next_track(self) -> Tuple[int, QueueItem]: - """ - Returns a tuple containing the index of the next track in the queue - and the track itself. - - Raises: - EmptyQueueError: If the queue is empty. - EndOfQueueError: If the last track in the queue is reached. - """ - if self.size == 0: - raise EmptyQueueError - - try: - i = self.calc_next_index() - track = self.queue[i] - except EndOfQueueError as err: - raise EndOfQueueError('No next track in queue.') from err - - return i, track - - @property - def previous_track(self) -> Tuple[int, QueueItem]: - """ - Returns a tuple containing the index of the previous track in the queue - and the track itself. - - Raises: - EmptyQueueError: If the queue is empty. - EndOfQueueError: If the first track in the queue is reached. - """ - if self.size == 0: - raise EmptyQueueError - - try: - i = self.calc_next_index(delta=-1) - track = self.queue[i] - except EndOfQueueError as err: - raise EndOfQueueError('No previous track in queue.') from err - - return i, track - - def calc_next_index(self, *, delta: int = 1) -> int: - """ - Calculate the next track index, accounting for shuffling and - looping a single track. - - Args: - delta: How far ahead or back to seek the next index. - - Returns: - The next track index in self._queue. - - Raises: - EndOfQueueError: If one of the ends of the queue is reached, - and the queue is not looping all tracks. - """ - forward = delta > 0 - - # Return the current index if the queue is looping a single track. - next_i = self.current_index - if self.is_looping_one: - return next_i - - # If we're shuffling, we need to use self._shuf_i to calculate the next index. - # Otherwise, we can just use the current index. - if self.is_shuffling: - next_i = self._shuf_i.index(next_i) - - # Calculate the next index. - next_i += delta - if (next_i >= self.size and forward) or (next_i < 0 and not forward): - if self.is_looping_all: - next_i = 0 if forward else self.size - 1 - else: - raise EndOfQueueError - - # If we're shuffling, we need to convert the next index back to - # an index in self._queue. - if self.is_shuffling: - next_i = self._shuf_i[next_i] - return next_i - - def skip(self) -> QueueItem: - """ - Returns the next track in the queue and adjusts the current - track index. - - Raises: - EmptyQueueError: If the queue is empty. - EndOfQueueError: If the last track in the queue is reached. - """ - i, track = self.next_track - self._i = i - return track - - def rewind(self) -> QueueItem: - """ - Returns the previous track in the queue and adjusts the current - track index. - - Raises: - EmptyQueueError: If the queue is empty. - EndOfQueueError: If the first track in the queue is reached. - """ - i, track = self.previous_track - self._i = i - return track - - def shuffle(self): - """ - Shuffles the queue non-destructively by generating a random - permutation of indices. Each call to shuffle() will generate - a different permutation, with the current track always at - the beginning. - - Raises: - EmptyQueueError: If the queue is empty. - """ - if self.size == 0: - raise EmptyQueueError - - # Shuffle everything except the current track. - indices = [i for i in range(self.size) if i != self.current_index] - shuffle(indices) - - # Prepend the current track index to the shuffle index list. - self._shuf_i = [self.current_index] + indices - - def unshuffle(self): - """ - Unshuffles the queue by clearing the shuffle index list. - """ - self._shuf_i = [] - - def extend(self, items: List[QueueItem]): - """ - Appends multiple items to the end of the queue. - - Args: - items: The QueueItems to append. - """ - new_queue = self.size == 0 - - # Append the items to the queue. - self.queue.extend(items) - if self.is_shuffling: - self._shuf_i.extend(list(range(self.size - len(items), self.size))) - - # Update index - if new_queue: - self.current_index = 0 - - def insert(self, item: QueueItem, /, index: int): - """ - Inserts an item in the queue at a specified index. - - Args: - item: The QueueItem to insert. - index: The index at which to insert the item. - - Raises: - EmptyQueueError: If the queue is empty and we are trying - to insert past index zero. Use enqueue() instead. - IndexError: If the index is out of range. - """ - if self.size == 0 and index != 0: - raise EmptyQueueError - if not 0 <= index <= self.size: - raise IndexError(f'Index {index} out of range.') - - if self.is_shuffling: - # If we're shuffling, insert the item at the end of self._queue, - # then insert the new index at the specified index in self._shuf_i. - self.queue.append(item) - self._shuf_i.insert(index, self.size - 1) - else: - # Otherwise, just insert the item at the specified index in self._queue. - self.queue.insert(index, item) - - def move(self, source_i: int, dest_i: int, /): - """ - Moves a queue item from one index to another. - - Args: - source_i: The index of the item to move. - dest_i: The index to move the item to. - - Raises: - EmptyQueueError: If the queue is empty. - IndexError: If either index is out of range, or if the - source and destination indices are the same, or if - the source index is the current track index. - """ - if self.size == 0: - raise EmptyQueueError - if not 0 <= source_i < self.size: - raise IndexError(f'Source index {source_i} out of range.') - if not 0 <= dest_i < self.size: - raise IndexError(f'Destination index {dest_i} out of range.') - if source_i == dest_i: - raise IndexError('Source and destination indices are the same.') - if source_i == self.current_index: - raise IndexError('Cannot move the current track.') - - self.insert(self.remove(source_i), dest_i) - - def remove(self, index: int, /) -> QueueItem: - """ - Removes an element at the given index and returns the element. - - Raises: - EmptyQueueError: If the queue is empty. - IndexError: If the index is out of range. - """ - if self.size == 0: - raise EmptyQueueError - if not 0 <= index < self.size: - raise IndexError(f'Index {index} out of range.') - - # Adjust the index if we're shuffling. - adjusted_index = index - if self.is_shuffling: - # Remove the index from self._shuf_i. - adjusted_index = self._shuf_i.pop(index) - - # Adjust the indices in self._shuf_i. - for i, j in enumerate(self._shuf_i): - if j > index: - self._shuf_i[i] -= 1 - - # If we're removing the current track, adjust the current track index. - if adjusted_index == self.current_index: - self._i = self.calc_next_index() - - # Remove the element from self._queue. - return self.queue.pop(adjusted_index) diff --git a/dashboard/static/css/.gitignore b/dashboard/static/css/.gitignore new file mode 100644 index 0000000..96f805b --- /dev/null +++ b/dashboard/static/css/.gitignore @@ -0,0 +1 @@ +main.css diff --git a/server/static/css/base.css b/dashboard/static/css/base.css similarity index 100% rename from server/static/css/base.css rename to dashboard/static/css/base.css diff --git a/server/static/images/favicon/android-chrome-192x192.png b/dashboard/static/images/favicon/android-chrome-192x192.png similarity index 100% rename from server/static/images/favicon/android-chrome-192x192.png rename to dashboard/static/images/favicon/android-chrome-192x192.png diff --git a/server/static/images/favicon/android-chrome-512x512.png b/dashboard/static/images/favicon/android-chrome-512x512.png similarity index 100% rename from server/static/images/favicon/android-chrome-512x512.png rename to dashboard/static/images/favicon/android-chrome-512x512.png diff --git a/server/static/images/favicon/apple-touch-icon.png b/dashboard/static/images/favicon/apple-touch-icon.png similarity index 100% rename from server/static/images/favicon/apple-touch-icon.png rename to dashboard/static/images/favicon/apple-touch-icon.png diff --git a/server/static/images/favicon/favicon-16x16.png b/dashboard/static/images/favicon/favicon-16x16.png similarity index 100% rename from server/static/images/favicon/favicon-16x16.png rename to dashboard/static/images/favicon/favicon-16x16.png diff --git a/server/static/images/favicon/favicon-32x32.png b/dashboard/static/images/favicon/favicon-32x32.png similarity index 100% rename from server/static/images/favicon/favicon-32x32.png rename to dashboard/static/images/favicon/favicon-32x32.png diff --git a/server/static/images/favicon/favicon.ico b/dashboard/static/images/favicon/favicon.ico similarity index 100% rename from server/static/images/favicon/favicon.ico rename to dashboard/static/images/favicon/favicon.ico diff --git a/server/static/images/favicon/site.webmanifest b/dashboard/static/images/favicon/site.webmanifest similarity index 80% rename from server/static/images/favicon/site.webmanifest rename to dashboard/static/images/favicon/site.webmanifest index 5ad2c38..a8c5765 100755 --- a/server/static/images/favicon/site.webmanifest +++ b/dashboard/static/images/favicon/site.webmanifest @@ -1 +1 @@ -{"name":"Blanco","short_name":"Blanco","icons":[{"src":"/static/images/favicon/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/static/images/favicon/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#7a80ba","background_color":"#f4f1de","display":"standalone"} \ No newline at end of file +{"name":"Blanco","short_name":"Blanco","icons":[{"src":"/static/images/favicon/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/static/images/favicon/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#7a80ba","background_color":"#f4f1de","display":"standalone"} diff --git a/server/static/images/logo.svg b/dashboard/static/images/logo.svg similarity index 100% rename from server/static/images/logo.svg rename to dashboard/static/images/logo.svg diff --git a/server/templates/base.html b/dashboard/templates/base.html similarity index 100% rename from server/templates/base.html rename to dashboard/templates/base.html diff --git a/server/templates/dashboard.html b/dashboard/templates/dashboard.html similarity index 99% rename from server/templates/dashboard.html rename to dashboard/templates/dashboard.html index 8a27dd6..2322f30 100644 --- a/server/templates/dashboard.html +++ b/dashboard/templates/dashboard.html @@ -169,4 +169,4 @@

Delete account

-{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/server/templates/homepage.html b/dashboard/templates/homepage.html similarity index 100% rename from server/templates/homepage.html rename to dashboard/templates/homepage.html diff --git a/database/__init__.py b/database/__init__.py deleted file mode 100644 index 153de7c..0000000 --- a/database/__init__.py +++ /dev/null @@ -1,401 +0,0 @@ -""" -Database module for Blanco. Interfaces with the bot's SQLite database. -""" - -import sqlite3 as sql -from typing import List, Optional -import time - -from dataclass.oauth import LastfmAuth, OAuth -from dataclass.bump import Bump -from utils.logger import create_logger - -from .migrations import run_migrations - - -class Database: - """ - Class for handling connections to the bot's SQLite DB. - """ - - def __init__(self, db_filename: str): - self._con = sql.connect(db_filename, check_same_thread=False) - self._cur = self._con.cursor() - self._logger = create_logger(self.__class__.__name__) - - # Run migrations - self._logger.info('Connected to database %s, running migrations...', db_filename) - run_migrations(self._logger, self._con) - - def init_guild(self, guild_id: int): - """ - Initialize a guild in the database if it hasn't been yet. - """ - self._cur.execute(f'INSERT OR IGNORE INTO player_settings (guild_id) VALUES ({guild_id})') - self._con.commit() - - def get_volume(self, guild_id: int) -> int: - """ - Get the volume for a guild. - """ - self._cur.execute(f'SELECT volume FROM player_settings WHERE guild_id = {guild_id}') - return self._cur.fetchone()[0] - - def set_volume(self, guild_id: int, volume: int): - """ - Set the volume for a guild. - """ - self._cur.execute( - f'UPDATE player_settings SET volume = {volume} WHERE guild_id = {guild_id}' - ) - self._con.commit() - - def get_loop(self, guild_id: int) -> bool: - """ - Get the loop setting for a guild. - """ - self._cur.execute(f'SELECT loop FROM player_settings WHERE guild_id = {guild_id}') - return self._cur.fetchone()[0] == 1 - - def set_loop(self, guild_id: int, loop: bool): - """ - Set the loop setting for a guild. - """ - self._cur.execute( - f'UPDATE player_settings SET loop = {int(loop)} WHERE guild_id = {guild_id}' - ) - self._con.commit() - - def get_loop_all(self, guild_id: int) -> bool: - """ - Get the whole-queue loop setting for a guild. - """ - self._cur.execute(f'SELECT loop_all FROM player_settings WHERE guild_id = {guild_id}') - return self._cur.fetchone()[0] == 1 - - def set_loop_all(self, guild_id: int, loop: bool): - """ - Set the whole-queue loop setting for a guild. - """ - self._cur.execute( - f'UPDATE player_settings SET loop_all = {int(loop)} WHERE guild_id = {guild_id}' - ) - self._con.commit() - - def get_now_playing(self, guild_id: int) -> int: - """ - Get the last now playing message ID for a guild. - """ - self._cur.execute( - f'SELECT last_np_msg FROM player_settings WHERE guild_id = {guild_id}' - ) - return self._cur.fetchone()[0] - - def set_now_playing(self, guild_id: int, msg_id: int): - """ - Set the last now playing message ID for a guild. - """ - self._cur.execute( - f'UPDATE player_settings SET last_np_msg = {msg_id} WHERE guild_id = {guild_id}' - ) - self._con.commit() - - def get_status_channel(self, guild_id: int) -> int: - """ - Get the status channel for a guild. - """ - self._cur.execute( - f'SELECT status_channel FROM player_settings WHERE guild_id = {guild_id}' - ) - return self._cur.fetchone()[0] - - def set_status_channel(self, guild_id: int, channel_id: int): - """ - Set the status channel for a guild. - """ - self._cur.execute( - f'UPDATE player_settings SET status_channel = {channel_id} WHERE guild_id = {guild_id}' - ) - self._con.commit() - - def set_last_bump(self, guild_id: int): - """ - Set the last bump for a guild. - """ - seconds = int(time.time()) - self._cur.execute( - f'UPDATE player_settings SET last_bump = {seconds} WHERE guild_id = {guild_id}' - ) - self._con.commit() - - def get_last_bump(self, guild_id: int) -> int: - """ - Get the last bump for a guild. - """ - self._cur.execute(f'SELECT last_bump FROM player_settings WHERE guild_id = {guild_id}') - return self._cur.fetchone()[0] - - def set_bumps_enabled(self, guild_id: int, enabled: bool): - """ - Set whether bumps are enabled for a guild. - """ - self._cur.execute( - f'UPDATE player_settings SET bumps_enabled = {int(enabled)} WHERE guild_id = {guild_id}' - ) - self._con.commit() - - def get_bumps_enabled(self, guild_id: int) -> bool: - """ - Get whether bumps are enabled for a guild. - """ - self._cur.execute( - f'SELECT bumps_enabled FROM player_settings WHERE guild_id = {guild_id}' - ) - return self._cur.fetchone()[0] == 1 - - def set_bump_interval(self, guild_id: int, interval: int): - """ - Set the bump interval for a guild. - """ - self._cur.execute( - f'UPDATE player_settings SET bump_interval = {interval} WHERE guild_id = {guild_id}' - ) - self._con.commit() - - def get_bump_interval(self, guild_id: int) -> int: - """ - Get the bump interval for a guild. - """ - self._cur.execute( - f'SELECT bump_interval FROM player_settings WHERE guild_id = {guild_id}' - ) - return self._cur.fetchone()[0] - - def get_session_id(self, node_id: str) -> str: - """ - Get the session ID for a Lavalink node. - """ - self._cur.execute(f'SELECT session_id FROM lavalink WHERE node_id = "{node_id}"') - return self._cur.fetchone()[0] - - def set_session_id(self, node_id: str, session_id: str): - """ - Set the session ID for a Lavalink node. - """ - self._cur.execute( - f'''INSERT OR REPLACE INTO lavalink ( - node_id, - session_id - ) VALUES ("{node_id}", "{session_id}")''' - ) - self._con.commit() - - def set_oauth(self, provider: str, credentials: OAuth): - """ - Save OAuth2 data for a user. - - :param provider: The provider to save the data for. Can be either 'discord' or 'spotify'. - :param credentials: The OAuth2 credentials to save. - """ - self._cur.execute(f''' - INSERT OR REPLACE INTO {provider}_oauth ( - user_id, - username, - access_token, - refresh_token, - expires_at - ) VALUES ( - {credentials.user_id}, - "{credentials.username}", - "{credentials.access_token}", - "{credentials.refresh_token}", - {credentials.expires_at} - ) - ''') - self._con.commit() - - def get_oauth(self, provider: str, user_id: int) -> Optional[OAuth]: - """ - Get OAuth2 data for a user from the database. - - :param provider: The provider to get credentials for. Can be either 'discord' or 'spotify'. - :param user_id: The user ID to get credentials for - """ - self._cur.execute(f'SELECT * FROM {provider}_oauth WHERE user_id = {user_id}') - row = self._cur.fetchone() - if row is None: - return None - return OAuth( - user_id=row[0], - username=row[1], - access_token=row[2], - refresh_token=row[3], - expires_at=row[4] - ) - - def set_lastfm_credentials(self, credentials: LastfmAuth): - """ - Save Last.fm credentials for a user. - """ - self._cur.execute(f''' - INSERT OR REPLACE INTO lastfm_oauth ( - user_id, - username, - session_key - ) VALUES ( - {credentials.user_id}, - "{credentials.username}", - "{credentials.session_key}" - ) - ''') - self._con.commit() - - def get_lastfm_credentials(self, user_id: int) -> Optional[LastfmAuth]: - """ - Get Last.fm credentials for a user. - """ - self._cur.execute( - f'SELECT * FROM lastfm_oauth WHERE user_id = {user_id}' - ) - row = self._cur.fetchone() - if row is None: - return None - return LastfmAuth(*row) - - def delete_oauth(self, provider: str, user_id: int): - """ - Delete OAuth2 data for a user from the database. - """ - self._cur.execute(f'DELETE FROM {provider}_oauth WHERE user_id = {user_id}') - self._con.commit() - - def set_spotify_scopes(self, user_id: int, scopes: List[str]): - """ - Set the Spotify scopes for a user. - """ - self._cur.execute(f''' - UPDATE spotify_oauth SET scopes = "{",".join(scopes)}" WHERE user_id = {user_id} - ''') - self._con.commit() - - def get_spotify_scopes(self, user_id: int) -> List[str]: - """ - Get the Spotify scopes for a user. - """ - self._cur.execute(f'SELECT scopes FROM spotify_oauth WHERE user_id = {user_id}') - return self._cur.fetchone()[0].split(',') - - def add_bump(self, guild_id: int, url: str, title: str, author: str): - """ - Set a bump for a guild. - """ - self._cur.execute(f'SELECT MAX(idx) FROM bumps WHERE guild_id = {guild_id}') - idx = self._cur.fetchone()[0] - if idx is None: - idx = 0 - idx += 1 - self._cur.execute(f''' - INSERT INTO bumps ( - guild_id, - idx, - url, - title, - author - ) VALUES ( - {guild_id}, - {idx}, - "{url}", - "{title}", - "{author}" - ) - ''' - ) - self._con.commit() - - def get_bumps(self, guild_id: int) -> Optional[List[Bump]]: - """ - Get every bump for a guild. - """ - self._cur.execute(f'''SELECT idx, guild_id, url, title, author - FROM bumps WHERE guild_id = {guild_id}''') - rows = self._cur.fetchall() - if len(rows) == 0: - return None - - return [ - Bump( - idx=row[0], - guild_id=row[1], - url=row[2], - title=row[3], - author=row[4] - ) - for row in rows - ] - - def get_bump(self, guild_id: int, idx: int) -> Optional[Bump]: - """ - Get a guild bump by its index. - """ - self._cur.execute( - f'''SELECT idx, guild_id, url, title, author FROM bumps - WHERE guild_id = {guild_id} AND idx = {idx} - ''' - ) - row = self._cur.fetchone() - if row is None: - return None - return Bump( - idx=row[0], - guild_id=row[1], - url=row[2], - title=row[3], - author=row[4] - ) - - def get_bump_by_url(self, guild_id: int, url: str) -> Optional[Bump]: - """ - Get a guild bump by its URL. - """ - self._cur.execute( - f'''SELECT idx, guild_id, url, title, author FROM bumps - WHERE guild_id = {guild_id} AND url = "{url}" - ''' - ) - row = self._cur.fetchone() - if row is None: - return None - return Bump( - idx=row[0], - guild_id=row[1], - url=row[2], - title=row[3], - author=row[4] - ) - - def get_random_bump(self, guild_id: int) -> Optional[Bump]: - """ - Get a random guild bump. - """ - self._cur.execute( - f'''SELECT idx, guild_id, url, title, author FROM bumps WHERE - guild_id = {guild_id} ORDER BY RANDOM() LIMIT 1 - ''' - ) - row = self._cur.fetchone() - if row is None: - return None - return Bump( - idx=row[0], - guild_id=row[1], - url=row[2], - title=row[3], - author=row[4] - ) - - def delete_bump(self, guild_id: int, idx: int): - """ - Delete a guild bump by its index. - """ - self._cur.execute(f'DELETE FROM bumps WHERE guild_id = {guild_id} AND idx = {idx}') - self._con.commit() diff --git a/database/migrations/0002-statuschannel.py b/database/migrations/0002-statuschannel.py deleted file mode 100644 index 5f84642..0000000 --- a/database/migrations/0002-statuschannel.py +++ /dev/null @@ -1,27 +0,0 @@ -""" -Add a column to the player_settings table to store the status channel ID. -""" -# pylint: disable=invalid-name - -from sqlite3 import OperationalError -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from sqlite3 import Connection - -def run(con: 'Connection'): - """ - Run the migration. - """ - cur = con.cursor() - - # There's no built-in way to check if a column exists in SQLite, - # so we just try to add it and ignore the error if it already exists. - try: - cur.execute(''' - ALTER TABLE player_settings ADD COLUMN status_channel INTEGER NOT NULL DEFAULT -1 - ''') - except OperationalError: - pass - - con.commit() diff --git a/database/migrations/0004-loop-all.py b/database/migrations/0004-loop-all.py deleted file mode 100644 index adb5c91..0000000 --- a/database/migrations/0004-loop-all.py +++ /dev/null @@ -1,27 +0,0 @@ -""" -Add a column to the player_settings table to store the whole-queue repeat preference per guild. -""" -# pylint: disable=invalid-name - -from sqlite3 import OperationalError -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from sqlite3 import Connection - -def run(con: 'Connection'): - """ - Run the migration. - """ - cur = con.cursor() - - # There's no built-in way to check if a column exists in SQLite, - # so we just try to add it and ignore the error if it already exists. - try: - cur.execute(''' - ALTER TABLE player_settings ADD COLUMN loop_all INTEGER NOT NULL DEFAULT 0 - ''') - except OperationalError: - pass - - con.commit() diff --git a/database/migrations/__init__.py b/database/migrations/__init__.py deleted file mode 100644 index e910133..0000000 --- a/database/migrations/__init__.py +++ /dev/null @@ -1,33 +0,0 @@ -""" -Database migrations module for Blanco. -Handles automatic adjustment of the SQLite database schema -across updates of the bot. -""" - -from importlib import import_module -from os import listdir, path -from sqlite3 import OperationalError -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from logging import Logger - from sqlite3 import Connection - - -def run_migrations(logger: 'Logger', con: 'Connection'): - """ - Run all migrations on Blanco's database. - - :param con: The Connection instance to the SQLite database. - """ - for file in sorted(listdir(path.dirname(__file__))): - if file != path.basename(__file__) and file.endswith('.py'): - logger.debug('Running migration: %s', file) - migration = import_module(f'database.migrations.{file[:-3]}') - - try: - migration.run(con) - except OperationalError as err: - logger.error('Error running migration %s: %s', file, err) - logger.critical('Aborting migrations.') - raise RuntimeError('Error running migrations.') from err diff --git a/database/redis.py b/database/redis.py deleted file mode 100644 index a8b2fd0..0000000 --- a/database/redis.py +++ /dev/null @@ -1,164 +0,0 @@ -""" -Redis client that takes care of caching MusicBrainz and Spotify lookups. -""" - -from typing import Optional - -import redis - -from dataclass.spotify import SpotifyTrack -from utils.config import REDIS_HOST, REDIS_PASSWORD, REDIS_PORT -from utils.logger import create_logger - - -class RedisClient: - """ - Redis client that takes care of caching MusicBrainz and Spotify lookups. - """ - def __init__(self, host: str, port: int, password: Optional[str] = None): - self._client = redis.StrictRedis( - host=host, - port=port, - password=password, - encoding='utf-8', - decode_responses=True - ) - - # Logger - self._logger = create_logger(self.__class__.__name__) - self._logger.debug('Attempting to connect to Redis server...') - - # Test connection - try: - self._client.ping() - except redis.ConnectionError as err: - self._logger.critical('Could not connect to Redis server. Check your configuration.') - raise RuntimeError('Could not connect to Redis server.') from err - - self._logger.info('Connected to Redis server. Enable debug logging to see cache hits.') - - def set_lavalink_track(self, key: str, value: str, *, key_type: str): - """ - Save an encoded Lavalink track. - - :param key: The key to save the track under. - :param value: The encoded track. - :param key_type: The type of key to save the track under, e.g. 'isrc' or 'spotify_id'. - """ - self._logger.debug('Caching Lavalink track for %s:%s', key_type, key) - self._client.set(f'lavalink:{key_type}:{key}', value) - - def get_lavalink_track(self, key: str, *, key_type: str) -> Optional[str]: - """ - Get an encoded Lavalink track. - - :param key: The key to get the track from. - :param key_type: The type of key to get the track from, e.g. 'isrc' or 'spotify_id'. - """ - if not self._client.exists(f'lavalink:{key_type}:{key}'): - return None - - self._logger.debug('Got cached Lavalink track for %s:%s', key_type, key) - return self._client.get(f'lavalink:{key_type}:{key}') # type: ignore - - def invalidate_lavalink_track(self, key: str, *, key_type: str): - """ - Removes a cached Lavalink track. - - :param key: The key to remove the track for. - :param key_type: The type of key to remove the track for, e.g. 'isrc' or 'spotify_id'. - """ - self._logger.debug('Invalidating Lavalink track for %s:%s', key_type, key) - if self._client.exists(f'lavalink:{key_type}:{key}'): - self._client.delete(f'lavalink:{key_type}:{key}') - - def set_spotify_track(self, spotify_id: str, track: 'SpotifyTrack'): - """ - Save a Spotify track. - """ - self._logger.debug('Caching info for Spotify track %s', spotify_id) - self._client.hmset(f'spotify:{spotify_id}', { - 'title': track.title, - 'artist': track.artist, - 'author': track.author, - 'duration_ms': track.duration_ms, - 'artwork': track.artwork if track.artwork is not None else '', - 'album': track.album if track.album is not None else '', - 'isrc': track.isrc if track.isrc is not None else '', - }) - - # Remove standalone ISRC cache - if self._client.exists(f'isrc:{spotify_id}'): - self._client.delete(f'isrc:{spotify_id}') - - def get_spotify_track(self, spotify_id: str) -> Optional['SpotifyTrack']: - """ - Get a Spotify track. - """ - track = self._client.hgetall(f'spotify:{spotify_id}') - - if not track: - return None - - self._logger.debug('Got cached info for Spotify track %s', spotify_id) - return SpotifyTrack( - title=track['title'], # type: ignore - artist=track['artist'], # type: ignore - author=track['author'], # type: ignore - duration_ms=int(track['duration_ms']), # type: ignore - artwork=track['artwork'] if track['artwork'] else None, # type: ignore - album=track['album'] if track['album'] else None, # type: ignore - isrc=track['isrc'] if track['isrc'] else None, # type: ignore - spotify_id=spotify_id - ) - - def set_mbid(self, spotify_id: str, mbid: str): - """ - Save a MusicBrainz ID for a Spotify track. - """ - self._logger.debug('Caching MusicBrainz ID for Spotify track %s', spotify_id) - self._client.set(f'mbid:{spotify_id}', mbid) - - def get_mbid(self, spotify_id: str) -> Optional[str]: - """ - Get a MusicBrainz ID for a Spotify track. - """ - if not self._client.exists(f'mbid:{spotify_id}'): - return None - - self._logger.debug('Got cached MusicBrainz ID for Spotify track %s', spotify_id) - return self._client.get(f'mbid:{spotify_id}') # type: ignore - - def set_isrc(self, spotify_id: str, isrc: str): - """ - Save an ISRC for a Spotify track. - """ - # Check if there is a Spotify track with this ID - if self._client.exists(f'spotify:{spotify_id}'): - # Update ISRC in Spotify track - self._logger.debug('Updating cached ISRC for Spotify track %s', spotify_id) - self._client.hset(f'spotify:{spotify_id}', 'isrc', isrc) - - self._logger.debug('Caching ISRC for Spotify track %s', spotify_id) - self._client.set(f'isrc:{spotify_id}', isrc) - - def get_isrc(self, spotify_id: str) -> Optional[str]: - """ - Get an ISRC for a Spotify track. - """ - # Check if there is a Spotify track with this ID - if self._client.exists(f'spotify:{spotify_id}'): - # Return ISRC from Spotify track - self._logger.debug('Got cached ISRC for Spotify track %s', spotify_id) - return self._client.hget(f'spotify:{spotify_id}', 'isrc') # type: ignore - - if not self._client.exists(f'isrc:{spotify_id}'): - return None - - self._logger.debug('Got cached ISRC for Spotify track %s', spotify_id) - return self._client.get(f'isrc:{spotify_id}') # type: ignore - - -REDIS = None -if REDIS_HOST is not None and REDIS_PORT != -1: - REDIS = RedisClient(REDIS_HOST, REDIS_PORT, REDIS_PASSWORD) diff --git a/dataclass/bump.py b/dataclass/bump.py deleted file mode 100644 index 28d9a54..0000000 --- a/dataclass/bump.py +++ /dev/null @@ -1,17 +0,0 @@ -""" -Dataclass for guild bumps. -""" -from dataclasses import dataclass - - -@dataclass -class Bump: - """ - Dataclass for guild bumps. - """ - - idx: int - guild_id: int - url: str - title: str - author: str diff --git a/dataclass/config.py b/dataclass/config.py deleted file mode 100644 index abcc97b..0000000 --- a/dataclass/config.py +++ /dev/null @@ -1,80 +0,0 @@ -""" -Dataclasses for storing Blanco's configuration objects. -""" - -from dataclasses import dataclass -from typing import Dict, List, Optional - - -@dataclass -class LavalinkNode: - """ - Dataclass for storing Lavalink node information. - """ - id: str # pylint: disable=invalid-name - password: str - host: str - port: int - regions: List[str] - secure: bool = False - deezer: bool = False - - # Type checking - def __post_init__(self): - # Check if host, password, and label are strings - if not isinstance(self.host, str): - raise TypeError('server must be a string') - if not isinstance(self.password, str): - raise TypeError('password must be a string') - if not isinstance(self.id, str): - raise TypeError('id must be a string') - - # Check if port is an int - if not isinstance(self.port, int): - raise TypeError('port must be an int') - - # Check if ssl is a bool - if not isinstance(self.secure, bool): - raise TypeError('ssl must be a bool') - - # Check if deezer is a bool - if not isinstance(self.deezer, bool): - raise TypeError('deezer must be a bool') - - # Check if regions is a list - if not isinstance(self.regions, list): - raise TypeError('regions must be a list') - - -@dataclass -class Config: - """ - Dataclass for storing Blanco's configuration. - """ - # Required - db_file: str - discord_token: str - spotify_client_id: str - spotify_client_secret: str - lavalink_nodes: Dict[str, LavalinkNode] - enable_server: bool - - # Optional - server_port: int = 8080 - base_url: Optional[str] = None - discord_oauth_id: Optional[str] = None - discord_oauth_secret: Optional[str] = None - lastfm_api_key: Optional[str] = None - lastfm_shared_secret: Optional[str] = None - match_ahead: bool = False - debug_enabled: bool = False - debug_guild_ids: Optional[List[int]] = None - reenqueue_paused: bool = False - - # Convenience - @property - def lastfm_enabled(self) -> bool: - """ - Returns whether Last.fm is enabled. - """ - return self.lastfm_api_key is not None and self.lastfm_shared_secret is not None diff --git a/dataclass/custom_embed.py b/dataclass/custom_embed.py deleted file mode 100644 index c95536b..0000000 --- a/dataclass/custom_embed.py +++ /dev/null @@ -1,77 +0,0 @@ -""" -Dataclass for an instance of nextcord.Embed with convenience fields -for the timestamp, multiline description, etc. -""" - -from dataclasses import dataclass, field -from datetime import datetime -from typing import List, Optional, Union - -from nextcord import Colour, Embed - - -@dataclass -class CustomEmbed: - """ - Dataclass for an instance of nextcord.Embed with convenience fields - for the timestamp, multiline description, etc. - """ - # All optional - title: Optional[str] = None - color: Colour = Colour.og_blurple() - description: Optional[Union[str, List[str]]] = None - fields: List[List[str]] = field(default_factory=list) - inline_fields: bool = False - thumbnail_url: Optional[str] = None - image_url: Optional[str] = None - - # Header and footer - header: Optional[str] = None - header_url: Optional[str] = None - header_icon_url: Optional[str] = None - footer: Optional[str] = None - footer_icon_url: Optional[str] = None - timestamp_now: bool = False - - # Create embed - def __post_init__(self): - # Can't specify header/footer icons without header/footer names - if self.header is None and self.header_icon_url is not None: - raise ValueError("Can't specify header icon without header text.") - if self.footer is None and self.footer_icon_url is not None: - raise ValueError("Can't specify footer icon without footer text.") - - # Create embed object - description = self.description - if isinstance(self.description, list): - description = '\n'.join(list(filter(None, self.description))) - embed = Embed(title=self.title, description=description, color=self.color) - - # Set embed parts - if self.header is not None: - embed.set_author(name=self.header) - if self.thumbnail_url is not None and self.thumbnail_url != '': - embed.set_thumbnail(url=self.thumbnail_url) - if self.image_url is not None: - embed.set_image(url=self.image_url) - if self.header is not None: - embed.set_author(name=self.header, url=self.header_url, icon_url=self.header_icon_url) - if self.footer is not None: - embed.set_footer(text=self.footer, icon_url=self.footer_icon_url) - if len(self.fields) > 0: - for f in self.fields: # pylint: disable=invalid-name - embed.add_field(name=f[0], value=f[1], inline=self.inline_fields) - - # Save embed - self.embed = embed - - # Get embed object - def get(self) -> Embed: - """ - Get the resulting nextcord.Embed object. - """ - # Add timestamp to embed - if self.timestamp_now: - self.embed.timestamp = datetime.now() - - return self.embed diff --git a/dataclass/lavalink_result.py b/dataclass/lavalink_result.py deleted file mode 100644 index 00cab0f..0000000 --- a/dataclass/lavalink_result.py +++ /dev/null @@ -1,22 +0,0 @@ -""" -Dataclass for storing Lavalink search results. -""" - -from dataclasses import dataclass -from typing import TYPE_CHECKING, Optional - -if TYPE_CHECKING: - from mafic import Track - - -@dataclass -class LavalinkResult: - """ - Dataclass for storing Lavalink search results. - """ - title: str - author: str - duration_ms: int - lavalink_track: 'Track' - artwork_url: Optional[str] = None - url: Optional[str] = None diff --git a/dataclass/oauth.py b/dataclass/oauth.py deleted file mode 100644 index db61570..0000000 --- a/dataclass/oauth.py +++ /dev/null @@ -1,26 +0,0 @@ -""" -Dataclasses for storing authentication data for Discord, Last.fm, Spotify, etc. -""" -from dataclasses import dataclass - - -@dataclass -class OAuth: - """ - Dataclass for storing authentication data for Discord, Spotify, etc. - """ - user_id: int - username: str - access_token: str - refresh_token: str - expires_at: int - - -@dataclass -class LastfmAuth: - """ - Dataclass for storing authentication data for Last.fm. - """ - user_id: int - username: str - session_key: str diff --git a/dataclass/queue_item.py b/dataclass/queue_item.py deleted file mode 100644 index a01fe2b..0000000 --- a/dataclass/queue_item.py +++ /dev/null @@ -1,72 +0,0 @@ -""" -Dataclass for storing a track in the player queue. -""" - -from __future__ import annotations - -from dataclasses import dataclass -from typing import TYPE_CHECKING, Optional, Tuple - -if TYPE_CHECKING: - from mafic import Track - - -@dataclass -class QueueItem: - """ - Dataclass for storing a track in the player queue. - """ - # Who requested the track (required) - requester: int - - # The Spotify ID for the track, if any - spotify_id: Optional[str] = None - - # The MusicBrainz ID for the track, if any - mbid: Optional[str] = None - - # International Standard Recording Code (ISRC) - isrc: Optional[str] = None - - # Direct track URL - url: Optional[str] = None - - # Album artwork - artwork: Optional[str] = None - - # Track details - title: Optional[str] = None - artist: Optional[str] = None # First artist - author: Optional[str] = None # All artists, separated by ', ' - album: Optional[str] = None - duration: Optional[int] = 0 # milliseconds - lavalink_track: Optional['Track'] = None - - # Imperfect match - True when ISRC is present but no match found on YouTube - is_imperfect: Optional[bool] = False - - # If annotate_track() was called on this track - is_annotated: Optional[bool] = False - - # When the track started playing - start_time: Optional[int] = None - - # Get title and artist - def get_details(self) -> Tuple[str, str]: - """ - Get a string of the form `title - artist` for the track. - """ - if self.title is not None: - title = self.title - if self.artist is not None: - artist = self.artist - else: - artist = 'Unknown artist' - elif self.url is not None: - title = self.url - artist = '(direct link)' - else: - title = 'Unknown title' - artist = 'Unknown query' - - return title, artist diff --git a/dataclass/spotify.py b/dataclass/spotify.py deleted file mode 100644 index 911114e..0000000 --- a/dataclass/spotify.py +++ /dev/null @@ -1,31 +0,0 @@ -""" -Dataclass for storing a Spotify track entity. -""" - -from dataclasses import dataclass -from typing import Optional - - -@dataclass -class SpotifyResult: - """ - Dataclass for storing a Spotify catalogue search result. - """ - name: str - description: str - spotify_id: str - - -@dataclass -class SpotifyTrack: - """ - Dataclass for storing a Spotify track entity. - """ - title: str - artist: str # First artist - author: str # All artists, separated by ', ' - spotify_id: str - duration_ms: int - artwork: Optional[str] = None - album: Optional[str] = None - isrc: Optional[str] = None diff --git a/dev_server.py b/dev_server.py deleted file mode 100644 index fd75664..0000000 --- a/dev_server.py +++ /dev/null @@ -1,42 +0,0 @@ -""" -This file is used to run the webserver without running the bot, -along with spawning the TailwindCSS compiler. This is useful for -development, as it allows you to see changes to the webserver -without having to restart the bot. -""" - -import asyncio -import threading -from subprocess import run - -from database import Database -from server.main import run_app -from utils.config import config - - -def run_tailwind(): - """ - Run the TailwindCSS compiler. - """ - run( - ' '.join([ - 'tailwindcss', - '-i', - './server/static/css/base.css', - '-o', - './server/static/css/main.css', - '--watch' - ]), - check=False, - shell=True - ) - - -if __name__ == '__main__': - thread = threading.Thread(target=run_tailwind) - thread.start() - - db = Database(config.db_file) - loop = asyncio.get_event_loop() - loop.create_task(run_app(db, config)) - loop.run_forever() diff --git a/main.py b/main.py deleted file mode 100644 index 60e8b6f..0000000 --- a/main.py +++ /dev/null @@ -1,70 +0,0 @@ -""" -Main bot file. -""" - -from nextcord import Intents - -from utils.blanco import BlancoBot -from utils.config import (REDIS_HOST, REDIS_PASSWORD, REDIS_PORT, SENTRY_DSN, - SENTRY_ENV, config) -from utils.constants import RELEASE -from utils.logger import create_logger - - -if __name__ == '__main__': - logger = create_logger('main') - - # Print parsed config - if config.debug_enabled: - logger.debug('Parsed configuration:') - logger.debug(' Database file: %s', config.db_file) - logger.debug(' Discord token: %s...', config.discord_token[:3]) - logger.debug(' Spotify client ID: %s...', config.spotify_client_id[:3]) - logger.debug(' Spotify client secret: %s...', config.spotify_client_secret[:3]) - logger.debug(' Match ahead: %s', 'enabled' if config.match_ahead else 'disabled') - - if SENTRY_DSN is not None and SENTRY_ENV is not None: - logger.debug(' Sentry DSN: %s...', SENTRY_DSN[:10]) - logger.debug(' Sentry environment: %s', SENTRY_ENV) - else: - logger.debug(' Sentry integration disabled') - - if REDIS_HOST is not None and REDIS_PORT != -1: - logger.debug(' Redis host: %s', REDIS_HOST) - logger.debug(' Redis port: %d', REDIS_PORT) - if REDIS_PASSWORD is not None: - logger.debug(' Redis password: %s...', REDIS_PASSWORD[:3]) - else: - logger.debug(' Redis integration disabled') - - if config.lastfm_enabled: - assert config.lastfm_api_key is not None and config.lastfm_shared_secret is not None - logger.debug(' Last.fm API key: %s...', config.lastfm_api_key[:3]) - logger.debug(' Last.fm shared secret: %s...', config.lastfm_shared_secret[:3]) - else: - logger.debug(' Last.fm integration disabled') - - logger.debug(' Webserver: %s', 'enabled' if config.enable_server else 'disabled') - if config.enable_server: - assert config.discord_oauth_secret is not None - logger.debug(' - Listening on port %d', config.server_port) - logger.debug(' - Base URL: %s', config.base_url) - logger.debug(' - OAuth ID: %s...', str(config.discord_oauth_id)[:3]) - logger.debug(' - OAuth secret: %s...', config.discord_oauth_secret[:3]) - - logger.debug(' Lavalink nodes:') - for node in config.lavalink_nodes.values(): - logger.debug(' - %s (%s:%d)', node.id, node.host, node.port) - logger.debug(' Secure: %s', 'yes' if node.secure else 'no') - logger.debug(' Supports Deezer: %s', 'yes' if node.deezer else 'no') - logger.debug(' Regions: %s', ', '.join(node.regions)) - - # Create bot instance - intents = Intents.default() - intents.members = True - client = BlancoBot(intents=intents, default_guild_ids=config.debug_guild_ids) - client.init_config(config) - - # Run client - logger.info('Blanco release %s booting up...', RELEASE) - client.run(config.discord_token) diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 0000000..78d8341 --- /dev/null +++ b/mypy.ini @@ -0,0 +1,3 @@ +[mypy] +files = bot +ignore_missing_imports = True diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..ca1dda9 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,1898 @@ +# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. + +[[package]] +name = "aiohttp" +version = "3.9.3" +description = "Async http client/server framework (asyncio)" +optional = false +python-versions = ">=3.8" +files = [ + {file = "aiohttp-3.9.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:939677b61f9d72a4fa2a042a5eee2a99a24001a67c13da113b2e30396567db54"}, + {file = "aiohttp-3.9.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1f5cd333fcf7590a18334c90f8c9147c837a6ec8a178e88d90a9b96ea03194cc"}, + {file = "aiohttp-3.9.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:82e6aa28dd46374f72093eda8bcd142f7771ee1eb9d1e223ff0fa7177a96b4a5"}, + {file = "aiohttp-3.9.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f56455b0c2c7cc3b0c584815264461d07b177f903a04481dfc33e08a89f0c26b"}, + {file = "aiohttp-3.9.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bca77a198bb6e69795ef2f09a5f4c12758487f83f33d63acde5f0d4919815768"}, + {file = "aiohttp-3.9.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e083c285857b78ee21a96ba1eb1b5339733c3563f72980728ca2b08b53826ca5"}, + {file = "aiohttp-3.9.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab40e6251c3873d86ea9b30a1ac6d7478c09277b32e14745d0d3c6e76e3c7e29"}, + {file = "aiohttp-3.9.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:df822ee7feaaeffb99c1a9e5e608800bd8eda6e5f18f5cfb0dc7eeb2eaa6bbec"}, + {file = "aiohttp-3.9.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:acef0899fea7492145d2bbaaaec7b345c87753168589cc7faf0afec9afe9b747"}, + {file = "aiohttp-3.9.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:cd73265a9e5ea618014802ab01babf1940cecb90c9762d8b9e7d2cc1e1969ec6"}, + {file = "aiohttp-3.9.3-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:a78ed8a53a1221393d9637c01870248a6f4ea5b214a59a92a36f18151739452c"}, + {file = "aiohttp-3.9.3-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:6b0e029353361f1746bac2e4cc19b32f972ec03f0f943b390c4ab3371840aabf"}, + {file = "aiohttp-3.9.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7cf5c9458e1e90e3c390c2639f1017a0379a99a94fdfad3a1fd966a2874bba52"}, + {file = "aiohttp-3.9.3-cp310-cp310-win32.whl", hash = "sha256:3e59c23c52765951b69ec45ddbbc9403a8761ee6f57253250c6e1536cacc758b"}, + {file = "aiohttp-3.9.3-cp310-cp310-win_amd64.whl", hash = "sha256:055ce4f74b82551678291473f66dc9fb9048a50d8324278751926ff0ae7715e5"}, + {file = "aiohttp-3.9.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6b88f9386ff1ad91ace19d2a1c0225896e28815ee09fc6a8932fded8cda97c3d"}, + {file = "aiohttp-3.9.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c46956ed82961e31557b6857a5ca153c67e5476972e5f7190015018760938da2"}, + {file = "aiohttp-3.9.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:07b837ef0d2f252f96009e9b8435ec1fef68ef8b1461933253d318748ec1acdc"}, + {file = "aiohttp-3.9.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad46e6f620574b3b4801c68255492e0159d1712271cc99d8bdf35f2043ec266"}, + {file = "aiohttp-3.9.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5ed3e046ea7b14938112ccd53d91c1539af3e6679b222f9469981e3dac7ba1ce"}, + {file = "aiohttp-3.9.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:039df344b45ae0b34ac885ab5b53940b174530d4dd8a14ed8b0e2155b9dddccb"}, + {file = "aiohttp-3.9.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7943c414d3a8d9235f5f15c22ace69787c140c80b718dcd57caaade95f7cd93b"}, + {file = "aiohttp-3.9.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:84871a243359bb42c12728f04d181a389718710129b36b6aad0fc4655a7647d4"}, + {file = "aiohttp-3.9.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:5eafe2c065df5401ba06821b9a054d9cb2848867f3c59801b5d07a0be3a380ae"}, + {file = "aiohttp-3.9.3-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:9d3c9b50f19704552f23b4eaea1fc082fdd82c63429a6506446cbd8737823da3"}, + {file = "aiohttp-3.9.3-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:f033d80bc6283092613882dfe40419c6a6a1527e04fc69350e87a9df02bbc283"}, + {file = "aiohttp-3.9.3-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:2c895a656dd7e061b2fd6bb77d971cc38f2afc277229ce7dd3552de8313a483e"}, + {file = "aiohttp-3.9.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1f5a71d25cd8106eab05f8704cd9167b6e5187bcdf8f090a66c6d88b634802b4"}, + {file = "aiohttp-3.9.3-cp311-cp311-win32.whl", hash = "sha256:50fca156d718f8ced687a373f9e140c1bb765ca16e3d6f4fe116e3df7c05b2c5"}, + {file = "aiohttp-3.9.3-cp311-cp311-win_amd64.whl", hash = "sha256:5fe9ce6c09668063b8447f85d43b8d1c4e5d3d7e92c63173e6180b2ac5d46dd8"}, + {file = "aiohttp-3.9.3-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:38a19bc3b686ad55804ae931012f78f7a534cce165d089a2059f658f6c91fa60"}, + {file = "aiohttp-3.9.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:770d015888c2a598b377bd2f663adfd947d78c0124cfe7b959e1ef39f5b13869"}, + {file = "aiohttp-3.9.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ee43080e75fc92bf36219926c8e6de497f9b247301bbf88c5c7593d931426679"}, + {file = "aiohttp-3.9.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:52df73f14ed99cee84865b95a3d9e044f226320a87af208f068ecc33e0c35b96"}, + {file = "aiohttp-3.9.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc9b311743a78043b26ffaeeb9715dc360335e5517832f5a8e339f8a43581e4d"}, + {file = "aiohttp-3.9.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b955ed993491f1a5da7f92e98d5dad3c1e14dc175f74517c4e610b1f2456fb11"}, + {file = "aiohttp-3.9.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:504b6981675ace64c28bf4a05a508af5cde526e36492c98916127f5a02354d53"}, + {file = "aiohttp-3.9.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a6fe5571784af92b6bc2fda8d1925cccdf24642d49546d3144948a6a1ed58ca5"}, + {file = "aiohttp-3.9.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ba39e9c8627edc56544c8628cc180d88605df3892beeb2b94c9bc857774848ca"}, + {file = "aiohttp-3.9.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:e5e46b578c0e9db71d04c4b506a2121c0cb371dd89af17a0586ff6769d4c58c1"}, + {file = "aiohttp-3.9.3-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:938a9653e1e0c592053f815f7028e41a3062e902095e5a7dc84617c87267ebd5"}, + {file = "aiohttp-3.9.3-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:c3452ea726c76e92f3b9fae4b34a151981a9ec0a4847a627c43d71a15ac32aa6"}, + {file = "aiohttp-3.9.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ff30218887e62209942f91ac1be902cc80cddb86bf00fbc6783b7a43b2bea26f"}, + {file = "aiohttp-3.9.3-cp312-cp312-win32.whl", hash = "sha256:38f307b41e0bea3294a9a2a87833191e4bcf89bb0365e83a8be3a58b31fb7f38"}, + {file = "aiohttp-3.9.3-cp312-cp312-win_amd64.whl", hash = "sha256:b791a3143681a520c0a17e26ae7465f1b6f99461a28019d1a2f425236e6eedb5"}, + {file = "aiohttp-3.9.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:0ed621426d961df79aa3b963ac7af0d40392956ffa9be022024cd16297b30c8c"}, + {file = "aiohttp-3.9.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:7f46acd6a194287b7e41e87957bfe2ad1ad88318d447caf5b090012f2c5bb528"}, + {file = "aiohttp-3.9.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:feeb18a801aacb098220e2c3eea59a512362eb408d4afd0c242044c33ad6d542"}, + {file = "aiohttp-3.9.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f734e38fd8666f53da904c52a23ce517f1b07722118d750405af7e4123933511"}, + {file = "aiohttp-3.9.3-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b40670ec7e2156d8e57f70aec34a7216407848dfe6c693ef131ddf6e76feb672"}, + {file = "aiohttp-3.9.3-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fdd215b7b7fd4a53994f238d0f46b7ba4ac4c0adb12452beee724ddd0743ae5d"}, + {file = "aiohttp-3.9.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:017a21b0df49039c8f46ca0971b3a7fdc1f56741ab1240cb90ca408049766168"}, + {file = "aiohttp-3.9.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e99abf0bba688259a496f966211c49a514e65afa9b3073a1fcee08856e04425b"}, + {file = "aiohttp-3.9.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:648056db9a9fa565d3fa851880f99f45e3f9a771dd3ff3bb0c048ea83fb28194"}, + {file = "aiohttp-3.9.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:8aacb477dc26797ee089721536a292a664846489c49d3ef9725f992449eda5a8"}, + {file = "aiohttp-3.9.3-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:522a11c934ea660ff8953eda090dcd2154d367dec1ae3c540aff9f8a5c109ab4"}, + {file = "aiohttp-3.9.3-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:5bce0dc147ca85caa5d33debc4f4d65e8e8b5c97c7f9f660f215fa74fc49a321"}, + {file = "aiohttp-3.9.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:4b4af9f25b49a7be47c0972139e59ec0e8285c371049df1a63b6ca81fdd216a2"}, + {file = "aiohttp-3.9.3-cp38-cp38-win32.whl", hash = "sha256:298abd678033b8571995650ccee753d9458dfa0377be4dba91e4491da3f2be63"}, + {file = "aiohttp-3.9.3-cp38-cp38-win_amd64.whl", hash = "sha256:69361bfdca5468c0488d7017b9b1e5ce769d40b46a9f4a2eed26b78619e9396c"}, + {file = "aiohttp-3.9.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:0fa43c32d1643f518491d9d3a730f85f5bbaedcbd7fbcae27435bb8b7a061b29"}, + {file = "aiohttp-3.9.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:835a55b7ca49468aaaac0b217092dfdff370e6c215c9224c52f30daaa735c1c1"}, + {file = "aiohttp-3.9.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:06a9b2c8837d9a94fae16c6223acc14b4dfdff216ab9b7202e07a9a09541168f"}, + {file = "aiohttp-3.9.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:abf151955990d23f84205286938796c55ff11bbfb4ccfada8c9c83ae6b3c89a3"}, + {file = "aiohttp-3.9.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:59c26c95975f26e662ca78fdf543d4eeaef70e533a672b4113dd888bd2423caa"}, + {file = "aiohttp-3.9.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f95511dd5d0e05fd9728bac4096319f80615aaef4acbecb35a990afebe953b0e"}, + {file = "aiohttp-3.9.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:595f105710293e76b9dc09f52e0dd896bd064a79346234b521f6b968ffdd8e58"}, + {file = "aiohttp-3.9.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7c8b816c2b5af5c8a436df44ca08258fc1a13b449393a91484225fcb7545533"}, + {file = "aiohttp-3.9.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:f1088fa100bf46e7b398ffd9904f4808a0612e1d966b4aa43baa535d1b6341eb"}, + {file = "aiohttp-3.9.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f59dfe57bb1ec82ac0698ebfcdb7bcd0e99c255bd637ff613760d5f33e7c81b3"}, + {file = "aiohttp-3.9.3-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:361a1026c9dd4aba0109e4040e2aecf9884f5cfe1b1b1bd3d09419c205e2e53d"}, + {file = "aiohttp-3.9.3-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:363afe77cfcbe3a36353d8ea133e904b108feea505aa4792dad6585a8192c55a"}, + {file = "aiohttp-3.9.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8e2c45c208c62e955e8256949eb225bd8b66a4c9b6865729a786f2aa79b72e9d"}, + {file = "aiohttp-3.9.3-cp39-cp39-win32.whl", hash = "sha256:f7217af2e14da0856e082e96ff637f14ae45c10a5714b63c77f26d8884cf1051"}, + {file = "aiohttp-3.9.3-cp39-cp39-win_amd64.whl", hash = "sha256:27468897f628c627230dba07ec65dc8d0db566923c48f29e084ce382119802bc"}, + {file = "aiohttp-3.9.3.tar.gz", hash = "sha256:90842933e5d1ff760fae6caca4b2b3edba53ba8f4b71e95dacf2818a2aca06f7"}, +] + +[package.dependencies] +aiosignal = ">=1.1.2" +attrs = ">=17.3.0" +frozenlist = ">=1.1.1" +multidict = ">=4.5,<7.0" +yarl = ">=1.0,<2.0" + +[package.extras] +speedups = ["Brotli", "aiodns", "brotlicffi"] + +[[package]] +name = "aiosignal" +version = "1.3.1" +description = "aiosignal: a list of registered asynchronous callbacks" +optional = false +python-versions = ">=3.7" +files = [ + {file = "aiosignal-1.3.1-py3-none-any.whl", hash = "sha256:f8376fb07dd1e86a584e4fcdec80b36b7f81aac666ebc724e2c090300dd83b17"}, + {file = "aiosignal-1.3.1.tar.gz", hash = "sha256:54cd96e15e1649b75d6c87526a6ff0b6c1b0dd3459f43d9ca11d48c339b68cfc"}, +] + +[package.dependencies] +frozenlist = ">=1.1.0" + +[[package]] +name = "annotated-types" +version = "0.6.0" +description = "Reusable constraint types to use with typing.Annotated" +optional = false +python-versions = ">=3.8" +files = [ + {file = "annotated_types-0.6.0-py3-none-any.whl", hash = "sha256:0641064de18ba7a25dee8f96403ebc39113d0cb953a01429249d5c7564666a43"}, + {file = "annotated_types-0.6.0.tar.gz", hash = "sha256:563339e807e53ffd9c267e99fc6d9ea23eb8443c08f112651963e24e22f84a5d"}, +] + +[[package]] +name = "anyio" +version = "4.3.0" +description = "High level compatibility layer for multiple asynchronous event loop implementations" +optional = false +python-versions = ">=3.8" +files = [ + {file = "anyio-4.3.0-py3-none-any.whl", hash = "sha256:048e05d0f6caeed70d731f3db756d35dcc1f35747c8c403364a8332c630441b8"}, + {file = "anyio-4.3.0.tar.gz", hash = "sha256:f75253795a87df48568485fd18cdd2a3fa5c4f7c5be8e5e36637733fce06fed6"}, +] + +[package.dependencies] +idna = ">=2.8" +sniffio = ">=1.1" + +[package.extras] +doc = ["Sphinx (>=7)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] +test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"] +trio = ["trio (>=0.23)"] + +[[package]] +name = "async-timeout" +version = "4.0.3" +description = "Timeout context manager for asyncio programs" +optional = false +python-versions = ">=3.7" +files = [ + {file = "async-timeout-4.0.3.tar.gz", hash = "sha256:4640d96be84d82d02ed59ea2b7105a0f7b33abe8703703cd0ab0bf87c427522f"}, + {file = "async_timeout-4.0.3-py3-none-any.whl", hash = "sha256:7405140ff1230c310e51dc27b3145b9092d659ce68ff733fb0cefe3ee42be028"}, +] + +[[package]] +name = "attrs" +version = "23.2.0" +description = "Classes Without Boilerplate" +optional = false +python-versions = ">=3.7" +files = [ + {file = "attrs-23.2.0-py3-none-any.whl", hash = "sha256:99b87a485a5820b23b879f04c2305b44b951b502fd64be915879d77a7e8fc6f1"}, + {file = "attrs-23.2.0.tar.gz", hash = "sha256:935dc3b529c262f6cf76e50877d35a4bd3c1de194fd41f47a2b7ae8f19971f30"}, +] + +[package.extras] +cov = ["attrs[tests]", "coverage[toml] (>=5.3)"] +dev = ["attrs[tests]", "pre-commit"] +docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier", "zope-interface"] +tests = ["attrs[tests-no-zope]", "zope-interface"] +tests-mypy = ["mypy (>=1.6)", "pytest-mypy-plugins"] +tests-no-zope = ["attrs[tests-mypy]", "cloudpickle", "hypothesis", "pympler", "pytest (>=4.3.0)", "pytest-xdist[psutil]"] + +[[package]] +name = "certifi" +version = "2024.2.2" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.6" +files = [ + {file = "certifi-2024.2.2-py3-none-any.whl", hash = "sha256:dc383c07b76109f368f6106eee2b593b04a011ea4d55f652c6ca24a754d1cdd1"}, + {file = "certifi-2024.2.2.tar.gz", hash = "sha256:0569859f95fc761b18b45ef421b1290a0f65f147e92a1e5eb3e635f9a5e4e66f"}, +] + +[[package]] +name = "cffi" +version = "1.16.0" +description = "Foreign Function Interface for Python calling C code." +optional = false +python-versions = ">=3.8" +files = [ + {file = "cffi-1.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6b3d6606d369fc1da4fd8c357d026317fbb9c9b75d36dc16e90e84c26854b088"}, + {file = "cffi-1.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ac0f5edd2360eea2f1daa9e26a41db02dd4b0451b48f7c318e217ee092a213e9"}, + {file = "cffi-1.16.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7e61e3e4fa664a8588aa25c883eab612a188c725755afff6289454d6362b9673"}, + {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a72e8961a86d19bdb45851d8f1f08b041ea37d2bd8d4fd19903bc3083d80c896"}, + {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b50bf3f55561dac5438f8e70bfcdfd74543fd60df5fa5f62d94e5867deca684"}, + {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7651c50c8c5ef7bdb41108b7b8c5a83013bfaa8a935590c5d74627c047a583c7"}, + {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4108df7fe9b707191e55f33efbcb2d81928e10cea45527879a4749cbe472614"}, + {file = "cffi-1.16.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:32c68ef735dbe5857c810328cb2481e24722a59a2003018885514d4c09af9743"}, + {file = "cffi-1.16.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:673739cb539f8cdaa07d92d02efa93c9ccf87e345b9a0b556e3ecc666718468d"}, + {file = "cffi-1.16.0-cp310-cp310-win32.whl", hash = "sha256:9f90389693731ff1f659e55c7d1640e2ec43ff725cc61b04b2f9c6d8d017df6a"}, + {file = "cffi-1.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:e6024675e67af929088fda399b2094574609396b1decb609c55fa58b028a32a1"}, + {file = "cffi-1.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b84834d0cf97e7d27dd5b7f3aca7b6e9263c56308ab9dc8aae9784abb774d404"}, + {file = "cffi-1.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1b8ebc27c014c59692bb2664c7d13ce7a6e9a629be20e54e7271fa696ff2b417"}, + {file = "cffi-1.16.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ee07e47c12890ef248766a6e55bd38ebfb2bb8edd4142d56db91b21ea68b7627"}, + {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8a9d3ebe49f084ad71f9269834ceccbf398253c9fac910c4fd7053ff1386936"}, + {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e70f54f1796669ef691ca07d046cd81a29cb4deb1e5f942003f401c0c4a2695d"}, + {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5bf44d66cdf9e893637896c7faa22298baebcd18d1ddb6d2626a6e39793a1d56"}, + {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7b78010e7b97fef4bee1e896df8a4bbb6712b7f05b7ef630f9d1da00f6444d2e"}, + {file = "cffi-1.16.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c6a164aa47843fb1b01e941d385aab7215563bb8816d80ff3a363a9f8448a8dc"}, + {file = "cffi-1.16.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e09f3ff613345df5e8c3667da1d918f9149bd623cd9070c983c013792a9a62eb"}, + {file = "cffi-1.16.0-cp311-cp311-win32.whl", hash = "sha256:2c56b361916f390cd758a57f2e16233eb4f64bcbeee88a4881ea90fca14dc6ab"}, + {file = "cffi-1.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:db8e577c19c0fda0beb7e0d4e09e0ba74b1e4c092e0e40bfa12fe05b6f6d75ba"}, + {file = "cffi-1.16.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:fa3a0128b152627161ce47201262d3140edb5a5c3da88d73a1b790a959126956"}, + {file = "cffi-1.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:68e7c44931cc171c54ccb702482e9fc723192e88d25a0e133edd7aff8fcd1f6e"}, + {file = "cffi-1.16.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:abd808f9c129ba2beda4cfc53bde801e5bcf9d6e0f22f095e45327c038bfe68e"}, + {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88e2b3c14bdb32e440be531ade29d3c50a1a59cd4e51b1dd8b0865c54ea5d2e2"}, + {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcc8eb6d5902bb1cf6dc4f187ee3ea80a1eba0a89aba40a5cb20a5087d961357"}, + {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b7be2d771cdba2942e13215c4e340bfd76398e9227ad10402a8767ab1865d2e6"}, + {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e715596e683d2ce000574bae5d07bd522c781a822866c20495e52520564f0969"}, + {file = "cffi-1.16.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2d92b25dbf6cae33f65005baf472d2c245c050b1ce709cc4588cdcdd5495b520"}, + {file = "cffi-1.16.0-cp312-cp312-win32.whl", hash = "sha256:b2ca4e77f9f47c55c194982e10f058db063937845bb2b7a86c84a6cfe0aefa8b"}, + {file = "cffi-1.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:68678abf380b42ce21a5f2abde8efee05c114c2fdb2e9eef2efdb0257fba1235"}, + {file = "cffi-1.16.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0c9ef6ff37e974b73c25eecc13952c55bceed9112be2d9d938ded8e856138bcc"}, + {file = "cffi-1.16.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a09582f178759ee8128d9270cd1344154fd473bb77d94ce0aeb2a93ebf0feaf0"}, + {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e760191dd42581e023a68b758769e2da259b5d52e3103c6060ddc02c9edb8d7b"}, + {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:80876338e19c951fdfed6198e70bc88f1c9758b94578d5a7c4c91a87af3cf31c"}, + {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a6a14b17d7e17fa0d207ac08642c8820f84f25ce17a442fd15e27ea18d67c59b"}, + {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6602bc8dc6f3a9e02b6c22c4fc1e47aa50f8f8e6d3f78a5e16ac33ef5fefa324"}, + {file = "cffi-1.16.0-cp38-cp38-win32.whl", hash = "sha256:131fd094d1065b19540c3d72594260f118b231090295d8c34e19a7bbcf2e860a"}, + {file = "cffi-1.16.0-cp38-cp38-win_amd64.whl", hash = "sha256:31d13b0f99e0836b7ff893d37af07366ebc90b678b6664c955b54561fc36ef36"}, + {file = "cffi-1.16.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:582215a0e9adbe0e379761260553ba11c58943e4bbe9c36430c4ca6ac74b15ed"}, + {file = "cffi-1.16.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b29ebffcf550f9da55bec9e02ad430c992a87e5f512cd63388abb76f1036d8d2"}, + {file = "cffi-1.16.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dc9b18bf40cc75f66f40a7379f6a9513244fe33c0e8aa72e2d56b0196a7ef872"}, + {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cb4a35b3642fc5c005a6755a5d17c6c8b6bcb6981baf81cea8bfbc8903e8ba8"}, + {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b86851a328eedc692acf81fb05444bdf1891747c25af7529e39ddafaf68a4f3f"}, + {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c0f31130ebc2d37cdd8e44605fb5fa7ad59049298b3f745c74fa74c62fbfcfc4"}, + {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f8e709127c6c77446a8c0a8c8bf3c8ee706a06cd44b1e827c3e6a2ee6b8c098"}, + {file = "cffi-1.16.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:748dcd1e3d3d7cd5443ef03ce8685043294ad6bd7c02a38d1bd367cfd968e000"}, + {file = "cffi-1.16.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8895613bcc094d4a1b2dbe179d88d7fb4a15cee43c052e8885783fac397d91fe"}, + {file = "cffi-1.16.0-cp39-cp39-win32.whl", hash = "sha256:ed86a35631f7bfbb28e108dd96773b9d5a6ce4811cf6ea468bb6a359b256b1e4"}, + {file = "cffi-1.16.0-cp39-cp39-win_amd64.whl", hash = "sha256:3686dffb02459559c74dd3d81748269ffb0eb027c39a6fc99502de37d501faa8"}, + {file = "cffi-1.16.0.tar.gz", hash = "sha256:bcb3ef43e58665bbda2fb198698fcae6776483e0c4a631aa5647806c25e02cc0"}, +] + +[package.dependencies] +pycparser = "*" + +[[package]] +name = "cfgv" +version = "3.4.0" +description = "Validate configuration and produce human readable error messages." +optional = false +python-versions = ">=3.8" +files = [ + {file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"}, + {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"}, +] + +[[package]] +name = "charset-normalizer" +version = "3.3.2" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +optional = false +python-versions = ">=3.7.0" +files = [ + {file = "charset-normalizer-3.3.2.tar.gz", hash = "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-win32.whl", hash = "sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-win32.whl", hash = "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-win32.whl", hash = "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c002b4ffc0be611f0d9da932eb0f704fe2602a9a949d1f738e4c34c75b0863d5"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a981a536974bbc7a512cf44ed14938cf01030a99e9b3a06dd59578882f06f985"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3287761bc4ee9e33561a7e058c72ac0938c4f57fe49a09eae428fd88aafe7bb6"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42cb296636fcc8b0644486d15c12376cb9fa75443e00fb25de0b8602e64c1714"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a55554a2fa0d408816b3b5cedf0045f4b8e1a6065aec45849de2d6f3f8e9786"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:c083af607d2515612056a31f0a8d9e0fcb5876b7bfc0abad3ecd275bc4ebc2d5"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:87d1351268731db79e0f8e745d92493ee2841c974128ef629dc518b937d9194c"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:bd8f7df7d12c2db9fab40bdd87a7c09b1530128315d047a086fa3ae3435cb3a8"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:c180f51afb394e165eafe4ac2936a14bee3eb10debc9d9e4db8958fe36afe711"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8c622a5fe39a48f78944a87d4fb8a53ee07344641b0562c540d840748571b811"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-win32.whl", hash = "sha256:db364eca23f876da6f9e16c9da0df51aa4f104a972735574842618b8c6d999d4"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-win_amd64.whl", hash = "sha256:86216b5cee4b06df986d214f664305142d9c76df9b6512be2738aa72a2048f99"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-win32.whl", hash = "sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-win32.whl", hash = "sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d"}, + {file = "charset_normalizer-3.3.2-py3-none-any.whl", hash = "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc"}, +] + +[[package]] +name = "click" +version = "8.1.7" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.7" +files = [ + {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, + {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "cryptography" +version = "42.0.5" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +optional = false +python-versions = ">=3.7" +files = [ + {file = "cryptography-42.0.5-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:a30596bae9403a342c978fb47d9b0ee277699fa53bbafad14706af51fe543d16"}, + {file = "cryptography-42.0.5-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:b7ffe927ee6531c78f81aa17e684e2ff617daeba7f189f911065b2ea2d526dec"}, + {file = "cryptography-42.0.5-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2424ff4c4ac7f6b8177b53c17ed5d8fa74ae5955656867f5a8affaca36a27abb"}, + {file = "cryptography-42.0.5-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:329906dcc7b20ff3cad13c069a78124ed8247adcac44b10bea1130e36caae0b4"}, + {file = "cryptography-42.0.5-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:b03c2ae5d2f0fc05f9a2c0c997e1bc18c8229f392234e8a0194f202169ccd278"}, + {file = "cryptography-42.0.5-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8837fe1d6ac4a8052a9a8ddab256bc006242696f03368a4009be7ee3075cdb7"}, + {file = "cryptography-42.0.5-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:0270572b8bd2c833c3981724b8ee9747b3ec96f699a9665470018594301439ee"}, + {file = "cryptography-42.0.5-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:b8cac287fafc4ad485b8a9b67d0ee80c66bf3574f655d3b97ef2e1082360faf1"}, + {file = "cryptography-42.0.5-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:16a48c23a62a2f4a285699dba2e4ff2d1cff3115b9df052cdd976a18856d8e3d"}, + {file = "cryptography-42.0.5-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2bce03af1ce5a5567ab89bd90d11e7bbdff56b8af3acbbec1faded8f44cb06da"}, + {file = "cryptography-42.0.5-cp37-abi3-win32.whl", hash = "sha256:b6cd2203306b63e41acdf39aa93b86fb566049aeb6dc489b70e34bcd07adca74"}, + {file = "cryptography-42.0.5-cp37-abi3-win_amd64.whl", hash = "sha256:98d8dc6d012b82287f2c3d26ce1d2dd130ec200c8679b6213b3c73c08b2b7940"}, + {file = "cryptography-42.0.5-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:5e6275c09d2badf57aea3afa80d975444f4be8d3bc58f7f80d2a484c6f9485c8"}, + {file = "cryptography-42.0.5-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4985a790f921508f36f81831817cbc03b102d643b5fcb81cd33df3fa291a1a1"}, + {file = "cryptography-42.0.5-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7cde5f38e614f55e28d831754e8a3bacf9ace5d1566235e39d91b35502d6936e"}, + {file = "cryptography-42.0.5-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:7367d7b2eca6513681127ebad53b2582911d1736dc2ffc19f2c3ae49997496bc"}, + {file = "cryptography-42.0.5-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:cd2030f6650c089aeb304cf093f3244d34745ce0cfcc39f20c6fbfe030102e2a"}, + {file = "cryptography-42.0.5-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:a2913c5375154b6ef2e91c10b5720ea6e21007412f6437504ffea2109b5a33d7"}, + {file = "cryptography-42.0.5-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:c41fb5e6a5fe9ebcd58ca3abfeb51dffb5d83d6775405305bfa8715b76521922"}, + {file = "cryptography-42.0.5-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3eaafe47ec0d0ffcc9349e1708be2aaea4c6dd4978d76bf6eb0cb2c13636c6fc"}, + {file = "cryptography-42.0.5-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1b95b98b0d2af784078fa69f637135e3c317091b615cd0905f8b8a087e86fa30"}, + {file = "cryptography-42.0.5-cp39-abi3-win32.whl", hash = "sha256:1f71c10d1e88467126f0efd484bd44bca5e14c664ec2ede64c32f20875c0d413"}, + {file = "cryptography-42.0.5-cp39-abi3-win_amd64.whl", hash = "sha256:a011a644f6d7d03736214d38832e030d8268bcff4a41f728e6030325fea3e400"}, + {file = "cryptography-42.0.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:9481ffe3cf013b71b2428b905c4f7a9a4f76ec03065b05ff499bb5682a8d9ad8"}, + {file = "cryptography-42.0.5-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:ba334e6e4b1d92442b75ddacc615c5476d4ad55cc29b15d590cc6b86efa487e2"}, + {file = "cryptography-42.0.5-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:ba3e4a42397c25b7ff88cdec6e2a16c2be18720f317506ee25210f6d31925f9c"}, + {file = "cryptography-42.0.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:111a0d8553afcf8eb02a4fea6ca4f59d48ddb34497aa8706a6cf536f1a5ec576"}, + {file = "cryptography-42.0.5-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:cd65d75953847815962c84a4654a84850b2bb4aed3f26fadcc1c13892e1e29f6"}, + {file = "cryptography-42.0.5-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:e807b3188f9eb0eaa7bbb579b462c5ace579f1cedb28107ce8b48a9f7ad3679e"}, + {file = "cryptography-42.0.5-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:f12764b8fffc7a123f641d7d049d382b73f96a34117e0b637b80643169cec8ac"}, + {file = "cryptography-42.0.5-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:37dd623507659e08be98eec89323469e8c7b4c1407c85112634ae3dbdb926fdd"}, + {file = "cryptography-42.0.5.tar.gz", hash = "sha256:6fe07eec95dfd477eb9530aef5bead34fec819b3aaf6c5bd6d20565da607bfe1"}, +] + +[package.dependencies] +cffi = {version = ">=1.12", markers = "platform_python_implementation != \"PyPy\""} + +[package.extras] +docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=1.1.1)"] +docstest = ["pyenchant (>=1.6.11)", "readme-renderer", "sphinxcontrib-spelling (>=4.0.1)"] +nox = ["nox"] +pep8test = ["check-sdist", "click", "mypy", "ruff"] +sdist = ["build"] +ssh = ["bcrypt (>=3.1.5)"] +test = ["certifi", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] +test-randomorder = ["pytest-randomly"] + +[[package]] +name = "distlib" +version = "0.3.8" +description = "Distribution utilities" +optional = false +python-versions = "*" +files = [ + {file = "distlib-0.3.8-py2.py3-none-any.whl", hash = "sha256:034db59a0b96f8ca18035f36290806a9a6e6bd9d1ff91e45a7f172eb17e51784"}, + {file = "distlib-0.3.8.tar.gz", hash = "sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64"}, +] + +[[package]] +name = "fastapi" +version = "0.110.0" +description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" +optional = false +python-versions = ">=3.8" +files = [ + {file = "fastapi-0.110.0-py3-none-any.whl", hash = "sha256:87a1f6fb632a218222c5984be540055346a8f5d8a68e8f6fb647b1dc9934de4b"}, + {file = "fastapi-0.110.0.tar.gz", hash = "sha256:266775f0dcc95af9d3ef39bad55cff525329a931d5fd51930aadd4f428bf7ff3"}, +] + +[package.dependencies] +pydantic = ">=1.7.4,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0 || >2.0.0,<2.0.1 || >2.0.1,<2.1.0 || >2.1.0,<3.0.0" +starlette = ">=0.36.3,<0.37.0" +typing-extensions = ">=4.8.0" + +[package.extras] +all = ["email-validator (>=2.0.0)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=2.11.2)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.7)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"] + +[[package]] +name = "filelock" +version = "3.13.3" +description = "A platform independent file lock." +optional = false +python-versions = ">=3.8" +files = [ + {file = "filelock-3.13.3-py3-none-any.whl", hash = "sha256:5ffa845303983e7a0b7ae17636509bc97997d58afeafa72fb141a17b152284cb"}, + {file = "filelock-3.13.3.tar.gz", hash = "sha256:a79895a25bbefdf55d1a2a0a80968f7dbb28edcd6d4234a0afb3f37ecde4b546"}, +] + +[package.extras] +docs = ["furo (>=2023.9.10)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"] +testing = ["covdefaults (>=2.3)", "coverage (>=7.3.2)", "diff-cover (>=8.0.1)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-timeout (>=2.2)"] +typing = ["typing-extensions (>=4.8)"] + +[[package]] +name = "frozenlist" +version = "1.4.1" +description = "A list-like structure which implements collections.abc.MutableSequence" +optional = false +python-versions = ">=3.8" +files = [ + {file = "frozenlist-1.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f9aa1878d1083b276b0196f2dfbe00c9b7e752475ed3b682025ff20c1c1f51ac"}, + {file = "frozenlist-1.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:29acab3f66f0f24674b7dc4736477bcd4bc3ad4b896f5f45379a67bce8b96868"}, + {file = "frozenlist-1.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:74fb4bee6880b529a0c6560885fce4dc95936920f9f20f53d99a213f7bf66776"}, + {file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:590344787a90ae57d62511dd7c736ed56b428f04cd8c161fcc5e7232c130c69a"}, + {file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:068b63f23b17df8569b7fdca5517edef76171cf3897eb68beb01341131fbd2ad"}, + {file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5c849d495bf5154cd8da18a9eb15db127d4dba2968d88831aff6f0331ea9bd4c"}, + {file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9750cc7fe1ae3b1611bb8cfc3f9ec11d532244235d75901fb6b8e42ce9229dfe"}, + {file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a9b2de4cf0cdd5bd2dee4c4f63a653c61d2408055ab77b151c1957f221cabf2a"}, + {file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:0633c8d5337cb5c77acbccc6357ac49a1770b8c487e5b3505c57b949b4b82e98"}, + {file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:27657df69e8801be6c3638054e202a135c7f299267f1a55ed3a598934f6c0d75"}, + {file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:f9a3ea26252bd92f570600098783d1371354d89d5f6b7dfd87359d669f2109b5"}, + {file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:4f57dab5fe3407b6c0c1cc907ac98e8a189f9e418f3b6e54d65a718aaafe3950"}, + {file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:e02a0e11cf6597299b9f3bbd3f93d79217cb90cfd1411aec33848b13f5c656cc"}, + {file = "frozenlist-1.4.1-cp310-cp310-win32.whl", hash = "sha256:a828c57f00f729620a442881cc60e57cfcec6842ba38e1b19fd3e47ac0ff8dc1"}, + {file = "frozenlist-1.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:f56e2333dda1fe0f909e7cc59f021eba0d2307bc6f012a1ccf2beca6ba362439"}, + {file = "frozenlist-1.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a0cb6f11204443f27a1628b0e460f37fb30f624be6051d490fa7d7e26d4af3d0"}, + {file = "frozenlist-1.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b46c8ae3a8f1f41a0d2ef350c0b6e65822d80772fe46b653ab6b6274f61d4a49"}, + {file = "frozenlist-1.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fde5bd59ab5357e3853313127f4d3565fc7dad314a74d7b5d43c22c6a5ed2ced"}, + {file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:722e1124aec435320ae01ee3ac7bec11a5d47f25d0ed6328f2273d287bc3abb0"}, + {file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2471c201b70d58a0f0c1f91261542a03d9a5e088ed3dc6c160d614c01649c106"}, + {file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c757a9dd70d72b076d6f68efdbb9bc943665ae954dad2801b874c8c69e185068"}, + {file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f146e0911cb2f1da549fc58fc7bcd2b836a44b79ef871980d605ec392ff6b0d2"}, + {file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f9c515e7914626b2a2e1e311794b4c35720a0be87af52b79ff8e1429fc25f19"}, + {file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:c302220494f5c1ebeb0912ea782bcd5e2f8308037b3c7553fad0e48ebad6ad82"}, + {file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:442acde1e068288a4ba7acfe05f5f343e19fac87bfc96d89eb886b0363e977ec"}, + {file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:1b280e6507ea8a4fa0c0a7150b4e526a8d113989e28eaaef946cc77ffd7efc0a"}, + {file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:fe1a06da377e3a1062ae5fe0926e12b84eceb8a50b350ddca72dc85015873f74"}, + {file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:db9e724bebd621d9beca794f2a4ff1d26eed5965b004a97f1f1685a173b869c2"}, + {file = "frozenlist-1.4.1-cp311-cp311-win32.whl", hash = "sha256:e774d53b1a477a67838a904131c4b0eef6b3d8a651f8b138b04f748fccfefe17"}, + {file = "frozenlist-1.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:fb3c2db03683b5767dedb5769b8a40ebb47d6f7f45b1b3e3b4b51ec8ad9d9825"}, + {file = "frozenlist-1.4.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:1979bc0aeb89b33b588c51c54ab0161791149f2461ea7c7c946d95d5f93b56ae"}, + {file = "frozenlist-1.4.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:cc7b01b3754ea68a62bd77ce6020afaffb44a590c2289089289363472d13aedb"}, + {file = "frozenlist-1.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c9c92be9fd329ac801cc420e08452b70e7aeab94ea4233a4804f0915c14eba9b"}, + {file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c3894db91f5a489fc8fa6a9991820f368f0b3cbdb9cd8849547ccfab3392d86"}, + {file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ba60bb19387e13597fb059f32cd4d59445d7b18b69a745b8f8e5db0346f33480"}, + {file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8aefbba5f69d42246543407ed2461db31006b0f76c4e32dfd6f42215a2c41d09"}, + {file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:780d3a35680ced9ce682fbcf4cb9c2bad3136eeff760ab33707b71db84664e3a"}, + {file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9acbb16f06fe7f52f441bb6f413ebae6c37baa6ef9edd49cdd567216da8600cd"}, + {file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:23b701e65c7b36e4bf15546a89279bd4d8675faabc287d06bbcfac7d3c33e1e6"}, + {file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:3e0153a805a98f5ada7e09826255ba99fb4f7524bb81bf6b47fb702666484ae1"}, + {file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:dd9b1baec094d91bf36ec729445f7769d0d0cf6b64d04d86e45baf89e2b9059b"}, + {file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:1a4471094e146b6790f61b98616ab8e44f72661879cc63fa1049d13ef711e71e"}, + {file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5667ed53d68d91920defdf4035d1cdaa3c3121dc0b113255124bcfada1cfa1b8"}, + {file = "frozenlist-1.4.1-cp312-cp312-win32.whl", hash = "sha256:beee944ae828747fd7cb216a70f120767fc9f4f00bacae8543c14a6831673f89"}, + {file = "frozenlist-1.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:64536573d0a2cb6e625cf309984e2d873979709f2cf22839bf2d61790b448ad5"}, + {file = "frozenlist-1.4.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:20b51fa3f588ff2fe658663db52a41a4f7aa6c04f6201449c6c7c476bd255c0d"}, + {file = "frozenlist-1.4.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:410478a0c562d1a5bcc2f7ea448359fcb050ed48b3c6f6f4f18c313a9bdb1826"}, + {file = "frozenlist-1.4.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:c6321c9efe29975232da3bd0af0ad216800a47e93d763ce64f291917a381b8eb"}, + {file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48f6a4533887e189dae092f1cf981f2e3885175f7a0f33c91fb5b7b682b6bab6"}, + {file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6eb73fa5426ea69ee0e012fb59cdc76a15b1283d6e32e4f8dc4482ec67d1194d"}, + {file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fbeb989b5cc29e8daf7f976b421c220f1b8c731cbf22b9130d8815418ea45887"}, + {file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:32453c1de775c889eb4e22f1197fe3bdfe457d16476ea407472b9442e6295f7a"}, + {file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:693945278a31f2086d9bf3df0fe8254bbeaef1fe71e1351c3bd730aa7d31c41b"}, + {file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:1d0ce09d36d53bbbe566fe296965b23b961764c0bcf3ce2fa45f463745c04701"}, + {file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:3a670dc61eb0d0eb7080890c13de3066790f9049b47b0de04007090807c776b0"}, + {file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:dca69045298ce5c11fd539682cff879cc1e664c245d1c64da929813e54241d11"}, + {file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:a06339f38e9ed3a64e4c4e43aec7f59084033647f908e4259d279a52d3757d09"}, + {file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b7f2f9f912dca3934c1baec2e4585a674ef16fe00218d833856408c48d5beee7"}, + {file = "frozenlist-1.4.1-cp38-cp38-win32.whl", hash = "sha256:e7004be74cbb7d9f34553a5ce5fb08be14fb33bc86f332fb71cbe5216362a497"}, + {file = "frozenlist-1.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:5a7d70357e7cee13f470c7883a063aae5fe209a493c57d86eb7f5a6f910fae09"}, + {file = "frozenlist-1.4.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:bfa4a17e17ce9abf47a74ae02f32d014c5e9404b6d9ac7f729e01562bbee601e"}, + {file = "frozenlist-1.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b7e3ed87d4138356775346e6845cccbe66cd9e207f3cd11d2f0b9fd13681359d"}, + {file = "frozenlist-1.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c99169d4ff810155ca50b4da3b075cbde79752443117d89429595c2e8e37fed8"}, + {file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:edb678da49d9f72c9f6c609fbe41a5dfb9a9282f9e6a2253d5a91e0fc382d7c0"}, + {file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6db4667b187a6742b33afbbaf05a7bc551ffcf1ced0000a571aedbb4aa42fc7b"}, + {file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:55fdc093b5a3cb41d420884cdaf37a1e74c3c37a31f46e66286d9145d2063bd0"}, + {file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:82e8211d69a4f4bc360ea22cd6555f8e61a1bd211d1d5d39d3d228b48c83a897"}, + {file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:89aa2c2eeb20957be2d950b85974b30a01a762f3308cd02bb15e1ad632e22dc7"}, + {file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9d3e0c25a2350080e9319724dede4f31f43a6c9779be48021a7f4ebde8b2d742"}, + {file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:7268252af60904bf52c26173cbadc3a071cece75f873705419c8681f24d3edea"}, + {file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:0c250a29735d4f15321007fb02865f0e6b6a41a6b88f1f523ca1596ab5f50bd5"}, + {file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:96ec70beabbd3b10e8bfe52616a13561e58fe84c0101dd031dc78f250d5128b9"}, + {file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:23b2d7679b73fe0e5a4560b672a39f98dfc6f60df63823b0a9970525325b95f6"}, + {file = "frozenlist-1.4.1-cp39-cp39-win32.whl", hash = "sha256:a7496bfe1da7fb1a4e1cc23bb67c58fab69311cc7d32b5a99c2007b4b2a0e932"}, + {file = "frozenlist-1.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:e6a20a581f9ce92d389a8c7d7c3dd47c81fd5d6e655c8dddf341e14aa48659d0"}, + {file = "frozenlist-1.4.1-py3-none-any.whl", hash = "sha256:04ced3e6a46b4cfffe20f9ae482818e34eba9b5fb0ce4056e4cc9b6e212d09b7"}, + {file = "frozenlist-1.4.1.tar.gz", hash = "sha256:c037a86e8513059a2613aaba4d817bb90b9d9b6b69aace3ce9c877e8c8ed402b"}, +] + +[[package]] +name = "h11" +version = "0.14.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +optional = false +python-versions = ">=3.7" +files = [ + {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, + {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, +] + +[[package]] +name = "httpcore" +version = "1.0.5" +description = "A minimal low-level HTTP client." +optional = false +python-versions = ">=3.8" +files = [ + {file = "httpcore-1.0.5-py3-none-any.whl", hash = "sha256:421f18bac248b25d310f3cacd198d55b8e6125c107797b609ff9b7a6ba7991b5"}, + {file = "httpcore-1.0.5.tar.gz", hash = "sha256:34a38e2f9291467ee3b44e89dd52615370e152954ba21721378a87b2960f7a61"}, +] + +[package.dependencies] +certifi = "*" +h11 = ">=0.13,<0.15" + +[package.extras] +asyncio = ["anyio (>=4.0,<5.0)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +trio = ["trio (>=0.22.0,<0.26.0)"] + +[[package]] +name = "httptools" +version = "0.6.1" +description = "A collection of framework independent HTTP protocol utils." +optional = false +python-versions = ">=3.8.0" +files = [ + {file = "httptools-0.6.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d2f6c3c4cb1948d912538217838f6e9960bc4a521d7f9b323b3da579cd14532f"}, + {file = "httptools-0.6.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:00d5d4b68a717765b1fabfd9ca755bd12bf44105eeb806c03d1962acd9b8e563"}, + {file = "httptools-0.6.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:639dc4f381a870c9ec860ce5c45921db50205a37cc3334e756269736ff0aac58"}, + {file = "httptools-0.6.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e57997ac7fb7ee43140cc03664de5f268813a481dff6245e0075925adc6aa185"}, + {file = "httptools-0.6.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:0ac5a0ae3d9f4fe004318d64b8a854edd85ab76cffbf7ef5e32920faef62f142"}, + {file = "httptools-0.6.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:3f30d3ce413088a98b9db71c60a6ada2001a08945cb42dd65a9a9fe228627658"}, + {file = "httptools-0.6.1-cp310-cp310-win_amd64.whl", hash = "sha256:1ed99a373e327f0107cb513b61820102ee4f3675656a37a50083eda05dc9541b"}, + {file = "httptools-0.6.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7a7ea483c1a4485c71cb5f38be9db078f8b0e8b4c4dc0210f531cdd2ddac1ef1"}, + {file = "httptools-0.6.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:85ed077c995e942b6f1b07583e4eb0a8d324d418954fc6af913d36db7c05a5a0"}, + {file = "httptools-0.6.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b0bb634338334385351a1600a73e558ce619af390c2b38386206ac6a27fecfc"}, + {file = "httptools-0.6.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d9ceb2c957320def533671fc9c715a80c47025139c8d1f3797477decbc6edd2"}, + {file = "httptools-0.6.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4f0f8271c0a4db459f9dc807acd0eadd4839934a4b9b892f6f160e94da309837"}, + {file = "httptools-0.6.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:6a4f5ccead6d18ec072ac0b84420e95d27c1cdf5c9f1bc8fbd8daf86bd94f43d"}, + {file = "httptools-0.6.1-cp311-cp311-win_amd64.whl", hash = "sha256:5cceac09f164bcba55c0500a18fe3c47df29b62353198e4f37bbcc5d591172c3"}, + {file = "httptools-0.6.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:75c8022dca7935cba14741a42744eee13ba05db00b27a4b940f0d646bd4d56d0"}, + {file = "httptools-0.6.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:48ed8129cd9a0d62cf4d1575fcf90fb37e3ff7d5654d3a5814eb3d55f36478c2"}, + {file = "httptools-0.6.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f58e335a1402fb5a650e271e8c2d03cfa7cea46ae124649346d17bd30d59c90"}, + {file = "httptools-0.6.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:93ad80d7176aa5788902f207a4e79885f0576134695dfb0fefc15b7a4648d503"}, + {file = "httptools-0.6.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:9bb68d3a085c2174c2477eb3ffe84ae9fb4fde8792edb7bcd09a1d8467e30a84"}, + {file = "httptools-0.6.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:b512aa728bc02354e5ac086ce76c3ce635b62f5fbc32ab7082b5e582d27867bb"}, + {file = "httptools-0.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:97662ce7fb196c785344d00d638fc9ad69e18ee4bfb4000b35a52efe5adcc949"}, + {file = "httptools-0.6.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:8e216a038d2d52ea13fdd9b9c9c7459fb80d78302b257828285eca1c773b99b3"}, + {file = "httptools-0.6.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:3e802e0b2378ade99cd666b5bffb8b2a7cc8f3d28988685dc300469ea8dd86cb"}, + {file = "httptools-0.6.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4bd3e488b447046e386a30f07af05f9b38d3d368d1f7b4d8f7e10af85393db97"}, + {file = "httptools-0.6.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe467eb086d80217b7584e61313ebadc8d187a4d95bb62031b7bab4b205c3ba3"}, + {file = "httptools-0.6.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:3c3b214ce057c54675b00108ac42bacf2ab8f85c58e3f324a4e963bbc46424f4"}, + {file = "httptools-0.6.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8ae5b97f690badd2ca27cbf668494ee1b6d34cf1c464271ef7bfa9ca6b83ffaf"}, + {file = "httptools-0.6.1-cp38-cp38-win_amd64.whl", hash = "sha256:405784577ba6540fa7d6ff49e37daf104e04f4b4ff2d1ac0469eaa6a20fde084"}, + {file = "httptools-0.6.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:95fb92dd3649f9cb139e9c56604cc2d7c7bf0fc2e7c8d7fbd58f96e35eddd2a3"}, + {file = "httptools-0.6.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:dcbab042cc3ef272adc11220517278519adf8f53fd3056d0e68f0a6f891ba94e"}, + {file = "httptools-0.6.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cf2372e98406efb42e93bfe10f2948e467edfd792b015f1b4ecd897903d3e8d"}, + {file = "httptools-0.6.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:678fcbae74477a17d103b7cae78b74800d795d702083867ce160fc202104d0da"}, + {file = "httptools-0.6.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:e0b281cf5a125c35f7f6722b65d8542d2e57331be573e9e88bc8b0115c4a7a81"}, + {file = "httptools-0.6.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:95658c342529bba4e1d3d2b1a874db16c7cca435e8827422154c9da76ac4e13a"}, + {file = "httptools-0.6.1-cp39-cp39-win_amd64.whl", hash = "sha256:7ebaec1bf683e4bf5e9fbb49b8cc36da482033596a415b3e4ebab5a4c0d7ec5e"}, + {file = "httptools-0.6.1.tar.gz", hash = "sha256:c6e26c30455600b95d94b1b836085138e82f177351454ee841c148f93a9bad5a"}, +] + +[package.extras] +test = ["Cython (>=0.29.24,<0.30.0)"] + +[[package]] +name = "httpx" +version = "0.27.0" +description = "The next generation HTTP client." +optional = false +python-versions = ">=3.8" +files = [ + {file = "httpx-0.27.0-py3-none-any.whl", hash = "sha256:71d5465162c13681bff01ad59b2cc68dd838ea1f10e51574bac27103f00c91a5"}, + {file = "httpx-0.27.0.tar.gz", hash = "sha256:a0cb88a46f32dc874e04ee956e4c2764aba2aa228f650b06788ba6bda2962ab5"}, +] + +[package.dependencies] +anyio = "*" +certifi = "*" +httpcore = "==1.*" +idna = "*" +sniffio = "*" + +[package.extras] +brotli = ["brotli", "brotlicffi"] +cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] + +[[package]] +name = "identify" +version = "2.5.35" +description = "File identification library for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "identify-2.5.35-py2.py3-none-any.whl", hash = "sha256:c4de0081837b211594f8e877a6b4fad7ca32bbfc1a9307fdd61c28bfe923f13e"}, + {file = "identify-2.5.35.tar.gz", hash = "sha256:10a7ca245cfcd756a554a7288159f72ff105ad233c7c4b9c6f0f4d108f5f6791"}, +] + +[package.extras] +license = ["ukkonen"] + +[[package]] +name = "idna" +version = "3.6" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.5" +files = [ + {file = "idna-3.6-py3-none-any.whl", hash = "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f"}, + {file = "idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca"}, +] + +[[package]] +name = "mafic" +version = "2.10.0" +description = "A properly typehinted lavalink client for discord.py, nextcord, disnake and py-cord." +optional = false +python-versions = ">=3.8,<4.0" +files = [ + {file = "mafic-2.10.0-py3-none-any.whl", hash = "sha256:001214abaa92ebcaef321b297bd5b3386ae4ac29fc0f798770c403318062fcf8"}, + {file = "mafic-2.10.0.tar.gz", hash = "sha256:dddec9e97808d231379583188286232d408face8b7b4eb3f16bf3c1d8ae8b5b0"}, +] + +[package.dependencies] +aiohttp = ">=3.6.0,<4.0.0" +yarl = ">=1.0.0,<2.0.0" + +[package.extras] +speedups = ["orjson (>=3.8.0,<4.0.0)"] + +[[package]] +name = "multidict" +version = "6.0.5" +description = "multidict implementation" +optional = false +python-versions = ">=3.7" +files = [ + {file = "multidict-6.0.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:228b644ae063c10e7f324ab1ab6b548bdf6f8b47f3ec234fef1093bc2735e5f9"}, + {file = "multidict-6.0.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:896ebdcf62683551312c30e20614305f53125750803b614e9e6ce74a96232604"}, + {file = "multidict-6.0.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:411bf8515f3be9813d06004cac41ccf7d1cd46dfe233705933dd163b60e37600"}, + {file = "multidict-6.0.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d147090048129ce3c453f0292e7697d333db95e52616b3793922945804a433c"}, + {file = "multidict-6.0.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:215ed703caf15f578dca76ee6f6b21b7603791ae090fbf1ef9d865571039ade5"}, + {file = "multidict-6.0.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c6390cf87ff6234643428991b7359b5f59cc15155695deb4eda5c777d2b880f"}, + {file = "multidict-6.0.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21fd81c4ebdb4f214161be351eb5bcf385426bf023041da2fd9e60681f3cebae"}, + {file = "multidict-6.0.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3cc2ad10255f903656017363cd59436f2111443a76f996584d1077e43ee51182"}, + {file = "multidict-6.0.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:6939c95381e003f54cd4c5516740faba40cf5ad3eeff460c3ad1d3e0ea2549bf"}, + {file = "multidict-6.0.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:220dd781e3f7af2c2c1053da9fa96d9cf3072ca58f057f4c5adaaa1cab8fc442"}, + {file = "multidict-6.0.5-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:766c8f7511df26d9f11cd3a8be623e59cca73d44643abab3f8c8c07620524e4a"}, + {file = "multidict-6.0.5-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:fe5d7785250541f7f5019ab9cba2c71169dc7d74d0f45253f8313f436458a4ef"}, + {file = "multidict-6.0.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1c1496e73051918fcd4f58ff2e0f2f3066d1c76a0c6aeffd9b45d53243702cc"}, + {file = "multidict-6.0.5-cp310-cp310-win32.whl", hash = "sha256:7afcdd1fc07befad18ec4523a782cde4e93e0a2bf71239894b8d61ee578c1319"}, + {file = "multidict-6.0.5-cp310-cp310-win_amd64.whl", hash = "sha256:99f60d34c048c5c2fabc766108c103612344c46e35d4ed9ae0673d33c8fb26e8"}, + {file = "multidict-6.0.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f285e862d2f153a70586579c15c44656f888806ed0e5b56b64489afe4a2dbfba"}, + {file = "multidict-6.0.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:53689bb4e102200a4fafa9de9c7c3c212ab40a7ab2c8e474491914d2305f187e"}, + {file = "multidict-6.0.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:612d1156111ae11d14afaf3a0669ebf6c170dbb735e510a7438ffe2369a847fd"}, + {file = "multidict-6.0.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7be7047bd08accdb7487737631d25735c9a04327911de89ff1b26b81745bd4e3"}, + {file = "multidict-6.0.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de170c7b4fe6859beb8926e84f7d7d6c693dfe8e27372ce3b76f01c46e489fcf"}, + {file = "multidict-6.0.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04bde7a7b3de05732a4eb39c94574db1ec99abb56162d6c520ad26f83267de29"}, + {file = "multidict-6.0.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85f67aed7bb647f93e7520633d8f51d3cbc6ab96957c71272b286b2f30dc70ed"}, + {file = "multidict-6.0.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:425bf820055005bfc8aa9a0b99ccb52cc2f4070153e34b701acc98d201693733"}, + {file = "multidict-6.0.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d3eb1ceec286eba8220c26f3b0096cf189aea7057b6e7b7a2e60ed36b373b77f"}, + {file = "multidict-6.0.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:7901c05ead4b3fb75113fb1dd33eb1253c6d3ee37ce93305acd9d38e0b5f21a4"}, + {file = "multidict-6.0.5-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:e0e79d91e71b9867c73323a3444724d496c037e578a0e1755ae159ba14f4f3d1"}, + {file = "multidict-6.0.5-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:29bfeb0dff5cb5fdab2023a7a9947b3b4af63e9c47cae2a10ad58394b517fddc"}, + {file = "multidict-6.0.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e030047e85cbcedbfc073f71836d62dd5dadfbe7531cae27789ff66bc551bd5e"}, + {file = "multidict-6.0.5-cp311-cp311-win32.whl", hash = "sha256:2f4848aa3baa109e6ab81fe2006c77ed4d3cd1e0ac2c1fbddb7b1277c168788c"}, + {file = "multidict-6.0.5-cp311-cp311-win_amd64.whl", hash = "sha256:2faa5ae9376faba05f630d7e5e6be05be22913782b927b19d12b8145968a85ea"}, + {file = "multidict-6.0.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:51d035609b86722963404f711db441cf7134f1889107fb171a970c9701f92e1e"}, + {file = "multidict-6.0.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:cbebcd5bcaf1eaf302617c114aa67569dd3f090dd0ce8ba9e35e9985b41ac35b"}, + {file = "multidict-6.0.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2ffc42c922dbfddb4a4c3b438eb056828719f07608af27d163191cb3e3aa6cc5"}, + {file = "multidict-6.0.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ceb3b7e6a0135e092de86110c5a74e46bda4bd4fbfeeb3a3bcec79c0f861e450"}, + {file = "multidict-6.0.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:79660376075cfd4b2c80f295528aa6beb2058fd289f4c9252f986751a4cd0496"}, + {file = "multidict-6.0.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e4428b29611e989719874670fd152b6625500ad6c686d464e99f5aaeeaca175a"}, + {file = "multidict-6.0.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d84a5c3a5f7ce6db1f999fb9438f686bc2e09d38143f2d93d8406ed2dd6b9226"}, + {file = "multidict-6.0.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:76c0de87358b192de7ea9649beb392f107dcad9ad27276324c24c91774ca5271"}, + {file = "multidict-6.0.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:79a6d2ba910adb2cbafc95dad936f8b9386e77c84c35bc0add315b856d7c3abb"}, + {file = "multidict-6.0.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:92d16a3e275e38293623ebf639c471d3e03bb20b8ebb845237e0d3664914caef"}, + {file = "multidict-6.0.5-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:fb616be3538599e797a2017cccca78e354c767165e8858ab5116813146041a24"}, + {file = "multidict-6.0.5-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:14c2976aa9038c2629efa2c148022ed5eb4cb939e15ec7aace7ca932f48f9ba6"}, + {file = "multidict-6.0.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:435a0984199d81ca178b9ae2c26ec3d49692d20ee29bc4c11a2a8d4514c67eda"}, + {file = "multidict-6.0.5-cp312-cp312-win32.whl", hash = "sha256:9fe7b0653ba3d9d65cbe7698cca585bf0f8c83dbbcc710db9c90f478e175f2d5"}, + {file = "multidict-6.0.5-cp312-cp312-win_amd64.whl", hash = "sha256:01265f5e40f5a17f8241d52656ed27192be03bfa8764d88e8220141d1e4b3556"}, + {file = "multidict-6.0.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:19fe01cea168585ba0f678cad6f58133db2aa14eccaf22f88e4a6dccadfad8b3"}, + {file = "multidict-6.0.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6bf7a982604375a8d49b6cc1b781c1747f243d91b81035a9b43a2126c04766f5"}, + {file = "multidict-6.0.5-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:107c0cdefe028703fb5dafe640a409cb146d44a6ae201e55b35a4af8e95457dd"}, + {file = "multidict-6.0.5-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:403c0911cd5d5791605808b942c88a8155c2592e05332d2bf78f18697a5fa15e"}, + {file = "multidict-6.0.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aeaf541ddbad8311a87dd695ed9642401131ea39ad7bc8cf3ef3967fd093b626"}, + {file = "multidict-6.0.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e4972624066095e52b569e02b5ca97dbd7a7ddd4294bf4e7247d52635630dd83"}, + {file = "multidict-6.0.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d946b0a9eb8aaa590df1fe082cee553ceab173e6cb5b03239716338629c50c7a"}, + {file = "multidict-6.0.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:b55358304d7a73d7bdf5de62494aaf70bd33015831ffd98bc498b433dfe5b10c"}, + {file = "multidict-6.0.5-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:a3145cb08d8625b2d3fee1b2d596a8766352979c9bffe5d7833e0503d0f0b5e5"}, + {file = "multidict-6.0.5-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:d65f25da8e248202bd47445cec78e0025c0fe7582b23ec69c3b27a640dd7a8e3"}, + {file = "multidict-6.0.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:c9bf56195c6bbd293340ea82eafd0071cb3d450c703d2c93afb89f93b8386ccc"}, + {file = "multidict-6.0.5-cp37-cp37m-win32.whl", hash = "sha256:69db76c09796b313331bb7048229e3bee7928eb62bab5e071e9f7fcc4879caee"}, + {file = "multidict-6.0.5-cp37-cp37m-win_amd64.whl", hash = "sha256:fce28b3c8a81b6b36dfac9feb1de115bab619b3c13905b419ec71d03a3fc1423"}, + {file = "multidict-6.0.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:76f067f5121dcecf0d63a67f29080b26c43c71a98b10c701b0677e4a065fbd54"}, + {file = "multidict-6.0.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b82cc8ace10ab5bd93235dfaab2021c70637005e1ac787031f4d1da63d493c1d"}, + {file = "multidict-6.0.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:5cb241881eefd96b46f89b1a056187ea8e9ba14ab88ba632e68d7a2ecb7aadf7"}, + {file = "multidict-6.0.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8e94e6912639a02ce173341ff62cc1201232ab86b8a8fcc05572741a5dc7d93"}, + {file = "multidict-6.0.5-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09a892e4a9fb47331da06948690ae38eaa2426de97b4ccbfafbdcbe5c8f37ff8"}, + {file = "multidict-6.0.5-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:55205d03e8a598cfc688c71ca8ea5f66447164efff8869517f175ea632c7cb7b"}, + {file = "multidict-6.0.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:37b15024f864916b4951adb95d3a80c9431299080341ab9544ed148091b53f50"}, + {file = "multidict-6.0.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2a1dee728b52b33eebff5072817176c172050d44d67befd681609b4746e1c2e"}, + {file = "multidict-6.0.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:edd08e6f2f1a390bf137080507e44ccc086353c8e98c657e666c017718561b89"}, + {file = "multidict-6.0.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:60d698e8179a42ec85172d12f50b1668254628425a6bd611aba022257cac1386"}, + {file = "multidict-6.0.5-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:3d25f19500588cbc47dc19081d78131c32637c25804df8414463ec908631e453"}, + {file = "multidict-6.0.5-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:4cc0ef8b962ac7a5e62b9e826bd0cd5040e7d401bc45a6835910ed699037a461"}, + {file = "multidict-6.0.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:eca2e9d0cc5a889850e9bbd68e98314ada174ff6ccd1129500103df7a94a7a44"}, + {file = "multidict-6.0.5-cp38-cp38-win32.whl", hash = "sha256:4a6a4f196f08c58c59e0b8ef8ec441d12aee4125a7d4f4fef000ccb22f8d7241"}, + {file = "multidict-6.0.5-cp38-cp38-win_amd64.whl", hash = "sha256:0275e35209c27a3f7951e1ce7aaf93ce0d163b28948444bec61dd7badc6d3f8c"}, + {file = "multidict-6.0.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e7be68734bd8c9a513f2b0cfd508802d6609da068f40dc57d4e3494cefc92929"}, + {file = "multidict-6.0.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1d9ea7a7e779d7a3561aade7d596649fbecfa5c08a7674b11b423783217933f9"}, + {file = "multidict-6.0.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ea1456df2a27c73ce51120fa2f519f1bea2f4a03a917f4a43c8707cf4cbbae1a"}, + {file = "multidict-6.0.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf590b134eb70629e350691ecca88eac3e3b8b3c86992042fb82e3cb1830d5e1"}, + {file = "multidict-6.0.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5c0631926c4f58e9a5ccce555ad7747d9a9f8b10619621f22f9635f069f6233e"}, + {file = "multidict-6.0.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dce1c6912ab9ff5f179eaf6efe7365c1f425ed690b03341911bf4939ef2f3046"}, + {file = "multidict-6.0.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0868d64af83169e4d4152ec612637a543f7a336e4a307b119e98042e852ad9c"}, + {file = "multidict-6.0.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:141b43360bfd3bdd75f15ed811850763555a251e38b2405967f8e25fb43f7d40"}, + {file = "multidict-6.0.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:7df704ca8cf4a073334e0427ae2345323613e4df18cc224f647f251e5e75a527"}, + {file = "multidict-6.0.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:6214c5a5571802c33f80e6c84713b2c79e024995b9c5897f794b43e714daeec9"}, + {file = "multidict-6.0.5-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:cd6c8fca38178e12c00418de737aef1261576bd1b6e8c6134d3e729a4e858b38"}, + {file = "multidict-6.0.5-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:e02021f87a5b6932fa6ce916ca004c4d441509d33bbdbeca70d05dff5e9d2479"}, + {file = "multidict-6.0.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ebd8d160f91a764652d3e51ce0d2956b38efe37c9231cd82cfc0bed2e40b581c"}, + {file = "multidict-6.0.5-cp39-cp39-win32.whl", hash = "sha256:04da1bb8c8dbadf2a18a452639771951c662c5ad03aefe4884775454be322c9b"}, + {file = "multidict-6.0.5-cp39-cp39-win_amd64.whl", hash = "sha256:d6f6d4f185481c9669b9447bf9d9cf3b95a0e9df9d169bbc17e363b7d5487755"}, + {file = "multidict-6.0.5-py3-none-any.whl", hash = "sha256:0d63c74e3d7ab26de115c49bffc92cc77ed23395303d496eae515d4204a625e7"}, + {file = "multidict-6.0.5.tar.gz", hash = "sha256:f7e301075edaf50500f0b341543c41194d8df3ae5caf4702f2095f3ca73dd8da"}, +] + +[[package]] +name = "mypy" +version = "1.9.0" +description = "Optional static typing for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "mypy-1.9.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f8a67616990062232ee4c3952f41c779afac41405806042a8126fe96e098419f"}, + {file = "mypy-1.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d357423fa57a489e8c47b7c85dfb96698caba13d66e086b412298a1a0ea3b0ed"}, + {file = "mypy-1.9.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49c87c15aed320de9b438ae7b00c1ac91cd393c1b854c2ce538e2a72d55df150"}, + {file = "mypy-1.9.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:48533cdd345c3c2e5ef48ba3b0d3880b257b423e7995dada04248725c6f77374"}, + {file = "mypy-1.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:4d3dbd346cfec7cb98e6cbb6e0f3c23618af826316188d587d1c1bc34f0ede03"}, + {file = "mypy-1.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:653265f9a2784db65bfca694d1edd23093ce49740b2244cde583aeb134c008f3"}, + {file = "mypy-1.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3a3c007ff3ee90f69cf0a15cbcdf0995749569b86b6d2f327af01fd1b8aee9dc"}, + {file = "mypy-1.9.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2418488264eb41f69cc64a69a745fad4a8f86649af4b1041a4c64ee61fc61129"}, + {file = "mypy-1.9.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:68edad3dc7d70f2f17ae4c6c1b9471a56138ca22722487eebacfd1eb5321d612"}, + {file = "mypy-1.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:85ca5fcc24f0b4aeedc1d02f93707bccc04733f21d41c88334c5482219b1ccb3"}, + {file = "mypy-1.9.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aceb1db093b04db5cd390821464504111b8ec3e351eb85afd1433490163d60cd"}, + {file = "mypy-1.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0235391f1c6f6ce487b23b9dbd1327b4ec33bb93934aa986efe8a9563d9349e6"}, + {file = "mypy-1.9.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d4d5ddc13421ba3e2e082a6c2d74c2ddb3979c39b582dacd53dd5d9431237185"}, + {file = "mypy-1.9.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:190da1ee69b427d7efa8aa0d5e5ccd67a4fb04038c380237a0d96829cb157913"}, + {file = "mypy-1.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:fe28657de3bfec596bbeef01cb219833ad9d38dd5393fc649f4b366840baefe6"}, + {file = "mypy-1.9.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e54396d70be04b34f31d2edf3362c1edd023246c82f1730bbf8768c28db5361b"}, + {file = "mypy-1.9.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:5e6061f44f2313b94f920e91b204ec600982961e07a17e0f6cd83371cb23f5c2"}, + {file = "mypy-1.9.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:81a10926e5473c5fc3da8abb04119a1f5811a236dc3a38d92015cb1e6ba4cb9e"}, + {file = "mypy-1.9.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b685154e22e4e9199fc95f298661deea28aaede5ae16ccc8cbb1045e716b3e04"}, + {file = "mypy-1.9.0-cp38-cp38-win_amd64.whl", hash = "sha256:5d741d3fc7c4da608764073089e5f58ef6352bedc223ff58f2f038c2c4698a89"}, + {file = "mypy-1.9.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:587ce887f75dd9700252a3abbc9c97bbe165a4a630597845c61279cf32dfbf02"}, + {file = "mypy-1.9.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f88566144752999351725ac623471661c9d1cd8caa0134ff98cceeea181789f4"}, + {file = "mypy-1.9.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61758fabd58ce4b0720ae1e2fea5cfd4431591d6d590b197775329264f86311d"}, + {file = "mypy-1.9.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:e49499be624dead83927e70c756970a0bc8240e9f769389cdf5714b0784ca6bf"}, + {file = "mypy-1.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:571741dc4194b4f82d344b15e8837e8c5fcc462d66d076748142327626a1b6e9"}, + {file = "mypy-1.9.0-py3-none-any.whl", hash = "sha256:a260627a570559181a9ea5de61ac6297aa5af202f06fd7ab093ce74e7181e43e"}, + {file = "mypy-1.9.0.tar.gz", hash = "sha256:3cc5da0127e6a478cddd906068496a97a7618a21ce9b54bde5bf7e539c7af974"}, +] + +[package.dependencies] +mypy-extensions = ">=1.0.0" +typing-extensions = ">=4.1.0" + +[package.extras] +dmypy = ["psutil (>=4.0)"] +install-types = ["pip"] +mypyc = ["setuptools (>=50)"] +reports = ["lxml"] + +[[package]] +name = "mypy-extensions" +version = "1.0.0" +description = "Type system extensions for programs checked with the mypy type checker." +optional = false +python-versions = ">=3.5" +files = [ + {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, + {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, +] + +[[package]] +name = "nextcord" +version = "2.6.0" +description = "A Python wrapper for the Discord API forked from discord.py" +optional = false +python-versions = ">=3.8.0" +files = [ + {file = "nextcord-2.6.0.tar.gz", hash = "sha256:ccf461157be682cbceaa474e8df4afa302351b8d6ced632f9ccb7aa647b092e7"}, +] + +[package.dependencies] +aiohttp = ">=3.8.0,<4.0.0" +typing_extensions = ">=4.2.0,<5" + +[package.extras] +docs = ["sphinx (==5.2.3)", "sphinxcontrib-websupport", "sphinxcontrib_trio (==1.1.2)", "typing_extensions (>=4.2.0,<5)"] +speed = ["aiohttp[speedups]", "orjson (>=3.5.4)"] +voice = ["PyNaCl (>=1.3.0,<1.5)"] + +[[package]] +name = "nodeenv" +version = "1.8.0" +description = "Node.js virtual environment builder" +optional = false +python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*" +files = [ + {file = "nodeenv-1.8.0-py2.py3-none-any.whl", hash = "sha256:df865724bb3c3adc86b3876fa209771517b0cfe596beff01a92700e0e8be4cec"}, + {file = "nodeenv-1.8.0.tar.gz", hash = "sha256:d51e0c37e64fbf47d017feac3145cdbb58836d7eee8c6f6d3b6880c5456227d2"}, +] + +[package.dependencies] +setuptools = "*" + +[[package]] +name = "platformdirs" +version = "4.2.0" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +optional = false +python-versions = ">=3.8" +files = [ + {file = "platformdirs-4.2.0-py3-none-any.whl", hash = "sha256:0614df2a2f37e1a662acbd8e2b25b92ccf8632929bc6d43467e17fe89c75e068"}, + {file = "platformdirs-4.2.0.tar.gz", hash = "sha256:ef0cc731df711022c174543cb70a9b5bd22e5a9337c8624ef2c2ceb8ddad8768"}, +] + +[package.extras] +docs = ["furo (>=2023.9.10)", "proselint (>=0.13)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)"] + +[[package]] +name = "pre-commit" +version = "3.7.0" +description = "A framework for managing and maintaining multi-language pre-commit hooks." +optional = false +python-versions = ">=3.9" +files = [ + {file = "pre_commit-3.7.0-py2.py3-none-any.whl", hash = "sha256:5eae9e10c2b5ac51577c3452ec0a490455c45a0533f7960f993a0d01e59decab"}, + {file = "pre_commit-3.7.0.tar.gz", hash = "sha256:e209d61b8acdcf742404408531f0c37d49d2c734fd7cff2d6076083d191cb060"}, +] + +[package.dependencies] +cfgv = ">=2.0.0" +identify = ">=1.0.0" +nodeenv = ">=0.11.1" +pyyaml = ">=5.1" +virtualenv = ">=20.10.0" + +[[package]] +name = "pycparser" +version = "2.22" +description = "C parser in Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"}, + {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, +] + +[[package]] +name = "pydantic" +version = "2.6.4" +description = "Data validation using Python type hints" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pydantic-2.6.4-py3-none-any.whl", hash = "sha256:cc46fce86607580867bdc3361ad462bab9c222ef042d3da86f2fb333e1d916c5"}, + {file = "pydantic-2.6.4.tar.gz", hash = "sha256:b1704e0847db01817624a6b86766967f552dd9dbf3afba4004409f908dcc84e6"}, +] + +[package.dependencies] +annotated-types = ">=0.4.0" +pydantic-core = "2.16.3" +typing-extensions = ">=4.6.1" + +[package.extras] +email = ["email-validator (>=2.0.0)"] + +[[package]] +name = "pydantic-core" +version = "2.16.3" +description = "" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pydantic_core-2.16.3-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:75b81e678d1c1ede0785c7f46690621e4c6e63ccd9192af1f0bd9d504bbb6bf4"}, + {file = "pydantic_core-2.16.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9c865a7ee6f93783bd5d781af5a4c43dadc37053a5b42f7d18dc019f8c9d2bd1"}, + {file = "pydantic_core-2.16.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:162e498303d2b1c036b957a1278fa0899d02b2842f1ff901b6395104c5554a45"}, + {file = "pydantic_core-2.16.3-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2f583bd01bbfbff4eaee0868e6fc607efdfcc2b03c1c766b06a707abbc856187"}, + {file = "pydantic_core-2.16.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b926dd38db1519ed3043a4de50214e0d600d404099c3392f098a7f9d75029ff8"}, + {file = "pydantic_core-2.16.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:716b542728d4c742353448765aa7cdaa519a7b82f9564130e2b3f6766018c9ec"}, + {file = "pydantic_core-2.16.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc4ad7f7ee1a13d9cb49d8198cd7d7e3aa93e425f371a68235f784e99741561f"}, + {file = "pydantic_core-2.16.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bd87f48924f360e5d1c5f770d6155ce0e7d83f7b4e10c2f9ec001c73cf475c99"}, + {file = "pydantic_core-2.16.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:0df446663464884297c793874573549229f9eca73b59360878f382a0fc085979"}, + {file = "pydantic_core-2.16.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4df8a199d9f6afc5ae9a65f8f95ee52cae389a8c6b20163762bde0426275b7db"}, + {file = "pydantic_core-2.16.3-cp310-none-win32.whl", hash = "sha256:456855f57b413f077dff513a5a28ed838dbbb15082ba00f80750377eed23d132"}, + {file = "pydantic_core-2.16.3-cp310-none-win_amd64.whl", hash = "sha256:732da3243e1b8d3eab8c6ae23ae6a58548849d2e4a4e03a1924c8ddf71a387cb"}, + {file = "pydantic_core-2.16.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:519ae0312616026bf4cedc0fe459e982734f3ca82ee8c7246c19b650b60a5ee4"}, + {file = "pydantic_core-2.16.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b3992a322a5617ded0a9f23fd06dbc1e4bd7cf39bc4ccf344b10f80af58beacd"}, + {file = "pydantic_core-2.16.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8d62da299c6ecb04df729e4b5c52dc0d53f4f8430b4492b93aa8de1f541c4aac"}, + {file = "pydantic_core-2.16.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2acca2be4bb2f2147ada8cac612f8a98fc09f41c89f87add7256ad27332c2fda"}, + {file = "pydantic_core-2.16.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1b662180108c55dfbf1280d865b2d116633d436cfc0bba82323554873967b340"}, + {file = "pydantic_core-2.16.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e7c6ed0dc9d8e65f24f5824291550139fe6f37fac03788d4580da0d33bc00c97"}, + {file = "pydantic_core-2.16.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a6b1bb0827f56654b4437955555dc3aeeebeddc47c2d7ed575477f082622c49e"}, + {file = "pydantic_core-2.16.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e56f8186d6210ac7ece503193ec84104da7ceb98f68ce18c07282fcc2452e76f"}, + {file = "pydantic_core-2.16.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:936e5db01dd49476fa8f4383c259b8b1303d5dd5fb34c97de194560698cc2c5e"}, + {file = "pydantic_core-2.16.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:33809aebac276089b78db106ee692bdc9044710e26f24a9a2eaa35a0f9fa70ba"}, + {file = "pydantic_core-2.16.3-cp311-none-win32.whl", hash = "sha256:ded1c35f15c9dea16ead9bffcde9bb5c7c031bff076355dc58dcb1cb436c4721"}, + {file = "pydantic_core-2.16.3-cp311-none-win_amd64.whl", hash = "sha256:d89ca19cdd0dd5f31606a9329e309d4fcbb3df860960acec32630297d61820df"}, + {file = "pydantic_core-2.16.3-cp311-none-win_arm64.whl", hash = "sha256:6162f8d2dc27ba21027f261e4fa26f8bcb3cf9784b7f9499466a311ac284b5b9"}, + {file = "pydantic_core-2.16.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0f56ae86b60ea987ae8bcd6654a887238fd53d1384f9b222ac457070b7ac4cff"}, + {file = "pydantic_core-2.16.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c9bd22a2a639e26171068f8ebb5400ce2c1bc7d17959f60a3b753ae13c632975"}, + {file = "pydantic_core-2.16.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4204e773b4b408062960e65468d5346bdfe139247ee5f1ca2a378983e11388a2"}, + {file = "pydantic_core-2.16.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f651dd19363c632f4abe3480a7c87a9773be27cfe1341aef06e8759599454120"}, + {file = "pydantic_core-2.16.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aaf09e615a0bf98d406657e0008e4a8701b11481840be7d31755dc9f97c44053"}, + {file = "pydantic_core-2.16.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8e47755d8152c1ab5b55928ab422a76e2e7b22b5ed8e90a7d584268dd49e9c6b"}, + {file = "pydantic_core-2.16.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:500960cb3a0543a724a81ba859da816e8cf01b0e6aaeedf2c3775d12ee49cade"}, + {file = "pydantic_core-2.16.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cf6204fe865da605285c34cf1172879d0314ff267b1c35ff59de7154f35fdc2e"}, + {file = "pydantic_core-2.16.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d33dd21f572545649f90c38c227cc8631268ba25c460b5569abebdd0ec5974ca"}, + {file = "pydantic_core-2.16.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:49d5d58abd4b83fb8ce763be7794d09b2f50f10aa65c0f0c1696c677edeb7cbf"}, + {file = "pydantic_core-2.16.3-cp312-none-win32.whl", hash = "sha256:f53aace168a2a10582e570b7736cc5bef12cae9cf21775e3eafac597e8551fbe"}, + {file = "pydantic_core-2.16.3-cp312-none-win_amd64.whl", hash = "sha256:0d32576b1de5a30d9a97f300cc6a3f4694c428d956adbc7e6e2f9cad279e45ed"}, + {file = "pydantic_core-2.16.3-cp312-none-win_arm64.whl", hash = "sha256:ec08be75bb268473677edb83ba71e7e74b43c008e4a7b1907c6d57e940bf34b6"}, + {file = "pydantic_core-2.16.3-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:b1f6f5938d63c6139860f044e2538baeee6f0b251a1816e7adb6cbce106a1f01"}, + {file = "pydantic_core-2.16.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:2a1ef6a36fdbf71538142ed604ad19b82f67b05749512e47f247a6ddd06afdc7"}, + {file = "pydantic_core-2.16.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:704d35ecc7e9c31d48926150afada60401c55efa3b46cd1ded5a01bdffaf1d48"}, + {file = "pydantic_core-2.16.3-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d937653a696465677ed583124b94a4b2d79f5e30b2c46115a68e482c6a591c8a"}, + {file = "pydantic_core-2.16.3-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c9803edf8e29bd825f43481f19c37f50d2b01899448273b3a7758441b512acf8"}, + {file = "pydantic_core-2.16.3-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:72282ad4892a9fb2da25defeac8c2e84352c108705c972db82ab121d15f14e6d"}, + {file = "pydantic_core-2.16.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f752826b5b8361193df55afcdf8ca6a57d0232653494ba473630a83ba50d8c9"}, + {file = "pydantic_core-2.16.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4384a8f68ddb31a0b0c3deae88765f5868a1b9148939c3f4121233314ad5532c"}, + {file = "pydantic_core-2.16.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:a4b2bf78342c40b3dc830880106f54328928ff03e357935ad26c7128bbd66ce8"}, + {file = "pydantic_core-2.16.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:13dcc4802961b5f843a9385fc821a0b0135e8c07fc3d9949fd49627c1a5e6ae5"}, + {file = "pydantic_core-2.16.3-cp38-none-win32.whl", hash = "sha256:e3e70c94a0c3841e6aa831edab1619ad5c511199be94d0c11ba75fe06efe107a"}, + {file = "pydantic_core-2.16.3-cp38-none-win_amd64.whl", hash = "sha256:ecdf6bf5f578615f2e985a5e1f6572e23aa632c4bd1dc67f8f406d445ac115ed"}, + {file = "pydantic_core-2.16.3-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:bda1ee3e08252b8d41fa5537413ffdddd58fa73107171a126d3b9ff001b9b820"}, + {file = "pydantic_core-2.16.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:21b888c973e4f26b7a96491c0965a8a312e13be108022ee510248fe379a5fa23"}, + {file = "pydantic_core-2.16.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be0ec334369316fa73448cc8c982c01e5d2a81c95969d58b8f6e272884df0074"}, + {file = "pydantic_core-2.16.3-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5b6079cc452a7c53dd378c6f881ac528246b3ac9aae0f8eef98498a75657805"}, + {file = "pydantic_core-2.16.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ee8d5f878dccb6d499ba4d30d757111847b6849ae07acdd1205fffa1fc1253c"}, + {file = "pydantic_core-2.16.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7233d65d9d651242a68801159763d09e9ec96e8a158dbf118dc090cd77a104c9"}, + {file = "pydantic_core-2.16.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c6119dc90483a5cb50a1306adb8d52c66e447da88ea44f323e0ae1a5fcb14256"}, + {file = "pydantic_core-2.16.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:578114bc803a4c1ff9946d977c221e4376620a46cf78da267d946397dc9514a8"}, + {file = "pydantic_core-2.16.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d8f99b147ff3fcf6b3cc60cb0c39ea443884d5559a30b1481e92495f2310ff2b"}, + {file = "pydantic_core-2.16.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:4ac6b4ce1e7283d715c4b729d8f9dab9627586dafce81d9eaa009dd7f25dd972"}, + {file = "pydantic_core-2.16.3-cp39-none-win32.whl", hash = "sha256:e7774b570e61cb998490c5235740d475413a1f6de823169b4cf94e2fe9e9f6b2"}, + {file = "pydantic_core-2.16.3-cp39-none-win_amd64.whl", hash = "sha256:9091632a25b8b87b9a605ec0e61f241c456e9248bfdcf7abdf344fdb169c81cf"}, + {file = "pydantic_core-2.16.3-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:36fa178aacbc277bc6b62a2c3da95226520da4f4e9e206fdf076484363895d2c"}, + {file = "pydantic_core-2.16.3-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:dcca5d2bf65c6fb591fff92da03f94cd4f315972f97c21975398bd4bd046854a"}, + {file = "pydantic_core-2.16.3-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2a72fb9963cba4cd5793854fd12f4cfee731e86df140f59ff52a49b3552db241"}, + {file = "pydantic_core-2.16.3-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b60cc1a081f80a2105a59385b92d82278b15d80ebb3adb200542ae165cd7d183"}, + {file = "pydantic_core-2.16.3-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cbcc558401de90a746d02ef330c528f2e668c83350f045833543cd57ecead1ad"}, + {file = "pydantic_core-2.16.3-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:fee427241c2d9fb7192b658190f9f5fd6dfe41e02f3c1489d2ec1e6a5ab1e04a"}, + {file = "pydantic_core-2.16.3-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f4cb85f693044e0f71f394ff76c98ddc1bc0953e48c061725e540396d5c8a2e1"}, + {file = "pydantic_core-2.16.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:b29eeb887aa931c2fcef5aa515d9d176d25006794610c264ddc114c053bf96fe"}, + {file = "pydantic_core-2.16.3-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:a425479ee40ff021f8216c9d07a6a3b54b31c8267c6e17aa88b70d7ebd0e5e5b"}, + {file = "pydantic_core-2.16.3-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:5c5cbc703168d1b7a838668998308018a2718c2130595e8e190220238addc96f"}, + {file = "pydantic_core-2.16.3-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:99b6add4c0b39a513d323d3b93bc173dac663c27b99860dd5bf491b240d26137"}, + {file = "pydantic_core-2.16.3-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75f76ee558751746d6a38f89d60b6228fa174e5172d143886af0f85aa306fd89"}, + {file = "pydantic_core-2.16.3-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:00ee1c97b5364b84cb0bd82e9bbf645d5e2871fb8c58059d158412fee2d33d8a"}, + {file = "pydantic_core-2.16.3-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:287073c66748f624be4cef893ef9174e3eb88fe0b8a78dc22e88eca4bc357ca6"}, + {file = "pydantic_core-2.16.3-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:ed25e1835c00a332cb10c683cd39da96a719ab1dfc08427d476bce41b92531fc"}, + {file = "pydantic_core-2.16.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:86b3d0033580bd6bbe07590152007275bd7af95f98eaa5bd36f3da219dcd93da"}, + {file = "pydantic_core-2.16.3.tar.gz", hash = "sha256:1cac689f80a3abab2d3c0048b29eea5751114054f032a941a32de4c852c59cad"}, +] + +[package.dependencies] +typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" + +[[package]] +name = "pyjwt" +version = "2.8.0" +description = "JSON Web Token implementation in Python" +optional = false +python-versions = ">=3.7" +files = [ + {file = "PyJWT-2.8.0-py3-none-any.whl", hash = "sha256:59127c392cc44c2da5bb3192169a91f429924e17aff6534d70fdc02ab3e04320"}, + {file = "PyJWT-2.8.0.tar.gz", hash = "sha256:57e28d156e3d5c10088e0c68abb90bfac3df82b40a71bd0daa20c65ccd5c23de"}, +] + +[package.extras] +crypto = ["cryptography (>=3.4.0)"] +dev = ["coverage[toml] (==5.0.4)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=6.0.0,<7.0.0)", "sphinx (>=4.5.0,<5.0.0)", "sphinx-rtd-theme", "zope.interface"] +docs = ["sphinx (>=4.5.0,<5.0.0)", "sphinx-rtd-theme", "zope.interface"] +tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"] + +[[package]] +name = "pylast" +version = "5.2.0" +description = "A Python interface to Last.fm and Libre.fm" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pylast-5.2.0-py3-none-any.whl", hash = "sha256:89c7c01ea9f08c83865999d8907835157a8096e77dd9dc23420246eb66cfcff5"}, + {file = "pylast-5.2.0.tar.gz", hash = "sha256:bb046804ef56a0c18072c750d61a282d47ac102a3b0b9c44a023eaf5b0934b0a"}, +] + +[package.dependencies] +httpx = "*" + +[package.extras] +tests = ["flaky", "pytest", "pytest-cov", "pytest-random-order", "pyyaml"] + +[[package]] +name = "python-dotenv" +version = "1.0.1" +description = "Read key-value pairs from a .env file and set them as environment variables" +optional = false +python-versions = ">=3.8" +files = [ + {file = "python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca"}, + {file = "python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a"}, +] + +[package.extras] +cli = ["click (>=5.0)"] + +[[package]] +name = "pyyaml" +version = "6.0.1" +description = "YAML parser and emitter for Python" +optional = false +python-versions = ">=3.6" +files = [ + {file = "PyYAML-6.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a"}, + {file = "PyYAML-6.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f"}, + {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, + {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, + {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, + {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, + {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, + {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, + {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, + {file = "PyYAML-6.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab"}, + {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, + {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, + {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, + {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, + {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, + {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, + {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"}, + {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, + {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, + {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, + {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, + {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, + {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, + {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, + {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd"}, + {file = "PyYAML-6.0.1-cp36-cp36m-win32.whl", hash = "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585"}, + {file = "PyYAML-6.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa"}, + {file = "PyYAML-6.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3"}, + {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27"}, + {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3"}, + {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c"}, + {file = "PyYAML-6.0.1-cp37-cp37m-win32.whl", hash = "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba"}, + {file = "PyYAML-6.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867"}, + {file = "PyYAML-6.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595"}, + {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, + {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, + {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, + {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, + {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, + {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, + {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, + {file = "PyYAML-6.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859"}, + {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, + {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, + {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, + {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, + {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, + {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, + {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, +] + +[[package]] +name = "rapidfuzz" +version = "3.7.0" +description = "rapid fuzzy string matching" +optional = false +python-versions = ">=3.8" +files = [ + {file = "rapidfuzz-3.7.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:860f438238f1807532aa5c5c25e74c284232ccc115fe84697b78e25d48f364f7"}, + {file = "rapidfuzz-3.7.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4bb9285abeb0477cdb2f8ea0cf7fd4b5f72ed5a9a7d3f0c0bb4a5239db2fc1ed"}, + {file = "rapidfuzz-3.7.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:08671280e0c04d2bb3f39511f13cae5914e6690036fd1eefc3d47a47f9fae634"}, + {file = "rapidfuzz-3.7.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:04bae4d9c16ce1bab6447d196fb8258d98139ed8f9b288a38b84887985e4227b"}, + {file = "rapidfuzz-3.7.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1efa2268b51b68156fb84d18ca1720311698a58051c4a19c40d670057ce60519"}, + {file = "rapidfuzz-3.7.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:600b4d4315f33ec0356c0dab3991a5d5761102420bcff29e0773706aa48936e8"}, + {file = "rapidfuzz-3.7.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:18bc2f13c73d5d34499ff6ada55b052c445d3aa64d22c2639e5ab45472568046"}, + {file = "rapidfuzz-3.7.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e11c5e6593be41a555475c9c20320342c1f5585d635a064924956944c465ad4"}, + {file = "rapidfuzz-3.7.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d7878025248b99ccca3285891899373f98548f2ca13835d83619ffc42241c626"}, + {file = "rapidfuzz-3.7.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:b4a7e37fe136022d944374fcd8a2f72b8a19f7b648d2cdfb946667e9ede97f9f"}, + {file = "rapidfuzz-3.7.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:b5881856f830351aaabd869151124f64a80bf61560546d9588a630a4e933a5de"}, + {file = "rapidfuzz-3.7.0-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:c788b11565cc176fab8fab6dfcd469031e906927db94bf7e422afd8ef8f88a5a"}, + {file = "rapidfuzz-3.7.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:9e17a3092e74025d896ef1d67ac236c83494da37a78ef84c712e4e2273c115f1"}, + {file = "rapidfuzz-3.7.0-cp310-cp310-win32.whl", hash = "sha256:e499c823206c9ffd9d89aa11f813a4babdb9219417d4efe4c8a6f8272da00e98"}, + {file = "rapidfuzz-3.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:91f798cc00cd94a0def43e9befc6e867c9bd8fa8f882d1eaa40042f528b7e2c7"}, + {file = "rapidfuzz-3.7.0-cp310-cp310-win_arm64.whl", hash = "sha256:d5a3872f35bec89f07b993fa1c5401d11b9e68bcdc1b9737494e279308a38a5f"}, + {file = "rapidfuzz-3.7.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ef6b6ab64c4c91c57a6b58e1d690b59453bfa1f1e9757a7e52e59b4079e36631"}, + {file = "rapidfuzz-3.7.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2f9070b42c0ba030b045bba16a35bdb498a0d6acb0bdb3ff4e325960e685e290"}, + {file = "rapidfuzz-3.7.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:63044c63565f50818d885bfcd40ac369947da4197de56b4d6c26408989d48edf"}, + {file = "rapidfuzz-3.7.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:49b0c47860c733a3d73a4b70b97b35c8cbf24ef24f8743732f0d1c412a8c85de"}, + {file = "rapidfuzz-3.7.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d1b14489b038f007f425a06fcf28ac6313c02cb603b54e3a28d9cfae82198cc0"}, + {file = "rapidfuzz-3.7.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be08f39e397a618aab907887465d7fabc2d1a4d15d1a67cb8b526a7fb5202a3e"}, + {file = "rapidfuzz-3.7.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:16895dc62a7b92028f9c8b6d22830f1cbc77306ee794f461afc6028e1a8d7539"}, + {file = "rapidfuzz-3.7.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:579cce49dfa57ffd8c8227b3fb53cced54b4df70cec502e63e9799b4d1f44004"}, + {file = "rapidfuzz-3.7.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:40998c8dc35fdd221790b8b5134a8d7499adbfab9a5dd9ec626c7e92e17a43ed"}, + {file = "rapidfuzz-3.7.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:dc3fdb4738a6b83ae27f1d8923b00d3a9c2b5c50da75b9f8b81841839c6e3e1f"}, + {file = "rapidfuzz-3.7.0-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:92b8146fbfb37ac358ef7e0f6b79619e4f793fbbe894b99ea87920f9c0a9d77d"}, + {file = "rapidfuzz-3.7.0-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:1dfceaa7c2914585bb8a043265c39ec09078f13fbf53b5525722fc074306b6fa"}, + {file = "rapidfuzz-3.7.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f332d61f51b0b9c8b55a0fb052b4764b6ad599ea8ce948ac47a4388e9083c35e"}, + {file = "rapidfuzz-3.7.0-cp311-cp311-win32.whl", hash = "sha256:dfd1e4819f1f3c47141f86159b44b7360ecb19bf675080b3b40437bf97273ab9"}, + {file = "rapidfuzz-3.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:594b9c33fc1a86784962043ee3fbaaed875fbaadff72e467c2f7a83cd6c5d69d"}, + {file = "rapidfuzz-3.7.0-cp311-cp311-win_arm64.whl", hash = "sha256:0b13a6823a1b83ae43f8bf35955df35032bee7bec0daf9b5ab836e0286067434"}, + {file = "rapidfuzz-3.7.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:075a419a0ec29be44b3d7f4bcfa5cb7e91e419379a85fc05eb33de68315bd96f"}, + {file = "rapidfuzz-3.7.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:51a5b96d2081c3afbef1842a61d63e55d0a5a201473e6975a80190ff2d6f22ca"}, + {file = "rapidfuzz-3.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a9460d8fddac7ea46dff9298eee9aa950dbfe79f2eb509a9f18fbaefcd10894c"}, + {file = "rapidfuzz-3.7.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f39eb1513ee139ba6b5c01fe47ddf2d87e9560dd7fdee1068f7f6efbae70de34"}, + {file = "rapidfuzz-3.7.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eace9fdde58a425d4c9a93021b24a0cac830df167a5b2fc73299e2acf9f41493"}, + {file = "rapidfuzz-3.7.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cc77237242303733de47829028a0a8b6ab9188b23ec9d9ff0a674fdcd3c8e7f"}, + {file = "rapidfuzz-3.7.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:74e692357dd324dff691d379ef2c094c9ec526c0ce83ed43a066e4e68fe70bf6"}, + {file = "rapidfuzz-3.7.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5f2075ac9ee5c15d33d24a1efc8368d095602b5fd9634c5b5f24d83e41903528"}, + {file = "rapidfuzz-3.7.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:5a8ba64d72329a940ff6c74b721268c2004eecc48558f648a38e96915b5d1c1b"}, + {file = "rapidfuzz-3.7.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:a1f268a2a37cd22573b4a06eccd481c04504b246d3cadc2d8e8dfa64b575636d"}, + {file = "rapidfuzz-3.7.0-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:42c2e8a2341363c7caf276efdbe1a673fc5267a02568c47c8e980f12e9bc8727"}, + {file = "rapidfuzz-3.7.0-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:a9acca34b34fb895ee6a84c436bb919f3b9cd8f43e7003d43e9573a1d990ff74"}, + {file = "rapidfuzz-3.7.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:9bad6a0fe3bc1753dacaa6229a8ba7d9844eb7ae24d44d17c5f4c51c91a8a95e"}, + {file = "rapidfuzz-3.7.0-cp312-cp312-win32.whl", hash = "sha256:c86bc4b1d2380739e6485396195e30021df509b4923f3f757914e171587bce7c"}, + {file = "rapidfuzz-3.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:d7361608c8e73a1dc0203a87d151cddebdade0098a047c46da43c469c07df964"}, + {file = "rapidfuzz-3.7.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fdc26e7863e0f63c2185d53bb61f5173ad4451c1c8287b535b30ea25a419a5a"}, + {file = "rapidfuzz-3.7.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:9b6167468f76779a14b9af66210f68741af94d32d086f19118de4e919f00585c"}, + {file = "rapidfuzz-3.7.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:5bd394e28ff221557ea4d8152fcec3e66d9f620557feca5f2bedc4c21f8cf2f9"}, + {file = "rapidfuzz-3.7.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:8e70f876ca89a6df344f8157ac60384e8c05a0dfb442da2490c3f1c45238ccf5"}, + {file = "rapidfuzz-3.7.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7c837f89d86a5affe9ee6574dad6b195475676a6ab171a67920fc99966f2ab2c"}, + {file = "rapidfuzz-3.7.0-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cda4550a98658f9a8bcdc03d0498ed1565c1563880e3564603a9eaae28d51b2a"}, + {file = "rapidfuzz-3.7.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ecd70212fd9f1f8b1d3bdd8bcb05acc143defebd41148bdab43e573b043bb241"}, + {file = "rapidfuzz-3.7.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:187db4cc8fb54f8c49c67b7f38ef3a122ce23be273032fa2ff34112a2694c3d8"}, + {file = "rapidfuzz-3.7.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4604dfc1098920c4eb6d0c6b5cc7bdd4bf95b48633e790c1d3f100a25870691d"}, + {file = "rapidfuzz-3.7.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:01581b688c5f4f6665b779135e32db0edab1d78028abf914bb91469928efa383"}, + {file = "rapidfuzz-3.7.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:0828b55ec8ad084febdf4ab0c942eb1f81c97c0935f1cb0be0b4ea84ce755988"}, + {file = "rapidfuzz-3.7.0-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:150c98b65faff17b917b9d36bff8a4d37b6173579c6bc2e38ff2044e209d37a4"}, + {file = "rapidfuzz-3.7.0-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:7e4eea225d2bff1aff4c85fcc44716596d3699374d99eb5906b7a7560297460e"}, + {file = "rapidfuzz-3.7.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:7bc944d7e830cfce0f8b4813875f05904207017b66e25ab7ee757507001310a9"}, + {file = "rapidfuzz-3.7.0-cp38-cp38-win32.whl", hash = "sha256:3e55f02105c451ab6ff0edaaba57cab1b6c0a0241cfb2b306d4e8e1503adba50"}, + {file = "rapidfuzz-3.7.0-cp38-cp38-win_amd64.whl", hash = "sha256:41851620d2900791d66d9b6092fc163441d7dd91a460c73b07957ff1c517bc30"}, + {file = "rapidfuzz-3.7.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e8041c6b2d339766efe6298fa272f79d6dd799965df364ef4e50f488c101c899"}, + {file = "rapidfuzz-3.7.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:4e09d81008e212fc824ea23603ff5270d75886e72372fa6c7c41c1880bcb57ed"}, + {file = "rapidfuzz-3.7.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:419c8961e861fb5fc5590056c66a279623d1ea27809baea17e00cdc313f1217a"}, + {file = "rapidfuzz-3.7.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1522eaab91b9400b3ef16eebe445940a19e70035b5bc5d98aef23d66e9ac1df0"}, + {file = "rapidfuzz-3.7.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:611278ce3136f4544d596af18ab8849827d64372e1d8888d9a8d071bf4a3f44d"}, + {file = "rapidfuzz-3.7.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4efa9bfc5b955b6474ee077eee154e240441842fa304f280b06e6b6aa58a1d1e"}, + {file = "rapidfuzz-3.7.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c0cc9d3c8261457af3f8756b1f71a9fdc4892978a9e8b967976d2803e08bf972"}, + {file = "rapidfuzz-3.7.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce728e2b582fd396bc2559160ee2e391e6a4b5d2e455624044699d96abe8a396"}, + {file = "rapidfuzz-3.7.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:3a6a36c9299e059e0bee3409218bc5235a46570c20fc980cdee5ed21ea6110ad"}, + {file = "rapidfuzz-3.7.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9ea720db8def684c1eb71dadad1f61c9b52f4d979263eb5d443f2b22b0d5430a"}, + {file = "rapidfuzz-3.7.0-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:358692f1df3f8aebcd48e69c77c948c9283b44c0efbaf1eeea01739efe3cd9a6"}, + {file = "rapidfuzz-3.7.0-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:faded69ffe79adcefa8da08f414a0fd52375e2b47f57be79471691dad9656b5a"}, + {file = "rapidfuzz-3.7.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:7f9f3dc14fadbd553975f824ac48c381f42192cec9d7e5711b528357662a8d8e"}, + {file = "rapidfuzz-3.7.0-cp39-cp39-win32.whl", hash = "sha256:7be5f460ff42d7d27729115bfe8a02e83fa0284536d8630ee900d17b75c29e65"}, + {file = "rapidfuzz-3.7.0-cp39-cp39-win_amd64.whl", hash = "sha256:dd5ad2c12dab2b98340c4b7b9592c8f349730bda9a2e49675ea592bbcbc1360b"}, + {file = "rapidfuzz-3.7.0-cp39-cp39-win_arm64.whl", hash = "sha256:aa163257a0ac4e70f9009d25e5030bdd83a8541dfa3ba78dc86b35c9e16a80b4"}, + {file = "rapidfuzz-3.7.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:4e50840a8a8e0229563eeaf22e21a203359859557db8829f4d0285c17126c5fb"}, + {file = "rapidfuzz-3.7.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:632f09e19365ace5ff2670008adc8bf23d03d668b03a30230e5b60ff9317ee93"}, + {file = "rapidfuzz-3.7.0-pp38-pypy38_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:209dda6ae66b702f74a78cef555397cdc2a83d7f48771774a20d2fc30808b28c"}, + {file = "rapidfuzz-3.7.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bc0b78572626af6ab134895e4dbfe4f4d615d18dcc43b8d902d8e45471aabba"}, + {file = "rapidfuzz-3.7.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:7ba14850cc8258b3764ea16b8a4409ac2ba16d229bde7a5f495dd479cd9ccd56"}, + {file = "rapidfuzz-3.7.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:b917764fd2b267addc9d03a96d26f751f6117a95f617428c44a069057653b528"}, + {file = "rapidfuzz-3.7.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1252ca156e1b053e84e5ae1c8e9e062ee80468faf23aa5c543708212a42795fd"}, + {file = "rapidfuzz-3.7.0-pp39-pypy39_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:86c7676a32d7524e40bc73546e511a408bc831ae5b163029d325ea3a2027d089"}, + {file = "rapidfuzz-3.7.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:20e7d729af2e5abb29caa070ec048aba042f134091923d9ca2ac662b5604577e"}, + {file = "rapidfuzz-3.7.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:86eea3e6c314a9238de568254a9c591ec73c2985f125675ed5f171d869c47773"}, + {file = "rapidfuzz-3.7.0.tar.gz", hash = "sha256:620df112c39c6d27316dc1e22046dc0382d6d91fd60d7c51bd41ca0333d867e9"}, +] + +[package.extras] +full = ["numpy"] + +[[package]] +name = "ratelimit" +version = "2.2.1" +description = "API rate limit decorator" +optional = false +python-versions = "*" +files = [ + {file = "ratelimit-2.2.1.tar.gz", hash = "sha256:af8a9b64b821529aca09ebaf6d8d279100d766f19e90b5059ac6a718ca6dee42"}, +] + +[[package]] +name = "redis" +version = "5.0.3" +description = "Python client for Redis database and key-value store" +optional = false +python-versions = ">=3.7" +files = [ + {file = "redis-5.0.3-py3-none-any.whl", hash = "sha256:5da9b8fe9e1254293756c16c008e8620b3d15fcc6dde6babde9541850e72a32d"}, + {file = "redis-5.0.3.tar.gz", hash = "sha256:4973bae7444c0fbed64a06b87446f79361cb7e4ec1538c022d696ed7a5015580"}, +] + +[package.dependencies] +async-timeout = {version = ">=4.0.3", markers = "python_full_version < \"3.11.3\""} + +[package.extras] +hiredis = ["hiredis (>=1.0.0)"] +ocsp = ["cryptography (>=36.0.1)", "pyopenssl (==20.0.1)", "requests (>=2.26.0)"] + +[[package]] +name = "requests" +version = "2.31.0" +description = "Python HTTP for Humans." +optional = false +python-versions = ">=3.7" +files = [ + {file = "requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f"}, + {file = "requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1"}, +] + +[package.dependencies] +certifi = ">=2017.4.17" +charset-normalizer = ">=2,<4" +idna = ">=2.5,<4" +urllib3 = ">=1.21.1,<3" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] + +[[package]] +name = "ruff" +version = "0.3.4" +description = "An extremely fast Python linter and code formatter, written in Rust." +optional = false +python-versions = ">=3.7" +files = [ + {file = "ruff-0.3.4-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:60c870a7d46efcbc8385d27ec07fe534ac32f3b251e4fc44b3cbfd9e09609ef4"}, + {file = "ruff-0.3.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6fc14fa742e1d8f24910e1fff0bd5e26d395b0e0e04cc1b15c7c5e5fe5b4af91"}, + {file = "ruff-0.3.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d3ee7880f653cc03749a3bfea720cf2a192e4f884925b0cf7eecce82f0ce5854"}, + {file = "ruff-0.3.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cf133dd744f2470b347f602452a88e70dadfbe0fcfb5fd46e093d55da65f82f7"}, + {file = "ruff-0.3.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3f3860057590e810c7ffea75669bdc6927bfd91e29b4baa9258fd48b540a4365"}, + {file = "ruff-0.3.4-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:986f2377f7cf12efac1f515fc1a5b753c000ed1e0a6de96747cdf2da20a1b369"}, + {file = "ruff-0.3.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c4fd98e85869603e65f554fdc5cddf0712e352fe6e61d29d5a6fe087ec82b76c"}, + {file = "ruff-0.3.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:64abeed785dad51801b423fa51840b1764b35d6c461ea8caef9cf9e5e5ab34d9"}, + {file = "ruff-0.3.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:df52972138318bc7546d92348a1ee58449bc3f9eaf0db278906eb511889c4b50"}, + {file = "ruff-0.3.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:98e98300056445ba2cc27d0b325fd044dc17fcc38e4e4d2c7711585bd0a958ed"}, + {file = "ruff-0.3.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:519cf6a0ebed244dce1dc8aecd3dc99add7a2ee15bb68cf19588bb5bf58e0488"}, + {file = "ruff-0.3.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:bb0acfb921030d00070539c038cd24bb1df73a2981e9f55942514af8b17be94e"}, + {file = "ruff-0.3.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:cf187a7e7098233d0d0c71175375c5162f880126c4c716fa28a8ac418dcf3378"}, + {file = "ruff-0.3.4-py3-none-win32.whl", hash = "sha256:af27ac187c0a331e8ef91d84bf1c3c6a5dea97e912a7560ac0cef25c526a4102"}, + {file = "ruff-0.3.4-py3-none-win_amd64.whl", hash = "sha256:de0d5069b165e5a32b3c6ffbb81c350b1e3d3483347196ffdf86dc0ef9e37dd6"}, + {file = "ruff-0.3.4-py3-none-win_arm64.whl", hash = "sha256:6810563cc08ad0096b57c717bd78aeac888a1bfd38654d9113cb3dc4d3f74232"}, + {file = "ruff-0.3.4.tar.gz", hash = "sha256:f0f4484c6541a99862b693e13a151435a279b271cff20e37101116a21e2a1ad1"}, +] + +[[package]] +name = "sentry-sdk" +version = "1.44.0" +description = "Python client for Sentry (https://sentry.io)" +optional = false +python-versions = "*" +files = [ + {file = "sentry-sdk-1.44.0.tar.gz", hash = "sha256:f7125a9235795811962d52ff796dc032cd1d0dd98b59beaced8380371cd9c13c"}, + {file = "sentry_sdk-1.44.0-py2.py3-none-any.whl", hash = "sha256:eb65289da013ca92fad2694851ad2f086aa3825e808dc285bd7dcaf63602bb18"}, +] + +[package.dependencies] +certifi = "*" +urllib3 = {version = ">=1.26.11", markers = "python_version >= \"3.6\""} + +[package.extras] +aiohttp = ["aiohttp (>=3.5)"] +arq = ["arq (>=0.23)"] +asyncpg = ["asyncpg (>=0.23)"] +beam = ["apache-beam (>=2.12)"] +bottle = ["bottle (>=0.12.13)"] +celery = ["celery (>=3)"] +celery-redbeat = ["celery-redbeat (>=2)"] +chalice = ["chalice (>=1.16.0)"] +clickhouse-driver = ["clickhouse-driver (>=0.2.0)"] +django = ["django (>=1.8)"] +falcon = ["falcon (>=1.4)"] +fastapi = ["fastapi (>=0.79.0)"] +flask = ["blinker (>=1.1)", "flask (>=0.11)", "markupsafe"] +grpcio = ["grpcio (>=1.21.1)"] +httpx = ["httpx (>=0.16.0)"] +huey = ["huey (>=2)"] +loguru = ["loguru (>=0.5)"] +openai = ["openai (>=1.0.0)", "tiktoken (>=0.3.0)"] +opentelemetry = ["opentelemetry-distro (>=0.35b0)"] +opentelemetry-experimental = ["opentelemetry-distro (>=0.40b0,<1.0)", "opentelemetry-instrumentation-aiohttp-client (>=0.40b0,<1.0)", "opentelemetry-instrumentation-django (>=0.40b0,<1.0)", "opentelemetry-instrumentation-fastapi (>=0.40b0,<1.0)", "opentelemetry-instrumentation-flask (>=0.40b0,<1.0)", "opentelemetry-instrumentation-requests (>=0.40b0,<1.0)", "opentelemetry-instrumentation-sqlite3 (>=0.40b0,<1.0)", "opentelemetry-instrumentation-urllib (>=0.40b0,<1.0)"] +pure-eval = ["asttokens", "executing", "pure-eval"] +pymongo = ["pymongo (>=3.1)"] +pyspark = ["pyspark (>=2.4.4)"] +quart = ["blinker (>=1.1)", "quart (>=0.16.1)"] +rq = ["rq (>=0.6)"] +sanic = ["sanic (>=0.8)"] +sqlalchemy = ["sqlalchemy (>=1.2)"] +starlette = ["starlette (>=0.19.1)"] +starlite = ["starlite (>=1.48)"] +tornado = ["tornado (>=5)"] + +[[package]] +name = "setuptools" +version = "69.2.0" +description = "Easily download, build, install, upgrade, and uninstall Python packages" +optional = false +python-versions = ">=3.8" +files = [ + {file = "setuptools-69.2.0-py3-none-any.whl", hash = "sha256:c21c49fb1042386df081cb5d86759792ab89efca84cf114889191cd09aacc80c"}, + {file = "setuptools-69.2.0.tar.gz", hash = "sha256:0ff4183f8f42cd8fa3acea16c45205521a4ef28f73c6391d8a25e92893134f2e"}, +] + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"] +testing = ["build[virtualenv]", "filelock (>=3.4.0)", "importlib-metadata", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "mypy (==1.9)", "packaging (>=23.2)", "pip (>=19.1)", "pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff (>=0.2.1)", "pytest-timeout", "pytest-xdist (>=3)", "tomli", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] +testing-integration = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.2)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] + +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +description = "Sniff out which async library your code is running under" +optional = false +python-versions = ">=3.7" +files = [ + {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, + {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, +] + +[[package]] +name = "spotipy" +version = "2.23.0" +description = "A light weight Python library for the Spotify Web API" +optional = false +python-versions = "*" +files = [ + {file = "spotipy-2.23.0-py2-none-any.whl", hash = "sha256:da850fbf62faaa05912132d2886c293a5fbbe8350d0821e7208a6a2fdd6a0079"}, + {file = "spotipy-2.23.0-py3-none-any.whl", hash = "sha256:6bf8b963c10d0a3e51037e4baf92e29732dee36b2a1f1b7dcc8cd5771e662a5b"}, + {file = "spotipy-2.23.0.tar.gz", hash = "sha256:0dfafe08239daae6c16faa68f60b5775d40c4110725e1a7c545ad4c7fb66d4e8"}, +] + +[package.dependencies] +redis = ">=3.5.3" +requests = ">=2.25.0" +six = ">=1.15.0" +urllib3 = ">=1.26.0" + +[package.extras] +doc = ["Sphinx (>=1.5.2)"] +test = ["mock (==2.0.0)"] + +[[package]] +name = "starlette" +version = "0.36.3" +description = "The little ASGI library that shines." +optional = false +python-versions = ">=3.8" +files = [ + {file = "starlette-0.36.3-py3-none-any.whl", hash = "sha256:13d429aa93a61dc40bf503e8c801db1f1bca3dc706b10ef2434a36123568f044"}, + {file = "starlette-0.36.3.tar.gz", hash = "sha256:90a671733cfb35771d8cc605e0b679d23b992f8dcfad48cc60b38cb29aeb7080"}, +] + +[package.dependencies] +anyio = ">=3.4.0,<5" + +[package.extras] +full = ["httpx (>=0.22.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.7)", "pyyaml"] + +[[package]] +name = "tenacity" +version = "8.2.3" +description = "Retry code until it succeeds" +optional = false +python-versions = ">=3.7" +files = [ + {file = "tenacity-8.2.3-py3-none-any.whl", hash = "sha256:ce510e327a630c9e1beaf17d42e6ffacc88185044ad85cf74c0a8887c6a0f88c"}, + {file = "tenacity-8.2.3.tar.gz", hash = "sha256:5398ef0d78e63f40007c1fb4c0bff96e1911394d2fa8d194f77619c05ff6cc8a"}, +] + +[package.extras] +doc = ["reno", "sphinx", "tornado (>=4.5)"] + +[[package]] +name = "thefuzz" +version = "0.22.1" +description = "Fuzzy string matching in python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "thefuzz-0.22.1-py3-none-any.whl", hash = "sha256:59729b33556850b90e1093c4cf9e618af6f2e4c985df193fdf3c5b5cf02ca481"}, + {file = "thefuzz-0.22.1.tar.gz", hash = "sha256:7138039a7ecf540da323792d8592ef9902b1d79eb78c147d4f20664de79f3680"}, +] + +[package.dependencies] +rapidfuzz = ">=3.0.0,<4.0.0" + +[[package]] +name = "typing-extensions" +version = "4.10.0" +description = "Backported and Experimental Type Hints for Python 3.8+" +optional = false +python-versions = ">=3.8" +files = [ + {file = "typing_extensions-4.10.0-py3-none-any.whl", hash = "sha256:69b1a937c3a517342112fb4c6df7e72fc39a38e7891a5730ed4985b5214b5475"}, + {file = "typing_extensions-4.10.0.tar.gz", hash = "sha256:b0abd7c89e8fb96f98db18d86106ff1d90ab692004eb746cf6eda2682f91b3cb"}, +] + +[[package]] +name = "urllib3" +version = "2.2.1" +description = "HTTP library with thread-safe connection pooling, file post, and more." +optional = false +python-versions = ">=3.8" +files = [ + {file = "urllib3-2.2.1-py3-none-any.whl", hash = "sha256:450b20ec296a467077128bff42b73080516e71b56ff59a60a02bef2232c4fa9d"}, + {file = "urllib3-2.2.1.tar.gz", hash = "sha256:d0570876c61ab9e520d776c38acbbb5b05a776d3f9ff98a5c8fd5162a444cf19"}, +] + +[package.extras] +brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] +h2 = ["h2 (>=4,<5)"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["zstandard (>=0.18.0)"] + +[[package]] +name = "uvicorn" +version = "0.29.0" +description = "The lightning-fast ASGI server." +optional = false +python-versions = ">=3.8" +files = [ + {file = "uvicorn-0.29.0-py3-none-any.whl", hash = "sha256:2c2aac7ff4f4365c206fd773a39bf4ebd1047c238f8b8268ad996829323473de"}, + {file = "uvicorn-0.29.0.tar.gz", hash = "sha256:6a69214c0b6a087462412670b3ef21224fa48cae0e452b5883e8e8bdfdd11dd0"}, +] + +[package.dependencies] +click = ">=7.0" +colorama = {version = ">=0.4", optional = true, markers = "sys_platform == \"win32\" and extra == \"standard\""} +h11 = ">=0.8" +httptools = {version = ">=0.5.0", optional = true, markers = "extra == \"standard\""} +python-dotenv = {version = ">=0.13", optional = true, markers = "extra == \"standard\""} +pyyaml = {version = ">=5.1", optional = true, markers = "extra == \"standard\""} +uvloop = {version = ">=0.14.0,<0.15.0 || >0.15.0,<0.15.1 || >0.15.1", optional = true, markers = "(sys_platform != \"win32\" and sys_platform != \"cygwin\") and platform_python_implementation != \"PyPy\" and extra == \"standard\""} +watchfiles = {version = ">=0.13", optional = true, markers = "extra == \"standard\""} +websockets = {version = ">=10.4", optional = true, markers = "extra == \"standard\""} + +[package.extras] +standard = ["colorama (>=0.4)", "httptools (>=0.5.0)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1)", "watchfiles (>=0.13)", "websockets (>=10.4)"] + +[[package]] +name = "uvloop" +version = "0.19.0" +description = "Fast implementation of asyncio event loop on top of libuv" +optional = false +python-versions = ">=3.8.0" +files = [ + {file = "uvloop-0.19.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:de4313d7f575474c8f5a12e163f6d89c0a878bc49219641d49e6f1444369a90e"}, + {file = "uvloop-0.19.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5588bd21cf1fcf06bded085f37e43ce0e00424197e7c10e77afd4bbefffef428"}, + {file = "uvloop-0.19.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b1fd71c3843327f3bbc3237bedcdb6504fd50368ab3e04d0410e52ec293f5b8"}, + {file = "uvloop-0.19.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5a05128d315e2912791de6088c34136bfcdd0c7cbc1cf85fd6fd1bb321b7c849"}, + {file = "uvloop-0.19.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:cd81bdc2b8219cb4b2556eea39d2e36bfa375a2dd021404f90a62e44efaaf957"}, + {file = "uvloop-0.19.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:5f17766fb6da94135526273080f3455a112f82570b2ee5daa64d682387fe0dcd"}, + {file = "uvloop-0.19.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4ce6b0af8f2729a02a5d1575feacb2a94fc7b2e983868b009d51c9a9d2149bef"}, + {file = "uvloop-0.19.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:31e672bb38b45abc4f26e273be83b72a0d28d074d5b370fc4dcf4c4eb15417d2"}, + {file = "uvloop-0.19.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:570fc0ed613883d8d30ee40397b79207eedd2624891692471808a95069a007c1"}, + {file = "uvloop-0.19.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5138821e40b0c3e6c9478643b4660bd44372ae1e16a322b8fc07478f92684e24"}, + {file = "uvloop-0.19.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:91ab01c6cd00e39cde50173ba4ec68a1e578fee9279ba64f5221810a9e786533"}, + {file = "uvloop-0.19.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:47bf3e9312f63684efe283f7342afb414eea4d3011542155c7e625cd799c3b12"}, + {file = "uvloop-0.19.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:da8435a3bd498419ee8c13c34b89b5005130a476bda1d6ca8cfdde3de35cd650"}, + {file = "uvloop-0.19.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:02506dc23a5d90e04d4f65c7791e65cf44bd91b37f24cfc3ef6cf2aff05dc7ec"}, + {file = "uvloop-0.19.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2693049be9d36fef81741fddb3f441673ba12a34a704e7b4361efb75cf30befc"}, + {file = "uvloop-0.19.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7010271303961c6f0fe37731004335401eb9075a12680738731e9c92ddd96ad6"}, + {file = "uvloop-0.19.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:5daa304d2161d2918fa9a17d5635099a2f78ae5b5960e742b2fcfbb7aefaa593"}, + {file = "uvloop-0.19.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:7207272c9520203fea9b93843bb775d03e1cf88a80a936ce760f60bb5add92f3"}, + {file = "uvloop-0.19.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:78ab247f0b5671cc887c31d33f9b3abfb88d2614b84e4303f1a63b46c046c8bd"}, + {file = "uvloop-0.19.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:472d61143059c84947aa8bb74eabbace30d577a03a1805b77933d6bd13ddebbd"}, + {file = "uvloop-0.19.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45bf4c24c19fb8a50902ae37c5de50da81de4922af65baf760f7c0c42e1088be"}, + {file = "uvloop-0.19.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:271718e26b3e17906b28b67314c45d19106112067205119dddbd834c2b7ce797"}, + {file = "uvloop-0.19.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:34175c9fd2a4bc3adc1380e1261f60306344e3407c20a4d684fd5f3be010fa3d"}, + {file = "uvloop-0.19.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:e27f100e1ff17f6feeb1f33968bc185bf8ce41ca557deee9d9bbbffeb72030b7"}, + {file = "uvloop-0.19.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:13dfdf492af0aa0a0edf66807d2b465607d11c4fa48f4a1fd41cbea5b18e8e8b"}, + {file = "uvloop-0.19.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6e3d4e85ac060e2342ff85e90d0c04157acb210b9ce508e784a944f852a40e67"}, + {file = "uvloop-0.19.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8ca4956c9ab567d87d59d49fa3704cf29e37109ad348f2d5223c9bf761a332e7"}, + {file = "uvloop-0.19.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f467a5fd23b4fc43ed86342641f3936a68ded707f4627622fa3f82a120e18256"}, + {file = "uvloop-0.19.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:492e2c32c2af3f971473bc22f086513cedfc66a130756145a931a90c3958cb17"}, + {file = "uvloop-0.19.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:2df95fca285a9f5bfe730e51945ffe2fa71ccbfdde3b0da5772b4ee4f2e770d5"}, + {file = "uvloop-0.19.0.tar.gz", hash = "sha256:0246f4fd1bf2bf702e06b0d45ee91677ee5c31242f39aab4ea6fe0c51aedd0fd"}, +] + +[package.extras] +docs = ["Sphinx (>=4.1.2,<4.2.0)", "sphinx-rtd-theme (>=0.5.2,<0.6.0)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)"] +test = ["Cython (>=0.29.36,<0.30.0)", "aiohttp (==3.9.0b0)", "aiohttp (>=3.8.1)", "flake8 (>=5.0,<6.0)", "mypy (>=0.800)", "psutil", "pyOpenSSL (>=23.0.0,<23.1.0)", "pycodestyle (>=2.9.0,<2.10.0)"] + +[[package]] +name = "validators" +version = "0.24.0" +description = "Python Data Validation for Humans™" +optional = false +python-versions = ">=3.8" +files = [ + {file = "validators-0.24.0-py3-none-any.whl", hash = "sha256:4a99eb368747e60900bae947418eb21e230ff4ff5e7b7944b9308c456d86da32"}, + {file = "validators-0.24.0.tar.gz", hash = "sha256:cd23defb36de42d14e7559cf0757f761bb46b10d9de2998e6ef805f769d859e3"}, +] + +[[package]] +name = "virtualenv" +version = "20.25.1" +description = "Virtual Python Environment builder" +optional = false +python-versions = ">=3.7" +files = [ + {file = "virtualenv-20.25.1-py3-none-any.whl", hash = "sha256:961c026ac520bac5f69acb8ea063e8a4f071bcc9457b9c1f28f6b085c511583a"}, + {file = "virtualenv-20.25.1.tar.gz", hash = "sha256:e08e13ecdca7a0bd53798f356d5831434afa5b07b93f0abdf0797b7a06ffe197"}, +] + +[package.dependencies] +distlib = ">=0.3.7,<1" +filelock = ">=3.12.2,<4" +platformdirs = ">=3.9.1,<5" + +[package.extras] +docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] +test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"] + +[[package]] +name = "watchfiles" +version = "0.21.0" +description = "Simple, modern and high performance file watching and code reload in python." +optional = false +python-versions = ">=3.8" +files = [ + {file = "watchfiles-0.21.0-cp310-cp310-macosx_10_7_x86_64.whl", hash = "sha256:27b4035013f1ea49c6c0b42d983133b136637a527e48c132d368eb19bf1ac6aa"}, + {file = "watchfiles-0.21.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c81818595eff6e92535ff32825f31c116f867f64ff8cdf6562cd1d6b2e1e8f3e"}, + {file = "watchfiles-0.21.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:6c107ea3cf2bd07199d66f156e3ea756d1b84dfd43b542b2d870b77868c98c03"}, + {file = "watchfiles-0.21.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d9ac347653ebd95839a7c607608703b20bc07e577e870d824fa4801bc1cb124"}, + {file = "watchfiles-0.21.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5eb86c6acb498208e7663ca22dbe68ca2cf42ab5bf1c776670a50919a56e64ab"}, + {file = "watchfiles-0.21.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f564bf68404144ea6b87a78a3f910cc8de216c6b12a4cf0b27718bf4ec38d303"}, + {file = "watchfiles-0.21.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d0f32ebfaa9c6011f8454994f86108c2eb9c79b8b7de00b36d558cadcedaa3d"}, + {file = "watchfiles-0.21.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b6d45d9b699ecbac6c7bd8e0a2609767491540403610962968d258fd6405c17c"}, + {file = "watchfiles-0.21.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:aff06b2cac3ef4616e26ba17a9c250c1fe9dd8a5d907d0193f84c499b1b6e6a9"}, + {file = "watchfiles-0.21.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d9792dff410f266051025ecfaa927078b94cc7478954b06796a9756ccc7e14a9"}, + {file = "watchfiles-0.21.0-cp310-none-win32.whl", hash = "sha256:214cee7f9e09150d4fb42e24919a1e74d8c9b8a9306ed1474ecaddcd5479c293"}, + {file = "watchfiles-0.21.0-cp310-none-win_amd64.whl", hash = "sha256:1ad7247d79f9f55bb25ab1778fd47f32d70cf36053941f07de0b7c4e96b5d235"}, + {file = "watchfiles-0.21.0-cp311-cp311-macosx_10_7_x86_64.whl", hash = "sha256:668c265d90de8ae914f860d3eeb164534ba2e836811f91fecc7050416ee70aa7"}, + {file = "watchfiles-0.21.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3a23092a992e61c3a6a70f350a56db7197242f3490da9c87b500f389b2d01eef"}, + {file = "watchfiles-0.21.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:e7941bbcfdded9c26b0bf720cb7e6fd803d95a55d2c14b4bd1f6a2772230c586"}, + {file = "watchfiles-0.21.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:11cd0c3100e2233e9c53106265da31d574355c288e15259c0d40a4405cbae317"}, + {file = "watchfiles-0.21.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d78f30cbe8b2ce770160d3c08cff01b2ae9306fe66ce899b73f0409dc1846c1b"}, + {file = "watchfiles-0.21.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6674b00b9756b0af620aa2a3346b01f8e2a3dc729d25617e1b89cf6af4a54eb1"}, + {file = "watchfiles-0.21.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fd7ac678b92b29ba630d8c842d8ad6c555abda1b9ef044d6cc092dacbfc9719d"}, + {file = "watchfiles-0.21.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c873345680c1b87f1e09e0eaf8cf6c891b9851d8b4d3645e7efe2ec20a20cc7"}, + {file = "watchfiles-0.21.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:49f56e6ecc2503e7dbe233fa328b2be1a7797d31548e7a193237dcdf1ad0eee0"}, + {file = "watchfiles-0.21.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:02d91cbac553a3ad141db016e3350b03184deaafeba09b9d6439826ee594b365"}, + {file = "watchfiles-0.21.0-cp311-none-win32.whl", hash = "sha256:ebe684d7d26239e23d102a2bad2a358dedf18e462e8808778703427d1f584400"}, + {file = "watchfiles-0.21.0-cp311-none-win_amd64.whl", hash = "sha256:4566006aa44cb0d21b8ab53baf4b9c667a0ed23efe4aaad8c227bfba0bf15cbe"}, + {file = "watchfiles-0.21.0-cp311-none-win_arm64.whl", hash = "sha256:c550a56bf209a3d987d5a975cdf2063b3389a5d16caf29db4bdddeae49f22078"}, + {file = "watchfiles-0.21.0-cp312-cp312-macosx_10_7_x86_64.whl", hash = "sha256:51ddac60b96a42c15d24fbdc7a4bfcd02b5a29c047b7f8bf63d3f6f5a860949a"}, + {file = "watchfiles-0.21.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:511f0b034120cd1989932bf1e9081aa9fb00f1f949fbd2d9cab6264916ae89b1"}, + {file = "watchfiles-0.21.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:cfb92d49dbb95ec7a07511bc9efb0faff8fe24ef3805662b8d6808ba8409a71a"}, + {file = "watchfiles-0.21.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f92944efc564867bbf841c823c8b71bb0be75e06b8ce45c084b46411475a915"}, + {file = "watchfiles-0.21.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:642d66b75eda909fd1112d35c53816d59789a4b38c141a96d62f50a3ef9b3360"}, + {file = "watchfiles-0.21.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d23bcd6c8eaa6324fe109d8cac01b41fe9a54b8c498af9ce464c1aeeb99903d6"}, + {file = "watchfiles-0.21.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:18d5b4da8cf3e41895b34e8c37d13c9ed294954907929aacd95153508d5d89d7"}, + {file = "watchfiles-0.21.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1b8d1eae0f65441963d805f766c7e9cd092f91e0c600c820c764a4ff71a0764c"}, + {file = "watchfiles-0.21.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:1fd9a5205139f3c6bb60d11f6072e0552f0a20b712c85f43d42342d162be1235"}, + {file = "watchfiles-0.21.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a1e3014a625bcf107fbf38eece0e47fa0190e52e45dc6eee5a8265ddc6dc5ea7"}, + {file = "watchfiles-0.21.0-cp312-none-win32.whl", hash = "sha256:9d09869f2c5a6f2d9df50ce3064b3391d3ecb6dced708ad64467b9e4f2c9bef3"}, + {file = "watchfiles-0.21.0-cp312-none-win_amd64.whl", hash = "sha256:18722b50783b5e30a18a8a5db3006bab146d2b705c92eb9a94f78c72beb94094"}, + {file = "watchfiles-0.21.0-cp312-none-win_arm64.whl", hash = "sha256:a3b9bec9579a15fb3ca2d9878deae789df72f2b0fdaf90ad49ee389cad5edab6"}, + {file = "watchfiles-0.21.0-cp38-cp38-macosx_10_7_x86_64.whl", hash = "sha256:4ea10a29aa5de67de02256a28d1bf53d21322295cb00bd2d57fcd19b850ebd99"}, + {file = "watchfiles-0.21.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:40bca549fdc929b470dd1dbfcb47b3295cb46a6d2c90e50588b0a1b3bd98f429"}, + {file = "watchfiles-0.21.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:9b37a7ba223b2f26122c148bb8d09a9ff312afca998c48c725ff5a0a632145f7"}, + {file = "watchfiles-0.21.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec8c8900dc5c83650a63dd48c4d1d245343f904c4b64b48798c67a3767d7e165"}, + {file = "watchfiles-0.21.0-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8ad3fe0a3567c2f0f629d800409cd528cb6251da12e81a1f765e5c5345fd0137"}, + {file = "watchfiles-0.21.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d353c4cfda586db2a176ce42c88f2fc31ec25e50212650c89fdd0f560ee507b"}, + {file = "watchfiles-0.21.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:83a696da8922314ff2aec02987eefb03784f473281d740bf9170181829133765"}, + {file = "watchfiles-0.21.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5a03651352fc20975ee2a707cd2d74a386cd303cc688f407296064ad1e6d1562"}, + {file = "watchfiles-0.21.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:3ad692bc7792be8c32918c699638b660c0de078a6cbe464c46e1340dadb94c19"}, + {file = "watchfiles-0.21.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:06247538e8253975bdb328e7683f8515ff5ff041f43be6c40bff62d989b7d0b0"}, + {file = "watchfiles-0.21.0-cp38-none-win32.whl", hash = "sha256:9a0aa47f94ea9a0b39dd30850b0adf2e1cd32a8b4f9c7aa443d852aacf9ca214"}, + {file = "watchfiles-0.21.0-cp38-none-win_amd64.whl", hash = "sha256:8d5f400326840934e3507701f9f7269247f7c026d1b6cfd49477d2be0933cfca"}, + {file = "watchfiles-0.21.0-cp39-cp39-macosx_10_7_x86_64.whl", hash = "sha256:7f762a1a85a12cc3484f77eee7be87b10f8c50b0b787bb02f4e357403cad0c0e"}, + {file = "watchfiles-0.21.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6e9be3ef84e2bb9710f3f777accce25556f4a71e15d2b73223788d528fcc2052"}, + {file = "watchfiles-0.21.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:4c48a10d17571d1275701e14a601e36959ffada3add8cdbc9e5061a6e3579a5d"}, + {file = "watchfiles-0.21.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c889025f59884423428c261f212e04d438de865beda0b1e1babab85ef4c0f01"}, + {file = "watchfiles-0.21.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:66fac0c238ab9a2e72d026b5fb91cb902c146202bbd29a9a1a44e8db7b710b6f"}, + {file = "watchfiles-0.21.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b4a21f71885aa2744719459951819e7bf5a906a6448a6b2bbce8e9cc9f2c8128"}, + {file = "watchfiles-0.21.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c9198c989f47898b2c22201756f73249de3748e0fc9de44adaf54a8b259cc0c"}, + {file = "watchfiles-0.21.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d8f57c4461cd24fda22493109c45b3980863c58a25b8bec885ca8bea6b8d4b28"}, + {file = "watchfiles-0.21.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:853853cbf7bf9408b404754b92512ebe3e3a83587503d766d23e6bf83d092ee6"}, + {file = "watchfiles-0.21.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:d5b1dc0e708fad9f92c296ab2f948af403bf201db8fb2eb4c8179db143732e49"}, + {file = "watchfiles-0.21.0-cp39-none-win32.whl", hash = "sha256:59137c0c6826bd56c710d1d2bda81553b5e6b7c84d5a676747d80caf0409ad94"}, + {file = "watchfiles-0.21.0-cp39-none-win_amd64.whl", hash = "sha256:6cb8fdc044909e2078c248986f2fc76f911f72b51ea4a4fbbf472e01d14faa58"}, + {file = "watchfiles-0.21.0-pp310-pypy310_pp73-macosx_10_7_x86_64.whl", hash = "sha256:ab03a90b305d2588e8352168e8c5a1520b721d2d367f31e9332c4235b30b8994"}, + {file = "watchfiles-0.21.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:927c589500f9f41e370b0125c12ac9e7d3a2fd166b89e9ee2828b3dda20bfe6f"}, + {file = "watchfiles-0.21.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1bd467213195e76f838caf2c28cd65e58302d0254e636e7c0fca81efa4a2e62c"}, + {file = "watchfiles-0.21.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:02b73130687bc3f6bb79d8a170959042eb56eb3a42df3671c79b428cd73f17cc"}, + {file = "watchfiles-0.21.0-pp38-pypy38_pp73-macosx_10_7_x86_64.whl", hash = "sha256:08dca260e85ffae975448e344834d765983237ad6dc308231aa16e7933db763e"}, + {file = "watchfiles-0.21.0-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:3ccceb50c611c433145502735e0370877cced72a6c70fd2410238bcbc7fe51d8"}, + {file = "watchfiles-0.21.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:57d430f5fb63fea141ab71ca9c064e80de3a20b427ca2febcbfcef70ff0ce895"}, + {file = "watchfiles-0.21.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0dd5fad9b9c0dd89904bbdea978ce89a2b692a7ee8a0ce19b940e538c88a809c"}, + {file = "watchfiles-0.21.0-pp39-pypy39_pp73-macosx_10_7_x86_64.whl", hash = "sha256:be6dd5d52b73018b21adc1c5d28ac0c68184a64769052dfeb0c5d9998e7f56a2"}, + {file = "watchfiles-0.21.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:b3cab0e06143768499384a8a5efb9c4dc53e19382952859e4802f294214f36ec"}, + {file = "watchfiles-0.21.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c6ed10c2497e5fedadf61e465b3ca12a19f96004c15dcffe4bd442ebadc2d85"}, + {file = "watchfiles-0.21.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:43babacef21c519bc6631c5fce2a61eccdfc011b4bcb9047255e9620732c8097"}, + {file = "watchfiles-0.21.0.tar.gz", hash = "sha256:c76c635fabf542bb78524905718c39f736a98e5ab25b23ec6d4abede1a85a6a3"}, +] + +[package.dependencies] +anyio = ">=3.0.0" + +[[package]] +name = "websockets" +version = "12.0" +description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" +optional = false +python-versions = ">=3.8" +files = [ + {file = "websockets-12.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d554236b2a2006e0ce16315c16eaa0d628dab009c33b63ea03f41c6107958374"}, + {file = "websockets-12.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2d225bb6886591b1746b17c0573e29804619c8f755b5598d875bb4235ea639be"}, + {file = "websockets-12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:eb809e816916a3b210bed3c82fb88eaf16e8afcf9c115ebb2bacede1797d2547"}, + {file = "websockets-12.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c588f6abc13f78a67044c6b1273a99e1cf31038ad51815b3b016ce699f0d75c2"}, + {file = "websockets-12.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5aa9348186d79a5f232115ed3fa9020eab66d6c3437d72f9d2c8ac0c6858c558"}, + {file = "websockets-12.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6350b14a40c95ddd53e775dbdbbbc59b124a5c8ecd6fbb09c2e52029f7a9f480"}, + {file = "websockets-12.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:70ec754cc2a769bcd218ed8d7209055667b30860ffecb8633a834dde27d6307c"}, + {file = "websockets-12.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6e96f5ed1b83a8ddb07909b45bd94833b0710f738115751cdaa9da1fb0cb66e8"}, + {file = "websockets-12.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4d87be612cbef86f994178d5186add3d94e9f31cc3cb499a0482b866ec477603"}, + {file = "websockets-12.0-cp310-cp310-win32.whl", hash = "sha256:befe90632d66caaf72e8b2ed4d7f02b348913813c8b0a32fae1cc5fe3730902f"}, + {file = "websockets-12.0-cp310-cp310-win_amd64.whl", hash = "sha256:363f57ca8bc8576195d0540c648aa58ac18cf85b76ad5202b9f976918f4219cf"}, + {file = "websockets-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5d873c7de42dea355d73f170be0f23788cf3fa9f7bed718fd2830eefedce01b4"}, + {file = "websockets-12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3f61726cae9f65b872502ff3c1496abc93ffbe31b278455c418492016e2afc8f"}, + {file = "websockets-12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ed2fcf7a07334c77fc8a230755c2209223a7cc44fc27597729b8ef5425aa61a3"}, + {file = "websockets-12.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e332c210b14b57904869ca9f9bf4ca32f5427a03eeb625da9b616c85a3a506c"}, + {file = "websockets-12.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5693ef74233122f8ebab026817b1b37fe25c411ecfca084b29bc7d6efc548f45"}, + {file = "websockets-12.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e9e7db18b4539a29cc5ad8c8b252738a30e2b13f033c2d6e9d0549b45841c04"}, + {file = "websockets-12.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:6e2df67b8014767d0f785baa98393725739287684b9f8d8a1001eb2839031447"}, + {file = "websockets-12.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:bea88d71630c5900690fcb03161ab18f8f244805c59e2e0dc4ffadae0a7ee0ca"}, + {file = "websockets-12.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:dff6cdf35e31d1315790149fee351f9e52978130cef6c87c4b6c9b3baf78bc53"}, + {file = "websockets-12.0-cp311-cp311-win32.whl", hash = "sha256:3e3aa8c468af01d70332a382350ee95f6986db479ce7af14d5e81ec52aa2b402"}, + {file = "websockets-12.0-cp311-cp311-win_amd64.whl", hash = "sha256:25eb766c8ad27da0f79420b2af4b85d29914ba0edf69f547cc4f06ca6f1d403b"}, + {file = "websockets-12.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0e6e2711d5a8e6e482cacb927a49a3d432345dfe7dea8ace7b5790df5932e4df"}, + {file = "websockets-12.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:dbcf72a37f0b3316e993e13ecf32f10c0e1259c28ffd0a85cee26e8549595fbc"}, + {file = "websockets-12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:12743ab88ab2af1d17dd4acb4645677cb7063ef4db93abffbf164218a5d54c6b"}, + {file = "websockets-12.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b645f491f3c48d3f8a00d1fce07445fab7347fec54a3e65f0725d730d5b99cb"}, + {file = "websockets-12.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9893d1aa45a7f8b3bc4510f6ccf8db8c3b62120917af15e3de247f0780294b92"}, + {file = "websockets-12.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f38a7b376117ef7aff996e737583172bdf535932c9ca021746573bce40165ed"}, + {file = "websockets-12.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:f764ba54e33daf20e167915edc443b6f88956f37fb606449b4a5b10ba42235a5"}, + {file = "websockets-12.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:1e4b3f8ea6a9cfa8be8484c9221ec0257508e3a1ec43c36acdefb2a9c3b00aa2"}, + {file = "websockets-12.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:9fdf06fd06c32205a07e47328ab49c40fc1407cdec801d698a7c41167ea45113"}, + {file = "websockets-12.0-cp312-cp312-win32.whl", hash = "sha256:baa386875b70cbd81798fa9f71be689c1bf484f65fd6fb08d051a0ee4e79924d"}, + {file = "websockets-12.0-cp312-cp312-win_amd64.whl", hash = "sha256:ae0a5da8f35a5be197f328d4727dbcfafa53d1824fac3d96cdd3a642fe09394f"}, + {file = "websockets-12.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:5f6ffe2c6598f7f7207eef9a1228b6f5c818f9f4d53ee920aacd35cec8110438"}, + {file = "websockets-12.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:9edf3fc590cc2ec20dc9d7a45108b5bbaf21c0d89f9fd3fd1685e223771dc0b2"}, + {file = "websockets-12.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:8572132c7be52632201a35f5e08348137f658e5ffd21f51f94572ca6c05ea81d"}, + {file = "websockets-12.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:604428d1b87edbf02b233e2c207d7d528460fa978f9e391bd8aaf9c8311de137"}, + {file = "websockets-12.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1a9d160fd080c6285e202327aba140fc9a0d910b09e423afff4ae5cbbf1c7205"}, + {file = "websockets-12.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87b4aafed34653e465eb77b7c93ef058516cb5acf3eb21e42f33928616172def"}, + {file = "websockets-12.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b2ee7288b85959797970114deae81ab41b731f19ebcd3bd499ae9ca0e3f1d2c8"}, + {file = "websockets-12.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:7fa3d25e81bfe6a89718e9791128398a50dec6d57faf23770787ff441d851967"}, + {file = "websockets-12.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:a571f035a47212288e3b3519944f6bf4ac7bc7553243e41eac50dd48552b6df7"}, + {file = "websockets-12.0-cp38-cp38-win32.whl", hash = "sha256:3c6cc1360c10c17463aadd29dd3af332d4a1adaa8796f6b0e9f9df1fdb0bad62"}, + {file = "websockets-12.0-cp38-cp38-win_amd64.whl", hash = "sha256:1bf386089178ea69d720f8db6199a0504a406209a0fc23e603b27b300fdd6892"}, + {file = "websockets-12.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:ab3d732ad50a4fbd04a4490ef08acd0517b6ae6b77eb967251f4c263011a990d"}, + {file = "websockets-12.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a1d9697f3337a89691e3bd8dc56dea45a6f6d975f92e7d5f773bc715c15dde28"}, + {file = "websockets-12.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1df2fbd2c8a98d38a66f5238484405b8d1d16f929bb7a33ed73e4801222a6f53"}, + {file = "websockets-12.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23509452b3bc38e3a057382c2e941d5ac2e01e251acce7adc74011d7d8de434c"}, + {file = "websockets-12.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2e5fc14ec6ea568200ea4ef46545073da81900a2b67b3e666f04adf53ad452ec"}, + {file = "websockets-12.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46e71dbbd12850224243f5d2aeec90f0aaa0f2dde5aeeb8fc8df21e04d99eff9"}, + {file = "websockets-12.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b81f90dcc6c85a9b7f29873beb56c94c85d6f0dac2ea8b60d995bd18bf3e2aae"}, + {file = "websockets-12.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:a02413bc474feda2849c59ed2dfb2cddb4cd3d2f03a2fedec51d6e959d9b608b"}, + {file = "websockets-12.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:bbe6013f9f791944ed31ca08b077e26249309639313fff132bfbf3ba105673b9"}, + {file = "websockets-12.0-cp39-cp39-win32.whl", hash = "sha256:cbe83a6bbdf207ff0541de01e11904827540aa069293696dd528a6640bd6a5f6"}, + {file = "websockets-12.0-cp39-cp39-win_amd64.whl", hash = "sha256:fc4e7fa5414512b481a2483775a8e8be7803a35b30ca805afa4998a84f9fd9e8"}, + {file = "websockets-12.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:248d8e2446e13c1d4326e0a6a4e9629cb13a11195051a73acf414812700badbd"}, + {file = "websockets-12.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f44069528d45a933997a6fef143030d8ca8042f0dfaad753e2906398290e2870"}, + {file = "websockets-12.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c4e37d36f0d19f0a4413d3e18c0d03d0c268ada2061868c1e6f5ab1a6d575077"}, + {file = "websockets-12.0-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d829f975fc2e527a3ef2f9c8f25e553eb7bc779c6665e8e1d52aa22800bb38b"}, + {file = "websockets-12.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:2c71bd45a777433dd9113847af751aae36e448bc6b8c361a566cb043eda6ec30"}, + {file = "websockets-12.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:0bee75f400895aef54157b36ed6d3b308fcab62e5260703add87f44cee9c82a6"}, + {file = "websockets-12.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:423fc1ed29f7512fceb727e2d2aecb952c46aa34895e9ed96071821309951123"}, + {file = "websockets-12.0-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:27a5e9964ef509016759f2ef3f2c1e13f403725a5e6a1775555994966a66e931"}, + {file = "websockets-12.0-pp38-pypy38_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c3181df4583c4d3994d31fb235dc681d2aaad744fbdbf94c4802485ececdecf2"}, + {file = "websockets-12.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:b067cb952ce8bf40115f6c19f478dc71c5e719b7fbaa511359795dfd9d1a6468"}, + {file = "websockets-12.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:00700340c6c7ab788f176d118775202aadea7602c5cc6be6ae127761c16d6b0b"}, + {file = "websockets-12.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e469d01137942849cff40517c97a30a93ae79917752b34029f0ec72df6b46399"}, + {file = "websockets-12.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffefa1374cd508d633646d51a8e9277763a9b78ae71324183693959cf94635a7"}, + {file = "websockets-12.0-pp39-pypy39_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba0cab91b3956dfa9f512147860783a1829a8d905ee218a9837c18f683239611"}, + {file = "websockets-12.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2cb388a5bfb56df4d9a406783b7f9dbefb888c09b71629351cc6b036e9259370"}, + {file = "websockets-12.0-py3-none-any.whl", hash = "sha256:dc284bbc8d7c78a6c69e0c7325ab46ee5e40bb4d50e494d8131a07ef47500e9e"}, + {file = "websockets-12.0.tar.gz", hash = "sha256:81df9cbcbb6c260de1e007e58c011bfebe2dafc8435107b0537f393dd38c8b1b"}, +] + +[[package]] +name = "yarl" +version = "1.9.4" +description = "Yet another URL library" +optional = false +python-versions = ">=3.7" +files = [ + {file = "yarl-1.9.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a8c1df72eb746f4136fe9a2e72b0c9dc1da1cbd23b5372f94b5820ff8ae30e0e"}, + {file = "yarl-1.9.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a3a6ed1d525bfb91b3fc9b690c5a21bb52de28c018530ad85093cc488bee2dd2"}, + {file = "yarl-1.9.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c38c9ddb6103ceae4e4498f9c08fac9b590c5c71b0370f98714768e22ac6fa66"}, + {file = "yarl-1.9.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d9e09c9d74f4566e905a0b8fa668c58109f7624db96a2171f21747abc7524234"}, + {file = "yarl-1.9.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b8477c1ee4bd47c57d49621a062121c3023609f7a13b8a46953eb6c9716ca392"}, + {file = "yarl-1.9.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5ff2c858f5f6a42c2a8e751100f237c5e869cbde669a724f2062d4c4ef93551"}, + {file = "yarl-1.9.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:357495293086c5b6d34ca9616a43d329317feab7917518bc97a08f9e55648455"}, + {file = "yarl-1.9.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:54525ae423d7b7a8ee81ba189f131054defdb122cde31ff17477951464c1691c"}, + {file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:801e9264d19643548651b9db361ce3287176671fb0117f96b5ac0ee1c3530d53"}, + {file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e516dc8baf7b380e6c1c26792610230f37147bb754d6426462ab115a02944385"}, + {file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:7d5aaac37d19b2904bb9dfe12cdb08c8443e7ba7d2852894ad448d4b8f442863"}, + {file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:54beabb809ffcacbd9d28ac57b0db46e42a6e341a030293fb3185c409e626b8b"}, + {file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:bac8d525a8dbc2a1507ec731d2867025d11ceadcb4dd421423a5d42c56818541"}, + {file = "yarl-1.9.4-cp310-cp310-win32.whl", hash = "sha256:7855426dfbddac81896b6e533ebefc0af2f132d4a47340cee6d22cac7190022d"}, + {file = "yarl-1.9.4-cp310-cp310-win_amd64.whl", hash = "sha256:848cd2a1df56ddbffeb375535fb62c9d1645dde33ca4d51341378b3f5954429b"}, + {file = "yarl-1.9.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:35a2b9396879ce32754bd457d31a51ff0a9d426fd9e0e3c33394bf4b9036b099"}, + {file = "yarl-1.9.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4c7d56b293cc071e82532f70adcbd8b61909eec973ae9d2d1f9b233f3d943f2c"}, + {file = "yarl-1.9.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d8a1c6c0be645c745a081c192e747c5de06e944a0d21245f4cf7c05e457c36e0"}, + {file = "yarl-1.9.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4b3c1ffe10069f655ea2d731808e76e0f452fc6c749bea04781daf18e6039525"}, + {file = "yarl-1.9.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:549d19c84c55d11687ddbd47eeb348a89df9cb30e1993f1b128f4685cd0ebbf8"}, + {file = "yarl-1.9.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a7409f968456111140c1c95301cadf071bd30a81cbd7ab829169fb9e3d72eae9"}, + {file = "yarl-1.9.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e23a6d84d9d1738dbc6e38167776107e63307dfc8ad108e580548d1f2c587f42"}, + {file = "yarl-1.9.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d8b889777de69897406c9fb0b76cdf2fd0f31267861ae7501d93003d55f54fbe"}, + {file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:03caa9507d3d3c83bca08650678e25364e1843b484f19986a527630ca376ecce"}, + {file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:4e9035df8d0880b2f1c7f5031f33f69e071dfe72ee9310cfc76f7b605958ceb9"}, + {file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:c0ec0ed476f77db9fb29bca17f0a8fcc7bc97ad4c6c1d8959c507decb22e8572"}, + {file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:ee04010f26d5102399bd17f8df8bc38dc7ccd7701dc77f4a68c5b8d733406958"}, + {file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:49a180c2e0743d5d6e0b4d1a9e5f633c62eca3f8a86ba5dd3c471060e352ca98"}, + {file = "yarl-1.9.4-cp311-cp311-win32.whl", hash = "sha256:81eb57278deb6098a5b62e88ad8281b2ba09f2f1147c4767522353eaa6260b31"}, + {file = "yarl-1.9.4-cp311-cp311-win_amd64.whl", hash = "sha256:d1d2532b340b692880261c15aee4dc94dd22ca5d61b9db9a8a361953d36410b1"}, + {file = "yarl-1.9.4-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0d2454f0aef65ea81037759be5ca9947539667eecebca092733b2eb43c965a81"}, + {file = "yarl-1.9.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:44d8ffbb9c06e5a7f529f38f53eda23e50d1ed33c6c869e01481d3fafa6b8142"}, + {file = "yarl-1.9.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:aaaea1e536f98754a6e5c56091baa1b6ce2f2700cc4a00b0d49eca8dea471074"}, + {file = "yarl-1.9.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3777ce5536d17989c91696db1d459574e9a9bd37660ea7ee4d3344579bb6f129"}, + {file = "yarl-1.9.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9fc5fc1eeb029757349ad26bbc5880557389a03fa6ada41703db5e068881e5f2"}, + {file = "yarl-1.9.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea65804b5dc88dacd4a40279af0cdadcfe74b3e5b4c897aa0d81cf86927fee78"}, + {file = "yarl-1.9.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa102d6d280a5455ad6a0f9e6d769989638718e938a6a0a2ff3f4a7ff8c62cc4"}, + {file = "yarl-1.9.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09efe4615ada057ba2d30df871d2f668af661e971dfeedf0c159927d48bbeff0"}, + {file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:008d3e808d03ef28542372d01057fd09168419cdc8f848efe2804f894ae03e51"}, + {file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:6f5cb257bc2ec58f437da2b37a8cd48f666db96d47b8a3115c29f316313654ff"}, + {file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:992f18e0ea248ee03b5a6e8b3b4738850ae7dbb172cc41c966462801cbf62cf7"}, + {file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:0e9d124c191d5b881060a9e5060627694c3bdd1fe24c5eecc8d5d7d0eb6faabc"}, + {file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3986b6f41ad22988e53d5778f91855dc0399b043fc8946d4f2e68af22ee9ff10"}, + {file = "yarl-1.9.4-cp312-cp312-win32.whl", hash = "sha256:4b21516d181cd77ebd06ce160ef8cc2a5e9ad35fb1c5930882baff5ac865eee7"}, + {file = "yarl-1.9.4-cp312-cp312-win_amd64.whl", hash = "sha256:a9bd00dc3bc395a662900f33f74feb3e757429e545d831eef5bb280252631984"}, + {file = "yarl-1.9.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:63b20738b5aac74e239622d2fe30df4fca4942a86e31bf47a81a0e94c14df94f"}, + {file = "yarl-1.9.4-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7d7f7de27b8944f1fee2c26a88b4dabc2409d2fea7a9ed3df79b67277644e17"}, + {file = "yarl-1.9.4-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c74018551e31269d56fab81a728f683667e7c28c04e807ba08f8c9e3bba32f14"}, + {file = "yarl-1.9.4-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ca06675212f94e7a610e85ca36948bb8fc023e458dd6c63ef71abfd482481aa5"}, + {file = "yarl-1.9.4-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5aef935237d60a51a62b86249839b51345f47564208c6ee615ed2a40878dccdd"}, + {file = "yarl-1.9.4-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2b134fd795e2322b7684155b7855cc99409d10b2e408056db2b93b51a52accc7"}, + {file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d25039a474c4c72a5ad4b52495056f843a7ff07b632c1b92ea9043a3d9950f6e"}, + {file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:f7d6b36dd2e029b6bcb8a13cf19664c7b8e19ab3a58e0fefbb5b8461447ed5ec"}, + {file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:957b4774373cf6f709359e5c8c4a0af9f6d7875db657adb0feaf8d6cb3c3964c"}, + {file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:d7eeb6d22331e2fd42fce928a81c697c9ee2d51400bd1a28803965883e13cead"}, + {file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:6a962e04b8f91f8c4e5917e518d17958e3bdee71fd1d8b88cdce74dd0ebbf434"}, + {file = "yarl-1.9.4-cp37-cp37m-win32.whl", hash = "sha256:f3bc6af6e2b8f92eced34ef6a96ffb248e863af20ef4fde9448cc8c9b858b749"}, + {file = "yarl-1.9.4-cp37-cp37m-win_amd64.whl", hash = "sha256:ad4d7a90a92e528aadf4965d685c17dacff3df282db1121136c382dc0b6014d2"}, + {file = "yarl-1.9.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ec61d826d80fc293ed46c9dd26995921e3a82146feacd952ef0757236fc137be"}, + {file = "yarl-1.9.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8be9e837ea9113676e5754b43b940b50cce76d9ed7d2461df1af39a8ee674d9f"}, + {file = "yarl-1.9.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:bef596fdaa8f26e3d66af846bbe77057237cb6e8efff8cd7cc8dff9a62278bbf"}, + {file = "yarl-1.9.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2d47552b6e52c3319fede1b60b3de120fe83bde9b7bddad11a69fb0af7db32f1"}, + {file = "yarl-1.9.4-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84fc30f71689d7fc9168b92788abc977dc8cefa806909565fc2951d02f6b7d57"}, + {file = "yarl-1.9.4-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4aa9741085f635934f3a2583e16fcf62ba835719a8b2b28fb2917bb0537c1dfa"}, + {file = "yarl-1.9.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:206a55215e6d05dbc6c98ce598a59e6fbd0c493e2de4ea6cc2f4934d5a18d130"}, + {file = "yarl-1.9.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07574b007ee20e5c375a8fe4a0789fad26db905f9813be0f9fef5a68080de559"}, + {file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:5a2e2433eb9344a163aced6a5f6c9222c0786e5a9e9cac2c89f0b28433f56e23"}, + {file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:6ad6d10ed9b67a382b45f29ea028f92d25bc0bc1daf6c5b801b90b5aa70fb9ec"}, + {file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:6fe79f998a4052d79e1c30eeb7d6c1c1056ad33300f682465e1b4e9b5a188b78"}, + {file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:a825ec844298c791fd28ed14ed1bffc56a98d15b8c58a20e0e08c1f5f2bea1be"}, + {file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8619d6915b3b0b34420cf9b2bb6d81ef59d984cb0fde7544e9ece32b4b3043c3"}, + {file = "yarl-1.9.4-cp38-cp38-win32.whl", hash = "sha256:686a0c2f85f83463272ddffd4deb5e591c98aac1897d65e92319f729c320eece"}, + {file = "yarl-1.9.4-cp38-cp38-win_amd64.whl", hash = "sha256:a00862fb23195b6b8322f7d781b0dc1d82cb3bcac346d1e38689370cc1cc398b"}, + {file = "yarl-1.9.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:604f31d97fa493083ea21bd9b92c419012531c4e17ea6da0f65cacdcf5d0bd27"}, + {file = "yarl-1.9.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8a854227cf581330ffa2c4824d96e52ee621dd571078a252c25e3a3b3d94a1b1"}, + {file = "yarl-1.9.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ba6f52cbc7809cd8d74604cce9c14868306ae4aa0282016b641c661f981a6e91"}, + {file = "yarl-1.9.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a6327976c7c2f4ee6816eff196e25385ccc02cb81427952414a64811037bbc8b"}, + {file = "yarl-1.9.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8397a3817d7dcdd14bb266283cd1d6fc7264a48c186b986f32e86d86d35fbac5"}, + {file = "yarl-1.9.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e0381b4ce23ff92f8170080c97678040fc5b08da85e9e292292aba67fdac6c34"}, + {file = "yarl-1.9.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:23d32a2594cb5d565d358a92e151315d1b2268bc10f4610d098f96b147370136"}, + {file = "yarl-1.9.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ddb2a5c08a4eaaba605340fdee8fc08e406c56617566d9643ad8bf6852778fc7"}, + {file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:26a1dc6285e03f3cc9e839a2da83bcbf31dcb0d004c72d0730e755b33466c30e"}, + {file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:18580f672e44ce1238b82f7fb87d727c4a131f3a9d33a5e0e82b793362bf18b4"}, + {file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:29e0f83f37610f173eb7e7b5562dd71467993495e568e708d99e9d1944f561ec"}, + {file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:1f23e4fe1e8794f74b6027d7cf19dc25f8b63af1483d91d595d4a07eca1fb26c"}, + {file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:db8e58b9d79200c76956cefd14d5c90af54416ff5353c5bfd7cbe58818e26ef0"}, + {file = "yarl-1.9.4-cp39-cp39-win32.whl", hash = "sha256:c7224cab95645c7ab53791022ae77a4509472613e839dab722a72abe5a684575"}, + {file = "yarl-1.9.4-cp39-cp39-win_amd64.whl", hash = "sha256:824d6c50492add5da9374875ce72db7a0733b29c2394890aef23d533106e2b15"}, + {file = "yarl-1.9.4-py3-none-any.whl", hash = "sha256:928cecb0ef9d5a7946eb6ff58417ad2fe9375762382f1bf5c55e61645f2c43ad"}, + {file = "yarl-1.9.4.tar.gz", hash = "sha256:566db86717cf8080b99b58b083b773a908ae40f06681e87e589a976faf8246bf"}, +] + +[package.dependencies] +idna = ">=2.0" +multidict = ">=4.0" + +[metadata] +lock-version = "2.0" +python-versions = "^3.11" +content-hash = "ebec7f6e9df00834d9935209d7b743946ce08fff493214ca083fc977b752fcbd" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..20f1ff2 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,46 @@ +[tool.poetry] +name = "blanco-bot" +version = "1.0.0" +description = "Simple, no-frills Discord music bot" +authors = ["Jared Dantis "] +license = "MIT" +readme = "README.md" +packages = [{ include = "bot" }] + +[tool.poetry.dependencies] +python = "^3.11" +cryptography = "^42.0.5" +mafic = "^2.10.0" +nextcord = "^2.6.0" +pylast = "^5.2.0" +pyyaml = "^6.0.1" +ratelimit = "^2.2.1" +redis = "^5.0.3" +requests = "^2.31.0" +sentry-sdk = "^1.44.0" +spotipy = "^2.23.0" +tenacity = "^8.2.3" +thefuzz = "^0.22.1" +validators = "^0.24.0" +fastapi = "^0.110.0" +uvicorn = {extras = ["standard"], version = "^0.29.0"} +pyjwt = "^2.8.0" + +[tool.poetry.group.dev.dependencies] +mypy = "^1.9.0" +pre-commit = "^3.7.0" +ruff = "^0.3.4" +watchfiles = "^0.21.0" + +[tool.ruff] +indent-width = 2 + +[tool.ruff.lint] +select = ["E4", "E7", "E9", "F", "I", "PL"] + +[tool.ruff.format] +quote-style = "single" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 4768fcb..0000000 --- a/requirements.txt +++ /dev/null @@ -1,43 +0,0 @@ -aiohttp==3.9.1 -aiohttp-jinja2==1.6 -aiohttp-session==2.12.0 -aiosignal==1.3.1 -anyio==4.2.0 -async-timeout==4.0.3 -attrs==23.2.0 -certifi==2023.11.17 -cffi==1.16.0 -charset-normalizer==3.3.2 -cryptography==41.0.7 -decorator==5.1.1 -Deprecated==1.2.14 -frozenlist==1.4.1 -h11==0.14.0 -httpcore==0.18.0 -httpx==0.25.0 -idna==3.6 -Jinja2==3.1.3 -mafic==2.10.0 -MarkupSafe==2.1.3 -multidict==6.0.4 -nextcord==2.6.0 -packaging==23.2 -pycparser==2.21 -pylast==5.2.0 -pyparsing==3.1.1 -PyYAML==6.0.1 -rapidfuzz==3.6.1 -ratelimit==2.2.1 -redis==5.0.1 -requests==2.31.0 -sentry-sdk==1.39.2 -six==1.16.0 -sniffio==1.3.0 -spotipy==2.23.0 -tenacity==8.2.3 -thefuzz==0.20.0 -typing_extensions==4.9.0 -urllib3==2.1.0 -validators==0.22.0 -wrapt==1.16.0 -yarl==1.9.4 diff --git a/server/__init__.py b/server/__init__.py deleted file mode 100644 index 8c87fc3..0000000 --- a/server/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -""" -Nextcord extension that runs the server for the bot. -""" - -from utils.blanco import BlancoBot - -from .main import run_app - - -def setup(bot: 'BlancoBot'): - """ - Run the web server as an async task. - """ - assert bot.config is not None - bot.loop.create_task(run_app(bot.database, bot.config)) diff --git a/server/main.py b/server/main.py deleted file mode 100644 index cdabf26..0000000 --- a/server/main.py +++ /dev/null @@ -1,62 +0,0 @@ -""" -Main module for the web server. -""" - -from base64 import urlsafe_b64decode -from typing import TYPE_CHECKING - -import aiohttp_jinja2 -import jinja2 -from aiohttp import web -from aiohttp.abc import AbstractAccessLogger -from aiohttp_session import setup as setup_sessions -from aiohttp_session.cookie_storage import EncryptedCookieStorage -from cryptography.fernet import Fernet - -from utils.logger import create_logger - -from .routes import setup_routes - -if TYPE_CHECKING: - from database import Database - from dataclass.config import Config - - -class AccessLogger(AbstractAccessLogger): - """ - Custom access logger that logs the response status code, request method, - path, and time taken to process the request. - """ - - def log(self, request, response, time): - log_fmt = 'Server: %s %s %s (took %.2f ms)' - self.logger.info(log_fmt, response.status, request.method, request.path, time*1000) - - -async def run_app(database: 'Database', config: 'Config'): - """ - Run the web server. - """ - # Create logger - logger = create_logger('server') - - # Create app - app = web.Application() - app['db'] = database - app['config'] = config - - # Setup sessions - fernet_key = Fernet.generate_key() - setup_sessions(app, EncryptedCookieStorage(urlsafe_b64decode(fernet_key))) - - # Setup templates and routes - aiohttp_jinja2.setup(app, loader=jinja2.FileSystemLoader('server/templates')) - setup_routes(app) - - # Run app - runner = web.AppRunner(app, access_log=logger, access_log_class=AccessLogger) - await runner.setup() - site = web.TCPSite(runner, port=config.server_port) - await site.start() - - logger.info('Web server listening on %s', config.base_url) diff --git a/server/routes.py b/server/routes.py deleted file mode 100644 index a8a3caf..0000000 --- a/server/routes.py +++ /dev/null @@ -1,29 +0,0 @@ -""" -Adds routes to the application. -""" - -from typing import TYPE_CHECKING - -from .views import * # pylint: disable=wildcard-import - -if TYPE_CHECKING: - from aiohttp.web import Application - - -def setup_routes(app: 'Application'): - """ - Add all available routes to the application. - """ - app.router.add_get('/', homepage) - app.router.add_get('/dashboard', dashboard) - app.router.add_get('/deleteaccount', delete_account) - app.router.add_get('/discordoauth', discordoauth) - app.router.add_get('/lastfmtoken', lastfm_token) - app.router.add_get('/linklastfm', link_lastfm) - app.router.add_get('/linkspotify', link_spotify) - app.router.add_get('/login', login) - app.router.add_get('/logout', logout) - app.router.add_get('/robots.txt', robotstxt) - app.router.add_get('/spotifyoauth', spotifyoauth) - app.router.add_get('/unlink', unlink) - app.router.add_static('/static/', path='server/static', name='static') diff --git a/server/static/css/.gitignore b/server/static/css/.gitignore deleted file mode 100644 index 5f93228..0000000 --- a/server/static/css/.gitignore +++ /dev/null @@ -1 +0,0 @@ -main.css \ No newline at end of file diff --git a/server/views/__init__.py b/server/views/__init__.py deleted file mode 100644 index ba40fe3..0000000 --- a/server/views/__init__.py +++ /dev/null @@ -1,16 +0,0 @@ -""" -This module imports all the views for the server. -""" - -from .dashboard import dashboard -from .deleteaccount import delete_account -from .discordoauth import discordoauth -from .homepage import homepage -from .lastfmtoken import lastfm_token -from .linklastfm import link_lastfm -from .linkspotify import link_spotify -from .login import login -from .logout import logout -from .robotstxt import robotstxt -from .spotifyoauth import spotifyoauth -from .unlink import unlink diff --git a/server/views/dashboard.py b/server/views/dashboard.py deleted file mode 100644 index 55e5192..0000000 --- a/server/views/dashboard.py +++ /dev/null @@ -1,50 +0,0 @@ -""" -Dashboard view. -""" - -from typing import TYPE_CHECKING - -import aiohttp_jinja2 -from aiohttp import web -from aiohttp_session import get_session - -if TYPE_CHECKING: - from dataclass.oauth import LastfmAuth, OAuth - - -@aiohttp_jinja2.template('dashboard.html') -async def dashboard(request: web.Request): - """ - Render the dashboard. - """ - # Get session - session = await get_session(request) - if 'user_id' not in session: - return web.HTTPFound('/login') - - # Get user info - database = request.app['db'] - user: OAuth = database.get_oauth('discord', session['user_id']) - if user is None: - return web.HTTPFound('/login') - - # Get Spotify info - spotify_username = None - spotify: OAuth = database.get_oauth('spotify', session['user_id']) - if spotify is not None: - spotify_username = spotify.username - - # Get Last.fm info - lastfm_username = None - lastfm: LastfmAuth = database.get_lastfm_credentials(session['user_id']) - if lastfm is not None: - lastfm_username = lastfm.username - - # Render template - return { - 'username': user.username, - 'spotify_logged_in': spotify is not None, - 'spotify_username': spotify_username, - 'lastfm_logged_in': lastfm is not None, - 'lastfm_username': lastfm_username - } diff --git a/server/views/deleteaccount.py b/server/views/deleteaccount.py deleted file mode 100644 index 25b409f..0000000 --- a/server/views/deleteaccount.py +++ /dev/null @@ -1,25 +0,0 @@ -""" -Delete account view (empty, redirects to logout). -""" - -from aiohttp import web -from aiohttp_session import get_session - - -async def delete_account(request: web.Request): - """ - Delete user data from all tables and redirect to logout. - """ - # Get session - session = await get_session(request) - if 'user_id' not in session: - return web.HTTPFound('/login') - - # Delete user data from all tables - database = request.app['db'] - database.delete_oauth('discord', session['user_id']) - database.delete_oauth('spotify', session['user_id']) - database.delete_oauth('lastfm', session['user_id']) - - # Redirect to logout - return web.HTTPFound('/logout') diff --git a/server/views/discordoauth.py b/server/views/discordoauth.py deleted file mode 100644 index bc7a082..0000000 --- a/server/views/discordoauth.py +++ /dev/null @@ -1,101 +0,0 @@ -""" -Discord OAuth2 token view. Displayed on redirect from Discord auth flow. -""" - -from time import time - -import requests -from aiohttp import web -from aiohttp_session import get_session -from requests.exceptions import HTTPError, Timeout - -from dataclass.oauth import OAuth -from utils.constants import DISCORD_API_BASE_URL, USER_AGENT - - -async def discordoauth(request: web.Request): - """ - Exchange the code for an access token and store it in the database. - """ - # Get session - session = await get_session(request) - - # Get state - if 'state' not in session: - return web.HTTPBadRequest(text='Missing state, try logging in again.') - state = session['state'] - - # Get OAuth ID, secret, and base URL - oauth_id = request.app['config'].discord_oauth_id - oauth_secret = request.app['config'].discord_oauth_secret - base_url = request.app['config'].base_url - - # Get code - try: - code = request.query['code'] - state = request.query['state'] - except KeyError as err: - return web.HTTPBadRequest(text=f'Missing parameter: {err.args[0]}') - - # Check state - if state != session['state']: - return web.HTTPBadRequest(text='Invalid state, try logging in again.') - - # Get access token - response = requests.post( - str(DISCORD_API_BASE_URL / 'oauth2/token'), - data={ - 'client_id': oauth_id, - 'client_secret': oauth_secret, - 'grant_type': 'authorization_code', - 'code': code, - 'redirect_uri': f'{base_url}/discordoauth' - }, - headers={ - 'Content-Type': 'application/x-www-form-urlencoded', - 'User-Agent': USER_AGENT - }, - timeout=5 - ) - try: - response.raise_for_status() - except HTTPError as err: - return web.HTTPBadRequest(text=f'Error getting access token: {err}') - except Timeout: - return web.HTTPBadRequest(text='Timed out while requesting access token') - - # Get user info - parsed = response.json() - user_info = requests.get( - str(DISCORD_API_BASE_URL / 'users/@me'), - headers={ - 'Authorization': f'Bearer {parsed["access_token"]}', - 'User-Agent': USER_AGENT - }, - timeout=5 - ) - try: - user_info.raise_for_status() - except HTTPError as err: - return web.HTTPBadRequest(text=f'Error getting user info: {err}') - except Timeout: - return web.HTTPBadRequest(text='Timed out while requesting user info') - - # Calculate expiry timestamp - user_parsed = user_info.json() - expires_at = int(time()) + parsed['expires_in'] - - # Store user info in DB - database = request.app['db'] - database.set_oauth('discord', OAuth( - user_id=user_parsed['id'], - username=user_parsed['username'], - access_token=parsed['access_token'], - refresh_token=parsed['refresh_token'], - expires_at=expires_at - )) - - # Redirect to dashboard - del session['state'] - session['user_id'] = user_parsed['id'] - return web.HTTPFound('/dashboard') diff --git a/server/views/homepage.py b/server/views/homepage.py deleted file mode 100644 index 99e7a20..0000000 --- a/server/views/homepage.py +++ /dev/null @@ -1,20 +0,0 @@ -""" -Homepage view. -""" - -import aiohttp_jinja2 -from aiohttp import web -from aiohttp_session import get_session - - -@aiohttp_jinja2.template('homepage.html') -async def homepage(request: web.Request): - """ - Render the homepage, or redirect to the dashboard if the user is logged in. - """ - # Get session - session = await get_session(request) - if 'user_id' in session: - return web.HTTPFound('/dashboard') - - return {} diff --git a/server/views/lastfmtoken.py b/server/views/lastfmtoken.py deleted file mode 100644 index c7b38f1..0000000 --- a/server/views/lastfmtoken.py +++ /dev/null @@ -1,91 +0,0 @@ -""" -Last.fm token view. Displayed on redirect from Last.fm auth flow. -""" - -from hashlib import md5 - -import requests -from aiohttp import web -from aiohttp_session import get_session -from requests.exceptions import HTTPError, Timeout - -from dataclass.oauth import LastfmAuth -from utils.constants import LASTFM_API_BASE_URL, USER_AGENT - - -async def lastfm_token(request: web.Request): - """ - Exchange the token for a session key and store it in the database. - """ - # Get session - session = await get_session(request) - - # Get state and Discord user ID - if 'user_id' not in session: - return web.HTTPBadRequest(text='You are not logged into Blanco with Discord.') - user_id = session['user_id'] - - # Get API key and secret - api_key = request.app['config'].lastfm_api_key - secret = request.app['config'].lastfm_shared_secret - - # Get token - try: - token = request.query['token'] - except KeyError: - return web.HTTPBadRequest(text='Missing token, try logging in again.') - - # Create signature - signature = ''.join([ - 'api_key', - api_key, - 'method', - 'auth.getSession', - 'token', - token, - secret - ]) - hashed = md5(signature.encode('utf-8')).hexdigest() - - # Get session key - url = LASTFM_API_BASE_URL.with_query({ - 'method': 'auth.getSession', - 'api_key': api_key, - 'token': token, - 'api_sig': hashed, - 'format': 'json' - }) - - # Get response - response = requests.get( - str(url), - headers={ - 'User-Agent': USER_AGENT - }, - timeout=5 - ) - try: - response.raise_for_status() - except HTTPError as err: - return web.HTTPBadRequest(text=f'Error logging into Last.fm: {err}') - except Timeout: - return web.HTTPBadRequest(text='Timed out while requesting session key') - - # Get JSON - json = response.json() - try: - session_key = json['session']['key'] - username = json['session']['name'] - except KeyError as err: - return web.HTTPBadRequest(text=f'Error logging into Last.fm: missing {err.args[0]}') - - # Store user info in DB - database = request.app['db'] - database.set_lastfm_credentials(LastfmAuth( - user_id=user_id, - username=username, - session_key=session_key - )) - - # Redirect to dashboard - return web.HTTPFound('/dashboard') diff --git a/server/views/linklastfm.py b/server/views/linklastfm.py deleted file mode 100644 index 5850b6f..0000000 --- a/server/views/linklastfm.py +++ /dev/null @@ -1,35 +0,0 @@ -""" -Last.fm auth view. -""" - -from aiohttp import web -from aiohttp_session import get_session -from yarl import URL - - -async def link_lastfm(request: web.Request): - """ - Redirect to Last.fm auth flow. - """ - # Create session - session = await get_session(request) - - # Check if user is logged in - if 'user_id' not in session: - return web.HTTPFound('/login') - - # Get API key and base URL - api_key = request.app['config'].lastfm_api_key - base_url = request.app['config'].base_url - - # Redirect to Last.fm - url = URL.build( - scheme='https', - host='www.last.fm', - path='/api/auth', - query={ - 'api_key': api_key, - 'cb': f'{base_url}/lastfmtoken' - } - ) - return web.HTTPFound(url) diff --git a/server/views/linkspotify.py b/server/views/linkspotify.py deleted file mode 100644 index 26d3aa2..0000000 --- a/server/views/linkspotify.py +++ /dev/null @@ -1,54 +0,0 @@ -""" -Spotify auth view. -""" - -from secrets import choice -from string import ascii_letters, digits - -from aiohttp import web -from aiohttp_session import get_session -from yarl import URL - - -async def link_spotify(request: web.Request): - """ - Construct a Spotify OAuth2 authorization URL and redirect to it. - """ - # Create session - session = await get_session(request) - - # Check if user is logged in - if 'user_id' not in session: - return web.HTTPFound('/login') - - # Get OAuth ID and base URL - oauth_id = request.app['config'].spotify_client_id - base_url = request.app['config'].base_url - - # Generate and store state - state = ''.join(choice(ascii_letters + digits) for _ in range(16)) - session['state'] = state - - # Build URL - scopes = [ - 'user-read-private', # Get username - 'user-read-email', # Also for username, weirdly - 'user-library-modify', # Add/remove Liked Songs - 'user-top-read', # Get top tracks, for recommendations/radio - 'playlist-read-private' # Get owned playlists - ] - url = URL.build( - scheme='https', - host='accounts.spotify.com', - path='/authorize', - query={ - 'client_id': oauth_id, - 'response_type': 'code', - 'scope': ' '.join(scopes), - 'redirect_uri': f'{base_url}/spotifyoauth', - 'state': state - } - ) - - # Redirect to Discord - return web.HTTPFound(url) diff --git a/server/views/login.py b/server/views/login.py deleted file mode 100644 index e3dfc81..0000000 --- a/server/views/login.py +++ /dev/null @@ -1,44 +0,0 @@ -""" -Login view. -""" - -from secrets import choice -from string import ascii_letters, digits - -from aiohttp import web -from aiohttp_session import get_session -from yarl import URL - - -async def login(request: web.Request): - """ - Construct a Discord OAuth2 authorization URL and redirect to it. - """ - # Create session - session = await get_session(request) - - # Get OAuth ID and base URL - oauth_id = request.app['config'].discord_oauth_id - base_url = request.app['config'].base_url - - # Generate and store state - state = ''.join(choice(ascii_letters + digits) for _ in range(16)) - session['state'] = state - - # Build URL - url = URL.build( - scheme='https', - host='discord.com', - path='/api/oauth2/authorize', - query={ - 'client_id': oauth_id, - 'response_type': 'code', - 'scope': 'identify guilds email', - 'redirect_uri': f'{base_url}/discordoauth', - 'state': state, - 'prompt': 'none' - } - ) - - # Redirect to Discord - return web.HTTPFound(url) diff --git a/server/views/logout.py b/server/views/logout.py deleted file mode 100644 index a36f9d2..0000000 --- a/server/views/logout.py +++ /dev/null @@ -1,20 +0,0 @@ -""" -Logout view. -""" - -from aiohttp import web -from aiohttp_session import get_session - - -async def logout(request: web.Request): - """ - Clear the session and redirect to home. - """ - # Get session - session = await get_session(request) - - # Clear session - session.clear() - - # Redirect to home - return web.HTTPFound('/') diff --git a/server/views/robotstxt.py b/server/views/robotstxt.py deleted file mode 100644 index 73f60ec..0000000 --- a/server/views/robotstxt.py +++ /dev/null @@ -1,16 +0,0 @@ -""" -View for robots.txt -""" - -from aiohttp import web - - -async def robotstxt(_: web.Request): - """ - Return robots.txt - """ - return web.Response(text='\n'.join([ - 'User-agent: *', - 'Allow: /$', # Allow homepage - 'Disallow: /' # Disallow everything else - ])) diff --git a/server/views/spotifyoauth.py b/server/views/spotifyoauth.py deleted file mode 100644 index 75a3980..0000000 --- a/server/views/spotifyoauth.py +++ /dev/null @@ -1,105 +0,0 @@ -""" -Spotify OAuth view. Displayed on redirect from Spotify auth flow. -""" - -from base64 import b64encode -from time import time - -import requests -from aiohttp import web -from aiohttp_session import get_session -from requests.exceptions import HTTPError, Timeout - -from dataclass.oauth import OAuth -from utils.constants import (SPOTIFY_ACCOUNTS_BASE_URL, SPOTIFY_API_BASE_URL, - USER_AGENT) - - -async def spotifyoauth(request: web.Request): - """ - Exchange the code for an access token and store it in the database. - """ - # Get session - session = await get_session(request) - - # Get state and Discord user ID - if 'state' not in session: - return web.HTTPBadRequest(text='Missing state, try logging in again.') - if 'user_id' not in session: - return web.HTTPBadRequest(text='You are not logged into Blanco with Discord.') - state = session['state'] - user_id = session['user_id'] - - # Get OAuth ID, secret, and base URL - oauth_id = request.app['config'].spotify_client_id - oauth_secret = request.app['config'].spotify_client_secret - base_url = request.app['config'].base_url - - # Get code - try: - code = request.query['code'] - state = request.query['state'] - except KeyError as err: - return web.HTTPBadRequest(text=f'Missing parameter: {err.args[0]}') - - # Check state - if state != session['state']: - return web.HTTPBadRequest(text='Invalid state, try logging in again.') - - # Get access token - response = requests.post( - str(SPOTIFY_ACCOUNTS_BASE_URL / 'token'), - data={ - 'grant_type': 'authorization_code', - 'code': code, - 'redirect_uri': f'{base_url}/spotifyoauth' - }, - headers={ - 'Authorization': f'Basic {b64encode(f"{oauth_id}:{oauth_secret}".encode()).decode()}', - 'Content-Type': 'application/x-www-form-urlencoded', - 'User-Agent': USER_AGENT - }, - timeout=5 - ) - try: - response.raise_for_status() - except HTTPError as err: - return web.HTTPBadRequest(text=f'Error getting Spotify access token: {err}') - except Timeout: - return web.HTTPBadRequest(text='Timed out while requesting Spotify access token') - - # Get user info - parsed = response.json() - user_info = requests.get( - str(SPOTIFY_API_BASE_URL / 'me'), - headers={ - 'Authorization': f'Bearer {parsed["access_token"]}', - 'User-Agent': USER_AGENT - }, - timeout=5 - ) - try: - user_info.raise_for_status() - except HTTPError as err: - return web.HTTPBadRequest(text=f'Error getting Spotify user info: {err}') - except Timeout: - return web.HTTPBadRequest(text='Timed out while requesting Spotify user info') - - # Calculate expiry timestamp - user_parsed = user_info.json() - expires_at = int(time()) + parsed['expires_in'] - - # Store user info in DB - database = request.app['db'] - database.set_oauth('spotify', OAuth( - user_id=user_id, - username=user_parsed['id'], - access_token=parsed['access_token'], - refresh_token=parsed['refresh_token'], - expires_at=expires_at - )) - database.set_spotify_scopes(user_id, parsed['scope'].split(' ')) - - # Redirect to dashboard - del session['state'] - return web.HTTPFound('/dashboard') diff --git a/server/views/unlink.py b/server/views/unlink.py deleted file mode 100644 index 42ec90a..0000000 --- a/server/views/unlink.py +++ /dev/null @@ -1,37 +0,0 @@ -""" -Account unlinking view. -""" - -from aiohttp import web -from aiohttp_session import get_session - - -async def unlink(request: web.Request): - """ - Delete authentication data for the user from the specified service. - """ - # Get session - session = await get_session(request) - if 'user_id' not in session: - return web.HTTPFound('/login') - user_id = session['user_id'] - - # Get user info - database = request.app['db'] - user = database.get_oauth('discord', user_id) - if user is None: - return web.HTTPFound('/login') - - # Which service to unlink? - try: - service = request.query['service'] - except KeyError as err: - return web.HTTPBadRequest(text=f'Missing parameter: {err.args[0]}') - - if service not in ('lastfm', 'spotify'): - raise web.HTTPBadRequest(text=f'Unknown service: {service}') - - database.delete_oauth(service, user_id) - - # Redirect to dashboard - return web.HTTPFound('/dashboard') diff --git a/tailwind.config.js b/tailwind.config.js index 1adbbf9..6147345 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -1,6 +1,6 @@ /** @type {import('tailwindcss').Config} */ module.exports = { - content: ["./server/templates/*.html"], + content: ["./dashboard/templates/*.html"], theme: { fontFamily: { sans: ['IBM Plex Mono', 'sans-serif'], diff --git a/utils/__init__.py b/utils/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/utils/blanco.py b/utils/blanco.py deleted file mode 100644 index 9023e88..0000000 --- a/utils/blanco.py +++ /dev/null @@ -1,542 +0,0 @@ -""" -Custom bot class for Blanco. -""" - -from asyncio import get_event_loop -from sqlite3 import OperationalError -from typing import TYPE_CHECKING, Dict, List, Optional, Union - -from aiohttp.client_exceptions import ClientConnectorError -from mafic import EndReason, NodePool, VoiceRegion -from nextcord import (Activity, ActivityType, Forbidden, HTTPException, - Interaction, NotFound, PartialMessageable, StageChannel, - TextChannel, Thread, VoiceChannel, MessageFlags) -from nextcord.ext.commands import Bot, ExtensionNotLoaded - -from cogs.player.jockey_helpers import find_lavalink_track -from database import Database -from views.now_playing import NowPlayingView - -from .embeds import create_error_embed -from .exceptions import EndOfQueueError, LavalinkSearchError -from .logger import create_logger -from .scrobbler import Scrobbler -from .spotify_client import Spotify -from .spotify_private import PrivateSpotify - -if TYPE_CHECKING: - from asyncio import Task - from logging import Logger - - from mafic import Node, TrackEndEvent, TrackStartEvent - - from cogs.player.jockey import Jockey - from dataclass.config import Config - - -StatusChannel = Union[PartialMessageable, VoiceChannel, TextChannel, StageChannel, Thread] - - -# Match-ahead wrapper for finding a Lavalink track with exception handling -async def match_ahead(logger: 'Logger', *args, **kwargs): - """ - Wrapper for find_lavalink_track with exception handling. - """ - try: - return await find_lavalink_track(*args, **kwargs) - except LavalinkSearchError: - logger.warning('Failed to match track ahead') - - # No need to do anything special, the user will see the causes - # when Blanco tries to play the track for real - return None - - -class BlancoBot(Bot): - """ - Custom bot class for Blanco. - """ - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self._config: Optional['Config'] = None - self._db: Optional[Database] = None - - # Status channels - self._status_channels: Dict[int, 'StatusChannel'] = {} - - # Spotify client - self._spotify_client: Optional[Spotify] = None - - # Lavalink - self._pool = NodePool(self) - self._pool_initialized = False - - # Loggers - self._logger = create_logger(self.__class__.__name__) - self._jockey_logger = create_logger('jockey') - - # Scrobblers and private Spotify clients per user - self._scrobblers: Dict[int, 'Scrobbler'] = {} - self._scrobbler_logger = create_logger('scrobbler') - self._spotify_clients: Dict[int, PrivateSpotify] = {} - - # Annotator tasks - self._tasks: Dict[int, List['Task']] = {} - - @property - def config(self) -> Optional['Config']: - """ - Gets the bot's config. - """ - return self._config - - @property - def debug(self) -> bool: - """ - Gets whether debug mode is enabled. - """ - if self._config is None or self._config.debug_guild_ids is None: - return False - return self._config.debug_enabled and len(self._config.debug_guild_ids) > 0 - - @property - def database(self) -> Database: - """ - Gets the bot's database. - """ - if self._db is None: - raise RuntimeError('Database has not been initialized') - return self._db - - @property - def jockey_logger(self) -> 'Logger': - """ - Gets the bot's reusable logger for all Jockey instances. - """ - return self._jockey_logger - - @property - def pool(self) -> NodePool: - """ - Gets the bot's Lavalink node pool. - """ - return self._pool - - @property - def pool_initialized(self) -> bool: - """ - Gets whether the Lavalink node pool has been initialized. - """ - return self._pool_initialized - - @property - def spotify(self) -> Spotify: - """ - Gets the bot's Spotify client. See utils/spotify_client.py. - """ - if self._spotify_client is None: - raise RuntimeError('Spotify client has not been initialized') - return self._spotify_client - - ################### - # Event listeners # - ################### - - async def on_ready(self): - """ - Called when the bot is ready. - """ - if self._config is None: - raise RuntimeError('Received on_ready event before config was initialized') - - self._logger.info('Logged in as %s', self.user) - - # Try to unload cogs first if the bot was restarted - try: - self.unload_extension('cogs') - except ExtensionNotLoaded: - pass - self.load_extension('cogs') - - # Load server extension if server is enabled - if self._config.enable_server: - # Try to unload server first if the bot was restarted - try: - self.unload_extension('server') - except ExtensionNotLoaded: - pass - self._logger.info('Starting web server...') - self.load_extension('server') - else: - if self._config.base_url is not None: - self._logger.warning( - 'Server is disabled, but base URL is set to %s', - self._config.base_url - ) - - if self.debug: - self._logger.warning('Debug mode enabled') - await self.change_presence( - activity=Activity(name='/play (debug)', type=ActivityType.listening) - ) - - # Sync commands with debug guilds - if self._config is not None and self._config.debug_guild_ids is not None: - for guild in self._config.debug_guild_ids: - self._logger.info('Syncing commands for debug guild %d', guild) - await self.sync_application_commands(guild_id=guild) - self._logger.info( - 'Synced commands for %d guild(s)!', - len(self._config.debug_guild_ids) - ) - else: - await self.change_presence( - activity=Activity(name='/play', type=ActivityType.listening) - ) - - # Sync commands - self._logger.info('Syncing global commands...') - await self.sync_application_commands() - self._logger.info('Synced commands!') - - async def on_application_command_error(self, itx: Interaction, error: Exception): - """ - Called when an error occurs while processing an interaction. - """ - embed = create_error_embed(str(error)) - - # Check if we can reply to this interaction - try: - if itx.response.is_done(): - if isinstance(itx.channel, PartialMessageable): - await itx.channel.send(embed=embed) - else: - await itx.response.send_message(embed=embed) - except NotFound: - self._logger.warning('Error 404 while sending error msg for interaction %d', itx.id) - - async def on_jockey_disconnect(self, jockey: 'Jockey'): - """ - Called when a player disconnects from voice. - """ - self._logger.debug('Jockey disconnected from voice in %s', jockey.guild.name) - - # Clear tasks for this guild - if jockey.guild.id in self._tasks: - for task in self._tasks[jockey.guild.id]: - task.cancel() - del self._tasks[jockey.guild.id] - - async def on_node_ready(self, node: 'Node'): - """ - Called when a Lavalink node is connected and ready. - """ - self._logger.info('Connected to Lavalink node `%s\'', node.label) - - # Store session ID in database - if node.session_id is not None: - try: - old_id = self.database.get_session_id(node.label) - except (OperationalError, TypeError): - old_id = None - - if old_id is not None and old_id != node.session_id: - self._logger.debug( - 'Replacing old session ID `%s\' for node `%s\'', - old_id, - node.label - ) - self.database.set_session_id(node.label, node.session_id) - - async def on_track_start(self, event: 'TrackStartEvent[Jockey]'): - """ - Called when a track starts playing. - """ - guild = event.player.guild - self._logger.info( - 'Started playing `%s\' in %s', - event.track.title, - guild.name - ) - - # Send now playing embed - try: - await self.send_now_playing(event) - except EndOfQueueError: - self._logger.warning( - 'Got track_start event for idle player in %s', - guild.name - ) - return - - # Get queue manager and node - q_mgr = event.player.queue_manager - node = event.player.node - - # Check if Deezer is enabled for this node - assert self._config is not None - deezer_enabled = self._config.lavalink_nodes[node.label].deezer - - # Prefetch the next track in the background - if self._config.match_ahead: - try: - _, next_track = q_mgr.next_track - except EndOfQueueError: - return - if next_track.lavalink_track is not None: - return - - self._logger.debug( - 'Matching next track `%s\' in the background', - next_track.title - ) - task = get_event_loop().create_task( - match_ahead( - self._logger, - node, - next_track, - deezer_enabled=deezer_enabled, - in_place=True, - lookup_mbid=self._config.lastfm_enabled - ) - ) - - # Store task so it can be cancelled if the player disconnects - if guild.id not in self._tasks: - self._tasks[guild.id] = [] - task.add_done_callback(lambda _: self._tasks[guild.id].remove(task)) - self._tasks[guild.id].append(task) - - async def on_track_end(self, event: 'TrackEndEvent[Jockey]'): - """ - Called when a track ends. - """ - if event.reason == EndReason.REPLACED: - self._logger.warning( - 'Skipped `%s\' in %s', - event.track.title, - event.player.guild.name - ) - elif event.reason == EndReason.FINISHED: - # Play next track in queue - self._logger.info( - 'Finished playing `%s\' in %s', - event.track.title, - event.player.guild.name - ) - await event.player.skip() - elif event.reason == EndReason.STOPPED: - self._logger.info( - 'Stopped player in %s', - event.player.guild.name - ) - elif event.reason == EndReason.LOAD_FAILED: - self._logger.critical( - 'Failed to load `%s\' in %s', - event.track.title, - event.player.guild.name - ) - - # Call load failed hook - await event.player.on_load_failed(event.track) - else: - self._logger.error( - 'Unhandled %s in %s for `%s\'', - event.reason, - event.player.guild.name, - event.track.title - ) - - ##################### - # Utility functions # - ##################### - - def get_scrobbler(self, user_id: int) -> Optional['Scrobbler']: - """ - Gets a Last.fm scrobbler instance for the specified user. - """ - assert self._config is not None and self._db is not None - - # Check if user is authenticated with Last.fm - creds = self._db.get_lastfm_credentials(user_id) - if creds is None: - if user_id in self._scrobblers: - # User must have unlinked their account, so delete the cached scrobbler - del self._scrobblers[user_id] - - return None - - # Check if a scrobbler already exists - if user_id not in self._scrobblers: - # Create scrobbler - self._scrobblers[user_id] = Scrobbler(self._config, creds, self._scrobbler_logger) - - return self._scrobblers[user_id] - - def get_spotify_client(self, user_id: int) -> Optional[PrivateSpotify]: - """ - Gets a Spotify client instance for the specified user. - """ - assert self._config is not None and self._db is not None - - # Try to get credentials - creds = self._db.get_oauth('spotify', user_id) - if creds is None: - # Check if there is a cached client for this user - if user_id in self._spotify_clients: - # User must have unlinked their account, so delete the cached client - del self._spotify_clients[user_id] - - raise ValueError(f'Please link your Spotify account [here.]({self._config.base_url})') - - # Check if a client already exists - if user_id not in self._spotify_clients: - self._spotify_clients[user_id] = PrivateSpotify( - config=self._config, - database=self._db, - credentials=creds - ) - self._logger.debug('Created Spotify client for user %d', user_id) - - return self._spotify_clients[user_id] - - def set_status_channel(self, guild_id: int, channel: 'StatusChannel'): - """ - Sets the status channel for the specified guild, which is used to send - now playing messages and announcements. - """ - # If channel is None, remove the status channel - if channel is None: - del self._status_channels[guild_id] - - self._status_channels[guild_id] = channel - self.database.set_status_channel(guild_id, -1 if channel is None else channel.id) - - def get_status_channel(self, guild_id: int) -> Optional['StatusChannel']: - """ - Gets the status channel for the specified guild. - """ - # Check if status channel is cached - if guild_id in self._status_channels: - return self._status_channels[guild_id] - - # Get status channel ID from database - channel_id = -1 - try: - channel_id = self.database.get_status_channel(guild_id) - except OperationalError: - self._logger.warning( - 'Failed to get status channel ID for guild %d from database', - guild_id - ) - - # Get status channel from ID - if channel_id != -1: - channel = self.get_channel(channel_id) - if channel is None: - self._logger.error( - 'Failed to get status channel for guild %d', - guild_id - ) - elif not isinstance(channel, StatusChannel): - self._logger.error( - 'Status channel for guild %d is not Messageable', - guild_id - ) - else: - self._status_channels[guild_id] = channel - return channel - - return None - - def init_config(self, config: 'Config'): - """ - Initialize the bot with a config. - """ - self._config = config - self._db = Database(config.db_file) - self._spotify_client = Spotify( - client_id=config.spotify_client_id, - client_secret=config.spotify_client_secret - ) - - async def init_pool(self): - """ - Initialize the Lavalink node pool. - """ - if self._config is None: - raise RuntimeError('Cannot initialize Lavalink without a config') - nodes = self._config.lavalink_nodes - - # Add local node - for node in nodes.values(): - # Try to match regions against enum - regions = [] - for region in node.regions: - regions.append(VoiceRegion(region)) - - # Get session ID from database - try: - session_id = self.database.get_session_id(node.id) - except (OperationalError, TypeError): - session_id = None - self._logger.debug('No session ID for node `%s\'', node.id) - else: - self._logger.debug( - 'Using session ID `%s\' for node `%s\'', - session_id, - node.id - ) - - try: - await self._pool.create_node( - host=node.host, - port=node.port, - password=node.password, - regions=regions, - resuming_session_id=session_id, - label=node.id, - secure=node.secure - ) - except ClientConnectorError: - self._logger.error( - 'Lavalink node `%s\' refused connection', - node.id - ) - - # Check if we have any nodes - if len(self._pool.nodes) == 0: - self._logger.critical('No Lavalink nodes available') - - self._pool_initialized = True - - async def send_now_playing(self, event: 'TrackStartEvent[Jockey]'): - """ - Send a now playing message for the specified track start event. - """ - guild_id = event.player.guild.id - channel = self.get_status_channel(guild_id) - if channel is None: - raise ValueError(f'Status channel has not been set for guild {guild_id}') - - # Delete last now playing message, if it exists - last_msg_id = self.database.get_now_playing(guild_id) - if last_msg_id != -1: - try: - last_msg = await channel.fetch_message(last_msg_id) - await last_msg.delete() - except (Forbidden, HTTPException, NotFound): - pass - - # Send now playing embed - current_track = event.player.queue_manager.current - embed = event.player.now_playing(event.track) - view = NowPlayingView(self, event.player, current_track.spotify_id) - - # Send message silently - flags = MessageFlags() - flags.suppress_notifications = True # pylint: disable=assigning-non-slot - msg = await channel.send(embed=embed, view=view, flags=flags) - - # Save now playing message ID - self.database.set_now_playing(guild_id, msg.id) diff --git a/utils/config.py b/utils/config.py deleted file mode 100644 index 87cdc4b..0000000 --- a/utils/config.py +++ /dev/null @@ -1,193 +0,0 @@ -""" -Configuration parser. - -This module parses the configuration file and environment variables and -provides a single object with the synthesized configuration values, -where the environment variables take precedence over the config file. -""" - -from os import environ -from os.path import isfile -from typing import Dict - -from yaml import safe_load - -from dataclass.config import Config, LavalinkNode - - -DATABASE_FILE = None -DISCORD_TOKEN = None -SPOTIFY_CLIENT_ID = None -SPOTIFY_CLIENT_SECRET = None -MATCH_AHEAD = False -ENABLE_SERVER = False -SERVER_PORT = 8080 -SERVER_BASE_URL = None -DISCORD_OAUTH_ID = None -DISCORD_OAUTH_SECRET = None -LASTFM_API_KEY = None -LASTFM_SHARED_SECRET = None -LAVALINK_NODES: Dict[str, LavalinkNode] = {} -SENTRY_DSN = None -SENTRY_ENV = None -REDIS_HOST = None -REDIS_PORT = -1 -REDIS_PASSWORD = None -DEBUG_ENABLED = False -DEBUG_GUILDS = None -REENQUEUE_PAUSED = False - -# Parse config file if it exists -if isfile('config.yml'): - with open('config.yml', encoding='UTF-8') as f: - try: - config_file = safe_load(f) - except Exception as e: - raise ValueError(f'Error parsing config.yml: {e}') from e - - # Get config values - try: - # Read config from config.yml - DATABASE_FILE = config_file['bot']['database'] - DISCORD_TOKEN = config_file['bot']['discord_token'] - SPOTIFY_CLIENT_ID = config_file['spotify']['client_id'] - SPOTIFY_CLIENT_SECRET = config_file['spotify']['client_secret'] - - # Parse Lavalink nodes from config.yml - for node in config_file['lavalink']: - lavalink_node = LavalinkNode( - id=node['id'], - password=node['password'], - host=node['server'], - port=node['port'], - regions=node['regions'], - secure=node.get('secure', False) - ) - - # Add optional config values - if 'deezer' in node: - lavalink_node.deezer = node['deezer'] - - LAVALINK_NODES[node['id']] = lavalink_node - - # Add optional config values - MATCH_AHEAD = config_file['bot'].get('match_ahead', False) - REENQUEUE_PAUSED = config_file['bot'].get('reenqueue_paused', False) - if 'server' in config_file: - ENABLE_SERVER = config_file['server']['enabled'] - SERVER_PORT = config_file['server'].get('port', 8080) - SERVER_BASE_URL = config_file['server'].get('base_url', None) - DISCORD_OAUTH_ID = config_file['server'].get('oauth_id', None) - DISCORD_OAUTH_SECRET = config_file['server'].get('oauth_secret', None) - if 'lastfm' in config_file: - LASTFM_API_KEY = config_file['lastfm']['api_key'] - LASTFM_SHARED_SECRET = config_file['lastfm']['shared_secret'] - if 'debug' in config_file['bot']: - DEBUG_ENABLED = config_file['bot']['debug']['enabled'] - DEBUG_GUILDS = config_file['bot']['debug']['guild_ids'] - if 'sentry' in config_file: - SENTRY_DSN = config_file['sentry']['dsn'] - SENTRY_ENV = config_file['sentry']['environment'] - if 'redis' in config_file: - REDIS_HOST = config_file['redis']['host'] - REDIS_PORT = config_file['redis']['port'] - REDIS_PASSWORD = config_file['redis']['password'] - except KeyError as e: - raise RuntimeError(f'Config missing from config.yml: {e.args[0]}') from e - - -# Override config from environment variables -DATABASE_FILE = environ.get('BLANCO_DB_FILE', DATABASE_FILE) -DISCORD_TOKEN = environ.get('BLANCO_TOKEN', DISCORD_TOKEN) -LASTFM_API_KEY = environ.get('BLANCO_LASTFM_KEY', LASTFM_API_KEY) -LASTFM_SHARED_SECRET = environ.get('BLANCO_LASTFM_SECRET', LASTFM_SHARED_SECRET) -SPOTIFY_CLIENT_ID = environ.get('BLANCO_SPOTIFY_ID', SPOTIFY_CLIENT_ID) -SPOTIFY_CLIENT_SECRET = environ.get('BLANCO_SPOTIFY_SECRET', SPOTIFY_CLIENT_SECRET) -SENTRY_DSN = environ.get('BLANCO_SENTRY_DSN', SENTRY_DSN) -SENTRY_ENV = environ.get('BLANCO_SENTRY_ENV', SENTRY_ENV) -REDIS_HOST = environ.get('BLANCO_REDIS_HOST', REDIS_HOST) -REDIS_PORT = int(environ.get('BLANCO_REDIS_PORT', REDIS_PORT)) -REDIS_PASSWORD = environ.get('BLANCO_REDIS_PASSWORD', REDIS_PASSWORD) -if 'BLANCO_REENQUEUE_PAUSED' in environ: - REENQUEUE_PAUSED = environ['BLANCO_REENQUEUE_PAUSED'].lower() == 'true' -if 'BLANCO_MATCH_AHEAD' in environ: - MATCH_AHEAD = environ['BLANCO_MATCH_AHEAD'].lower() == 'true' -if 'BLANCO_DEBUG' in environ: - DEBUG_ENABLED = environ['BLANCO_DEBUG'].lower() == 'true' - DEBUG_GUILDS = [int(id) for id in environ['BLANCO_DEBUG_GUILDS'].split(',')] -if 'BLANCO_ENABLE_SERVER' in environ: - ENABLE_SERVER = environ['BLANCO_ENABLE_SERVER'].lower() == 'true' - SERVER_PORT = int(environ.get('BLANCO_SERVER_PORT', SERVER_PORT)) - SERVER_BASE_URL = environ.get('BLANCO_BASE_URL', SERVER_BASE_URL) - DISCORD_OAUTH_ID = environ.get('BLANCO_OAUTH_ID', DISCORD_OAUTH_ID) - DISCORD_OAUTH_SECRET = environ.get('BLANCO_OAUTH_SECRET', DISCORD_OAUTH_SECRET) - -# Parse Lavalink nodes from environment variables -i = 1 -while True: - try: - credentials, host = environ[f'BLANCO_NODE_{i}'].split('@') - node_id, password = credentials.split(':') - server, port = host.split(':') - regions = environ[f'BLANCO_NODE_{i}_REGIONS'].split(',') - secure = environ.get(f'BLANCO_NODE_{i}_SECURE', 'false').lower() == 'true' - deezer = environ.get(f'BLANCO_NODE_{i}_DEEZER', 'false').lower() == 'true' - except KeyError as e: - missing_key = e.args[0] - if missing_key == f'BLANCO_NODE_{i}': - if len(LAVALINK_NODES) == 0: - raise ValueError('No Lavalink nodes specified') from e - break - - if missing_key == f'BLANCO_NODE_{i}_REGIONS': - raise ValueError(f'No regions specified for Lavalink node {i}') from e - - break - else: - # Add node to list - LAVALINK_NODES[node_id] = LavalinkNode( - id=node_id, - password=password, - host=server, - port=int(port), - regions=regions, - secure=secure, - deezer=deezer - ) - - i += 1 - - -# Final checks -if DATABASE_FILE is None: - raise ValueError('No database file specified') -if DISCORD_TOKEN is None: - raise ValueError('No Discord token specified') -if SPOTIFY_CLIENT_ID is None: - raise ValueError('No Spotify client ID specified') -if SPOTIFY_CLIENT_SECRET is None: - raise ValueError('No Spotify client secret specified') -if ENABLE_SERVER and (DISCORD_OAUTH_ID is None or - DISCORD_OAUTH_SECRET is None or SERVER_BASE_URL is None): - raise ValueError('Discord OAuth ID, secret, and base URL must be specified to enable server') - - -# Create config object -config = Config( - db_file=DATABASE_FILE, - discord_token=DISCORD_TOKEN, - spotify_client_id=SPOTIFY_CLIENT_ID, - spotify_client_secret=SPOTIFY_CLIENT_SECRET, - lavalink_nodes=LAVALINK_NODES, - debug_enabled=DEBUG_ENABLED, - debug_guild_ids=DEBUG_GUILDS, - enable_server=ENABLE_SERVER, - match_ahead=MATCH_AHEAD, - server_port=SERVER_PORT, - base_url=SERVER_BASE_URL, - discord_oauth_id=DISCORD_OAUTH_ID, - discord_oauth_secret=DISCORD_OAUTH_SECRET, - lastfm_api_key=LASTFM_API_KEY, - lastfm_shared_secret=LASTFM_SHARED_SECRET, - reenqueue_paused=REENQUEUE_PAUSED -) diff --git a/utils/embeds.py b/utils/embeds.py deleted file mode 100644 index 90b128c..0000000 --- a/utils/embeds.py +++ /dev/null @@ -1,40 +0,0 @@ -""" -Success and error embeds for the bot. -""" - -from typing import Optional - -from nextcord import Colour, Embed - -from dataclass.custom_embed import CustomEmbed - - -def create_error_embed(message: str) -> Embed: - """ - Create an error embed. - """ - embed = CustomEmbed( - color=Colour.red(), - title=':x:|Error', - description=message - ) - return embed.get() - - -def create_success_embed(title: Optional[str] = None, body: Optional[str] = None) -> Embed: - """ - Create a success embed. - """ - if body is None: - if title is None: - raise ValueError('Either title or body must be specified') - - body = title - title = 'Success' - - embed = CustomEmbed( - color=Colour.green(), - title=f':white_check_mark:|{title}', - description=body - ) - return embed.get() diff --git a/utils/exceptions.py b/utils/exceptions.py deleted file mode 100644 index 819745e..0000000 --- a/utils/exceptions.py +++ /dev/null @@ -1,81 +0,0 @@ -""" -Custom exceptions for Blanco -""" - -class EmptyQueueError(Exception): - """ - Raised when the queue is empty. - """ - def __init__(self): - self.message = 'The queue is empty.' - super().__init__(self.message) - -class EndOfQueueError(Exception): - """ - Raised when the end of the queue is reached. - """ - - -class JockeyError(Exception): - """ - Raised when an error warrants disconnection from the voice channel. - """ - - -class JockeyException(Exception): - """ - Raised when an error does not warrant disconnection from the voice channel. - """ - -class LavalinkInvalidIdentifierError(Exception): - """ - Raised when an invalid identifier is passed to Lavalink. - """ - def __init__(self, url, reason=None): - self.message = f'Error encountered while processing "{url}": `{reason}`' - super().__init__(self.message) - - -class LavalinkSearchError(Exception): - """ - Raised when Lavalink fails to search for a query. - """ - def __init__(self, query, reason=None): - self.message = f'Could not search for "{query}" on YouTube. Reason: {reason}' - super().__init__(self.message) - - -class SpotifyInvalidURLError(Exception): - """ - Raised when an invalid Spotify link or URI is passed. - """ - def __init__(self, url): - self.message = f'Invalid Spotify link or URI: {url}' - super().__init__(self.message) - - -class SpotifyNoResultsError(Exception): - """ - Raised when no results are found for a Spotify query. - """ - - -class VoiceCommandError(Exception): - """ - Raised when a command that requires a voice channel is invoked outside of one. - """ - -class BumpError(Exception): - """ - Raised when encountering an error while playing a bump. - """ - -class BumpNotReadyError(Exception): - """ - Raised when it hasn't been long enough between bumps. - """ - -class BumpNotEnabledError(Exception): - """ - Raised when bumps are not enabled in a guild. - """ \ No newline at end of file diff --git a/utils/fuzzy.py b/utils/fuzzy.py deleted file mode 100644 index 59a7edf..0000000 --- a/utils/fuzzy.py +++ /dev/null @@ -1,57 +0,0 @@ -""" -Utilities for fuzzy string matching. -""" - -from difflib import get_close_matches - -from thefuzz import fuzz - - -def check_similarity(actual: str, candidate: str) -> float: - """ - Checks the similarity between two strings. Meant for comparing - song titles and artists with search results. - - :param actual: The actual string. - :param candidate: The candidate string, i.e. from a search result. - :return: A float from 0 to 1, where 1 is a perfect match. - """ - actual_words = set(actual.lower().split(' ')) - candidate_words = set(candidate.lower().split(' ')) - intersection = actual_words.intersection(candidate_words) - difference = actual_words.difference(candidate_words) - - # Get words not in intersection - for word in difference: - # Look for close matches - close_matches = get_close_matches(word, candidate_words, cutoff=0.9) - if len(close_matches) > 0: - intersection.add(close_matches[0]) - - return len(intersection) / len(actual_words) - - -def check_similarity_weighted(actual: str, candidate: str, candidate_rank: int) -> int: - """ - Checks the similarity between two strings using a weighted average - of a given similarity score and the results of multiple fuzzy string - matching algorithms. Meant for refining search results that are - already ranked. - - :param actual: The actual string. - :param candidate: The candidate string, i.e. from a search result. - :param candidate_rank: The rank of the candidate, from 0 to 100. - :return: An integer from 0 to 100, where 100 is the closest match. - """ - naive = check_similarity(actual, candidate) * 100 - tsr = fuzz.token_set_ratio(actual, candidate) - tsor = fuzz.token_sort_ratio(actual, candidate) - ptsr = fuzz.partial_token_sort_ratio(actual, candidate) - - return int( - (naive * 0.7) + - (tsr * 0.12) + - (candidate_rank * 0.08) + - (tsor * 0.06) + - (ptsr * 0.04) - ) diff --git a/utils/logger.py b/utils/logger.py deleted file mode 100644 index da70771..0000000 --- a/utils/logger.py +++ /dev/null @@ -1,91 +0,0 @@ -""" -Custom logger module that supports ANSI color codes. -""" - -import logging -from typing import Optional - -import sentry_sdk -from sentry_sdk.integrations.logging import EventHandler - -from .config import DEBUG_ENABLED, SENTRY_DSN, SENTRY_ENV -from .constants import RELEASE - -# Log line format -LOG_FMT_STR = '{0}%(asctime)s.%(msecs)03d {1}[%(levelname)s]{2} %(message)s (%(filename)s:%(lineno)d)' # pylint: disable=line-too-long - -# ANSI terminal colors (for logging) -ANSI_BLUE = '\x1b[36;20m' -ANSI_GREEN = '\x1b[32;20m' -ANSI_GREY = '\x1b[37;1m' -ANSI_RED = '\x1b[31;20m' -ANSI_RED_BOLD = '\x1b[41;1m' -ANSI_YELLOW = '\x1b[33;20m' -ANSI_RESET = '\x1b[0m' -LOG_FMT_COLOR = { - logging.DEBUG: LOG_FMT_STR.format(ANSI_GREY, ANSI_GREEN, ANSI_RESET), - logging.INFO: LOG_FMT_STR.format(ANSI_GREY, ANSI_BLUE, ANSI_RESET), - logging.WARNING: LOG_FMT_STR.format(ANSI_GREY, ANSI_YELLOW, ANSI_RESET), - logging.ERROR: LOG_FMT_STR.format(ANSI_GREY, ANSI_RED, ANSI_RESET), - logging.CRITICAL: LOG_FMT_STR.format(ANSI_RED_BOLD, ANSI_RED_BOLD, ANSI_RESET), -} - - -# Initialize sentry -if SENTRY_DSN is not None and SENTRY_ENV is not None: - sentry_sdk.init( - dsn=SENTRY_DSN, - environment=SENTRY_ENV, - release=RELEASE, - traces_sample_rate=1.0 - ) - -class ColorFormatter(logging.Formatter): - """ - Custom logging formatter that supports ANSI color codes. - - Adapted from https://stackoverflow.com/a/384125 - """ - def __init__(self, fmt: Optional[str] = None, datefmt: Optional[str] = None): - logging.Formatter.__init__(self, fmt, datefmt) - - def format(self, record: logging.LogRecord): - log_fmt = LOG_FMT_COLOR.get(record.levelno) - formatter = logging.Formatter(fmt=log_fmt, datefmt='%Y-%m-%d %H:%M:%S') - return formatter.format(record) - - -def create_logger(name: str) -> logging.Logger: - """ - Creates a logger with the given name and returns it. - - :param name: Name of the logger - :return: Logger object - """ - logger = logging.getLogger(name) - if logger.hasHandlers(): - logger.handlers.clear() - - # Set level - level = logging.DEBUG if DEBUG_ENABLED else logging.INFO - logger.setLevel(level) - - # Set level names - logging.addLevelName(logging.DEBUG, 'DBUG') - logging.addLevelName(logging.INFO, 'INFO') - logging.addLevelName(logging.WARNING, 'WARN') - logging.addLevelName(logging.ERROR, 'ERR!') - logging.addLevelName(logging.CRITICAL, 'CRIT') - - # Add color formatter - color_handler = logging.StreamHandler() - color_handler.setFormatter(ColorFormatter()) - logger.addHandler(color_handler) - - # Add Sentry handler - if SENTRY_DSN is not None and SENTRY_ENV is not None: - sentry_handler = EventHandler() - sentry_handler.setLevel(logging.ERROR) - logger.addHandler(sentry_handler) - - return logger diff --git a/utils/musicbrainz.py b/utils/musicbrainz.py deleted file mode 100644 index a16131e..0000000 --- a/utils/musicbrainz.py +++ /dev/null @@ -1,268 +0,0 @@ -""" -Utility functions for interfacing with the MusicBrainz API. -""" - -from typing import TYPE_CHECKING, Optional, Tuple - -from ratelimit import limits, sleep_and_retry -from requests import HTTPError, Timeout, get - -from database.redis import REDIS - -from .constants import DURATION_THRESHOLD, MUSICBRAINZ_API_BASE_URL, USER_AGENT -from .fuzzy import check_similarity_weighted -from .logger import create_logger - -if TYPE_CHECKING: - from dataclass.queue_item import QueueItem - - -LOGGER = create_logger('musicbrainz') - - -@limits(calls=25, period=1) -@sleep_and_retry -def annotate_track( - track: 'QueueItem', - *, - in_place: bool = True -) -> Optional[Tuple[str | None, str | None]]: - """ - Annotates a track with MusicBrainz ID and ISRC if they are not already present. - - Can be called up to 25 times per second, and will sleep and retry if this limit - is exceeded. This is because MusicBrainz has a rate limit of 50 requests per second, - but we need to make at most two requests per track (one to search for the track by ISRC, - and one to search for it by title and artist if the ISRC search fails). - - :param track: The track to annotate. Must be an instance of - dataclass.queue_item.QueueItem. - :param in_place: Whether to modify the track in place. If False, a tuple containing - the MusicBrainz ID and ISRC will be returned instead. - """ - # Check if track has already been annotated - if track.is_annotated: - return track.mbid, track.isrc - - # Check if information is already cached - mbid = track.mbid - isrc = track.isrc - mbid_cached = False - isrc_cached = False - if REDIS is not None: - # Check for cached MusicBrainz ID - if mbid is None and track.spotify_id is not None: - mbid = REDIS.get_mbid(track.spotify_id) - if mbid is not None: - mbid_cached = True - - # Check for cached ISRC - if isrc is None and track.spotify_id is not None: - isrc = REDIS.get_isrc(track.spotify_id) - if isrc is not None: - isrc_cached = True - - # Lookup MusicBrainz ID and ISRC if not cached - if mbid is None: - if isrc is not None: - LOGGER.info( - 'Looking up MusicBrainz ID for `%s\'', - track.title - ) - try: - mbid = mb_lookup_isrc(track) - except HTTPError as err: - if err.response is not None and err.response.status_code == 404: - mbid, isrc = mb_lookup(track) - else: - raise - else: - LOGGER.info( - 'Looking up MusicBrainz ID and ISRC for `%s\'', - track.title - ) - mbid, isrc = mb_lookup(track) - - # Log MusicBrainz ID if found - if track.mbid is None and mbid is not None: - if in_place: - track.mbid = mbid - if REDIS is not None and track.spotify_id is not None: - REDIS.set_mbid(track.spotify_id, mbid) - - LOGGER.info( - 'Found %sMusicBrainz ID `%s\' for `%s\'', - 'cached ' if mbid_cached else '', - track.mbid, - track.title - ) - - # Log ISRC if found - if track.isrc is None and isrc is not None: - if in_place: - track.isrc = isrc - if REDIS is not None and track.spotify_id is not None: - REDIS.set_isrc(track.spotify_id, isrc) - - LOGGER.info( - 'Found %sISRC `%s\' for `%s\'', - 'cached ' if isrc_cached else '', - isrc, - track.title - ) - - if in_place: - # Signal that the track has been annotated - track.is_annotated = True - - return mbid, isrc - -def mb_lookup(track: 'QueueItem') -> Tuple[str | None, str | None]: - """ - Looks up a track on MusicBrainz and returns a tuple containing - a matching MusicBrainz ID and ISRC, if available. - """ - # Build MusicBrainz query - assert track.title is not None and track.artist is not None - query = f'recording:{track.title} && artist:{track.artist}' - if track.album is not None: - query += f' && release:{track.album}' - - # Perform search - response = get( - str(MUSICBRAINZ_API_BASE_URL / 'recording'), - headers={ - 'User-Agent': USER_AGENT, - 'Accept': 'application/json' - }, - params={ - 'query': query, - 'limit': 10, - 'inc': 'isrcs', - 'fmt': 'json' - }, - timeout=5.0 - ) - try: - response.raise_for_status() - except HTTPError as err: - LOGGER.error( - 'Error %d looking up track `%s\' on MusicBrainz.\n%s', - err.response.status_code if err.response is not None else -1, - track.title, - err - ) - raise - except Timeout: - LOGGER.warning( - 'Timed out while looking up track `%s\' on MusicBrainz', - track.title - ) - return None, None - - # Parse response - parsed = response.json() - if len(parsed['recordings']) == 0: - LOGGER.error( - 'No results found for track `%s\' on MusicBrainz', - track.title - ) - return None, None - - # Filter by duration difference - results = [ - result - for result in parsed['recordings'] - if 'length' in result and abs(track.duration - result['length']) < DURATION_THRESHOLD - ] - if len(results) == 0: - LOGGER.error( - 'No results found for track `%s\' on MusicBrainz', - track.title - ) - return None, None - - # Sort remaining results by similarity and ISRC presence - query = f'{track.title} {track.artist}' - best_match = results[0] - if len(results) > 1: - similarities = [ - check_similarity_weighted( - query, - f'{result["title"]} {result["artist-credit"][0]["name"]}', - result['score'] - ) for result in results - ] - isrc_presence = [ - 'isrcs' in result and len(result['isrcs']) > 0 - for result in results - ] - ranked = sorted( - zip(results, similarities, isrc_presence), - key=lambda x: (x[1], x[2]), - reverse=True - ) - best_match = ranked[0][0] - - # Print confidences for debugging - LOGGER.debug('MusicBrainz results and confidences for "%s":', query) - for result, confidence, has_isrc in ranked: - LOGGER.debug( - ' %3d %-20s %-20s isrc=%s', - confidence, - result['artist-credit'][0]['name'][:20], - result['title'][:20], - has_isrc - ) - - # Extract ID and ISRC - mbid = best_match['id'] - isrc = None - if 'isrcs' in best_match and len(best_match['isrcs']) > 0: - isrc = best_match['isrcs'][0] - - return mbid, isrc - - -def mb_lookup_isrc(track: 'QueueItem') -> Optional[str]: - """ - Looks up a track by its ISRC on MusicBrainz and returns a MusicBrainz ID. - """ - assert track.isrc is not None - response = get( - str(MUSICBRAINZ_API_BASE_URL / 'isrc' / track.isrc.upper()), - headers={ - 'User-Agent': USER_AGENT, - 'Accept': 'application/json' - }, - params={'fmt': 'json'}, - timeout=5.0 - ) - - try: - response.raise_for_status() - except HTTPError: - LOGGER.error( - 'ISRC %s (`%s\') is not on MusicBrainz', - track.isrc, - track.title - ) - raise - except Timeout: - LOGGER.warning( - 'Timed out while looking up track `%s\' (%s) on MusicBrainz', - track.title, - track.isrc - ) - return None - - parsed = response.json() - if len(parsed['recordings']) == 0: - LOGGER.error( - 'No results found for track `%s\' (%s) on MusicBrainz', - track.title, - track.isrc - ) - return None - - return parsed['recordings'][0]['id'] diff --git a/utils/paginator.py b/utils/paginator.py deleted file mode 100644 index bf5235b..0000000 --- a/utils/paginator.py +++ /dev/null @@ -1,127 +0,0 @@ -""" -Paginator class for sending embeds with controls to change pages. - -Based on https://github.com/toxicrecker/DiscordUtils/blob/master/DiscordUtils/Pagination.py -but with support for custom home page and adapted for Interaction responses. -""" - -from asyncio import sleep -from itertools import islice -from typing import TYPE_CHECKING, Callable, List, Optional, Any, Generator - -from nextcord import Embed, Forbidden, HTTPException, Interaction - -from views.paginator import PaginatorView - -if TYPE_CHECKING: - from nextcord import Message - - -def list_chunks(data: List[Any]) -> Generator[List[Any], Any, Any]: - """ - Yield 10-element chunks of a list. Used for pagination. - """ - for i in range(0, len(data), 10): - yield list(islice(data, i, i + 10)) - - -class Paginator: - """ - Paginator class for sending embeds with controls to change pages. - """ - def __init__(self, itx: Interaction): - self.current = 0 - self.embeds = [] - self.home = 0 - self.itx = itx - self.msg: Optional['Message'] = None - self.original_timeout = 0 - self.timeout = 0 - - async def run( - self, - embeds: List[Embed], - start: int = 0, - timeout: int = 0, - callback: Optional[Callable[[int], None]] = None - ): - """ - Sends the given embeds and adds controls to change pages if there's more than one. - """ - # If there's only one page, just send it as is - if len(embeds) == 1: - msg = await self.itx.followup.send(embed=embeds[0], wait=True) - if callback is not None: - callback(msg.id) - return - - timeout = timeout if timeout > 0 else 60 - self.original_timeout = timeout - self.timeout = timeout - - # Add footer and timestamp to every embed - for i, embed in enumerate(embeds): - embed.timestamp = self.itx.created_at - embed.set_footer(text=f'Page {i + 1} of {len(embeds)}') - - # Send initial embed and call callback with message ID - self.home = start - self.current = start - self.embeds = embeds - msg = await self.itx.followup.send( - embed=self.embeds[start], - view=PaginatorView(self), - wait=True - ) - self.msg = await msg.channel.fetch_message(msg.id) - if callback is not None: - callback(msg.id) - - # Remove controls if inactive for more than timeout amount - while True: - await sleep(1) - self.timeout -= 1 - if self.timeout <= 0: - return await self.msg.edit(view=None) - - async def _switch_page(self, new_page: int) -> Optional['Message']: - self.current = new_page - if self.msg is not None: - try: - msg = await self.msg.edit(embed=self.embeds[self.current]) - except (Forbidden, HTTPException): - return None - - self.timeout = self.original_timeout - return msg - - async def first_page(self): - """ - Switches to the first page. - """ - await self._switch_page(0) - - async def previous_page(self): - """ - Switches to the previous page. - """ - await self._switch_page(self.current - 1) - - async def home_page(self): - """ - Switches to the home page, which is the first page by default, - but can be changed with the `start` parameter in `Paginator.run()`. - """ - await self._switch_page(self.home) - - async def next_page(self): - """ - Switches to the next page. - """ - await self._switch_page(self.current + 1) - - async def last_page(self): - """ - Switches to the last page. - """ - await self._switch_page(len(self.embeds) - 1) diff --git a/utils/player_checks.py b/utils/player_checks.py deleted file mode 100644 index 02f191c..0000000 --- a/utils/player_checks.py +++ /dev/null @@ -1,62 +0,0 @@ -""" -Check functions for the music player. These are called before -the player is instantiated, and are used to check if the bot -can connect and play music in a channel. -""" -from typing import TYPE_CHECKING - -from nextcord import Interaction, Member - -from utils.exceptions import VoiceCommandError - -if TYPE_CHECKING: - from cogs.player.jockey import Jockey - - -def check_mutual_voice(itx: Interaction, slash: bool = True) -> bool: - """ - This check ensures that the bot and command author are in the same voice channel. - - :param itx: The interaction object. - :param slash: Whether this check is being called as part of a slash command. See now_playing.py. - """ - - # Check that the user is in a voice channel in the first place. - if itx.guild is not None and isinstance(itx.user, Member): - if not itx.user.voice or not itx.user.voice.channel: - raise VoiceCommandError('Join a voice channel first.') - else: - # Not allowed in DMs - raise VoiceCommandError('You can only use this command in a server.') - - if itx.application_command is None and not slash: - raise VoiceCommandError('Abnormal invocation of command. Please try again.') - - player: 'Jockey' = itx.guild.voice_client # type: ignore - if player is None and slash: - assert itx.application_command is not None - if itx.application_command.name == 'play': - # The /play command causes the bot to connect to voice, - # so we don't have to worry about the rest of the checks here. - return True - raise VoiceCommandError('Please `/play` something first before using this command.') - - voice_channel = itx.user.voice.channel - if not player.is_connected(): - # Bot needs to already be in voice channel to pause, unpause, skip etc. - if itx.application_command is not None and itx.application_command.name != 'play': - raise VoiceCommandError('I\'m not connected to voice.') - - # Bot needs to have permissions to connect to voice. - permissions = voice_channel.permissions_for(itx.guild.me) - if not permissions.connect or not permissions.speak: - raise VoiceCommandError('I need the `CONNECT` and `SPEAK` permissions to play music.') - - # Bot needs to connect to a channel that isn't full. - if voice_channel.user_limit and voice_channel.user_limit <= len(voice_channel.members): - raise VoiceCommandError('Your voice channel is full.') - else: - if int(player.channel.id) != voice_channel.id: # type: ignore - raise VoiceCommandError('You need to be in my voice channel.') - - return True diff --git a/utils/scrobbler.py b/utils/scrobbler.py deleted file mode 100644 index eefd344..0000000 --- a/utils/scrobbler.py +++ /dev/null @@ -1,82 +0,0 @@ -""" -Last.fm scrobbling client. -""" - -from datetime import datetime -from time import mktime -from typing import TYPE_CHECKING - -import pylast - -if TYPE_CHECKING: - from logging import Logger - - from dataclass.config import Config - from dataclass.oauth import LastfmAuth - from dataclass.queue_item import QueueItem - - -class Scrobbler: - """ - Scrobbler class for scrobbling songs to Last.fm. - Meant for single use, i.e., one instance per user per listening session. - """ - def __init__(self, config: 'Config', creds: 'LastfmAuth', logger: 'Logger'): - if config.lastfm_api_key is None or config.lastfm_shared_secret is None: - raise ValueError('Last.fm API key and/or shared secret not set.') - self._user_id = creds.user_id - - # Create Network object - self._net = pylast.LastFMNetwork( - api_key=config.lastfm_api_key, - api_secret=config.lastfm_shared_secret - ) - - # Set session key - self._net.session_key = creds.session_key - - # Logger - self._logger = logger - self._logger.debug('Created scrobbler for user %d', creds.user_id) - - def scrobble(self, track: 'QueueItem'): - """ - Scrobbles a QueueItem from the music player. - """ - timestamp = track.start_time - if timestamp is None: - timestamp = int(mktime(datetime.now().timetuple())) - - duration = None - if track.duration is not None: - duration = track.duration // 1000 - - # Warn if MBID is not set - if track.mbid is None: - self._logger.warning( - 'MBID not set for track `%s\'; scrobble might not be accurate.', - track.title - ) - - try: - self._net.scrobble( - artist=track.artist, - title=track.title, - timestamp=timestamp, - duration=duration, - mbid=track.mbid - ) - except pylast.PyLastError as err: - self._logger.error( - 'Error scrobbling `%s\' for user %d: %s', - track.title, - self._user_id, - err - ) - raise - - self._logger.debug( - 'Scrobbled `%s\' for user %d', - track.title, - self._user_id - ) diff --git a/utils/spotify_client.py b/utils/spotify_client.py deleted file mode 100644 index 5b2dc0f..0000000 --- a/utils/spotify_client.py +++ /dev/null @@ -1,344 +0,0 @@ -""" -Wrapper for the spotipy Spotify client which supports pagination by default. -""" - -from typing import Any, Dict, List, Optional, Tuple - -import spotipy -from requests.exceptions import ConnectionError as RequestsConnectionError -from tenacity import (RetryCallState, retry, retry_if_exception_type, - stop_after_attempt, wait_fixed, wait_random) - -from database.redis import REDIS -from dataclass.spotify import SpotifyResult, SpotifyTrack - -from .constants import BLACKLIST -from .exceptions import SpotifyInvalidURLError, SpotifyNoResultsError -from .logger import create_logger -from .time import human_readable_time - -# Retry logger -RETRY_LOGGER = create_logger('spotify_retry') - - -def log_call(retry_state: RetryCallState) -> None: - """ - Logs an API call - """ - RETRY_LOGGER.debug( - 'Calling Spotify API: %s(%s, %s)', - getattr(retry_state.fn, '__name__', repr(retry_state.fn)), - retry_state.args, - retry_state.kwargs - ) - - -def log_failure(retry_state: RetryCallState) -> None: - """ - Logs a retry attempt. - """ - func_name = getattr(retry_state.fn, '__name__', repr(retry_state.fn)) - - # Log outcome - if retry_state.outcome is not None: - RETRY_LOGGER.debug('%s() failed: %s', func_name, retry_state.outcome) - RETRY_LOGGER.debug(' Exception: %s', retry_state.outcome.exception()) - RETRY_LOGGER.debug(' Args: %s', retry_state.args) - RETRY_LOGGER.debug(' Kwargs: %s', retry_state.kwargs) - - RETRY_LOGGER.warning( - 'Retrying %s(), attempt %s', - func_name, - retry_state.attempt_number - ) - - -def extract_track_info( - track_obj: Dict[str, Any], - artwork: Optional[str] = None, - album_name: Optional[str] = None -) -> SpotifyTrack: - """ - Extracts track information from the Spotify API and returns a SpotifyTrack object. - """ - if 'track' in track_obj.keys(): - # Nested track (playlist track object) - track_obj = track_obj['track'] - - # Extract ISRC if present - isrc = None - if 'external_ids' in track_obj.keys(): - if 'isrc' in track_obj['external_ids'].keys(): - isrc = track_obj['external_ids']['isrc'].upper().replace('-', '') - - # Extract album artwork if present - if 'album' in track_obj.keys(): - album_name = track_obj['album']['name'] - if 'images' in track_obj['album'].keys(): - if len(track_obj['album']['images']) > 0: - artwork = track_obj['album']['images'][0]['url'] - - return SpotifyTrack( - title=track_obj['name'], - artist=track_obj['artists'][0]['name'], - author=', '.join([x['name'] for x in track_obj['artists']]), - album=album_name, - spotify_id=track_obj['id'], - duration_ms=int(track_obj['duration_ms']), - artwork=artwork, - isrc=isrc - ) - - -class Spotify: - """ - Wrapper for the spotipy Spotify client which supports pagination by default. - """ - def __init__(self, client_id: str, client_secret: str): - self._client = spotipy.Spotify( - auth_manager=spotipy.oauth2.SpotifyClientCredentials( - client_id=client_id, - client_secret=client_secret - ) - ) - - @property - def client(self): - """ - Returns the internal spotipy client. - """ - return self._client - - def __get_art(self, art: List[Dict[str, str]], default='') -> str: - """ - Returns the first image URL from a list of artwork images, - or a specified default if the list is empty. - """ - if len(art) == 0: - return default - return art[0]['url'] - - @retry( - retry=retry_if_exception_type(RequestsConnectionError), - stop=stop_after_attempt(3), - wait=wait_fixed(1) + wait_random(0, 2), - before=log_call, - before_sleep=log_failure - ) - def get_artist_top_tracks(self, artist_id: str) -> List[SpotifyTrack]: - """ - Returns a list of SpotifyTrack objects for a given artist's - top 10 tracks. - """ - response = self._client.artist_top_tracks(artist_id) - if response is None: - raise SpotifyInvalidURLError(f'spotify:artist:{artist_id}') - - return [extract_track_info(track) for track in response['tracks']] - - @retry( - retry=retry_if_exception_type(RequestsConnectionError), - stop=stop_after_attempt(3), - wait=wait_fixed(1) + wait_random(0, 2), - before=log_call, - before_sleep=log_failure - ) - def get_track_art(self, track_id: str) -> str: - """ - Returns the track artwork for a given track ID. - """ - result = self._client.track(track_id) - if result is None: - raise SpotifyInvalidURLError(f'spotify:track:{track_id}') - return self.__get_art(result['album']['images']) - - @retry( - retry=retry_if_exception_type(RequestsConnectionError), - stop=stop_after_attempt(3), - wait=wait_fixed(1) + wait_random(0, 2), - before=log_call, - before_sleep=log_failure - ) - def get_track(self, track_id: str) -> SpotifyTrack: - """ - Returns a SpotifyTrack object for a given track ID. - """ - # Check cache - if REDIS is not None: - cached_track = REDIS.get_spotify_track(track_id) - if cached_track is not None: - return cached_track - - result = self._client.track(track_id) - if result is None: - raise SpotifyInvalidURLError(f'spotify:track:{track_id}') - - # Save to cache - if REDIS is not None: - REDIS.set_spotify_track(track_id, extract_track_info(result)) - - return extract_track_info(result) - - @retry( - retry=retry_if_exception_type(RequestsConnectionError), - stop=stop_after_attempt(3), - wait=wait_fixed(1) + wait_random(0, 2), - before=log_call, - before_sleep=log_failure - ) - def get_tracks(self, list_type: str, list_id: str) -> Tuple[str, str, List[SpotifyTrack]]: - """ - Returns a list of SpotifyTrack objects for a given album or playlist ID. - May take a long time to complete if the list is large. - """ - offset = 0 - tracks = [] - - # Get list name and author - list_artwork = None - if list_type == 'album': - album_info = self._client.album(list_id) - if album_info is None: - raise SpotifyInvalidURLError(f'spotify:{list_type}:{list_id}') - - list_artwork = album_info['images'][0]['url'] - list_name = album_info['name'] - list_author = album_info['artists'][0]['name'] - elif list_type == 'playlist': - playlist_info = self._client.playlist(list_id, fields='name,owner.display_name') - if playlist_info is None: - raise SpotifyInvalidURLError(f'spotify:{list_type}:{list_id}') - - list_name = playlist_info['name'] - list_author = playlist_info['owner']['display_name'] - else: - raise SpotifyInvalidURLError(f'spotify:{list_type}:{list_id}') - - # Get tracks - while True: - if list_type == 'album': - response = self._client.album_tracks(list_id, offset=offset) - else: - fields = ','.join([ - 'items.track.name', - 'items.track.artists', - 'items.track.album', - 'items.track.id', - 'items.track.duration_ms', - 'items.track.external_ids.isrc' - ]) - response = self._client.playlist_items(list_id, offset=offset, - fields=fields, - additional_types=['track']) - - if response is None: - raise SpotifyInvalidURLError(f'spotify:{list_type}:{list_id}') - if len(response['items']) == 0: - break - - tracks.extend(response['items']) - offset = offset + len(response['items']) - - if list_type == 'playlist': - return list_name, list_author, [ - extract_track_info(x) - for x in tracks if x['track'] is not None - ] - return list_name, list_author, [ - extract_track_info(x, list_artwork, album_name=list_name) - for x in tracks - ] - - @retry( - retry=retry_if_exception_type(RequestsConnectionError), - stop=stop_after_attempt(3), - wait=wait_fixed(1) + wait_random(0, 2), - before=log_call, - before_sleep=log_failure - ) - def search_track(self, query, limit: int = 1) -> List[SpotifyTrack]: - """ - Searches Spotify for a given query and returns a list of SpotifyTrack objects. - - :param query: The name of a track to search for. - :param limit: The maximum number of results to return. - """ - response = self._client.search(query, limit=20, type='track') - if response is None or len(response['tracks']['items']) == 0: - raise SpotifyNoResultsError - - # Filter out tracks with blacklisted words not in the original query - results = [] - for result in response['tracks']['items']: - for word in BLACKLIST: - if word in result['name'].lower() and word not in query.lower(): - break - else: - results.append(extract_track_info(result)) - - return results[:limit] - - @retry( - retry=retry_if_exception_type(RequestsConnectionError), - stop=stop_after_attempt(3), - wait=wait_fixed(1) + wait_random(0, 2), - before=log_call, - before_sleep=log_failure - ) - def search(self, query: str, search_type: str) -> List[SpotifyResult]: - """ - Searches Spotify for a given artist, album, or playlist, - and returns a list of SpotifyResult objects. - - If you want to search for tracks specifically, use search_track(), - as that will yield a list of SpotifyTrack objects instead of SpotifyResults. - - :param query: The artist/album/playlist to search for. - :param search_type: The type of entity to search for. - Must be one of 'artist', 'album', 'playlist', or 'track'. - """ - if search_type not in ('artist', 'album', 'playlist', 'track'): - raise ValueError(f'Invalid search type: {search_type}') - - response = self._client.search(query, limit=10, type=search_type) - if response is None or len(response[f'{search_type}s']['items']) == 0: - raise SpotifyNoResultsError - - # Parse results - items = response[f'{search_type}s']['items'] - if search_type == 'artist': - # Sort artists by followers - items = sorted(items, key=lambda x: x['followers']['total'], reverse=True) - results = [SpotifyResult( - name=entity['name'], - description=f'{entity["followers"]["total"]} followers', - spotify_id=entity['id'] - ) for entity in items] - elif search_type == 'album': - # Include artist name, track count, and release date in album results - results = [SpotifyResult( - name=entity['name'], - description=f'{entity["artists"][0]["name"]} ' - f'({entity["total_tracks"]} tracks, ' - f'released {entity["release_date"]})', - spotify_id=entity['id'] - ) for entity in items] - elif search_type == 'playlist': - # Include author name and track count in playlist results - results = [SpotifyResult( - name=entity['name'], - description=f'{entity["owner"]["display_name"]} ' - f'({entity["tracks"]["total"]} tracks)', - spotify_id=entity['id'] - ) for entity in items] - else: - # Include artist name and release date in track results - results = [SpotifyResult( - name=f'{entity["name"]} ' - f'({human_readable_time(entity["duration_ms"])})', - description=f'{entity["artists"][0]["name"]} - ' - f'{entity["album"]["name"]} ', - spotify_id=entity['id'] - ) for entity in items] - - return results diff --git a/utils/spotify_private.py b/utils/spotify_private.py deleted file mode 100644 index fd44963..0000000 --- a/utils/spotify_private.py +++ /dev/null @@ -1,175 +0,0 @@ -""" -Custom Spotify client designed to work with predefined credentials -obtained using the Authorization Code Flow. Used for instances where -the user has already authorized the application and wants to access -their data through Blanco. -""" - -from base64 import b64encode -from time import time -from typing import TYPE_CHECKING, List - -import requests -from requests import HTTPError, Timeout - -from dataclass.oauth import OAuth -from dataclass.spotify import SpotifyResult - -from .constants import (SPOTIFY_ACCOUNTS_BASE_URL, SPOTIFY_API_BASE_URL, - USER_AGENT) -from .logger import create_logger - -if TYPE_CHECKING: - from database import Database - from dataclass.config import Config - - -class PrivateSpotify: - """ - Custom Spotify client designed to work with predefined credentials - obtained using the Authorization Code Flow. Used for instances where - the user has already authorized the application and wants to access - their data through Blanco. - """ - def __init__(self, config: 'Config', database: 'Database', credentials: 'OAuth'): - self._client_id = config.spotify_client_id - self._client_secret = config.spotify_client_secret - self._credentials = credentials - self._db = database - self._logger = create_logger(self.__class__.__name__) - - def _refresh_token(self): - """ - Refresh the access token for a user. - """ - auth_token = b64encode(f"{self._client_id}:{self._client_secret}".encode()).decode() - response = requests.post( - str(SPOTIFY_ACCOUNTS_BASE_URL / 'token'), - headers={ - 'Authorization': f'Basic {auth_token}', - }, - data={ - 'grant_type': 'refresh_token', - 'refresh_token': self._credentials.refresh_token - }, - timeout=10 - ) - - try: - response.raise_for_status() - except HTTPError as err: - self._logger.error( - 'Error refreshing Spotify access token for user %d: %s', - self._credentials.user_id, - err - ) - raise - except Timeout: - self._logger.error( - 'Timed out while refreshing Spotify access token for user %d', - self._credentials.user_id - ) - - # Delete the user's credentials from the database - self._db.delete_oauth('spotify', self._credentials.user_id) - raise - - # Update the credentials - parsed = response.json() - new_credentials = OAuth( - user_id=self._credentials.user_id, - username=self._credentials.username, - access_token=parsed['access_token'], - refresh_token=self._credentials.refresh_token, - expires_at=int(time() + parsed['expires_in']) - ) - self._db.set_oauth('spotify', new_credentials) - self._db.set_spotify_scopes(self._credentials.user_id, parsed['scope'].split(' ')) - self._credentials = new_credentials - - def _ensure_auth(self): - """ - Makes sure that the credentials are up to date. - """ - if self._credentials.expires_at < time() + 60: - # Refresh token - self._logger.debug( - 'Refreshing Spotify token for user %d', - self._credentials.user_id - ) - self._refresh_token() - - def get_user_playlists(self) -> List[SpotifyResult]: - """ - Gets a list of 25 of the user's playlists. - """ - self._ensure_auth() - response = requests.get( - str(SPOTIFY_API_BASE_URL / 'me' / 'playlists'), - headers={ - 'Authorization': f'Bearer {self._credentials.access_token}', - 'User-Agent': USER_AGENT - }, - params={ - 'limit': 25 - }, - timeout=10 - ) - - try: - response.raise_for_status() - except HTTPError as err: - self._logger.error( - 'Error %d getting Spotify playlists for user %d.\n%s', - err.response.status_code if err.response is not None else -1, - self._credentials.user_id, - err - ) - raise - except Timeout: - self._logger.error( - 'Timed out while getting Spotify playlists for user %d', - self._credentials.user_id - ) - return [] - - parsed = response.json() - return [SpotifyResult( - name=playlist['name'], - description=f'{playlist["tracks"]["total"]} tracks', - spotify_id=playlist['id'] - ) for playlist in parsed['items']] - - def save_track(self, spotify_id: str): - """ - Adds a track to the user's Liked Songs. - """ - self._ensure_auth() - response = requests.put( - str(SPOTIFY_API_BASE_URL / 'me' / 'tracks'), - headers={ - 'Authorization': f'Bearer {self._credentials.access_token}', - 'User-Agent': USER_AGENT - }, - params={ - 'ids': spotify_id - }, - timeout=10 - ) - - try: - response.raise_for_status() - except HTTPError as err: - self._logger.error( - 'Error %d while trying to Like track %s.\n%s', - err.response.status_code if err.response is not None else -1, - spotify_id, - err - ) - raise - except Timeout: - self._logger.error( - 'Timed out while liking track %s', - spotify_id - ) - raise diff --git a/utils/time.py b/utils/time.py deleted file mode 100644 index 2af1ea7..0000000 --- a/utils/time.py +++ /dev/null @@ -1,42 +0,0 @@ -""" -Utility methods for converting between human and machine readable time formats. -""" - -from math import floor -from typing import Tuple, Union - - -def get_time_components(msec: Union[int, float]) -> Tuple[int, int, int]: - """ - Decompose milliseconds into a tuple of hours, minutes, and seconds. - """ - minute, sec = divmod(msec / 1000, 60) - hour, minute = divmod(minute, 60) - return floor(hour), floor(minute), floor(sec) - - -def human_readable_time(msec: Union[int, float]) -> str: - """ - Turn milliseconds into a human readable time string. - """ - hour, minute, sec = get_time_components(msec) - string = '' - if hour > 0: - string += f'{hour} hr' - if minute > 0: - string += f' {minute} min' - if sec > 0: - string += f' {sec} sec' - - return string.strip() - - -def machine_readable_time(colon_delimited_time: str) -> int: - """ - Parse colon delimited time (e.g. "1:30:00") into milliseconds. - """ - time_segments = colon_delimited_time.split(':') - sec = int(time_segments[-1]) - minute = int(time_segments[-2]) - hour = int(time_segments[0]) if len(time_segments) == 3 else 0 - return hour * 3600000 + minute * 60000 + sec * 1000 diff --git a/utils/url.py b/utils/url.py deleted file mode 100644 index 1109f67..0000000 --- a/utils/url.py +++ /dev/null @@ -1,181 +0,0 @@ -""" -String methods for validating and parsing URLs. -""" - -import re -from urllib.parse import parse_qs, urlparse - -import validators - -from .exceptions import LavalinkInvalidIdentifierError, SpotifyInvalidURLError - - -def check_contains_ytlistid(url: str) -> bool: - """ - Checks if the URL is a YouTube URL with a 'list' query parameter. - """ - if not check_youtube_url(url): - return False - - parsed_url = urlparse(url) - query = parse_qs(parsed_url.query) - return 'list' in query and len(query['list']) > 0 - - -def check_url(url: str) -> bool: - """ - Checks if the URL is a valid URL. - """ - return validators.domain(url) or validators.url(url) # type: ignore - - -def check_sc_url(url: str) -> bool: - """ - Checks if the URL is a valid SoundCloud URL. - """ - url_regex = r"(^http(s)?://)?(soundcloud\.com|snd\.sc)/(.*)$" - return re.match(url_regex, url) is not None - - -def check_spotify_url(url: str) -> bool: - """ - Checks if the URL is a valid Spotify URL. - """ - url_regex = r"(https?://open\.)*spotify(\.com)*[/:]+(track|artist|album|playlist)[/:]+[A-Za-z0-9]+" # pylint: disable=line-too-long - return re.match(url_regex, url) is not None - - -def check_twitch_url(url: str) -> bool: - """ - Checks if the URL is a valid Twitch URL. - """ - url_regex = r"(^http(s)?://)?((www|en-es|en-gb|secure|beta|ro|www-origin|en-ca|fr-ca|lt|zh-tw|he|id|ca|mk|lv|ma|tl|hi|ar|bg|vi|th)\.)?twitch.tv/(?!directory|p|user/legal|admin|login|signup|jobs)(?P\w+)" # pylint: disable=line-too-long - return re.match(url_regex, url) is not None - - -def check_youtube_url(url: str) -> bool: - """ - Checks if the URL is a valid YouTube URL. - """ - url_regex = r"(?:https?://)?(?:youtu\.be/|(?:www\.|m\.)?youtube\.com/(?:watch|v|embed)(?:\.php)?(?:\?.*v=|/))([a-zA-Z0-9_-]+)" # pylint: disable=line-too-long - return re.match(url_regex, url) is not None - - -def check_youtube_playlist_url(url: str) -> bool: - """ - Checks if the URL is a valid YouTube playlist URL. - """ - url_regex = r"(?:https?://)?(?:www\.)?youtube\.com/playlist\?list=([a-zA-Z0-9_-]+)" - return re.match(url_regex, url) is not None - - -def check_ytmusic_url(url: str) -> bool: - """ - Checks if the URL is a valid YouTube Music URL. - """ - url_regex = r"(?:https?://)?music\.youtube\.com/(?:watch|v|embed)(?:\.php)?(?:\?.*v=|/)([a-zA-Z0-9_-]+)" # pylint: disable=line-too-long - return re.match(url_regex, url) is not None - - -def check_ytmusic_playlist_url(url: str) -> bool: - """ - Checks if the URL is a valid YouTube Music playlist URL. - """ - url_regex = r"(?:https?://)?music\.youtube\.com/playlist\?list=([a-zA-Z0-9_-]+)" - return re.match(url_regex, url) is not None - - -def get_sctype_from_url(url: str) -> bool: - """ - Determine SoundCloud entity type from URL. - - Returns - ------- - True if URL is a SoundCloud track, False if URL is a SoundCloud playlist. - """ - if url.startswith(('soundcloud', 'www')): - url = 'http://' + url - - query = urlparse(url) - path = [x for x in query.path.split('/') if x] - if len(path) == 1: - raise LavalinkInvalidIdentifierError( - url, - reason='SoundCloud URL does not point to a track or set.' - ) - if len(path) == 2 and path[1] != 'sets': - return True - if path[1] == 'sets': - return False - raise LavalinkInvalidIdentifierError(url, reason='Unrecognized SoundCloud URL.') - - -def get_spinfo_from_url(url: str) -> tuple[str, str]: - """ - Gets the Spotify type and ID from a Spotify URL. - Must be a URL that Blanco can play, i.e. a track, album, or playlist. - - :returns: A tuple containing the type and ID of the Spotify entity. - """ - if not check_spotify_url(url): - raise SpotifyInvalidURLError(url) - - parsed_path = [] - if re.match(r"^https?://open\.spotify\.com", url): - # We are dealing with a link - parsed_url = urlparse(url) - parsed_path = parsed_url.path.split("/")[1:] - elif re.match(r"^spotify:[a-z]", url): - # We are dealing with a Spotify URI - parsed_path = url.split(":")[1:] - if (len(parsed_path) < 2 or - parsed_path[0] not in ('track', 'album', 'playlist', 'artist')): - raise SpotifyInvalidURLError(url) - - return parsed_path[0], parsed_path[1] - - -def get_ytid_from_url(url: str, id_type: str = 'v') -> str: - """ - Gets the YouTube ID from a YouTube URL. - """ - # https://gist.github.com/kmonsoor/2a1afba4ee127cce50a0 - if url.startswith(('youtu', 'www')): - url = 'http://' + url - - query = urlparse(url) - if query.hostname is None: - raise LavalinkInvalidIdentifierError(url, reason='Not a valid YouTube URL') - - if 'youtube' in query.hostname: - if re.match(r"^/watch", query.path): - if len(query.query): - return parse_qs(query.query)[id_type][0] - return query.path.split("/")[2] - if query.path.startswith(('/embed/', '/v/')): - return query.path.split('/')[2] - elif 'youtu.be' in query.hostname: - return query.path[1:] - - raise LavalinkInvalidIdentifierError(url, reason='Could not get video ID from YouTube URL') - - -def get_ytlistid_from_url(url: str, force_extract: bool = False) -> str: - """ - Gets the YouTube playlist ID from a YouTube URL. - """ - if url.startswith(('youtu', 'www')): - url = 'http://' + url - - query = urlparse(url) - if query.hostname is None: - raise LavalinkInvalidIdentifierError(url, reason='Not a valid YouTube URL') - - if 'youtube' in query.hostname: - if re.match(r"^/playlist", query.path) or force_extract: - if len(query.query): - return parse_qs(query.query)['list'][0] - else: - raise ValueError('Not a YouTube playlist URL') - - raise LavalinkInvalidIdentifierError(url, reason='Could not get playlist ID from YouTube URL') diff --git a/views/__init__.py b/views/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/views/now_playing.py b/views/now_playing.py deleted file mode 100644 index 3d480cb..0000000 --- a/views/now_playing.py +++ /dev/null @@ -1,210 +0,0 @@ -""" -Now Playing view for the player. -""" - -from typing import TYPE_CHECKING, Optional - -from nextcord import ButtonStyle -from nextcord.ui import Button, View, button -from requests.exceptions import HTTPError, Timeout - -from utils.constants import SPOTIFY_403_ERR_MSG -from utils.embeds import create_error_embed, create_success_embed -from utils.exceptions import VoiceCommandError -from utils.player_checks import check_mutual_voice - -if TYPE_CHECKING: - from nextcord import Interaction - - from cogs.player import PlayerCog - from cogs.player.jockey import Jockey - from utils.blanco import BlancoBot - - -class ShuffleButton(Button): - """ - Shuffle button for the Now Playing view. - """ - def __init__(self, init_state: bool = False): - """ - Initialize the shuffle button. - - :param init_state: Initial state of the shuffle button. - True if the queue is shuffled, False otherwise. - """ - super().__init__( - style=ButtonStyle.grey, - label='Unshuffle' if init_state else 'Shuffle' - ) - - async def callback(self, interaction: 'Interaction'): - """ - Toggle shuffle on the current queue. - """ - assert self.view is not None - view: NowPlayingView = self.view - - if await view.check_mutual_voice(interaction): - status = view.player.queue_manager.is_shuffling - self.label = 'Shuffle' if status else 'Unshuffle' - await interaction.response.edit_message(view=view) - - # Shuffle or unshuffle - if status: - return await view.cog.unshuffle(interaction, quiet=True) - return await view.cog.shuffle(interaction, quiet=True) - - -class NowPlayingView(View): - """ - View for the Now Playing message, which contains buttons for interacting - with the player. - """ - def __init__(self, bot: 'BlancoBot', player: 'Jockey', spotify_id: Optional[str] = None): - super().__init__(timeout=None) - self._bot = bot - self._cog: 'PlayerCog' = bot.get_cog('PlayerCog') # type: ignore - if self._cog is None: - raise ValueError('PlayerCog not found') - - self._spotify_id = spotify_id - self._player = player - - # Add shuffle button - self.add_item(ShuffleButton(player.queue_manager.is_shuffling)) - - @property - def cog(self) -> 'PlayerCog': - """ - Return the PlayerCog that this View was created by. - """ - return self._cog - - @property - def player(self) -> 'Jockey': - """ - Return the player that this View is bound to. - """ - return self._player - - async def check_mutual_voice(self, interaction: 'Interaction') -> bool: - """ - Check if the user is in the same voice channel as the bot. - """ - try: - _ = check_mutual_voice(interaction) - except VoiceCommandError as err: - await interaction.response.send_message(err.args[0], ephemeral=True) - return False - - return True - - @button(label='📋', style=ButtonStyle.green) - async def queue(self, _: 'Button', interaction: 'Interaction'): - """ - Display the current queue. - """ - if await self.check_mutual_voice(interaction): - return await self._cog.queue(interaction) - - @button(label='⏮️', style=ButtonStyle.grey) - async def skip_backward(self, _: 'Button', interaction: 'Interaction'): - """ - Skip to the previous track. - """ - if await self.check_mutual_voice(interaction): - return await self._cog.previous(interaction) - - @button(label='⏸️', style=ButtonStyle.blurple) - async def toggle_pause(self, btn: 'Button', interaction: 'Interaction'): - """ - Toggle pause on the current track. - """ - if await self.check_mutual_voice(interaction): - if self._player.paused: - btn.label = '⏸️' - await interaction.response.edit_message(view=self) - return await self._cog.unpause(interaction, quiet=True) - - btn.label = '▶️' - await interaction.response.edit_message(view=self) - return await self._cog.pause(interaction, quiet=True) - - @button(label='⏭️', style=ButtonStyle.grey) - async def skip_forward(self, _: 'Button', interaction: 'Interaction'): - """ - Skip to the next track. - """ - if await self.check_mutual_voice(interaction): - return await self._cog.skip(interaction) - - @button(label='⏹️', style=ButtonStyle.red) - async def stop_player(self, _: 'Button', interaction: 'Interaction'): - """ - Stop the player. - """ - if await self.check_mutual_voice(interaction): - return await self._cog.stop(interaction) - - @button(label='Like on Spotify', style=ButtonStyle.grey) - async def like(self, _: 'Button', interaction: 'Interaction'): - """ - Like the current track on Spotify. - """ - if not interaction.user: - return - - await interaction.response.defer(ephemeral=True) - if self._spotify_id is None: - return await interaction.followup.send( - embed=create_error_embed('This track does not have a Spotify ID.'), - ephemeral=True - ) - - # Get Spotify client - try: - spotify = self._bot.get_spotify_client(interaction.user.id) - if spotify is None: - raise ValueError('Spotify client not initialized') - except ValueError as err: - return await interaction.followup.send(err.args[0]) - - # Save track - try: - spotify.save_track(self._spotify_id) - except HTTPError as err: - if err.response is not None: - if err.response.status_code == 403: - message = SPOTIFY_403_ERR_MSG.format('Like this track') - else: - message = ''.join([ - f'**Error {err.response.status_code}** while trying to Like this track.', - 'Please try again later.\n', - f'```\n{err}```' - ]) - else: - message = ''.join([ - 'Error while trying to Like this track.', - 'Please try again later.\n', - f'```\n{err}```' - ]) - - return await interaction.followup.send( - embed=create_error_embed(message), - ephemeral=True - ) - except Timeout as err: - return await interaction.followup.send( - embed=create_error_embed('\n'.join([ - 'Timed out while trying to Like this track.', - 'Please try again later.\n', - f'```{err}```' - ])), - ephemeral=True - ) - - # Send response - return await interaction.followup.send( - embed=create_success_embed('Added to your Liked Songs.'), - ephemeral=True - ) diff --git a/views/paginator.py b/views/paginator.py deleted file mode 100644 index 119ac91..0000000 --- a/views/paginator.py +++ /dev/null @@ -1,56 +0,0 @@ -""" -View for the Paginator. See utils/paginator.py for more information. -""" - -from typing import TYPE_CHECKING - -from nextcord import ButtonStyle -from nextcord.ui import View, button - -if TYPE_CHECKING: - from nextcord import Interaction - from nextcord.ui import Button - - -class PaginatorView(View): - """ - Controls for the Paginator. See utils/paginator.py for more information. - """ - def __init__(self, paginator, timeout: int = 60): - super().__init__(timeout=None) - self.paginator = paginator - - @button(label='⏮️', style=ButtonStyle.grey) - async def first_page(self, _b: 'Button', _i: 'Interaction'): - """ - Go to the first page. - """ - return await self.paginator.first_page() - - @button(label='⏪', style=ButtonStyle.grey) - async def previous_page(self, _b: 'Button', _i: 'Interaction'): - """ - Go to the previous page. - """ - return await self.paginator.previous_page() - - @button(label='🏠', style=ButtonStyle.grey) - async def home_page(self, _b: 'Button', _i: 'Interaction'): - """ - Go to the home page. - """ - return await self.paginator.home_page() - - @button(label='⏩', style=ButtonStyle.grey) - async def next_page(self, _b: 'Button', _i: 'Interaction'): - """ - Go to the next page. - """ - return await self.paginator.next_page() - - @button(label='⏭️', style=ButtonStyle.grey) - async def last_page(self, _b: 'Button', _i: 'Interaction'): - """ - Go to the last page. - """ - return await self.paginator.last_page() diff --git a/views/spotify_dropdown.py b/views/spotify_dropdown.py deleted file mode 100644 index a98c8b8..0000000 --- a/views/spotify_dropdown.py +++ /dev/null @@ -1,112 +0,0 @@ -""" -View for the `/playlists` command, which contains a dropdown menu for selecting -a Spotify playlist. -""" - -from typing import TYPE_CHECKING, List - -from nextcord import Colour, SelectOption -from nextcord.ui import Select, View - -from dataclass.custom_embed import CustomEmbed - -if TYPE_CHECKING: - from nextcord import Interaction - - from cogs.player import PlayerCog - from dataclass.spotify import SpotifyResult - from utils.blanco import BlancoBot - - -class SpotifyDropdown(Select): - """ - Dropdown menu for selecting a Spotify entity. - """ - def __init__( - self, - bot: 'BlancoBot', - choices: List['SpotifyResult'], - user_id: int, - entity_type: str - ): - self._cog: 'PlayerCog' = bot.get_cog('PlayerCog') # type: ignore - self._user_id = user_id - self._choices = { x.spotify_id: x.name for x in choices } - self._type = entity_type - - # Create options - options = [] - for choice in choices: - # Truncate names to 100 characters - choice_name = choice.name - if len(choice_name) > 100: - choice_name = choice_name[:97] + '...' - elif len(choice_name) == 0: - # Some playlists have empty names, for example: - # https://open.spotify.com/playlist/6HlbMZPay5jlI7KWA0Mwyu - choice_name = '(no name)' - - # Truncate descriptions to 100 characters - choice_desc = choice.description - if len(choice_desc) > 100: - choice_desc = choice_desc[:97] + '...' - - options.append(SelectOption( - label=choice_name, - description=choice_desc, - value=choice.spotify_id - )) - - super().__init__( - placeholder=f'Choose {entity_type}...', - options=options, - min_values=1, - max_values=1 - ) - - async def callback(self, interaction: 'Interaction'): - """ - Callback for the dropdown menu. Calls the `/play` command with the - selected entity. - """ - # Ignore if the user isn't the one who invoked the command - if not interaction.user or interaction.user.id != self._user_id: - return - - # Edit message - entity_id = self.values[0] - entity_url = f'https://open.spotify.com/{self._type}/{entity_id}' - if interaction.message: - embed = CustomEmbed( - color=Colour.yellow(), - title=':hourglass:|Loading...', - description=f'Selected {self._type} [{self._choices[entity_id]}]({entity_url}).' - ) - await interaction.message.edit( - embed=embed.get(), - view=None - ) - - # Call the `/play` command with the entity URL - await self._cog.play(interaction, query=entity_url) - - # Delete message - if interaction.message: - await interaction.message.delete() - - -class SpotifyDropdownView(View): - """ - View for the `/playlists` command, which contains a dropdown menu for selecting - a Spotify entity. - """ - def __init__( - self, - bot: 'BlancoBot', - playlists: List['SpotifyResult'], - user_id: int, - entity_type: str - ): - super().__init__(timeout=None) - - self.add_item(SpotifyDropdown(bot, playlists, user_id, entity_type))