├── docker ├── .dockerignore ├── startup.sh └── nginx.conf ├── overpass ├── static │ ├── sign_in_with_discord.png │ └── sign_in_with_discord_small.png ├── templates │ ├── alert.html │ ├── layout.html │ ├── manage_stream.html │ ├── header.html │ ├── index.html │ ├── generate_stream.html │ ├── archive.html │ ├── watch.html │ └── manage_user.html ├── forms.py ├── jinja_filters.py ├── routes │ ├── index.py │ ├── manage_user.py │ ├── archive.py │ ├── hls.py │ ├── rtmp_server_api.py │ ├── auth.py │ ├── watch.py │ └── stream.py ├── schema.sql ├── watch.py ├── rtmp_server_api.py ├── db.py ├── auth.py ├── __init__.py ├── archive.py ├── stream_api.py └── stream_utils.py ├── .gitignore ├── Dockerfile ├── app.py ├── docker-compose.example ├── pyproject.toml ├── LICENSE ├── config.py ├── README.md └── poetry.lock /docker/.dockerignore: -------------------------------------------------------------------------------- 1 | docker -------------------------------------------------------------------------------- /overpass/static/sign_in_with_discord.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GOATS2K/overpass/HEAD/overpass/static/sign_in_with_discord.png -------------------------------------------------------------------------------- /overpass/static/sign_in_with_discord_small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GOATS2K/overpass/HEAD/overpass/static/sign_in_with_discord_small.png -------------------------------------------------------------------------------- /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/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 |
%s
" % p.replace("\n", Markup("| Date | 13 |User | 14 |Title | 15 |Duration | 16 |17 | |
|---|---|---|---|---|
| {{ stream['start_date'] }} UTC | 23 |{{ stream['username'] }} | 24 |{{ stream['title'] }} | 25 |{{ stream["duration"] }} | 26 |27 | 31 | | 32 |
|
35 |
36 |
40 |
37 | {{ stream["description"] | nl2br }}
38 |
39 | |
41 | ||||
Please verify that you are a member of the target Discord server.
85 | """ 86 | -------------------------------------------------------------------------------- /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/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("/Last login date: {{user['last_login_date']}} UTC
21 |Discord User ID: {{user['snowflake']}}
22 || Date | 33 |Title | 34 |Duration | 35 |Status | 36 |37 | | ||
|---|---|---|---|---|---|---|
| {{ 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 |
|
83 |
84 |
92 |
85 | {{ stream["description"] | nl2br }}
86 |
91 |
87 | Stream Key: {{ stream["stream_key"] }}
88 | Unique ID: {{ stream["unique_id"] }}
89 |
90 | |
93 | ||||||