├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── app.py ├── config.py ├── docker-compose.example ├── docker ├── .dockerignore ├── nginx.conf └── startup.sh ├── overpass ├── __init__.py ├── archive.py ├── auth.py ├── db.py ├── forms.py ├── jinja_filters.py ├── routes │ ├── archive.py │ ├── auth.py │ ├── hls.py │ ├── index.py │ ├── manage_user.py │ ├── rtmp_server_api.py │ ├── stream.py │ └── watch.py ├── rtmp_server_api.py ├── schema.sql ├── static │ ├── sign_in_with_discord.png │ └── sign_in_with_discord_small.png ├── stream_api.py ├── stream_utils.py ├── templates │ ├── alert.html │ ├── archive.html │ ├── generate_stream.html │ ├── header.html │ ├── index.html │ ├── layout.html │ ├── manage_stream.html │ ├── manage_user.html │ └── watch.html └── watch.py ├── poetry.lock └── pyproject.toml /.gitignore: -------------------------------------------------------------------------------- 1 | /.vscode 2 | 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | 8 | # Distribution / packaging 9 | .Python 10 | build/ 11 | develop-eggs/ 12 | dist/ 13 | downloads/ 14 | eggs/ 15 | .eggs/ 16 | lib/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | wheels/ 22 | share/python-wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | .DS_Store 28 | 29 | test.py 30 | dev_notes.md 31 | overpass/secrets.py 32 | .env 33 | overpass.db 34 | docker-compose.yml -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.10.7 2 | 3 | RUN apt update 4 | RUN apt install git nginx libnginx-mod-rtmp ffmpeg -y 5 | RUN mkdir /archive && mkdir /hls 6 | 7 | ENV HLS_PATH=/hls 8 | ENV REC_PATH=/archive 9 | ENV OVERPASS_DATABASE_PATH=/database 10 | ENV FLASK_ENV=production 11 | 12 | COPY ./docker/nginx.conf /etc/nginx/nginx.conf 13 | COPY ./docker/startup.sh /startup.sh 14 | RUN chmod +x /startup.sh 15 | 16 | # RUN git clone https://github.com/GOATS2K/overpass.git . 17 | WORKDIR /app 18 | COPY . . 19 | RUN pip3 install . 20 | 21 | EXPOSE 8000 22 | EXPOSE 1935 23 | 24 | CMD ["/startup.sh"] 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 John Patrick Glattetre 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Overpass 2 | 3 | Overpass makes it easy to host your own live streaming server with features such as authentication via Discord, stream playback in your web browser, and an easy way to archive your streams for your users to rewatch! 4 | 5 | Overpass also lets you run a private instance for a users on a single Discord server. Simply add the server ID of your Discord server to the configuration file and Overpass will take care of the rest. 6 | 7 | Powered by nginx-rtmp. 8 | 9 | # Notice 10 | This project is no longer maintained and I encourage you to find an alternative. 11 | I had many ideas for Overpass but honestly could never be arsed to work on it again as it was just about good enough when it was in use. 12 | 13 | # Dependencies 14 | - Python 3.8+ 15 | - Nginx with the [nginx-rtmp module](https://github.com/arut/nginx-rtmp-module) installed 16 | - FFmpeg 17 | - A Discord app 18 | 19 | # Getting Started 20 | ## Creating the Discord app for Overpass 21 | Navigate to [Discord's Developer Portal](https://discord.com/developers/applications). 22 | 23 | - Select "New Application" in the top left corner 24 | - Choose a name for your application 25 | - Select the "OAuth2" tab 26 | - Find the "Client Information" section of the page, and copy your "Client ID" and your "Client Secret" and save these for use in the configuration file 27 | - Click "Add redirect" and type in a URI like so: 28 | 29 | `https://overpass.dev/auth/callback` - replacing `overpass.dev` with your domain name. 30 | 31 | **Note:** If you wish to develop on Overpass, you will have to add `http://localhost:5000/auth/callback` to your list of redirect URIs. 32 | 33 | # Install 34 | ## Docker Usage 35 | 36 | There is a Docker image for Overpass, which you can either build yourself with the Dockerfile, or [download from the Docker Hub](https://hub.docker.com/r/goats2k/overpass). 37 | 38 | This image is pre-configured to run Overpass in production mode with Gunicorn, so if you wish to develop on Overpass, you may need to change `docker/startup.sh` to execute `flask run`, and modifying the route to Overpass' API in the nginx configuration. 39 | 40 | _Continue reading if you wish to run Overpass on bare-metal, otherwise, you can use the [example Docker Compose file](docker-compose.example)._ 41 | 42 | ## Creating a config file 43 | **Note: If you are using Docker, set these values as environment variables. See the [example Docker Compose file](docker-compose.example). You will _not_ have to create the `.env` file.** 44 | 45 | ### Generate a secret key 46 | 47 | Run `python -c "import os; print(os.urandom(16))"` and copy the output **after the byte symbol** into `OVERPASS_SECRET_KEY` 48 | 49 | **Create an empty `.env` file, in the projects' root directory** which contains the following: 50 | 51 | ``` 52 | DISCORD_CLIENT_ID = 53 | DISCORD_CLIENT_SECRET = "" 54 | DISCORD_REDIRECT_URI = "" 55 | DISCORD_GUILD_ID = (if you want to restrict access to the tool to users from a certain guild ID - set one here) 56 | 57 | 58 | OVERPASS_SECRET_KEY = "your generated key here" 59 | 60 | HLS_PATH = "" 61 | REC_PATH = "" 62 | RTMP_SERVER = (IP address and port of your RTMP server - as a string) 63 | ``` 64 | 65 | ### Example config 66 | 67 | ``` 68 | DISCORD_CLIENT_ID = 31040105101013151 69 | DISCORD_CLIENT_SECRET = "1251XXXXXXXXXXXXXXXXXXXXX" 70 | DISCORD_REDIRECT_URI = "https://overpass.dev/auth/callback" 71 | DISCORD_GUILD_ID = 05105010105619519 72 | 73 | OVERPASS_SECRET_KEY = "#\x1an\x1an\x1an\x1an\x1an" 74 | 75 | HLS_PATH = "/storage/overpass/hls" 76 | REC_PATH = "/storage/overpass/archive" 77 | 78 | RTMP_SERVER = "overpass.dev:1935/live" 79 | ``` 80 | 81 | ## Streaming server setup 82 | 83 | - Create the directories you defined in `HLS_PATH` and `REC_PATH` and make sure to give `www-data` write permissions to said folder. 84 | 85 | *Make sure the user the Overpass is running as also has read and write access to the same folders.* 86 | 87 | *Remember to change the `on_publish` and `on_done` URIs, `record_path` and `hls_path` variables to match your environment* 88 | 89 | Edit your `nginx.conf` file to contain the following information. 90 | ```nginx 91 | rtmp { 92 | server { 93 | listen 1935; 94 | on_publish http://127.0.0.1:5000/api/rtmp/connect; 95 | on_done http://127.0.0.1:5000/api/rtmp/done; 96 | 97 | application live { 98 | deny play all; 99 | live on; 100 | record all; 101 | record_path /storage/overpass/archive; 102 | record_append on; 103 | 104 | hls on; 105 | hls_path /storage/overpass/hls; 106 | hls_fragment 2; 107 | hls_playlist_length 10; 108 | exec_record_done bash -c "/usr/bin/ffmpeg -i $path -acodec copy -vcodec copy -movflags +faststart /your/recording/path/$basename.mp4 && rm $path"; 109 | } 110 | } 111 | } 112 | 113 | 114 | ``` 115 | 116 | # Running the application 117 | - Run `flask init-db` to initialize the database. 118 | 119 | ## Development mode 120 | - Run `flask run` 121 | 122 | ## Deploying to production 123 | 124 | In the same folder as Overpass, while in a virtual environment, run the following command: 125 | 126 | `gunicorn -w 10 app:app --timeout 600 --log-level=debug --access-logformat "%({X-Real-IP}i)s %(l)s %(t)s %(b)s '%(f)s' '%(a)s'" --access-logfile '-'` 127 | 128 | ## NGINX setup 129 | ```nginx 130 | location / { 131 | proxy_pass http://127.0.0.1:8000; 132 | proxy_set_header Host $host; 133 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 134 | proxy_set_header X-Real-IP $remote_addr; 135 | } 136 | ``` 137 | 138 | # Screenshots 139 | 140 | ![Homepage](https://i.imgur.com/3UvgBbh.png) 141 | ![Web Player](https://i.imgur.com/h1yV3r1.png) 142 | ![Archive](https://i.imgur.com/TYbHzkm.png) 143 | ![Profile Page](https://i.imgur.com/KwC9hPt.png) 144 | -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | from overpass import create_app 2 | import logging 3 | import config 4 | import os 5 | 6 | if os.environ.get("FLASK_ENV") == "development": 7 | app = create_app(config.DevConfig()) 8 | app.logger.info("Development environment detected.") 9 | else: 10 | app = create_app(config.ProdConfig()) 11 | gunicorn_logger = logging.getLogger("gunicorn.error") 12 | app.logger.handlers = gunicorn_logger.handlers 13 | app.logger.setLevel(gunicorn_logger.level) 14 | app.logger.info("No environment variable set, using production config.") 15 | 16 | if __name__ == "__main__": 17 | app.run() -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | from genericpath import exists 2 | import os 3 | from pathlib import Path 4 | from dotenv import load_dotenv 5 | 6 | if Path(".env").exists(): 7 | load_dotenv(".env") 8 | 9 | 10 | def get_secret_key(): 11 | env_key = os.environ.get("OVERPASS_SECRET_KEY") 12 | if env_key: 13 | return env_key.encode() 14 | 15 | print( 16 | "No secret key found, please set the environment variable OVERPASS_SECRET_KEY" 17 | ) 18 | print("Generating key for temporary usage...") 19 | return os.urandom(16) 20 | 21 | 22 | def get_database_directory(dev: bool = False) -> str: 23 | database_name = "overpass.db" if not dev else "overpass-dev.db" 24 | env_database_path = os.environ.get("OVERPASS_DATABASE_PATH") 25 | if env_database_path: 26 | database_dir = Path(env_database_path) 27 | database_dir.mkdir(exist_ok=True) 28 | return database_dir / database_name 29 | return database_name 30 | 31 | 32 | class Config(object): 33 | DEBUG = False 34 | TESTING = False 35 | DATABASE = get_database_directory() 36 | DISCORD_CLIENT_ID = os.environ.get("DISCORD_CLIENT_ID") 37 | DISCORD_CLIENT_SECRET = os.environ.get("DISCORD_CLIENT_SECRET") 38 | DISCORD_REDIRECT_URI = os.environ.get("DISCORD_REDIRECT_URI") 39 | 40 | 41 | class DevConfig(Config): 42 | DEBUG = True 43 | DATABASE = get_database_directory(dev=True) 44 | SECRET_KEY = get_secret_key() 45 | 46 | 47 | class ProdConfig(Config): 48 | SECRET_KEY = get_secret_key() 49 | SESSION_COOKIE_SECURE = True 50 | REMEMBER_COOKIE_SECURE = True 51 | -------------------------------------------------------------------------------- /docker-compose.example: -------------------------------------------------------------------------------- 1 | services: 2 | overpass: 3 | image: goats2k/overpass:0.2.6 4 | container_name: overpass 5 | environment: 6 | - OVERPASS_SECRET_KEY=example_key_1337 7 | - RTMP_SERVER=127.0.0.1:1935/live 8 | - DISCORD_CLIENT_ID=yourclientid 9 | - DISCORD_CLIENT_SECRET=yourclientsecret 10 | - DISCORD_REDIRECT_URI=yourredirecturi 11 | - DISCORD_GUILD_ID=yoursecretguildid 12 | volumes: 13 | - /home/user/overpass/database:/database 14 | - /home/user/overpass/log:/var/log/nginx:ro 15 | - /home/user/overpass/archive:/archive:rw 16 | - /home/user/overpass/hls:/hls:rw 17 | ports: 18 | - 1935:1935 19 | - 8000:8000 20 | -------------------------------------------------------------------------------- /docker/.dockerignore: -------------------------------------------------------------------------------- 1 | docker -------------------------------------------------------------------------------- /docker/nginx.conf: -------------------------------------------------------------------------------- 1 | user www-data; 2 | worker_processes auto; 3 | pid /run/nginx.pid; 4 | daemon off; 5 | include /etc/nginx/modules-enabled/*.conf; 6 | rtmp_auto_push on; 7 | 8 | events { 9 | worker_connections 4096; 10 | # multi_accept on; 11 | } 12 | 13 | http { 14 | 15 | ## 16 | # Basic Settings 17 | ## 18 | 19 | sendfile on; 20 | tcp_nopush on; 21 | tcp_nodelay on; 22 | keepalive_timeout 65; 23 | types_hash_max_size 2048; 24 | 25 | include /etc/nginx/mime.types; 26 | default_type application/octet-stream; 27 | 28 | ## 29 | # Logging Settings 30 | ## 31 | 32 | log_format '$remote_addr - $remote_user [$time_local] ' 33 | '"$request" $status $body_bytes_sent ' 34 | '"$http_referer" "$http_user_agent"'; 35 | 36 | access_log /var/log/nginx/access.log; 37 | error_log /var/log/nginx/error.log; 38 | 39 | ## 40 | # Gzip Settings 41 | ## 42 | 43 | gzip on; 44 | 45 | ## 46 | # Virtual Host Configs 47 | ## 48 | 49 | include /etc/nginx/conf.d/*.conf; 50 | include /etc/nginx/sites-enabled/*; 51 | } 52 | 53 | rtmp { 54 | server { 55 | listen 0.0.0.0:1935; 56 | on_publish http://127.0.0.1:8000/api/rtmp/connect; 57 | on_publish_done http://127.0.0.1:8000/api/rtmp/done; 58 | 59 | application live { 60 | live on; 61 | record all; 62 | record_path /archive; 63 | record_append on; 64 | 65 | hls on; 66 | hls_path /hls; 67 | hls_fragment 2s; 68 | hls_playlist_length 10s; 69 | exec_record_done bash -c "/usr/bin/ffmpeg -i $path -acodec copy -vcodec copy -movflags +faststart /archive/$basename.mp4 && rm $path"; 70 | } 71 | } 72 | } -------------------------------------------------------------------------------- /docker/startup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | if [ ! -f /database/overpass.db ]; then 3 | cd /app 4 | flask init-db --yes 5 | chown -R www-data:root /archive /hls 6 | fi 7 | 8 | nginx & 9 | gunicorn app:app --workers=2 --threads=4 --worker-class=gthread --timeout=600 --bind=0.0.0.0 -------------------------------------------------------------------------------- /overpass/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Flask 2 | from flask.templating import render_template 3 | from flask_discord import DiscordOAuth2Session 4 | import os 5 | from overpass.db import init_app, close_db, init_db_command 6 | from dotenv import load_dotenv 7 | import config 8 | 9 | basedir = os.path.abspath(os.path.dirname(__file__)) 10 | load_dotenv(os.path.join(basedir, ".env")) 11 | 12 | 13 | os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1" 14 | discord = DiscordOAuth2Session() 15 | 16 | 17 | def create_app(config_instance: config.Config) -> Flask: 18 | app = Flask(__name__) 19 | app.config.from_object(config_instance) 20 | 21 | app.teardown_appcontext(close_db) 22 | app.cli.add_command(init_db_command) 23 | 24 | discord.init_app(app) 25 | init_app(app) 26 | 27 | with app.app_context(): 28 | # Imports are done here to prevent circular import errors when 29 | # importing extensions from this file 30 | from overpass.routes.auth import auth 31 | from overpass.routes.stream import bp as stream 32 | from overpass.routes.rtmp_server_api import bp as rtmp 33 | from overpass.routes.index import bp as index 34 | from overpass.routes.archive import bp as archive 35 | from overpass.routes.hls import bp as hls 36 | from overpass.routes.watch import bp as watch 37 | from overpass.routes.manage_user import bp as manage 38 | 39 | app.register_blueprint(index) 40 | app.register_blueprint(auth, url_prefix="/auth") 41 | app.register_blueprint(stream, url_prefix="/stream") 42 | app.register_blueprint(rtmp, url_prefix="/api/rtmp") 43 | app.register_blueprint(archive, url_prefix="/archive") 44 | app.register_blueprint(hls, url_prefix="/hls") 45 | app.register_blueprint(watch, url_prefix="/watch") 46 | app.register_blueprint(manage, url_prefix="/manage") 47 | 48 | from overpass.jinja_filters import nl2br 49 | 50 | app.add_template_filter(nl2br) 51 | 52 | @app.errorhandler(404) 53 | def page_not_found(e): 54 | return render_template("alert.html", error="Page not found."), 404 55 | 56 | @app.errorhandler(403) 57 | def forbidden(e): 58 | return ( 59 | render_template( 60 | "alert.html", 61 | error="You are not allowed to perform this action.", 62 | ), 63 | 403, 64 | ) 65 | 66 | return app 67 | -------------------------------------------------------------------------------- /overpass/archive.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, List 2 | from flask.helpers import send_from_directory 3 | from flask.wrappers import Response 4 | from overpass.db import get_db, query_many 5 | from flask import current_app 6 | from os import environ 7 | from pathlib import Path 8 | from overpass.stream_utils import get_username_from_snowflake 9 | 10 | 11 | def archive_stream(stream_key: str): 12 | """Archives a stream. 13 | 14 | Args: 15 | stream_key (str): The stream's stream key. 16 | """ 17 | stream_path = Path(environ.get("REC_PATH", "")) / f"{stream_key}.mp4" 18 | current_app.logger.info(f"Adding stream {stream_path} to archive") 19 | db = get_db() 20 | db.execute( 21 | "UPDATE stream SET archived_file = ? WHERE stream_key = ?", 22 | (str(stream_path), stream_key), 23 | ) 24 | db.commit() 25 | 26 | 27 | def get_archived_streams( 28 | all_metadata: bool = False, private: bool = False 29 | ) -> List[Dict[str, Any]]: 30 | """Get a list of all archived streams. 31 | 32 | Args: 33 | all_metadata (bool, optional): 34 | Get all metadata for stream. 35 | Defaults to False. 36 | 37 | private (bool, optional): 38 | Include streams that aren't publically archived. 39 | Defaults to False. 40 | 41 | Returns: 42 | List[Dict[str, Any]]: List of dicts containing the streams. 43 | """ 44 | if all_metadata: 45 | items = "*" 46 | else: 47 | items = "id, user_snowflake, start_date, end_date, title, description, category, unique_id" # noqa: E501 48 | 49 | if private: 50 | res = query_many( 51 | f"SELECT {items} FROM stream WHERE archived_file IS NOT NULL" 52 | ) 53 | else: 54 | res = query_many( 55 | f"SELECT {items} FROM stream WHERE unlisted = 0 AND archivable = 1 AND archived_file IS NOT NULL" # noqa: E501 56 | ) 57 | 58 | for stream in res: 59 | duration = stream["end_date"] - stream["start_date"] 60 | stream["duration"] = str(duration) 61 | stream["username"] = get_username_from_snowflake( 62 | stream["user_snowflake"] 63 | ) 64 | stream["download"] = f"/archive/download/{stream['unique_id']}" 65 | if not private: 66 | del stream["user_snowflake"] 67 | 68 | return res 69 | 70 | 71 | def serve_file(archived_stream: Dict[str, Any]) -> Response: 72 | """Serves an archived stream from storage. 73 | 74 | Args: 75 | archived_stream (Dict[str, Any]): 76 | Dict containing information about the stream from the database. 77 | 78 | Returns: 79 | Response: File being served by Flask's Response class. 80 | """ 81 | username = get_username_from_snowflake(archived_stream["user_snowflake"]) 82 | filename = f"{username} - {archived_stream['title']} - {archived_stream['start_date']}.mp4" 83 | return send_from_directory( 84 | environ.get("REC_PATH"), 85 | filename=f"{archived_stream['stream_key']}.mp4", 86 | as_attachment=True, 87 | attachment_filename=filename, 88 | ) 89 | -------------------------------------------------------------------------------- /overpass/auth.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa E501 2 | # from flask import current_app as app 3 | from typing import Union 4 | 5 | from flask.globals import current_app 6 | from overpass import discord 7 | from datetime import datetime 8 | from overpass.db import get_db 9 | import os 10 | 11 | DISCORD_GUILD_ID = os.environ.get("DISCORD_GUILD_ID") or "" 12 | 13 | 14 | def verify() -> bool: 15 | """Verifies if user exists in the Discord guild 16 | 17 | Returns: 18 | bool: User exists in guild 19 | """ 20 | guilds = discord.fetch_guilds() 21 | return bool( 22 | next((i for i in guilds if i.id == int(DISCORD_GUILD_ID)), False) 23 | ) 24 | 25 | 26 | def add_user(username: str, snowflake: int, avatar: Union[str, None]) -> None: 27 | """Adds user to database 28 | 29 | Args: 30 | username (str): User's username 31 | snowflake (int): User's account ID 32 | avatar (Union[str, None]): User's avatar URL 33 | """ 34 | current_date = datetime.now() 35 | db = get_db() 36 | current_app.logger.info(f"Adding user {username} to User table") 37 | db.execute( 38 | "INSERT INTO user (username, snowflake, avatar, last_login_date) VALUES (?, ?, ?, ?)", 39 | ( 40 | username, 41 | snowflake, 42 | avatar, 43 | current_date.strftime("%Y-%m-%d %H:%M:%S"), 44 | ), 45 | ) 46 | db.commit() 47 | 48 | 49 | def check_if_user_exists(snowflake: int) -> bool: 50 | """Returns True if user exists in database 51 | 52 | Args: 53 | snowflake (int): User's ID 54 | 55 | Returns: 56 | bool: User exists in database 57 | """ 58 | db = get_db() 59 | q = db.execute("SELECT * FROM user WHERE snowflake = ?", (snowflake,)) 60 | result = q.fetchone() 61 | 62 | if result: 63 | return True 64 | else: 65 | return False 66 | 67 | 68 | def update_login_time(snowflake: int) -> None: 69 | """Update the user's last logged in time 70 | 71 | Args: 72 | snowflake (int): User's ID 73 | """ 74 | current_date = datetime.now() 75 | db = get_db() 76 | db.execute( 77 | "UPDATE user SET last_login_date = ? WHERE snowflake = ?", 78 | (current_date.strftime("%Y-%m-%d %H:%M:%S"), snowflake), 79 | ) 80 | db.commit() 81 | -------------------------------------------------------------------------------- /overpass/db.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, List 2 | import click 3 | from flask import current_app, g 4 | from flask.app import Flask 5 | from flask.cli import with_appcontext 6 | import sqlite3 7 | 8 | 9 | def make_dicts(cursor: sqlite3.Cursor, row: sqlite3.Row) -> dict: 10 | return dict((cursor.description[idx][0], value) for idx, value in enumerate(row)) 11 | 12 | 13 | def get_db() -> sqlite3.Connection: 14 | if "db" not in g: 15 | g.db = sqlite3.connect( 16 | current_app.config["DATABASE"], 17 | detect_types=sqlite3.PARSE_DECLTYPES, 18 | ) 19 | g.db.row_factory = make_dicts 20 | 21 | return g.db 22 | 23 | 24 | def close_db(e=None) -> None: 25 | db = g.pop("db", None) 26 | 27 | if db is not None: 28 | db.close() 29 | 30 | 31 | def query_many(query: str, args: Any = ()) -> List[Dict[str, Any]]: 32 | cur = get_db().execute(query, args) 33 | rv = cur.fetchall() 34 | cur.close() 35 | return rv 36 | 37 | 38 | def query_one(query: str, args: Any = ()) -> Dict[str, Any]: 39 | cur = get_db().execute(query, args) 40 | rv = cur.fetchall() 41 | cur.close() 42 | return rv[0] if rv else {} 43 | 44 | 45 | def init_db() -> None: 46 | db = get_db() 47 | 48 | with current_app.open_resource("schema.sql") as f: 49 | db.executescript(f.read().decode("utf8")) 50 | 51 | 52 | def init_app(app: Flask): 53 | app.teardown_appcontext(close_db) 54 | app.cli.add_command(init_db_command) 55 | 56 | 57 | @click.command("init-db") 58 | @click.option("--yes", type=click.BOOL, is_flag=True) 59 | @with_appcontext 60 | def init_db_command(yes: bool) -> None: 61 | """Clear the existing data and create new tables.""" 62 | click.echo( 63 | f"You're now about to initialize the following database file: {current_app.config['DATABASE']}" 64 | ) 65 | if yes or click.confirm("Are you sure about this?"): 66 | init_db() 67 | click.secho("Initialized the database.", fg="green") 68 | else: 69 | click.secho("Initialization aborted.", fg="red") 70 | -------------------------------------------------------------------------------- /overpass/forms.py: -------------------------------------------------------------------------------- 1 | from wtforms import Form, StringField, TextAreaField, BooleanField, validators 2 | 3 | 4 | class StreamGenerationForm(Form): 5 | title = StringField("Title", [validators.DataRequired()]) 6 | description = TextAreaField("Description", [validators.DataRequired()]) 7 | category = StringField("Category") 8 | archivable = BooleanField("Archive the stream") 9 | unlisted = BooleanField("Unlisted") 10 | -------------------------------------------------------------------------------- /overpass/jinja_filters.py: -------------------------------------------------------------------------------- 1 | import re 2 | from jinja2 import evalcontextfilter, Markup, escape 3 | 4 | _paragraph_re = re.compile(r"(?:\r\n|\r(?!\n)|\n){2,}") 5 | 6 | 7 | @evalcontextfilter 8 | def nl2br(eval_ctx, value): 9 | result = u"\n\n".join( 10 | u"

