├── .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 | 
141 | 
142 | 
143 | 
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 |
11 | {{error}}
12 |
13 | {% endif %}
14 | {% if info %}
15 |
16 | {{info}}
17 |
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 | Date |
13 | User |
14 | Title |
15 | Duration |
16 | |
17 |
18 |
19 |
20 | {% for stream in archive %}
21 |
22 | {{ stream['start_date'] }} UTC |
23 | {{ stream['username'] }} |
24 | {{ stream['title'] }} |
25 | {{ stream["duration"] }} |
26 |
27 |
31 | |
32 |
33 |
34 |
35 |
36 |
37 | {{ stream["description"] | nl2br }}
38 |
39 |
40 | |
41 |
42 | {% endfor %}
43 |
44 |
45 | {% else %}
46 |
47 | There are no streams in the archive yet.
48 |
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 |
8 | Streaming server: {{ server }}
9 |
10 |
31 | {% if key %}
32 |
33 | Stream key: {{ key }}
34 |
35 | {% endif %}
36 | {% if unlisted %}
37 |
38 | Your unlisted stream will be available
here.
39 |
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 |
20 |
21 |
{{stream['title']}}
22 |
{{stream['description']}}
23 |
Play
24 |
25 |
26 | {% endfor %}
27 | {% else %}
28 |
29 | No one is streaming at the moment. Would you like to
go live?
31 |
32 | {% endif %}
33 | {% else %}
34 |
35 |
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 |
10 | Stream settings updated.
11 |
12 | {% endif %}
13 |
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 | Date |
33 | Title |
34 | Duration |
35 | Status |
36 | |
37 |
38 |
39 |
40 | {% for stream in streams %}
41 |
42 | {% if stream['start_date'] %}
43 | {{ stream['start_date'] }} UTC |
44 | {% else %}
45 | Not Started Yet |
46 | {% endif %}
47 | {{ stream['title'] }} |
48 | {% if stream["duration"] %}
49 | {{ stream['duration'] }} |
50 | {% else %}
51 | N/A |
52 | {% endif %}
53 |
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 | |
69 |
70 |
71 | Edit Stream
72 | {% if not stream["duration"] and not stream["unlisted"] %}
73 | Play
74 | {% else %}
75 | Play
76 | {% endif %}
77 | Details
78 |
79 | |
80 |
81 |
82 |
83 |
84 |
85 | {{ stream["description"] | nl2br }}
86 |
87 | Stream Key: {{ stream["stream_key"] }}
88 | Unique ID: {{ stream["unique_id"] }}
89 |
90 |
91 |
92 | |
93 |
94 | {% endfor %}
95 |
96 |
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 |
61 | The user is offline.
62 |
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 |
--------------------------------------------------------------------------------