%s

" % p.replace("\n", Markup("
\n")) 11 | for p in _paragraph_re.split(escape(value)) 12 | ) 13 | if eval_ctx.autoescape: 14 | result = Markup(result) 15 | return result 16 | -------------------------------------------------------------------------------- /overpass/routes/archive.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Text 2 | 3 | from flask import Blueprint, abort, redirect, url_for 4 | from flask.templating import render_template 5 | from flask.wrappers import Response 6 | from flask_discord import Unauthorized, requires_authorization 7 | from overpass import discord 8 | from overpass.archive import get_archived_streams, serve_file 9 | from overpass.db import query_one 10 | 11 | bp = Blueprint("archive", __name__) 12 | 13 | 14 | @bp.errorhandler(Unauthorized) 15 | def redirect_discord_unauthorized(e) -> Any: 16 | return redirect(url_for("auth.login")) 17 | 18 | 19 | @bp.before_request 20 | @requires_authorization 21 | def require_auth() -> None: 22 | pass 23 | 24 | 25 | @bp.route("/") 26 | def list_archived_streams() -> Text: 27 | """List publically archived streams. 28 | 29 | Returns: 30 | Text: Static page rendered by Flask. 31 | """ 32 | archive = get_archived_streams() 33 | return render_template("archive.html", archive=archive) 34 | 35 | 36 | @bp.route("/download/") 37 | def serve_archive(unique_id: str) -> Response: 38 | """Serves stream from the archive. 39 | 40 | Args: 41 | unique_id (str): The stream's unique ID. 42 | 43 | Returns: 44 | Response: File being served by Flask's Response class. 45 | """ 46 | user = discord.fetch_user() 47 | stream = query_one( 48 | """ 49 | SELECT * FROM stream 50 | WHERE archived_file IS NOT NULL 51 | AND unique_id = ? 52 | """, 53 | [unique_id], 54 | ) 55 | if user.id == stream["user_snowflake"]: 56 | # The user requesting the stream created it. 57 | # Therefore they shall always have access to the file. 58 | return serve_file(stream) 59 | elif bool(stream["archivable"]): 60 | # The stream is publically archived. 61 | return serve_file(stream) 62 | else: 63 | # The stream does not exist. 64 | return abort(404) 65 | -------------------------------------------------------------------------------- /overpass/routes/auth.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Text 2 | 3 | from flask import Blueprint, abort, current_app, redirect, session, url_for 4 | from flask.templating import render_template 5 | from flask_discord import RateLimited, Unauthorized 6 | from overpass import discord 7 | from overpass.auth import ( 8 | DISCORD_GUILD_ID, 9 | add_user, 10 | check_if_user_exists, 11 | update_login_time, 12 | verify, 13 | ) 14 | 15 | auth = Blueprint("auth", __name__) 16 | 17 | 18 | @auth.route("/login/") 19 | def login() -> Any: 20 | """Redirect user to Discord's SSO page 21 | 22 | Returns: 23 | Any: Redirection to Discord's SSO page 24 | """ 25 | return discord.create_session(scope=["identify", "guilds"]) 26 | 27 | 28 | @auth.route("/logout/") 29 | def logout() -> Text: 30 | """Revoke a user's session 31 | 32 | Returns: 33 | Text: Flask rendered template 34 | """ 35 | discord.revoke() 36 | return render_template("alert.html", info="You've been logged out.") 37 | 38 | 39 | @auth.route("/callback/") 40 | def callback() -> Any: 41 | """Callback endpoint for SSO service 42 | 43 | Returns: 44 | Any: Redirect to home page if everything went well, else return 401. 45 | """ 46 | try: 47 | discord.callback() 48 | user = discord.fetch_user() 49 | if DISCORD_GUILD_ID: 50 | if not verify(): 51 | # When the callback succeeds, the token for the user gets 52 | # set in memory 53 | # Since the user isn't a member of the guild 54 | # we reset the session to prevent access to the API 55 | current_app.logger.error( 56 | f"Username {user.username} with ID {user.id} is not a member of the target guild" # noqa: E501 57 | ) 58 | session.clear() 59 | return abort(401) 60 | 61 | # Assume successful login 62 | if not check_if_user_exists(user.id): 63 | add_user(user.username, user.id, user.avatar_url) 64 | else: 65 | current_app.logger.info(f"User {user.username} has just signed in") 66 | # Update last login time 67 | update_login_time(user.id) 68 | 69 | return redirect(url_for("index.home")) 70 | except RateLimited: 71 | return "We are currently being rate limited, try again later." 72 | 73 | 74 | @auth.errorhandler(Unauthorized) 75 | def redirect_discord_unauthorized(e) -> Any: 76 | return redirect(url_for("auth.login")) 77 | 78 | 79 | # Runs when abort(401) is called. 80 | @auth.errorhandler(401) 81 | def redirect_unauthorized(e) -> Any: 82 | return """ 83 |

Your Discord user is not authorized to use this application.

84 |

Please verify that you are a member of the target Discord server.

85 | """ 86 | -------------------------------------------------------------------------------- /overpass/routes/hls.py: -------------------------------------------------------------------------------- 1 | from os import environ 2 | from typing import Any 3 | 4 | from flask import Blueprint, abort, redirect, send_from_directory, url_for 5 | from flask.helpers import send_file 6 | from flask_discord import Unauthorized, requires_authorization 7 | from overpass.db import query_one 8 | from overpass.stream_utils import ( 9 | get_stream_key_from_unique_id, 10 | rewrite_stream_playlist, 11 | ) 12 | from requests.sessions import Request 13 | 14 | bp = Blueprint("hls", __name__) 15 | 16 | 17 | @bp.errorhandler(Unauthorized) 18 | def redirect_discord_unauthorized(e): 19 | return redirect(url_for("auth.login")) 20 | 21 | 22 | @bp.before_request 23 | @requires_authorization 24 | def require_auth() -> None: 25 | pass 26 | 27 | 28 | @bp.after_request 29 | def disable_cache(r: Request): 30 | # https://stackoverflow.com/questions/31918035/javascript-only-works-after-opening-developer-tools-in-chrome 31 | # Finally fixed the HLS buffer issue... 32 | r.headers["Cache-Control"] = "no-cache, no-store, must-revalidate" 33 | return r 34 | 35 | 36 | @bp.route("//") 37 | def serve_stream(unique_id: str, file: str) -> Any: 38 | """Serve stream via its unique ID 39 | 40 | Example: 41 | A stream uses the following IDs and keys: 42 | Stream key = 1451fgsa 43 | Unique ID = klzfls156 44 | 45 | GET /watch/klzfls156/index.m3u8 46 | < HLS_DIR/1451fgsa-index.m3u8 47 | 48 | GET /watch/klzfls156/1.ts 49 | < HLS_DIR/1451fgsa-1.ts 50 | 51 | Args: 52 | unique_id (str): The stream's unique ID 53 | file (str): The requested file 54 | 55 | Returns: 56 | Any: Serves file from HLS path if exists. 57 | """ 58 | stream_key = get_stream_key_from_unique_id(unique_id) 59 | stream = query_one( 60 | "SELECT end_date FROM stream WHERE stream_key = ?", [stream_key] 61 | ) 62 | if stream_key and not stream["end_date"]: 63 | if file == "index.m3u8": 64 | try: 65 | playlist = rewrite_stream_playlist(stream_key) 66 | return send_file(playlist, mimetype="application/x-mpegURL") 67 | except FileNotFoundError: 68 | return abort(404) 69 | else: 70 | return send_from_directory( 71 | environ.get("HLS_PATH"), f"{stream_key}-{file}" 72 | ) 73 | 74 | return abort(404) 75 | -------------------------------------------------------------------------------- /overpass/routes/index.py: -------------------------------------------------------------------------------- 1 | from typing import Text 2 | 3 | from flask import Blueprint 4 | from flask.templating import render_template 5 | from overpass import discord 6 | from overpass.stream_api import get_current_livestreams 7 | 8 | bp = Blueprint("index", __name__) 9 | 10 | 11 | @bp.route("/") 12 | def home() -> Text: 13 | """Render Overpass' index page. 14 | 15 | Returns: 16 | Text: Returns login page if the user hasn't logged in, 17 | else return streams currently live. 18 | """ 19 | auth = discord.authorized 20 | if not auth: 21 | return render_template("index.html", authorized=auth) 22 | else: 23 | streams = get_current_livestreams() 24 | return render_template("index.html", authorized=auth, streams=streams) 25 | -------------------------------------------------------------------------------- /overpass/routes/manage_user.py: -------------------------------------------------------------------------------- 1 | from typing import Text 2 | 3 | from flask import Blueprint, redirect, url_for 4 | from flask.templating import render_template 5 | from flask_discord import Unauthorized, requires_authorization 6 | from overpass import discord 7 | from overpass.db import query_many, query_one 8 | 9 | bp = Blueprint("manage", __name__) 10 | 11 | 12 | @bp.errorhandler(Unauthorized) 13 | def redirect_discord_unauthorized(e): 14 | return redirect(url_for("auth.login")) 15 | 16 | 17 | @bp.before_request 18 | @requires_authorization 19 | def require_auth(): 20 | pass 21 | 22 | 23 | @bp.route("/me") 24 | def me() -> Text: 25 | """Render a page to manage stream properties 26 | 27 | Returns: 28 | Text: Static page rendered by Flask. 29 | """ 30 | discord_user = discord.fetch_user() 31 | user = query_one( 32 | "SELECT * FROM user WHERE snowflake = ?", [discord_user.id] 33 | ) 34 | streams = query_many( 35 | "SELECT * FROM stream WHERE user_snowflake = ?", [discord_user.id] 36 | ) 37 | if streams: 38 | for stream in streams: 39 | try: 40 | duration = stream["end_date"] - stream["start_date"] 41 | stream["duration"] = str(duration) 42 | except TypeError: 43 | continue 44 | stream["username"] = user["username"] 45 | return render_template("manage_user.html", user=user, streams=streams) 46 | 47 | return render_template("manage_user.html", user=user) 48 | -------------------------------------------------------------------------------- /overpass/routes/rtmp_server_api.py: -------------------------------------------------------------------------------- 1 | import ipaddress 2 | from typing import Any 3 | 4 | from flask import Blueprint, abort, current_app, jsonify, request 5 | from overpass.archive import archive_stream 6 | from overpass.db import query_one 7 | from overpass.rtmp_server_api import ( 8 | end_stream, 9 | start_stream, 10 | verify_stream_key, 11 | ) 12 | from overpass.stream_utils import get_unique_stream_id_from_stream_key 13 | 14 | bp = Blueprint("rtmp", __name__) 15 | 16 | 17 | @bp.before_request 18 | def accept_only_private_ips() -> Any: 19 | """Make sure to only accept requests originating from 20 | the nginx-rtmp server, or an internal IP address. 21 | 22 | This is to ensure no one tampers with the streaming part 23 | of the application. 24 | 25 | Returns: 26 | Any: Returns a 401 if the request IP is invalid. 27 | """ 28 | req_ip = ipaddress.IPv4Address(request.remote_addr) 29 | if not req_ip.is_private: 30 | return abort(401) 31 | 32 | 33 | @bp.route("/connect", methods=["POST"]) 34 | def connect() -> Any: 35 | """Accepts the initial connection request from nginx-rtmp 36 | If the stream key used is valid, accept the connection, 37 | else return 401. 38 | 39 | Returns: 40 | Any: 200 response if stream key is valid, 41 | 401 response if stream key is invalid. 42 | """ 43 | stream_key = request.form["name"] 44 | if verify_stream_key(stream_key): 45 | # Write stream start date to db 46 | start_stream(stream_key) 47 | unique_id = get_unique_stream_id_from_stream_key(stream_key) 48 | current_app.logger.info(f"This stream's unique ID is {unique_id}") 49 | return jsonify({"message": "Stream key is valid!"}), 200 50 | else: 51 | return jsonify({"message": "Incorrect stream key."}), 401 52 | 53 | 54 | @bp.route("/done", methods=["POST"]) 55 | def done() -> Any: 56 | """Mark the stream as done and archive the stream. 57 | 58 | Returns: 59 | Any: Returns 200 when the function has completed. 60 | """ 61 | stream_key = request.form["name"] 62 | end_stream(stream_key) 63 | 64 | stream = query_one( 65 | "SELECT * FROM stream WHERE stream_key = ?", [stream_key] 66 | ) 67 | if bool(stream["archivable"]): 68 | current_app.logger.info("Stream is archivable.") 69 | else: 70 | current_app.logger.info("Stream is going to be archived privately.") 71 | 72 | archive_stream(stream_key) 73 | return jsonify({"message": "Stream has successfully ended"}), 200 74 | -------------------------------------------------------------------------------- /overpass/routes/stream.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from os import environ 3 | from typing import Any 4 | 5 | from flask import Blueprint, abort, redirect, request, url_for 6 | from flask.templating import render_template 7 | from flask_discord import Unauthorized, requires_authorization 8 | from overpass import discord 9 | from overpass.db import query_one 10 | from overpass.forms import StreamGenerationForm 11 | from overpass.stream_api import add_stream_to_db, update_db_fields 12 | 13 | bp = Blueprint("stream", __name__) 14 | 15 | 16 | @bp.errorhandler(Unauthorized) 17 | def redirect_discord_unauthorized(e) -> Any: 18 | return redirect(url_for("auth.login")) 19 | 20 | 21 | @bp.before_request 22 | @requires_authorization 23 | def require_auth() -> None: 24 | pass 25 | 26 | 27 | @bp.route("/manage/", methods=["GET", "POST"]) 28 | def manage_stream(unique_id: str) -> Any: 29 | """Manage a stream's properties via its unique ID. 30 | 31 | Args: 32 | unique_id (str): The stream's unique ID. 33 | 34 | Returns: 35 | Any: A static page rendered by Flask. 36 | """ 37 | user = discord.fetch_user() 38 | form = StreamGenerationForm() 39 | stream = query_one( 40 | """SELECT title, 41 | description, 42 | category, 43 | archivable, 44 | unlisted, 45 | user_snowflake 46 | FROM stream 47 | WHERE unique_id = ? 48 | """, 49 | [unique_id], 50 | ) 51 | 52 | if not stream: 53 | return render_template("alert.html", error="Invalid stream ID."), 404 54 | 55 | # Populate the stream editing form. 56 | form.title.data = stream["title"] 57 | form.description.data = stream["description"] 58 | form.category.data = stream["category"] 59 | form.archivable.data = stream["archivable"] 60 | form.unlisted.data = stream["unlisted"] 61 | 62 | if stream["user_snowflake"] == user.id: 63 | if request.method == "GET": 64 | return render_template( 65 | "manage_stream.html", form=form, unique_id=unique_id 66 | ) 67 | 68 | if request.method == "POST": 69 | form = StreamGenerationForm(request.form) 70 | if form.validate(): 71 | keys_to_change = {} 72 | for key, value in form.data.items(): 73 | if stream[key] != value: 74 | keys_to_change[key] = value 75 | update_db_fields(unique_id, **keys_to_change) 76 | return render_template( 77 | "manage_stream.html", 78 | form=form, 79 | unique_id=unique_id, 80 | update=True, 81 | ) 82 | else: 83 | # User does not have access to manage this stream ID. 84 | return abort(403) 85 | 86 | 87 | @bp.route("/generate", methods=["GET", "POST"]) 88 | def generate_stream_key() -> Any: 89 | form = StreamGenerationForm(request.form) 90 | server = f"rtmp://{environ.get('RTMP_SERVER')}" 91 | if request.method == "GET": 92 | return render_template( 93 | "generate_stream.html", form=form, server=server 94 | ) 95 | else: 96 | user = discord.fetch_user() 97 | snowflake = user.id 98 | 99 | keyvar = uuid.uuid4() 100 | stream_key = str(keyvar)[0:8] 101 | unique_id = str(keyvar)[24:32] 102 | 103 | if form.validate(): 104 | title = form.title.data 105 | description = form.description.data 106 | category = form.category.data 107 | archivable = form.archivable.data 108 | unlisted = form.unlisted.data 109 | 110 | add_stream_to_db( 111 | snowflake, 112 | title, 113 | description, 114 | category, 115 | archivable, 116 | unique_id, 117 | stream_key, 118 | unlisted, 119 | ) 120 | 121 | return render_template( 122 | "generate_stream.html", 123 | form=form, 124 | user=user, 125 | key=stream_key, 126 | id=unique_id, 127 | unlisted=unlisted, 128 | server=server, 129 | ) 130 | -------------------------------------------------------------------------------- /overpass/routes/watch.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Union 2 | 3 | from flask import Blueprint, redirect, render_template, url_for 4 | from flask_discord import Unauthorized, requires_authorization 5 | from overpass import discord 6 | from overpass.stream_utils import ( 7 | get_livestreams_by_username, 8 | get_unlisted_livestreams_by_username, 9 | ) 10 | from overpass.watch import get_single_archived_stream, return_stream_page 11 | 12 | bp = Blueprint("watch", __name__) 13 | 14 | 15 | @bp.errorhandler(Unauthorized) 16 | def redirect_discord_unauthorized(e) -> Any: 17 | return redirect(url_for("auth.login")) 18 | 19 | 20 | @bp.before_request 21 | @requires_authorization 22 | def require_auth() -> None: 23 | pass 24 | 25 | 26 | @bp.route("/") 27 | @bp.route("//") 28 | def watch_stream(username: str, unique_id: Union[str, None] = None) -> Any: 29 | """Generates a page to watch streams with. 30 | 31 | Args: 32 | username (str): The user whose stream to watch 33 | unique_id (Union[str, None], optional): The unique ID of a stream. 34 | This is only set if the stream is either private, or archived. 35 | Defaults to None. 36 | 37 | Returns: 38 | Any: Static page rendered by Flask. 39 | """ 40 | stream = get_livestreams_by_username(username) 41 | if stream and not unique_id: 42 | # Regular livestream 43 | return render_template("watch.html", live=True, stream=stream) 44 | 45 | if unique_id: 46 | # Lets first check if its an unlisted stream 47 | unlisted = get_unlisted_livestreams_by_username(username) 48 | if unlisted: 49 | # Unlisted stream 50 | return render_template("watch.html", live=True, stream=unlisted) 51 | 52 | # It's an archived stream at this point 53 | try: 54 | stream = get_single_archived_stream(unique_id) 55 | return return_stream_page(unique_id, stream) 56 | except StopIteration: 57 | pass 58 | 59 | # Unlisted and archived 60 | try: 61 | stream = get_single_archived_stream( 62 | unique_id, all_metadata=True, private=True 63 | ) 64 | if bool(stream["archivable"]) and bool(stream["unlisted"]): 65 | return return_stream_page(unique_id, stream) 66 | except StopIteration: 67 | pass 68 | 69 | # Private stream 70 | try: 71 | user = discord.fetch_user() 72 | stream = get_single_archived_stream(unique_id, private=True) 73 | if stream["user_snowflake"] == user.id: 74 | return return_stream_page(unique_id, stream) 75 | else: 76 | return render_template( 77 | "alert.html", error="Invalid stream key." 78 | ) 79 | except StopIteration: 80 | # The stream ID doesn't exist at all at this point 81 | return ( 82 | render_template("alert.html", error="Invalid stream key."), 83 | 404, 84 | ) 85 | 86 | return render_template("watch.html") 87 | -------------------------------------------------------------------------------- /overpass/rtmp_server_api.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from flask import current_app 4 | 5 | from overpass.db import get_db, query_one 6 | 7 | 8 | def verify_stream_key(stream_key: str) -> bool: 9 | """Validate that the stream key exists in the database 10 | 11 | Args: 12 | stream_key (str): The stream key to validate. 13 | 14 | Returns: 15 | bool: True if the key exists. 16 | """ 17 | res = query_one( 18 | "SELECT * from stream WHERE stream_key = ? AND end_date IS NULL", 19 | [stream_key], 20 | ) 21 | try: 22 | if res["stream_key"]: 23 | current_app.logger.info(f"Accepting stream: {stream_key}") 24 | return True 25 | except KeyError: 26 | current_app.logger.info(f"Invalid stream key: {stream_key}") 27 | 28 | return False 29 | 30 | 31 | def start_stream(stream_key: str) -> None: 32 | """Sets the stream's start date in the database. 33 | 34 | Args: 35 | stream_key (str): The stream key to update. 36 | """ 37 | db = get_db() 38 | current_app.logger.info(f"Stream {stream_key} is now live!") 39 | res = query_one("SELECT * FROM stream WHERE stream_key = ?", [stream_key]) 40 | db.execute( 41 | "UPDATE stream SET start_date = ? WHERE id = ?", 42 | (datetime.now().strftime("%Y-%m-%d %H:%M:%S"), res["id"]), 43 | ) 44 | db.commit() 45 | 46 | 47 | def end_stream(stream_key: str) -> None: 48 | """Sets the stream's end date in the database. 49 | 50 | Args: 51 | stream_key (str): The stream key to update. 52 | """ 53 | db = get_db() 54 | current_app.logger.info(f"Ending stream {stream_key}") 55 | res = query_one("SELECT * FROM stream WHERE stream_key = ?", [stream_key]) 56 | db.execute( 57 | "UPDATE stream SET end_date = ? WHERE id = ?", 58 | (datetime.now().strftime("%Y-%m-%d %H:%M:%S"), res["id"]), 59 | ) 60 | db.commit() 61 | -------------------------------------------------------------------------------- /overpass/schema.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS user; 2 | DROP TABLE IF EXISTS stream; 3 | 4 | CREATE TABLE user ( 5 | id INTEGER PRIMARY KEY, 6 | snowflake INTEGER NOT NULL, 7 | username TEXT NOT NULL, 8 | avatar TEXT, 9 | last_login_date DEFAULT CURRENT_TIMESTAMP NOT NULL 10 | ); 11 | 12 | CREATE TABLE stream ( 13 | id INTEGER PRIMARY KEY, 14 | user_snowflake INTEGER NOT NULL, 15 | stream_key TEXT NOT NULL, 16 | unique_id TEXT NOT NULL, 17 | generate_date DEFAULT CURRENT_TIMESTAMP NOT NULL, 18 | start_date TIMESTAMP, 19 | end_date TIMESTAMP, 20 | title TEXT NOT NULL, 21 | description TEXT NOT NULL, 22 | category TEXT, 23 | archived_file TEXT, 24 | archivable INTEGER, 25 | unlisted INTEGER, 26 | 27 | FOREIGN KEY (user_snowflake) REFERENCES user (snowflake) 28 | ); -------------------------------------------------------------------------------- /overpass/static/sign_in_with_discord.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GOATS2K/overpass/f42e9b9ce166478f16627e81af5308f600dcc0c7/overpass/static/sign_in_with_discord.png -------------------------------------------------------------------------------- /overpass/static/sign_in_with_discord_small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GOATS2K/overpass/f42e9b9ce166478f16627e81af5308f600dcc0c7/overpass/static/sign_in_with_discord_small.png -------------------------------------------------------------------------------- /overpass/stream_api.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa E501 2 | 3 | from typing import Any, Dict, List 4 | from flask import current_app 5 | 6 | 7 | from overpass.db import get_db, query_many, query_one 8 | from overpass.stream_utils import get_username_from_snowflake 9 | from pathlib import Path 10 | from os import environ 11 | 12 | # from overpass.app import app 13 | 14 | 15 | def add_stream_to_db( 16 | snowflake: int, 17 | title: str, 18 | description: str, 19 | category: str, 20 | archivable: bool, 21 | unique_id: str, 22 | stream_key: str, 23 | unlisted: bool, 24 | ) -> None: 25 | """Adds the stream to the database. 26 | 27 | Args: 28 | snowflake (int): User ID of the user initiating the stream. 29 | title (str): Title of the stream. 30 | description (str): The stream's description. 31 | category (str): The stream's category. 32 | archivable (bool): Is the stream going to be publically archived? 33 | unique_id (str): The stream's unique ID. 34 | stream_key (str): The stream's stream key. 35 | unlisted (bool): Is the stream unlisted? 36 | """ 37 | db = get_db() 38 | current_app.logger.info( 39 | f"Adding stream {title} by {snowflake} with stream key {stream_key} to database" 40 | ) 41 | db.execute( 42 | "INSERT INTO stream (user_snowflake, title, description, category, archivable, unique_id, stream_key, unlisted) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", 43 | ( 44 | snowflake, 45 | title, 46 | description, 47 | category, 48 | archivable, 49 | unique_id, 50 | stream_key, 51 | unlisted, 52 | ), 53 | ) 54 | db.commit() 55 | 56 | 57 | def verify_livestream(stream_key: str) -> bool: 58 | """Verifies that the stream's playlist exists on disk. 59 | 60 | Args: 61 | stream_key (str): The stream's key 62 | 63 | Returns: 64 | bool: True if the playlist exists, false if not. 65 | """ 66 | stream_path = Path(environ.get("HLS_PATH", "")) / f"{stream_key}.m3u8" 67 | if stream_path.exists(): 68 | return True 69 | else: 70 | return False 71 | 72 | 73 | def get_current_livestreams() -> List[Dict[str, Any]]: 74 | """Gets all ongoing livestreams 75 | 76 | Returns: 77 | List[Dict[str, Any]]: A list of all ongoing livestreams in a dict. 78 | """ 79 | items = "id, user_snowflake, start_date, title, description, category, stream_key, unique_id" 80 | res = query_many( 81 | f"SELECT {items} FROM stream WHERE start_date IS NOT NULL AND end_date IS NULL AND unlisted = 0" 82 | ) 83 | for stream in res.copy(): 84 | # It takes a while for nginx-rtmp to generate the HLS playlist 85 | # Therefore we won't show the stream until the file has been generated 86 | if not verify_livestream(stream["stream_key"]): 87 | res.remove(stream) 88 | 89 | stream["username"] = get_username_from_snowflake( 90 | stream["user_snowflake"] 91 | ) 92 | stream["hls"] = f"/hls/{stream['unique_id']}/index.m3u8" 93 | del stream["user_snowflake"] 94 | del stream["stream_key"] 95 | 96 | return res 97 | 98 | 99 | def update_db_fields(unique_id: str, **kwargs) -> None: 100 | """Update the stream's properties in the database. 101 | 102 | Args: 103 | unique_id (str): The stream's unique ID. 104 | """ 105 | db = get_db() 106 | for key, value in kwargs.items(): 107 | current_app.logger.info( 108 | f"Updating field '{key}' in stream {unique_id}" 109 | ) 110 | db.execute( 111 | f"UPDATE stream SET {key} = ? WHERE unique_id = ?", 112 | (value, unique_id), 113 | ) 114 | 115 | db.commit() 116 | -------------------------------------------------------------------------------- /overpass/stream_utils.py: -------------------------------------------------------------------------------- 1 | from io import BytesIO 2 | from os import environ 3 | from typing import Any, Dict 4 | 5 | from flask import current_app 6 | 7 | from overpass.db import query_one 8 | 9 | 10 | def rewrite_stream_playlist(stream_key: str) -> BytesIO: 11 | """Re-write the stream's playlist file. 12 | This is run every time /hls/unique_id/index.m3u8 is requested. 13 | 14 | This is to ensure the stream key used doesn't get exposed 15 | to the users of the application. 16 | 17 | Args: 18 | stream_key (str): The stream's key. 19 | """ 20 | path = environ.get("HLS_PATH", "") 21 | playlist_bytes = BytesIO() 22 | current_app.logger.info(f"Rewriting playlist for {stream_key}") 23 | with open(f"{path}/{stream_key}.m3u8", "r") as infile: 24 | lines = infile.readlines() 25 | 26 | for line in lines: 27 | playlist_bytes.write( 28 | str(line.replace(f"{stream_key}-", "")).encode("utf-8") 29 | ) 30 | 31 | playlist_bytes.seek(0) 32 | 33 | return playlist_bytes 34 | 35 | 36 | def get_livestreams_by_username(username: str) -> Dict[str, Any]: 37 | """Get a user's current livestreams via their username. 38 | 39 | Args: 40 | username (str): The username you want to check for. 41 | 42 | Returns: 43 | Dict[str, Any]: The livestream's details. 44 | """ 45 | items = ( 46 | "user_snowflake, start_date, title, description, category, unique_id" 47 | ) 48 | user_id = query_one( 49 | "SELECT snowflake FROM user WHERE username = ?", [username] 50 | ) 51 | stream = query_one( 52 | f"SELECT {items} FROM stream WHERE user_snowflake = ? AND unlisted = 0 AND end_date IS NULL AND start_date IS NOT NULL", # noqa: E501 53 | [user_id["snowflake"]], 54 | ) 55 | if stream: 56 | stream["username"] = username 57 | return stream 58 | 59 | 60 | def get_unlisted_livestreams_by_username(username: str) -> Dict[str, Any]: 61 | """Get a user's unlisted livestreams from their username. 62 | 63 | Args: 64 | username (str): The user's username. 65 | 66 | Returns: 67 | Dict[str, Any]: The unlisted livestream. 68 | """ 69 | items = ( 70 | "user_snowflake, start_date, title, description, category, unique_id" 71 | ) 72 | res = query_one( 73 | "SELECT snowflake FROM user WHERE username = ?", [username] 74 | ) 75 | stream = query_one( 76 | f"SELECT {items} FROM stream WHERE user_snowflake = ? AND unlisted = 1 AND end_date IS NULL AND start_date IS NOT NULL", # noqa: E501 77 | [res["snowflake"]], 78 | ) 79 | if stream: 80 | stream["username"] = username 81 | return stream 82 | 83 | 84 | def get_username_from_snowflake(snowflake: int) -> str: 85 | """Get a user's username from a user ID. 86 | 87 | Args: 88 | snowflake (int): The user's ID. 89 | 90 | Returns: 91 | str: The user's username. 92 | """ 93 | user = query_one( 94 | "SELECT username FROM user WHERE snowflake = ?", 95 | [snowflake], 96 | ) 97 | return user["username"] 98 | 99 | 100 | def get_unique_stream_id_from_stream_key(stream_key: str) -> str: 101 | """Get a stream's unique ID from its key. 102 | 103 | Args: 104 | stream_key (str): The stream's key 105 | 106 | Returns: 107 | str: The unique ID. 108 | """ 109 | res = query_one( 110 | "SELECT unique_id FROM stream WHERE stream_key = ?", [stream_key] 111 | ) 112 | return res["unique_id"] 113 | 114 | 115 | def get_stream_key_from_unique_id(unique_id: str) -> str: 116 | """Get a stream's stream key from its unique ID. 117 | 118 | Args: 119 | unique_id (str): The stream's unique ID. 120 | 121 | Returns: 122 | str: The stream key. 123 | """ 124 | res = query_one( 125 | "SELECT stream_key FROM stream WHERE unique_id = ?", [unique_id] 126 | ) 127 | return res["stream_key"] 128 | -------------------------------------------------------------------------------- /overpass/templates/alert.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | 3 | {% block title %} 4 | Alert 5 | {% endblock %} 6 | 7 | {% block content %} 8 | {% include "header.html" %} 9 | {% if error %} 10 | 13 | {% endif %} 14 | {% if info %} 15 | 18 | {% endif %} 19 | {% endblock %} -------------------------------------------------------------------------------- /overpass/templates/archive.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block title %} 3 | Archive 4 | {% endblock %} 5 | {% block content %} 6 | {% include "header.html" %} 7 |
Archive
8 | {% if archive is defined and archive|length %} 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | {% for stream in archive %} 21 | 22 | 23 | 24 | 25 | 26 | 32 | 33 | 34 | 41 | 42 | {% endfor %} 43 | 44 |
DateUserTitleDuration
{{ stream['start_date'] }} UTC{{ stream['username'] }}{{ stream['title'] }}{{ stream["duration"] }} 27 |
28 | Play 29 | 30 |
31 |
35 |
36 |
37 | {{ stream["description"] | nl2br }} 38 |
39 |
40 |
45 | {% else %} 46 | 49 | {% endif %} 50 | {% endblock %} -------------------------------------------------------------------------------- /overpass/templates/generate_stream.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block title %} 3 | Go Live 4 | {% endblock %} 5 | {% block content %} 6 | {% include "header.html" %} 7 | 10 |
11 | 12 |
13 | {{ form.title(class="form-control", placeholder="Title") }} 14 |
15 |
16 | {{ form.category(class="form-control", placeholder="Category") }} 17 |
18 |
19 | {{ form.description(class="form-control", placeholder="Description") }} 20 |
21 |
22 | {{ form.archivable.label }} 23 | {{ form.archivable(class="form-check-input") }} 24 |
25 |
26 | {{ form.unlisted.label }} 27 | {{ form.unlisted(class="form-check-input") }} 28 |
29 | 30 |
31 | {% if key %} 32 | 35 | {% endif %} 36 | {% if unlisted %} 37 | 40 | {% endif %} 41 | {% endblock %} -------------------------------------------------------------------------------- /overpass/templates/header.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /overpass/templates/index.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block title %} 3 | Home 4 | {% endblock %} 5 | 6 | {% block meta %} 7 | 8 | {% endblock %} 9 | 10 | 11 | {% block content %} 12 | {% if authorized %} 13 | {% include "header.html" %} 14 | {% if streams is defined and streams|length %} 15 | {% for stream in streams %} 16 |
17 |
18 | {{stream['username']}} is streaming! 19 |
20 |
21 |
{{stream['title']}}
22 |

{{stream['description']}}

23 | Play 24 |
25 |
26 | {% endfor %} 27 | {% else %} 28 | 32 | {% endif %} 33 | {% else %} 34 |
35 |
36 | Overpass 37 |
38 |
39 |
Sign in
40 |

Click the button to sign in with Discord.
41 |
42 | 44 |

45 |
46 | {% endif %} 47 | {% endblock %} -------------------------------------------------------------------------------- /overpass/templates/layout.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | {% block title %}{% endblock %} - Overpass 4 | {% block meta %}{% endblock %} 5 | 6 | 8 | 9 | 10 | 11 | 12 | 13 |
14 | {% block content %}{% endblock %} 15 |
16 | 17 | -------------------------------------------------------------------------------- /overpass/templates/manage_stream.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block title %} 3 | Edit Stream 4 | {% endblock %} 5 | 6 | {% block content %} 7 | {% include "header.html" %} 8 | {% if update %} 9 | 12 | {% endif %} 13 |
14 |
15 | {{ form.title.label }} 16 | {{ form.title(class="form-control") }} 17 |
18 |
19 | {{ form.category.label }} 20 | {{ form.category(class="form-control") }} 21 |
22 |
23 | {{ form.description.label }} 24 | {{ form.description(class="form-control") }} 25 |
26 |
27 | {{ form.archivable.label }} 28 | {{ form.archivable(class="form-check-input") }} 29 |
30 |
31 | {{ form.unlisted.label }} 32 | {{ form.unlisted(class="form-check-input") }} 33 |
34 | 35 |
36 | {% endblock %} -------------------------------------------------------------------------------- /overpass/templates/manage_user.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block title %} 3 | Manage Profile 4 | {% endblock %} 5 | 6 | {% block content %} 7 | {% include 'header.html' %} 8 |

User Info

9 |
10 |
11 |
12 |
13 | {% if user['avatar'] %} 14 |
15 | 16 |
17 | {% endif %} 18 |
19 |
{{user['username']}}
20 |

Last login date: {{user['last_login_date']}} UTC

21 |

Discord User ID: {{user['snowflake']}}

22 |
23 |
24 |
25 |
26 |
27 | {% if streams is defined %} 28 |
My streams
29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | {% for stream in streams %} 41 | 42 | {% if stream['start_date'] %} 43 | 44 | {% else %} 45 | 46 | {% endif %} 47 | 48 | {% if stream["duration"] %} 49 | 50 | {% else %} 51 | 52 | {% endif %} 53 | 69 | 80 | 81 | 82 | 93 | 94 | {% endfor %} 95 | 96 |
DateTitleDurationStatus
{{ stream['start_date'] }} UTCNot Started Yet{{ stream['title'] }}{{ stream['duration'] }}N/A 54 |
55 | 56 | {% if not stream["duration"] and stream["start_date"] %} 57 | LIVE 58 | {% endif %} 59 | {% if stream["archivable"] %} 60 | Archived 61 | {% else %} 62 | Not Archived 63 | {% endif %} 64 | {% if stream["unlisted"] %} 65 | Unlisted 66 | {% endif %} 67 |
68 |
70 |
71 | Edit Stream 72 | {% if not stream["duration"] and not stream["unlisted"] %} 73 | Play 74 | {% else %} 75 | Play 76 | {% endif %} 77 | 78 |
79 |
83 |
84 |
85 | {{ stream["description"] | nl2br }} 86 |
87 | Stream Key: {{ stream["stream_key"] }} 88 | Unique ID: {{ stream["unique_id"] }} 89 |
90 |
91 |
92 |
97 | {% endif %} 98 | {% endblock %} 99 | 100 | 101 | -------------------------------------------------------------------------------- /overpass/templates/watch.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | 3 | 4 | {% block title %} 5 | Watch 6 | {% endblock %} 7 | 8 | {% block content %} 9 | {% include "header.html" %} 10 | 11 | 12 | 13 | {% if stream %} 14 |
15 | 26 | {% else %} 27 | 28 | 29 | {% endif %} 30 | 48 |
49 |
50 |

{{stream['title']}}

51 |
by {{stream['username']}}
52 |
53 |
54 | {{stream['description'] | nl2br}} 55 |
56 |
57 |
58 |
59 | {% else %} 60 | 63 | {% endif %} 64 | {% endblock %} 65 | -------------------------------------------------------------------------------- /overpass/watch.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, Text 2 | 3 | from flask import render_template 4 | 5 | from overpass.archive import get_archived_streams 6 | 7 | 8 | def get_single_archived_stream( 9 | unique_id: str, all_metadata: bool = False, private: bool = False 10 | ) -> Dict[str, Any]: 11 | """Get a single stream from the archive 12 | 13 | Args: 14 | unique_id (str): The stream's unique ID. 15 | 16 | all_metadata (bool, optional): Return all metadata about the stream. 17 | Defaults to False. 18 | 19 | private (bool, optional): 20 | Include streams that aren't publically archived. 21 | Defaults to False. 22 | 23 | Returns: 24 | Dict[str, Any]: Dict containing information about the stream. 25 | """ 26 | archived_streams = get_archived_streams( 27 | all_metadata=all_metadata, private=private 28 | ) 29 | stream = next( 30 | stream 31 | for stream in archived_streams 32 | if stream["unique_id"] == unique_id 33 | ) 34 | return stream 35 | 36 | 37 | def return_stream_page(unique_id: str, stream: Dict[str, Any]) -> Text: 38 | """Helper function used in the watch_stream function 39 | 40 | Args: 41 | unique_id (str): The stream's unique ID. 42 | stream (Dict[str, Any]): Metadata about the stream. 43 | 44 | Returns: 45 | Text: Static page rendered by Flask. 46 | """ 47 | return render_template( 48 | "watch.html", 49 | id=unique_id, 50 | stream=stream, 51 | archive_link=stream["download"], 52 | ) 53 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | [[package]] 2 | name = "atomicwrites" 3 | version = "1.4.1" 4 | description = "Atomic file writes." 5 | category = "dev" 6 | optional = false 7 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 8 | 9 | [[package]] 10 | name = "attrs" 11 | version = "22.1.0" 12 | description = "Classes Without Boilerplate" 13 | category = "dev" 14 | optional = false 15 | python-versions = ">=3.5" 16 | 17 | [package.extras] 18 | dev = ["cloudpickle", "coverage[toml] (>=5.0.2)", "furo", "hypothesis", "mypy (>=0.900,!=0.940)", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "sphinx", "sphinx-notfound-page", "zope.interface"] 19 | docs = ["furo", "sphinx", "sphinx-notfound-page", "zope.interface"] 20 | tests = ["cloudpickle", "coverage[toml] (>=5.0.2)", "hypothesis", "mypy (>=0.900,!=0.940)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "zope.interface"] 21 | tests_no_zope = ["cloudpickle", "coverage[toml] (>=5.0.2)", "hypothesis", "mypy (>=0.900,!=0.940)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins"] 22 | 23 | [[package]] 24 | name = "black" 25 | version = "22.8.0" 26 | description = "The uncompromising code formatter." 27 | category = "dev" 28 | optional = false 29 | python-versions = ">=3.6.2" 30 | 31 | [package.dependencies] 32 | click = ">=8.0.0" 33 | mypy-extensions = ">=0.4.3" 34 | pathspec = ">=0.9.0" 35 | platformdirs = ">=2" 36 | tomli = {version = ">=1.1.0", markers = "python_full_version < \"3.11.0a7\""} 37 | typing-extensions = {version = ">=3.10.0.0", markers = "python_version < \"3.10\""} 38 | 39 | [package.extras] 40 | colorama = ["colorama (>=0.4.3)"] 41 | d = ["aiohttp (>=3.7.4)"] 42 | jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] 43 | uvloop = ["uvloop (>=0.15.2)"] 44 | 45 | [[package]] 46 | name = "cachetools" 47 | version = "5.2.0" 48 | description = "Extensible memoizing collections and decorators" 49 | category = "main" 50 | optional = false 51 | python-versions = "~=3.7" 52 | 53 | [[package]] 54 | name = "certifi" 55 | version = "2022.6.15.1" 56 | description = "Python package for providing Mozilla's CA Bundle." 57 | category = "main" 58 | optional = false 59 | python-versions = ">=3.6" 60 | 61 | [[package]] 62 | name = "cffi" 63 | version = "1.15.1" 64 | description = "Foreign Function Interface for Python calling C code." 65 | category = "main" 66 | optional = false 67 | python-versions = "*" 68 | 69 | [package.dependencies] 70 | pycparser = "*" 71 | 72 | [[package]] 73 | name = "charset-normalizer" 74 | version = "2.1.1" 75 | description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." 76 | category = "main" 77 | optional = false 78 | python-versions = ">=3.6.0" 79 | 80 | [package.extras] 81 | unicode_backport = ["unicodedata2"] 82 | 83 | [[package]] 84 | name = "click" 85 | version = "8.1.3" 86 | description = "Composable command line interface toolkit" 87 | category = "main" 88 | optional = false 89 | python-versions = ">=3.7" 90 | 91 | [package.dependencies] 92 | colorama = {version = "*", markers = "platform_system == \"Windows\""} 93 | 94 | [[package]] 95 | name = "colorama" 96 | version = "0.4.5" 97 | description = "Cross-platform colored terminal text." 98 | category = "main" 99 | optional = false 100 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 101 | 102 | [[package]] 103 | name = "cryptography" 104 | version = "38.0.1" 105 | description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." 106 | category = "main" 107 | optional = false 108 | python-versions = ">=3.6" 109 | 110 | [package.dependencies] 111 | cffi = ">=1.12" 112 | 113 | [package.extras] 114 | docs = ["sphinx (>=1.6.5,!=1.8.0,!=3.1.0,!=3.1.1)", "sphinx-rtd-theme"] 115 | docstest = ["pyenchant (>=1.6.11)", "sphinxcontrib-spelling (>=4.0.1)", "twine (>=1.12.0)"] 116 | pep8test = ["black", "flake8", "flake8-import-order", "pep8-naming"] 117 | sdist = ["setuptools-rust (>=0.11.4)"] 118 | ssh = ["bcrypt (>=3.1.5)"] 119 | test = ["hypothesis (>=1.11.4,!=3.79.2)", "iso8601", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-subtests", "pytest-xdist", "pytz"] 120 | 121 | [[package]] 122 | name = "Flask" 123 | version = "1.1.2" 124 | description = "A simple framework for building complex web applications." 125 | category = "main" 126 | optional = false 127 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 128 | 129 | [package.dependencies] 130 | click = ">=5.1" 131 | itsdangerous = ">=0.24" 132 | Jinja2 = ">=2.10.1" 133 | Werkzeug = ">=0.15" 134 | 135 | [package.extras] 136 | dev = ["coverage", "pallets-sphinx-themes", "pytest", "sphinx", "sphinx-issues", "sphinxcontrib-log-cabinet", "tox"] 137 | docs = ["pallets-sphinx-themes", "sphinx", "sphinx-issues", "sphinxcontrib-log-cabinet"] 138 | dotenv = ["python-dotenv"] 139 | 140 | [[package]] 141 | name = "Flask-Discord" 142 | version = "0.1.69" 143 | description = "Discord OAuth2 extension for Flask." 144 | category = "main" 145 | optional = false 146 | python-versions = "*" 147 | 148 | [package.dependencies] 149 | cachetools = "*" 150 | Flask = "*" 151 | oauthlib = "*" 152 | pyjwt = ">=2.4.0" 153 | requests = "*" 154 | requests-oauthlib = "*" 155 | 156 | [package.extras] 157 | docs = ["sphinx (==1.8.3)"] 158 | 159 | [[package]] 160 | name = "Flask-Login" 161 | version = "0.5.0" 162 | description = "User session management for Flask" 163 | category = "main" 164 | optional = false 165 | python-versions = "*" 166 | 167 | [package.dependencies] 168 | Flask = "*" 169 | 170 | [[package]] 171 | name = "Flask-WTF" 172 | version = "0.14.3" 173 | description = "Simple integration of Flask and WTForms." 174 | category = "main" 175 | optional = false 176 | python-versions = "*" 177 | 178 | [package.dependencies] 179 | Flask = "*" 180 | itsdangerous = "*" 181 | WTForms = "*" 182 | 183 | [[package]] 184 | name = "gunicorn" 185 | version = "20.1.0" 186 | description = "WSGI HTTP Server for UNIX" 187 | category = "main" 188 | optional = false 189 | python-versions = ">=3.5" 190 | 191 | [package.dependencies] 192 | setuptools = ">=3.0" 193 | 194 | [package.extras] 195 | eventlet = ["eventlet (>=0.24.1)"] 196 | gevent = ["gevent (>=1.4.0)"] 197 | setproctitle = ["setproctitle"] 198 | tornado = ["tornado (>=0.2)"] 199 | 200 | [[package]] 201 | name = "idna" 202 | version = "3.3" 203 | description = "Internationalized Domain Names in Applications (IDNA)" 204 | category = "main" 205 | optional = false 206 | python-versions = ">=3.5" 207 | 208 | [[package]] 209 | name = "itsdangerous" 210 | version = "1.1.0" 211 | description = "Various helpers to pass data to untrusted environments and back." 212 | category = "main" 213 | optional = false 214 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 215 | 216 | [[package]] 217 | name = "Jinja2" 218 | version = "2.11.3" 219 | description = "A very fast and expressive template engine." 220 | category = "main" 221 | optional = false 222 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 223 | 224 | [package.dependencies] 225 | MarkupSafe = ">=0.23" 226 | 227 | [package.extras] 228 | i18n = ["Babel (>=0.8)"] 229 | 230 | [[package]] 231 | name = "MarkupSafe" 232 | version = "2.0.1" 233 | description = "Safely add untrusted strings to HTML/XML markup." 234 | category = "main" 235 | optional = false 236 | python-versions = ">=3.6" 237 | 238 | [[package]] 239 | name = "more-itertools" 240 | version = "8.14.0" 241 | description = "More routines for operating on iterables, beyond itertools" 242 | category = "dev" 243 | optional = false 244 | python-versions = ">=3.5" 245 | 246 | [[package]] 247 | name = "mypy-extensions" 248 | version = "0.4.3" 249 | description = "Experimental type system extensions for programs checked with the mypy typechecker." 250 | category = "dev" 251 | optional = false 252 | python-versions = "*" 253 | 254 | [[package]] 255 | name = "oauthlib" 256 | version = "3.2.1" 257 | description = "A generic, spec-compliant, thorough implementation of the OAuth request-signing logic" 258 | category = "main" 259 | optional = false 260 | python-versions = ">=3.6" 261 | 262 | [package.extras] 263 | rsa = ["cryptography (>=3.0.0)"] 264 | signals = ["blinker (>=1.4.0)"] 265 | signedtoken = ["cryptography (>=3.0.0)", "pyjwt (>=2.0.0,<3)"] 266 | 267 | [[package]] 268 | name = "packaging" 269 | version = "21.3" 270 | description = "Core utilities for Python packages" 271 | category = "dev" 272 | optional = false 273 | python-versions = ">=3.6" 274 | 275 | [package.dependencies] 276 | pyparsing = ">=2.0.2,<3.0.5 || >3.0.5" 277 | 278 | [[package]] 279 | name = "pathspec" 280 | version = "0.10.1" 281 | description = "Utility library for gitignore style pattern matching of file paths." 282 | category = "dev" 283 | optional = false 284 | python-versions = ">=3.7" 285 | 286 | [[package]] 287 | name = "platformdirs" 288 | version = "2.5.2" 289 | description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." 290 | category = "dev" 291 | optional = false 292 | python-versions = ">=3.7" 293 | 294 | [package.extras] 295 | docs = ["furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx (>=4)", "sphinx-autodoc-typehints (>=1.12)"] 296 | test = ["appdirs (==1.4.4)", "pytest (>=6)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)"] 297 | 298 | [[package]] 299 | name = "pluggy" 300 | version = "0.13.1" 301 | description = "plugin and hook calling mechanisms for python" 302 | category = "dev" 303 | optional = false 304 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 305 | 306 | [package.extras] 307 | dev = ["pre-commit", "tox"] 308 | 309 | [[package]] 310 | name = "py" 311 | version = "1.11.0" 312 | description = "library with cross-python path, ini-parsing, io, code, log facilities" 313 | category = "dev" 314 | optional = false 315 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 316 | 317 | [[package]] 318 | name = "pycparser" 319 | version = "2.21" 320 | description = "C parser in Python" 321 | category = "main" 322 | optional = false 323 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 324 | 325 | [[package]] 326 | name = "PyJWT" 327 | version = "2.4.0" 328 | description = "JSON Web Token implementation in Python" 329 | category = "main" 330 | optional = false 331 | python-versions = ">=3.6" 332 | 333 | [package.extras] 334 | crypto = ["cryptography (>=3.3.1)"] 335 | dev = ["coverage[toml] (==5.0.4)", "cryptography (>=3.3.1)", "mypy", "pre-commit", "pytest (>=6.0.0,<7.0.0)", "sphinx", "sphinx-rtd-theme", "zope.interface"] 336 | docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"] 337 | tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"] 338 | 339 | [[package]] 340 | name = "pyOpenSSL" 341 | version = "19.1.0" 342 | description = "Python wrapper module around the OpenSSL library" 343 | category = "main" 344 | optional = false 345 | python-versions = "*" 346 | 347 | [package.dependencies] 348 | cryptography = ">=2.8" 349 | six = ">=1.5.2" 350 | 351 | [package.extras] 352 | docs = ["sphinx", "sphinx-rtd-theme"] 353 | test = ["flaky", "pretend", "pytest (>=3.0.1)"] 354 | 355 | [[package]] 356 | name = "pyparsing" 357 | version = "3.0.9" 358 | description = "pyparsing module - Classes and methods to define and execute parsing grammars" 359 | category = "dev" 360 | optional = false 361 | python-versions = ">=3.6.8" 362 | 363 | [package.extras] 364 | diagrams = ["jinja2", "railroad-diagrams"] 365 | 366 | [[package]] 367 | name = "pytest" 368 | version = "5.4.3" 369 | description = "pytest: simple powerful testing with Python" 370 | category = "dev" 371 | optional = false 372 | python-versions = ">=3.5" 373 | 374 | [package.dependencies] 375 | atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} 376 | attrs = ">=17.4.0" 377 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 378 | more-itertools = ">=4.0.0" 379 | packaging = "*" 380 | pluggy = ">=0.12,<1.0" 381 | py = ">=1.5.0" 382 | wcwidth = "*" 383 | 384 | [package.extras] 385 | checkqa-mypy = ["mypy (==v0.761)"] 386 | testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] 387 | 388 | [[package]] 389 | name = "python-dotenv" 390 | version = "0.15.0" 391 | description = "Add .env support to your django/flask apps in development and deployments" 392 | category = "main" 393 | optional = false 394 | python-versions = "*" 395 | 396 | [package.extras] 397 | cli = ["click (>=5.0)"] 398 | 399 | [[package]] 400 | name = "requests" 401 | version = "2.28.1" 402 | description = "Python HTTP for Humans." 403 | category = "main" 404 | optional = false 405 | python-versions = ">=3.7, <4" 406 | 407 | [package.dependencies] 408 | certifi = ">=2017.4.17" 409 | charset-normalizer = ">=2,<3" 410 | idna = ">=2.5,<4" 411 | urllib3 = ">=1.21.1,<1.27" 412 | 413 | [package.extras] 414 | socks = ["PySocks (>=1.5.6,!=1.5.7)"] 415 | use_chardet_on_py3 = ["chardet (>=3.0.2,<6)"] 416 | 417 | [[package]] 418 | name = "requests-oauthlib" 419 | version = "1.3.1" 420 | description = "OAuthlib authentication support for Requests." 421 | category = "main" 422 | optional = false 423 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 424 | 425 | [package.dependencies] 426 | oauthlib = ">=3.0.0" 427 | requests = ">=2.0.0" 428 | 429 | [package.extras] 430 | rsa = ["oauthlib[signedtoken] (>=3.0.0)"] 431 | 432 | [[package]] 433 | name = "setuptools" 434 | version = "65.3.0" 435 | description = "Easily download, build, install, upgrade, and uninstall Python packages" 436 | category = "main" 437 | optional = false 438 | python-versions = ">=3.7" 439 | 440 | [package.extras] 441 | docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx", "sphinx-favicon", "sphinx-hoverxref (<2)", "sphinx-inline-tabs", "sphinx-notfound-page (==0.8.3)", "sphinx-reredirects", "sphinxcontrib-towncrier"] 442 | testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8 (<5)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "mock", "pip (>=19.1)", "pip-run (>=8.8)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] 443 | testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] 444 | 445 | [[package]] 446 | name = "six" 447 | version = "1.16.0" 448 | description = "Python 2 and 3 compatibility utilities" 449 | category = "main" 450 | optional = false 451 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" 452 | 453 | [[package]] 454 | name = "tomli" 455 | version = "2.0.1" 456 | description = "A lil' TOML parser" 457 | category = "dev" 458 | optional = false 459 | python-versions = ">=3.7" 460 | 461 | [[package]] 462 | name = "typing-extensions" 463 | version = "4.3.0" 464 | description = "Backported and Experimental Type Hints for Python 3.7+" 465 | category = "dev" 466 | optional = false 467 | python-versions = ">=3.7" 468 | 469 | [[package]] 470 | name = "urllib3" 471 | version = "1.26.12" 472 | description = "HTTP library with thread-safe connection pooling, file post, and more." 473 | category = "main" 474 | optional = false 475 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*, <4" 476 | 477 | [package.extras] 478 | brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)", "brotlipy (>=0.6.0)"] 479 | secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "ipaddress", "pyOpenSSL (>=0.14)", "urllib3-secure-extra"] 480 | socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] 481 | 482 | [[package]] 483 | name = "wcwidth" 484 | version = "0.2.5" 485 | description = "Measures the displayed width of unicode strings in a terminal" 486 | category = "dev" 487 | optional = false 488 | python-versions = "*" 489 | 490 | [[package]] 491 | name = "Werkzeug" 492 | version = "1.0.1" 493 | description = "The comprehensive WSGI web application library." 494 | category = "main" 495 | optional = false 496 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 497 | 498 | [package.extras] 499 | dev = ["coverage", "pallets-sphinx-themes", "pytest", "pytest-timeout", "sphinx", "sphinx-issues", "tox"] 500 | watchdog = ["watchdog"] 501 | 502 | [[package]] 503 | name = "WTForms" 504 | version = "3.0.1" 505 | description = "Form validation and rendering for Python web development." 506 | category = "main" 507 | optional = false 508 | python-versions = ">=3.7" 509 | 510 | [package.dependencies] 511 | MarkupSafe = "*" 512 | 513 | [package.extras] 514 | email = ["email-validator"] 515 | 516 | [metadata] 517 | lock-version = "1.1" 518 | python-versions = "^3.8" 519 | content-hash = "51ffea4490b1467099180aed93f42ebbda5c4b0927cc88129caac456462fa4a9" 520 | 521 | [metadata.files] 522 | atomicwrites = [ 523 | {file = "atomicwrites-1.4.1.tar.gz", hash = "sha256:81b2c9071a49367a7f770170e5eec8cb66567cfbbc8c73d20ce5ca4a8d71cf11"}, 524 | ] 525 | attrs = [ 526 | {file = "attrs-22.1.0-py2.py3-none-any.whl", hash = "sha256:86efa402f67bf2df34f51a335487cf46b1ec130d02b8d39fd248abfd30da551c"}, 527 | {file = "attrs-22.1.0.tar.gz", hash = "sha256:29adc2665447e5191d0e7c568fde78b21f9672d344281d0c6e1ab085429b22b6"}, 528 | ] 529 | black = [ 530 | {file = "black-22.8.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ce957f1d6b78a8a231b18e0dd2d94a33d2ba738cd88a7fe64f53f659eea49fdd"}, 531 | {file = "black-22.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5107ea36b2b61917956d018bd25129baf9ad1125e39324a9b18248d362156a27"}, 532 | {file = "black-22.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e8166b7bfe5dcb56d325385bd1d1e0f635f24aae14b3ae437102dedc0c186747"}, 533 | {file = "black-22.8.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd82842bb272297503cbec1a2600b6bfb338dae017186f8f215c8958f8acf869"}, 534 | {file = "black-22.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:d839150f61d09e7217f52917259831fe2b689f5c8e5e32611736351b89bb2a90"}, 535 | {file = "black-22.8.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:a05da0430bd5ced89176db098567973be52ce175a55677436a271102d7eaa3fe"}, 536 | {file = "black-22.8.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a098a69a02596e1f2a58a2a1c8d5a05d5a74461af552b371e82f9fa4ada8342"}, 537 | {file = "black-22.8.0-cp36-cp36m-win_amd64.whl", hash = "sha256:5594efbdc35426e35a7defa1ea1a1cb97c7dbd34c0e49af7fb593a36bd45edab"}, 538 | {file = "black-22.8.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a983526af1bea1e4cf6768e649990f28ee4f4137266921c2c3cee8116ae42ec3"}, 539 | {file = "black-22.8.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b2c25f8dea5e8444bdc6788a2f543e1fb01494e144480bc17f806178378005e"}, 540 | {file = "black-22.8.0-cp37-cp37m-win_amd64.whl", hash = "sha256:78dd85caaab7c3153054756b9fe8c611efa63d9e7aecfa33e533060cb14b6d16"}, 541 | {file = "black-22.8.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:cea1b2542d4e2c02c332e83150e41e3ca80dc0fb8de20df3c5e98e242156222c"}, 542 | {file = "black-22.8.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:5b879eb439094751185d1cfdca43023bc6786bd3c60372462b6f051efa6281a5"}, 543 | {file = "black-22.8.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:0a12e4e1353819af41df998b02c6742643cfef58282915f781d0e4dd7a200411"}, 544 | {file = "black-22.8.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c3a73f66b6d5ba7288cd5d6dad9b4c9b43f4e8a4b789a94bf5abfb878c663eb3"}, 545 | {file = "black-22.8.0-cp38-cp38-win_amd64.whl", hash = "sha256:e981e20ec152dfb3e77418fb616077937378b322d7b26aa1ff87717fb18b4875"}, 546 | {file = "black-22.8.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8ce13ffed7e66dda0da3e0b2eb1bdfc83f5812f66e09aca2b0978593ed636b6c"}, 547 | {file = "black-22.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:32a4b17f644fc288c6ee2bafdf5e3b045f4eff84693ac069d87b1a347d861497"}, 548 | {file = "black-22.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0ad827325a3a634bae88ae7747db1a395d5ee02cf05d9aa7a9bd77dfb10e940c"}, 549 | {file = "black-22.8.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:53198e28a1fb865e9fe97f88220da2e44df6da82b18833b588b1883b16bb5d41"}, 550 | {file = "black-22.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:bc4d4123830a2d190e9cc42a2e43570f82ace35c3aeb26a512a2102bce5af7ec"}, 551 | {file = "black-22.8.0-py3-none-any.whl", hash = "sha256:d2c21d439b2baf7aa80d6dd4e3659259be64c6f49dfd0f32091063db0e006db4"}, 552 | {file = "black-22.8.0.tar.gz", hash = "sha256:792f7eb540ba9a17e8656538701d3eb1afcb134e3b45b71f20b25c77a8db7e6e"}, 553 | ] 554 | cachetools = [ 555 | {file = "cachetools-5.2.0-py3-none-any.whl", hash = "sha256:f9f17d2aec496a9aa6b76f53e3b614c965223c061982d434d160f930c698a9db"}, 556 | {file = "cachetools-5.2.0.tar.gz", hash = "sha256:6a94c6402995a99c3970cc7e4884bb60b4a8639938157eeed436098bf9831757"}, 557 | ] 558 | certifi = [ 559 | {file = "certifi-2022.6.15.1-py3-none-any.whl", hash = "sha256:43dadad18a7f168740e66944e4fa82c6611848ff9056ad910f8f7a3e46ab89e0"}, 560 | {file = "certifi-2022.6.15.1.tar.gz", hash = "sha256:cffdcd380919da6137f76633531a5817e3a9f268575c128249fb637e4f9e73fb"}, 561 | ] 562 | cffi = [ 563 | {file = "cffi-1.15.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:a66d3508133af6e8548451b25058d5812812ec3798c886bf38ed24a98216fab2"}, 564 | {file = "cffi-1.15.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:470c103ae716238bbe698d67ad020e1db9d9dba34fa5a899b5e21577e6d52ed2"}, 565 | {file = "cffi-1.15.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:9ad5db27f9cabae298d151c85cf2bad1d359a1b9c686a275df03385758e2f914"}, 566 | {file = "cffi-1.15.1-cp27-cp27m-win32.whl", hash = "sha256:b3bbeb01c2b273cca1e1e0c5df57f12dce9a4dd331b4fa1635b8bec26350bde3"}, 567 | {file = "cffi-1.15.1-cp27-cp27m-win_amd64.whl", hash = "sha256:e00b098126fd45523dd056d2efba6c5a63b71ffe9f2bbe1a4fe1716e1d0c331e"}, 568 | {file = "cffi-1.15.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:d61f4695e6c866a23a21acab0509af1cdfd2c013cf256bbf5b6b5e2695827162"}, 569 | {file = "cffi-1.15.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:ed9cb427ba5504c1dc15ede7d516b84757c3e3d7868ccc85121d9310d27eed0b"}, 570 | {file = "cffi-1.15.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:39d39875251ca8f612b6f33e6b1195af86d1b3e60086068be9cc053aa4376e21"}, 571 | {file = "cffi-1.15.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:285d29981935eb726a4399badae8f0ffdff4f5050eaa6d0cfc3f64b857b77185"}, 572 | {file = "cffi-1.15.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3eb6971dcff08619f8d91607cfc726518b6fa2a9eba42856be181c6d0d9515fd"}, 573 | {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:21157295583fe8943475029ed5abdcf71eb3911894724e360acff1d61c1d54bc"}, 574 | {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5635bd9cb9731e6d4a1132a498dd34f764034a8ce60cef4f5319c0541159392f"}, 575 | {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2012c72d854c2d03e45d06ae57f40d78e5770d252f195b93f581acf3ba44496e"}, 576 | {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd86c085fae2efd48ac91dd7ccffcfc0571387fe1193d33b6394db7ef31fe2a4"}, 577 | {file = "cffi-1.15.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:fa6693661a4c91757f4412306191b6dc88c1703f780c8234035eac011922bc01"}, 578 | {file = "cffi-1.15.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:59c0b02d0a6c384d453fece7566d1c7e6b7bae4fc5874ef2ef46d56776d61c9e"}, 579 | {file = "cffi-1.15.1-cp310-cp310-win32.whl", hash = "sha256:cba9d6b9a7d64d4bd46167096fc9d2f835e25d7e4c121fb2ddfc6528fb0413b2"}, 580 | {file = "cffi-1.15.1-cp310-cp310-win_amd64.whl", hash = "sha256:ce4bcc037df4fc5e3d184794f27bdaab018943698f4ca31630bc7f84a7b69c6d"}, 581 | {file = "cffi-1.15.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3d08afd128ddaa624a48cf2b859afef385b720bb4b43df214f85616922e6a5ac"}, 582 | {file = "cffi-1.15.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3799aecf2e17cf585d977b780ce79ff0dc9b78d799fc694221ce814c2c19db83"}, 583 | {file = "cffi-1.15.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a591fe9e525846e4d154205572a029f653ada1a78b93697f3b5a8f1f2bc055b9"}, 584 | {file = "cffi-1.15.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3548db281cd7d2561c9ad9984681c95f7b0e38881201e157833a2342c30d5e8c"}, 585 | {file = "cffi-1.15.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:91fc98adde3d7881af9b59ed0294046f3806221863722ba7d8d120c575314325"}, 586 | {file = "cffi-1.15.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94411f22c3985acaec6f83c6df553f2dbe17b698cc7f8ae751ff2237d96b9e3c"}, 587 | {file = "cffi-1.15.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:03425bdae262c76aad70202debd780501fabeaca237cdfddc008987c0e0f59ef"}, 588 | {file = "cffi-1.15.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:cc4d65aeeaa04136a12677d3dd0b1c0c94dc43abac5860ab33cceb42b801c1e8"}, 589 | {file = "cffi-1.15.1-cp311-cp311-win32.whl", hash = "sha256:a0f100c8912c114ff53e1202d0078b425bee3649ae34d7b070e9697f93c5d52d"}, 590 | {file = "cffi-1.15.1-cp311-cp311-win_amd64.whl", hash = "sha256:04ed324bda3cda42b9b695d51bb7d54b680b9719cfab04227cdd1e04e5de3104"}, 591 | {file = "cffi-1.15.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50a74364d85fd319352182ef59c5c790484a336f6db772c1a9231f1c3ed0cbd7"}, 592 | {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e263d77ee3dd201c3a142934a086a4450861778baaeeb45db4591ef65550b0a6"}, 593 | {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cec7d9412a9102bdc577382c3929b337320c4c4c4849f2c5cdd14d7368c5562d"}, 594 | {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4289fc34b2f5316fbb762d75362931e351941fa95fa18789191b33fc4cf9504a"}, 595 | {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:173379135477dc8cac4bc58f45db08ab45d228b3363adb7af79436135d028405"}, 596 | {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:6975a3fac6bc83c4a65c9f9fcab9e47019a11d3d2cf7f3c0d03431bf145a941e"}, 597 | {file = "cffi-1.15.1-cp36-cp36m-win32.whl", hash = "sha256:2470043b93ff09bf8fb1d46d1cb756ce6132c54826661a32d4e4d132e1977adf"}, 598 | {file = "cffi-1.15.1-cp36-cp36m-win_amd64.whl", hash = "sha256:30d78fbc8ebf9c92c9b7823ee18eb92f2e6ef79b45ac84db507f52fbe3ec4497"}, 599 | {file = "cffi-1.15.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:198caafb44239b60e252492445da556afafc7d1e3ab7a1fb3f0584ef6d742375"}, 600 | {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5ef34d190326c3b1f822a5b7a45f6c4535e2f47ed06fec77d3d799c450b2651e"}, 601 | {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8102eaf27e1e448db915d08afa8b41d6c7ca7a04b7d73af6514df10a3e74bd82"}, 602 | {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5df2768244d19ab7f60546d0c7c63ce1581f7af8b5de3eb3004b9b6fc8a9f84b"}, 603 | {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8c4917bd7ad33e8eb21e9a5bbba979b49d9a97acb3a803092cbc1133e20343c"}, 604 | {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2642fe3142e4cc4af0799748233ad6da94c62a8bec3a6648bf8ee68b1c7426"}, 605 | {file = "cffi-1.15.1-cp37-cp37m-win32.whl", hash = "sha256:e229a521186c75c8ad9490854fd8bbdd9a0c9aa3a524326b55be83b54d4e0ad9"}, 606 | {file = "cffi-1.15.1-cp37-cp37m-win_amd64.whl", hash = "sha256:a0b71b1b8fbf2b96e41c4d990244165e2c9be83d54962a9a1d118fd8657d2045"}, 607 | {file = "cffi-1.15.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:320dab6e7cb2eacdf0e658569d2575c4dad258c0fcc794f46215e1e39f90f2c3"}, 608 | {file = "cffi-1.15.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e74c6b51a9ed6589199c787bf5f9875612ca4a8a0785fb2d4a84429badaf22a"}, 609 | {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5c84c68147988265e60416b57fc83425a78058853509c1b0629c180094904a5"}, 610 | {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b926aa83d1edb5aa5b427b4053dc420ec295a08e40911296b9eb1b6170f6cca"}, 611 | {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:87c450779d0914f2861b8526e035c5e6da0a3199d8f1add1a665e1cbc6fc6d02"}, 612 | {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f2c9f67e9821cad2e5f480bc8d83b8742896f1242dba247911072d4fa94c192"}, 613 | {file = "cffi-1.15.1-cp38-cp38-win32.whl", hash = "sha256:8b7ee99e510d7b66cdb6c593f21c043c248537a32e0bedf02e01e9553a172314"}, 614 | {file = "cffi-1.15.1-cp38-cp38-win_amd64.whl", hash = "sha256:00a9ed42e88df81ffae7a8ab6d9356b371399b91dbdf0c3cb1e84c03a13aceb5"}, 615 | {file = "cffi-1.15.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:54a2db7b78338edd780e7ef7f9f6c442500fb0d41a5a4ea24fff1c929d5af585"}, 616 | {file = "cffi-1.15.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:fcd131dd944808b5bdb38e6f5b53013c5aa4f334c5cad0c72742f6eba4b73db0"}, 617 | {file = "cffi-1.15.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7473e861101c9e72452f9bf8acb984947aa1661a7704553a9f6e4baa5ba64415"}, 618 | {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c9a799e985904922a4d207a94eae35c78ebae90e128f0c4e521ce339396be9d"}, 619 | {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3bcde07039e586f91b45c88f8583ea7cf7a0770df3a1649627bf598332cb6984"}, 620 | {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33ab79603146aace82c2427da5ca6e58f2b3f2fb5da893ceac0c42218a40be35"}, 621 | {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5d598b938678ebf3c67377cdd45e09d431369c3b1a5b331058c338e201f12b27"}, 622 | {file = "cffi-1.15.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:db0fbb9c62743ce59a9ff687eb5f4afbe77e5e8403d6697f7446e5f609976f76"}, 623 | {file = "cffi-1.15.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:98d85c6a2bef81588d9227dde12db8a7f47f639f4a17c9ae08e773aa9c697bf3"}, 624 | {file = "cffi-1.15.1-cp39-cp39-win32.whl", hash = "sha256:40f4774f5a9d4f5e344f31a32b5096977b5d48560c5592e2f3d2c4374bd543ee"}, 625 | {file = "cffi-1.15.1-cp39-cp39-win_amd64.whl", hash = "sha256:70df4e3b545a17496c9b3f41f5115e69a4f2e77e94e1d2a8e1070bc0c38c8a3c"}, 626 | {file = "cffi-1.15.1.tar.gz", hash = "sha256:d400bfb9a37b1351253cb402671cea7e89bdecc294e8016a707f6d1d8ac934f9"}, 627 | ] 628 | charset-normalizer = [ 629 | {file = "charset-normalizer-2.1.1.tar.gz", hash = "sha256:5a3d016c7c547f69d6f81fb0db9449ce888b418b5b9952cc5e6e66843e9dd845"}, 630 | {file = "charset_normalizer-2.1.1-py3-none-any.whl", hash = "sha256:83e9a75d1911279afd89352c68b45348559d1fc0506b054b346651b5e7fee29f"}, 631 | ] 632 | click = [ 633 | {file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"}, 634 | {file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"}, 635 | ] 636 | colorama = [ 637 | {file = "colorama-0.4.5-py2.py3-none-any.whl", hash = "sha256:854bf444933e37f5824ae7bfc1e98d5bce2ebe4160d46b5edf346a89358e99da"}, 638 | {file = "colorama-0.4.5.tar.gz", hash = "sha256:e6c6b4334fc50988a639d9b98aa429a0b57da6e17b9a44f0451f930b6967b7a4"}, 639 | ] 640 | cryptography = [ 641 | {file = "cryptography-38.0.1-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:10d1f29d6292fc95acb597bacefd5b9e812099d75a6469004fd38ba5471a977f"}, 642 | {file = "cryptography-38.0.1-cp36-abi3-macosx_10_10_x86_64.whl", hash = "sha256:3fc26e22840b77326a764ceb5f02ca2d342305fba08f002a8c1f139540cdfaad"}, 643 | {file = "cryptography-38.0.1-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:3b72c360427889b40f36dc214630e688c2fe03e16c162ef0aa41da7ab1455153"}, 644 | {file = "cryptography-38.0.1-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:194044c6b89a2f9f169df475cc167f6157eb9151cc69af8a2a163481d45cc407"}, 645 | {file = "cryptography-38.0.1-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca9f6784ea96b55ff41708b92c3f6aeaebde4c560308e5fbbd3173fbc466e94e"}, 646 | {file = "cryptography-38.0.1-cp36-abi3-manylinux_2_24_x86_64.whl", hash = "sha256:16fa61e7481f4b77ef53991075de29fc5bacb582a1244046d2e8b4bb72ef66d0"}, 647 | {file = "cryptography-38.0.1-cp36-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d4ef6cc305394ed669d4d9eebf10d3a101059bdcf2669c366ec1d14e4fb227bd"}, 648 | {file = "cryptography-38.0.1-cp36-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3261725c0ef84e7592597606f6583385fed2a5ec3909f43bc475ade9729a41d6"}, 649 | {file = "cryptography-38.0.1-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:0297ffc478bdd237f5ca3a7dc96fc0d315670bfa099c04dc3a4a2172008a405a"}, 650 | {file = "cryptography-38.0.1-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:89ed49784ba88c221756ff4d4755dbc03b3c8d2c5103f6d6b4f83a0fb1e85294"}, 651 | {file = "cryptography-38.0.1-cp36-abi3-win32.whl", hash = "sha256:ac7e48f7e7261207d750fa7e55eac2d45f720027d5703cd9007e9b37bbb59ac0"}, 652 | {file = "cryptography-38.0.1-cp36-abi3-win_amd64.whl", hash = "sha256:ad7353f6ddf285aeadfaf79e5a6829110106ff8189391704c1d8801aa0bae45a"}, 653 | {file = "cryptography-38.0.1-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:896dd3a66959d3a5ddcfc140a53391f69ff1e8f25d93f0e2e7830c6de90ceb9d"}, 654 | {file = "cryptography-38.0.1-pp37-pypy37_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:d3971e2749a723e9084dd507584e2a2761f78ad2c638aa31e80bc7a15c9db4f9"}, 655 | {file = "cryptography-38.0.1-pp37-pypy37_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:79473cf8a5cbc471979bd9378c9f425384980fcf2ab6534b18ed7d0d9843987d"}, 656 | {file = "cryptography-38.0.1-pp38-pypy38_pp73-macosx_10_10_x86_64.whl", hash = "sha256:d9e69ae01f99abe6ad646947bba8941e896cb3aa805be2597a0400e0764b5818"}, 657 | {file = "cryptography-38.0.1-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5067ee7f2bce36b11d0e334abcd1ccf8c541fc0bbdaf57cdd511fdee53e879b6"}, 658 | {file = "cryptography-38.0.1-pp38-pypy38_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:3e3a2599e640927089f932295a9a247fc40a5bdf69b0484532f530471a382750"}, 659 | {file = "cryptography-38.0.1-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c2e5856248a416767322c8668ef1845ad46ee62629266f84a8f007a317141013"}, 660 | {file = "cryptography-38.0.1-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:64760ba5331e3f1794d0bcaabc0d0c39e8c60bf67d09c93dc0e54189dfd7cfe5"}, 661 | {file = "cryptography-38.0.1-pp39-pypy39_pp73-macosx_10_10_x86_64.whl", hash = "sha256:b6c9b706316d7b5a137c35e14f4103e2115b088c412140fdbd5f87c73284df61"}, 662 | {file = "cryptography-38.0.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b0163a849b6f315bf52815e238bc2b2346604413fa7c1601eea84bcddb5fb9ac"}, 663 | {file = "cryptography-38.0.1-pp39-pypy39_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:d1a5bd52d684e49a36582193e0b89ff267704cd4025abefb9e26803adeb3e5fb"}, 664 | {file = "cryptography-38.0.1-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:765fa194a0f3372d83005ab83ab35d7c5526c4e22951e46059b8ac678b44fa5a"}, 665 | {file = "cryptography-38.0.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:52e7bee800ec869b4031093875279f1ff2ed12c1e2f74923e8f49c916afd1d3b"}, 666 | {file = "cryptography-38.0.1.tar.gz", hash = "sha256:1db3d807a14931fa317f96435695d9ec386be7b84b618cc61cfa5d08b0ae33d7"}, 667 | ] 668 | Flask = [ 669 | {file = "Flask-1.1.2-py2.py3-none-any.whl", hash = "sha256:8a4fdd8936eba2512e9c85df320a37e694c93945b33ef33c89946a340a238557"}, 670 | {file = "Flask-1.1.2.tar.gz", hash = "sha256:4efa1ae2d7c9865af48986de8aeb8504bf32c7f3d6fdc9353d34b21f4b127060"}, 671 | ] 672 | Flask-Discord = [ 673 | {file = "Flask-Discord-0.1.69.tar.gz", hash = "sha256:1029a26a57a1edcd78fc1243d8cf39385adad27cbbfd01d3c59cb5ceb8834645"}, 674 | {file = "Flask_Discord-0.1.69-py3-none-any.whl", hash = "sha256:d48fc6c2975f876b9c1476f285d98966039be396b9d9585402e01350cf1d39fb"}, 675 | ] 676 | Flask-Login = [ 677 | {file = "Flask-Login-0.5.0.tar.gz", hash = "sha256:6d33aef15b5bcead780acc339464aae8a6e28f13c90d8b1cf9de8b549d1c0b4b"}, 678 | {file = "Flask_Login-0.5.0-py2.py3-none-any.whl", hash = "sha256:7451b5001e17837ba58945aead261ba425fdf7b4f0448777e597ddab39f4fba0"}, 679 | ] 680 | Flask-WTF = [ 681 | {file = "Flask-WTF-0.14.3.tar.gz", hash = "sha256:d417e3a0008b5ba583da1763e4db0f55a1269d9dd91dcc3eb3c026d3c5dbd720"}, 682 | {file = "Flask_WTF-0.14.3-py2.py3-none-any.whl", hash = "sha256:57b3faf6fe5d6168bda0c36b0df1d05770f8e205e18332d0376ddb954d17aef2"}, 683 | ] 684 | gunicorn = [ 685 | {file = "gunicorn-20.1.0-py3-none-any.whl", hash = "sha256:9dcc4547dbb1cb284accfb15ab5667a0e5d1881cc443e0677b4882a4067a807e"}, 686 | {file = "gunicorn-20.1.0.tar.gz", hash = "sha256:e0a968b5ba15f8a328fdfd7ab1fcb5af4470c28aaf7e55df02a99bc13138e6e8"}, 687 | ] 688 | idna = [ 689 | {file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"}, 690 | {file = "idna-3.3.tar.gz", hash = "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"}, 691 | ] 692 | itsdangerous = [ 693 | {file = "itsdangerous-1.1.0-py2.py3-none-any.whl", hash = "sha256:b12271b2047cb23eeb98c8b5622e2e5c5e9abd9784a153e9d8ef9cb4dd09d749"}, 694 | {file = "itsdangerous-1.1.0.tar.gz", hash = "sha256:321b033d07f2a4136d3ec762eac9f16a10ccd60f53c0c91af90217ace7ba1f19"}, 695 | ] 696 | Jinja2 = [ 697 | {file = "Jinja2-2.11.3-py2.py3-none-any.whl", hash = "sha256:03e47ad063331dd6a3f04a43eddca8a966a26ba0c5b7207a9a9e4e08f1b29419"}, 698 | {file = "Jinja2-2.11.3.tar.gz", hash = "sha256:a6d58433de0ae800347cab1fa3043cebbabe8baa9d29e668f1c768cb87a333c6"}, 699 | ] 700 | MarkupSafe = [ 701 | {file = "MarkupSafe-2.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d8446c54dc28c01e5a2dbac5a25f071f6653e6e40f3a8818e8b45d790fe6ef53"}, 702 | {file = "MarkupSafe-2.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:36bc903cbb393720fad60fc28c10de6acf10dc6cc883f3e24ee4012371399a38"}, 703 | {file = "MarkupSafe-2.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2d7d807855b419fc2ed3e631034685db6079889a1f01d5d9dac950f764da3dad"}, 704 | {file = "MarkupSafe-2.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:add36cb2dbb8b736611303cd3bfcee00afd96471b09cda130da3581cbdc56a6d"}, 705 | {file = "MarkupSafe-2.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:168cd0a3642de83558a5153c8bd34f175a9a6e7f6dc6384b9655d2697312a646"}, 706 | {file = "MarkupSafe-2.0.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:4dc8f9fb58f7364b63fd9f85013b780ef83c11857ae79f2feda41e270468dd9b"}, 707 | {file = "MarkupSafe-2.0.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:20dca64a3ef2d6e4d5d615a3fd418ad3bde77a47ec8a23d984a12b5b4c74491a"}, 708 | {file = "MarkupSafe-2.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:cdfba22ea2f0029c9261a4bd07e830a8da012291fbe44dc794e488b6c9bb353a"}, 709 | {file = "MarkupSafe-2.0.1-cp310-cp310-win32.whl", hash = "sha256:99df47edb6bda1249d3e80fdabb1dab8c08ef3975f69aed437cb69d0a5de1e28"}, 710 | {file = "MarkupSafe-2.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:e0f138900af21926a02425cf736db95be9f4af72ba1bb21453432a07f6082134"}, 711 | {file = "MarkupSafe-2.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f9081981fe268bd86831e5c75f7de206ef275defcb82bc70740ae6dc507aee51"}, 712 | {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:0955295dd5eec6cb6cc2fe1698f4c6d84af2e92de33fbcac4111913cd100a6ff"}, 713 | {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:0446679737af14f45767963a1a9ef7620189912317d095f2d9ffa183a4d25d2b"}, 714 | {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:f826e31d18b516f653fe296d967d700fddad5901ae07c622bb3705955e1faa94"}, 715 | {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:fa130dd50c57d53368c9d59395cb5526eda596d3ffe36666cd81a44d56e48872"}, 716 | {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:905fec760bd2fa1388bb5b489ee8ee5f7291d692638ea5f67982d968366bef9f"}, 717 | {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf5d821ffabf0ef3533c39c518f3357b171a1651c1ff6827325e4489b0e46c3c"}, 718 | {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0d4b31cc67ab36e3392bbf3862cfbadac3db12bdd8b02a2731f509ed5b829724"}, 719 | {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:baa1a4e8f868845af802979fcdbf0bb11f94f1cb7ced4c4b8a351bb60d108145"}, 720 | {file = "MarkupSafe-2.0.1-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:deb993cacb280823246a026e3b2d81c493c53de6acfd5e6bfe31ab3402bb37dd"}, 721 | {file = "MarkupSafe-2.0.1-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:63f3268ba69ace99cab4e3e3b5840b03340efed0948ab8f78d2fd87ee5442a4f"}, 722 | {file = "MarkupSafe-2.0.1-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:8d206346619592c6200148b01a2142798c989edcb9c896f9ac9722a99d4e77e6"}, 723 | {file = "MarkupSafe-2.0.1-cp36-cp36m-win32.whl", hash = "sha256:6c4ca60fa24e85fe25b912b01e62cb969d69a23a5d5867682dd3e80b5b02581d"}, 724 | {file = "MarkupSafe-2.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b2f4bf27480f5e5e8ce285a8c8fd176c0b03e93dcc6646477d4630e83440c6a9"}, 725 | {file = "MarkupSafe-2.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0717a7390a68be14b8c793ba258e075c6f4ca819f15edfc2a3a027c823718567"}, 726 | {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:6557b31b5e2c9ddf0de32a691f2312a32f77cd7681d8af66c2692efdbef84c18"}, 727 | {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:49e3ceeabbfb9d66c3aef5af3a60cc43b85c33df25ce03d0031a608b0a8b2e3f"}, 728 | {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:d7f9850398e85aba693bb640262d3611788b1f29a79f0c93c565694658f4071f"}, 729 | {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:6a7fae0dd14cf60ad5ff42baa2e95727c3d81ded453457771d02b7d2b3f9c0c2"}, 730 | {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:b7f2d075102dc8c794cbde1947378051c4e5180d52d276987b8d28a3bd58c17d"}, 731 | {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e9936f0b261d4df76ad22f8fee3ae83b60d7c3e871292cd42f40b81b70afae85"}, 732 | {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:2a7d351cbd8cfeb19ca00de495e224dea7e7d919659c2841bbb7f420ad03e2d6"}, 733 | {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:60bf42e36abfaf9aff1f50f52644b336d4f0a3fd6d8a60ca0d054ac9f713a864"}, 734 | {file = "MarkupSafe-2.0.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d6c7ebd4e944c85e2c3421e612a7057a2f48d478d79e61800d81468a8d842207"}, 735 | {file = "MarkupSafe-2.0.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:f0567c4dc99f264f49fe27da5f735f414c4e7e7dd850cfd8e69f0862d7c74ea9"}, 736 | {file = "MarkupSafe-2.0.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:89c687013cb1cd489a0f0ac24febe8c7a666e6e221b783e53ac50ebf68e45d86"}, 737 | {file = "MarkupSafe-2.0.1-cp37-cp37m-win32.whl", hash = "sha256:a30e67a65b53ea0a5e62fe23682cfe22712e01f453b95233b25502f7c61cb415"}, 738 | {file = "MarkupSafe-2.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:611d1ad9a4288cf3e3c16014564df047fe08410e628f89805e475368bd304914"}, 739 | {file = "MarkupSafe-2.0.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:5bb28c636d87e840583ee3adeb78172efc47c8b26127267f54a9c0ec251d41a9"}, 740 | {file = "MarkupSafe-2.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:be98f628055368795d818ebf93da628541e10b75b41c559fdf36d104c5787066"}, 741 | {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:1d609f577dc6e1aa17d746f8bd3c31aa4d258f4070d61b2aa5c4166c1539de35"}, 742 | {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7d91275b0245b1da4d4cfa07e0faedd5b0812efc15b702576d103293e252af1b"}, 743 | {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:01a9b8ea66f1658938f65b93a85ebe8bc016e6769611be228d797c9d998dd298"}, 744 | {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:47ab1e7b91c098ab893b828deafa1203de86d0bc6ab587b160f78fe6c4011f75"}, 745 | {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:97383d78eb34da7e1fa37dd273c20ad4320929af65d156e35a5e2d89566d9dfb"}, 746 | {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6fcf051089389abe060c9cd7caa212c707e58153afa2c649f00346ce6d260f1b"}, 747 | {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:5855f8438a7d1d458206a2466bf82b0f104a3724bf96a1c781ab731e4201731a"}, 748 | {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:3dd007d54ee88b46be476e293f48c85048603f5f516008bee124ddd891398ed6"}, 749 | {file = "MarkupSafe-2.0.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:aca6377c0cb8a8253e493c6b451565ac77e98c2951c45f913e0b52facdcff83f"}, 750 | {file = "MarkupSafe-2.0.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:04635854b943835a6ea959e948d19dcd311762c5c0c6e1f0e16ee57022669194"}, 751 | {file = "MarkupSafe-2.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6300b8454aa6930a24b9618fbb54b5a68135092bc666f7b06901f897fa5c2fee"}, 752 | {file = "MarkupSafe-2.0.1-cp38-cp38-win32.whl", hash = "sha256:023cb26ec21ece8dc3907c0e8320058b2e0cb3c55cf9564da612bc325bed5e64"}, 753 | {file = "MarkupSafe-2.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:984d76483eb32f1bcb536dc27e4ad56bba4baa70be32fa87152832cdd9db0833"}, 754 | {file = "MarkupSafe-2.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:2ef54abee730b502252bcdf31b10dacb0a416229b72c18b19e24a4509f273d26"}, 755 | {file = "MarkupSafe-2.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3c112550557578c26af18a1ccc9e090bfe03832ae994343cfdacd287db6a6ae7"}, 756 | {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux1_i686.whl", hash = "sha256:53edb4da6925ad13c07b6d26c2a852bd81e364f95301c66e930ab2aef5b5ddd8"}, 757 | {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:f5653a225f31e113b152e56f154ccbe59eeb1c7487b39b9d9f9cdb58e6c79dc5"}, 758 | {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:4efca8f86c54b22348a5467704e3fec767b2db12fc39c6d963168ab1d3fc9135"}, 759 | {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:ab3ef638ace319fa26553db0624c4699e31a28bb2a835c5faca8f8acf6a5a902"}, 760 | {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:f8ba0e8349a38d3001fae7eadded3f6606f0da5d748ee53cc1dab1d6527b9509"}, 761 | {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c47adbc92fc1bb2b3274c4b3a43ae0e4573d9fbff4f54cd484555edbf030baf1"}, 762 | {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:37205cac2a79194e3750b0af2a5720d95f786a55ce7df90c3af697bfa100eaac"}, 763 | {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:1f2ade76b9903f39aa442b4aadd2177decb66525062db244b35d71d0ee8599b6"}, 764 | {file = "MarkupSafe-2.0.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:4296f2b1ce8c86a6aea78613c34bb1a672ea0e3de9c6ba08a960efe0b0a09047"}, 765 | {file = "MarkupSafe-2.0.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9f02365d4e99430a12647f09b6cc8bab61a6564363f313126f775eb4f6ef798e"}, 766 | {file = "MarkupSafe-2.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5b6d930f030f8ed98e3e6c98ffa0652bdb82601e7a016ec2ab5d7ff23baa78d1"}, 767 | {file = "MarkupSafe-2.0.1-cp39-cp39-win32.whl", hash = "sha256:10f82115e21dc0dfec9ab5c0223652f7197feb168c940f3ef61563fc2d6beb74"}, 768 | {file = "MarkupSafe-2.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:693ce3f9e70a6cf7d2fb9e6c9d8b204b6b39897a2c4a1aa65728d5ac97dcc1d8"}, 769 | {file = "MarkupSafe-2.0.1.tar.gz", hash = "sha256:594c67807fb16238b30c44bdf74f36c02cdf22d1c8cda91ef8a0ed8dabf5620a"}, 770 | ] 771 | more-itertools = [ 772 | {file = "more-itertools-8.14.0.tar.gz", hash = "sha256:c09443cd3d5438b8dafccd867a6bc1cb0894389e90cb53d227456b0b0bccb750"}, 773 | {file = "more_itertools-8.14.0-py3-none-any.whl", hash = "sha256:1bc4f91ee5b1b31ac7ceacc17c09befe6a40a503907baf9c839c229b5095cfd2"}, 774 | ] 775 | mypy-extensions = [ 776 | {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, 777 | {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, 778 | ] 779 | oauthlib = [ 780 | {file = "oauthlib-3.2.1-py3-none-any.whl", hash = "sha256:88e912ca1ad915e1dcc1c06fc9259d19de8deacd6fd17cc2df266decc2e49066"}, 781 | {file = "oauthlib-3.2.1.tar.gz", hash = "sha256:1565237372795bf6ee3e5aba5e2a85bd5a65d0e2aa5c628b9a97b7d7a0da3721"}, 782 | ] 783 | packaging = [ 784 | {file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"}, 785 | {file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"}, 786 | ] 787 | pathspec = [ 788 | {file = "pathspec-0.10.1-py3-none-any.whl", hash = "sha256:46846318467efc4556ccfd27816e004270a9eeeeb4d062ce5e6fc7a87c573f93"}, 789 | {file = "pathspec-0.10.1.tar.gz", hash = "sha256:7ace6161b621d31e7902eb6b5ae148d12cfd23f4a249b9ffb6b9fee12084323d"}, 790 | ] 791 | platformdirs = [ 792 | {file = "platformdirs-2.5.2-py3-none-any.whl", hash = "sha256:027d8e83a2d7de06bbac4e5ef7e023c02b863d7ea5d079477e722bb41ab25788"}, 793 | {file = "platformdirs-2.5.2.tar.gz", hash = "sha256:58c8abb07dcb441e6ee4b11d8df0ac856038f944ab98b7be6b27b2a3c7feef19"}, 794 | ] 795 | pluggy = [ 796 | {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, 797 | {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, 798 | ] 799 | py = [ 800 | {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, 801 | {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, 802 | ] 803 | pycparser = [ 804 | {file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"}, 805 | {file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"}, 806 | ] 807 | PyJWT = [ 808 | {file = "PyJWT-2.4.0-py3-none-any.whl", hash = "sha256:72d1d253f32dbd4f5c88eaf1fdc62f3a19f676ccbadb9dbc5d07e951b2b26daf"}, 809 | {file = "PyJWT-2.4.0.tar.gz", hash = "sha256:d42908208c699b3b973cbeb01a969ba6a96c821eefb1c5bfe4c390c01d67abba"}, 810 | ] 811 | pyOpenSSL = [ 812 | {file = "pyOpenSSL-19.1.0-py2.py3-none-any.whl", hash = "sha256:621880965a720b8ece2f1b2f54ea2071966ab00e2970ad2ce11d596102063504"}, 813 | {file = "pyOpenSSL-19.1.0.tar.gz", hash = "sha256:9a24494b2602aaf402be5c9e30a0b82d4a5c67528fe8fb475e3f3bc00dd69507"}, 814 | ] 815 | pyparsing = [ 816 | {file = "pyparsing-3.0.9-py3-none-any.whl", hash = "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc"}, 817 | {file = "pyparsing-3.0.9.tar.gz", hash = "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb"}, 818 | ] 819 | pytest = [ 820 | {file = "pytest-5.4.3-py3-none-any.whl", hash = "sha256:5c0db86b698e8f170ba4582a492248919255fcd4c79b1ee64ace34301fb589a1"}, 821 | {file = "pytest-5.4.3.tar.gz", hash = "sha256:7979331bfcba207414f5e1263b5a0f8f521d0f457318836a7355531ed1a4c7d8"}, 822 | ] 823 | python-dotenv = [ 824 | {file = "python-dotenv-0.15.0.tar.gz", hash = "sha256:587825ed60b1711daea4832cf37524dfd404325b7db5e25ebe88c495c9f807a0"}, 825 | {file = "python_dotenv-0.15.0-py2.py3-none-any.whl", hash = "sha256:0c8d1b80d1a1e91717ea7d526178e3882732420b03f08afea0406db6402e220e"}, 826 | ] 827 | requests = [ 828 | {file = "requests-2.28.1-py3-none-any.whl", hash = "sha256:8fefa2a1a1365bf5520aac41836fbee479da67864514bdb821f31ce07ce65349"}, 829 | {file = "requests-2.28.1.tar.gz", hash = "sha256:7c5599b102feddaa661c826c56ab4fee28bfd17f5abca1ebbe3e7f19d7c97983"}, 830 | ] 831 | requests-oauthlib = [ 832 | {file = "requests-oauthlib-1.3.1.tar.gz", hash = "sha256:75beac4a47881eeb94d5ea5d6ad31ef88856affe2332b9aafb52c6452ccf0d7a"}, 833 | {file = "requests_oauthlib-1.3.1-py2.py3-none-any.whl", hash = "sha256:2577c501a2fb8d05a304c09d090d6e47c306fef15809d102b327cf8364bddab5"}, 834 | ] 835 | setuptools = [ 836 | {file = "setuptools-65.3.0-py3-none-any.whl", hash = "sha256:2e24e0bec025f035a2e72cdd1961119f557d78ad331bb00ff82efb2ab8da8e82"}, 837 | {file = "setuptools-65.3.0.tar.gz", hash = "sha256:7732871f4f7fa58fb6bdcaeadb0161b2bd046c85905dbaa066bdcbcc81953b57"}, 838 | ] 839 | six = [ 840 | {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, 841 | {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, 842 | ] 843 | tomli = [ 844 | {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, 845 | {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, 846 | ] 847 | typing-extensions = [ 848 | {file = "typing_extensions-4.3.0-py3-none-any.whl", hash = "sha256:25642c956049920a5aa49edcdd6ab1e06d7e5d467fc00e0506c44ac86fbfca02"}, 849 | {file = "typing_extensions-4.3.0.tar.gz", hash = "sha256:e6d2677a32f47fc7eb2795db1dd15c1f34eff616bcaf2cfb5e997f854fa1c4a6"}, 850 | ] 851 | urllib3 = [ 852 | {file = "urllib3-1.26.12-py2.py3-none-any.whl", hash = "sha256:b930dd878d5a8afb066a637fbb35144fe7901e3b209d1cd4f524bd0e9deee997"}, 853 | {file = "urllib3-1.26.12.tar.gz", hash = "sha256:3fa96cf423e6987997fc326ae8df396db2a8b7c667747d47ddd8ecba91f4a74e"}, 854 | ] 855 | wcwidth = [ 856 | {file = "wcwidth-0.2.5-py2.py3-none-any.whl", hash = "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784"}, 857 | {file = "wcwidth-0.2.5.tar.gz", hash = "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83"}, 858 | ] 859 | Werkzeug = [ 860 | {file = "Werkzeug-1.0.1-py2.py3-none-any.whl", hash = "sha256:2de2a5db0baeae7b2d2664949077c2ac63fbd16d98da0ff71837f7d1dea3fd43"}, 861 | {file = "Werkzeug-1.0.1.tar.gz", hash = "sha256:6c80b1e5ad3665290ea39320b91e1be1e0d5f60652b964a3070216de83d2e47c"}, 862 | ] 863 | WTForms = [ 864 | {file = "WTForms-3.0.1-py3-none-any.whl", hash = "sha256:837f2f0e0ca79481b92884962b914eba4e72b7a2daaf1f939c890ed0124b834b"}, 865 | {file = "WTForms-3.0.1.tar.gz", hash = "sha256:6b351bbb12dd58af57ffef05bc78425d08d1914e0fd68ee14143b7ade023c5bc"}, 866 | ] 867 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "overpass" 3 | version = "0.2.6" 4 | description = "A self-hosted streaming platform with Discord authentication, auto-recording and more!" 5 | authors = ["GOATS2K "] 6 | 7 | [tool.poetry.dependencies] 8 | python = "^3.8" 9 | flask = "^1.1.2" 10 | flask-login = "^0.5.0" 11 | flask-discord = "^0.1.63" 12 | pyopenssl = "^19.1.0" 13 | python-dotenv = "^0.15.0" 14 | flask-wtf = "^0.14.3" 15 | gunicorn = "^20.0.4" 16 | MarkupSafe = "2.0.1" 17 | 18 | [tool.poetry.dev-dependencies] 19 | pytest = "^5.2" 20 | black = "^20.8b1" 21 | 22 | [tool.poetry.group.dev.dependencies] 23 | black = {version = "^22.8.0", allow-prereleases = true} 24 | 25 | [build-system] 26 | requires = ["poetry>=0.12"] 27 | build-backend = "poetry.masonry.api" 28 | --------------------------------------------------------------------------------