include zombstream 0.4 frontend
This commit is contained in:
parent
0bc61708f3
commit
8e53bc8014
9 changed files with 494 additions and 0 deletions
11
frontend/Dockerfile
Normal file
11
frontend/Dockerfile
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
FROM python:3
|
||||||
|
RUN mkdir /code
|
||||||
|
RUN pip --no-cache-dir install \
|
||||||
|
flask \
|
||||||
|
pyyaml \
|
||||||
|
uwsgi
|
||||||
|
COPY ./app /code
|
||||||
|
ADD https://github.com/bilibili/flv.js/releases/download/v1.5.0/flv.min.js /code/static/flv.min.js
|
||||||
|
RUN chmod 644 /code/static/flv.min.js
|
||||||
|
USER nobody
|
||||||
|
WORKDIR /code
|
31
frontend/app/api.py
Normal file
31
frontend/app/api.py
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
import flask
|
||||||
|
import json
|
||||||
|
|
||||||
|
from zomstream import Zomstream
|
||||||
|
|
||||||
|
api_version = "0.2"
|
||||||
|
api_base = "/api/v" + api_version
|
||||||
|
|
||||||
|
api = flask.Blueprint('api', __name__)
|
||||||
|
zomstream = Zomstream()
|
||||||
|
|
||||||
|
def construct_response(streams):
|
||||||
|
# Expecting a JSON-serializable list as an argument
|
||||||
|
# Returning a JSON string with the API response
|
||||||
|
|
||||||
|
r = {"streams":streams}
|
||||||
|
return flask.jsonify(r)
|
||||||
|
|
||||||
|
@api.route(api_base + "/streams/", methods = ['GET'])
|
||||||
|
def api_list_streams():
|
||||||
|
streams = []
|
||||||
|
for stream in zomstream.getStreamNames():
|
||||||
|
streams.append({'app':stream[0],'name':stream[1]})
|
||||||
|
return construct_response(streams)
|
||||||
|
|
||||||
|
|
||||||
|
@api.route(api_base + "/streams/<stream_name>/", methods = ['GET'])
|
||||||
|
def api_stream( stream_name):
|
||||||
|
# Filter for streams with 'name' == stream_name
|
||||||
|
stream = list(filter(lambda stream: stream['name'] == stream_name, zomstream.getStreams()))
|
||||||
|
return construct_response(stream)
|
11
frontend/app/app.py
Normal file
11
frontend/app/app.py
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
import flask
|
||||||
|
import urllib
|
||||||
|
import frontend
|
||||||
|
import api
|
||||||
|
|
||||||
|
import logging
|
||||||
|
logging.basicConfig(level=logging.DEBUG)
|
||||||
|
|
||||||
|
web = flask.Flask(__name__)
|
||||||
|
web.register_blueprint(api.api)
|
||||||
|
web.register_blueprint(frontend.frontend)
|
33
frontend/app/frontend.py
Normal file
33
frontend/app/frontend.py
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
# imports
|
||||||
|
import flask
|
||||||
|
import urllib
|
||||||
|
from zomstream import Zomstream
|
||||||
|
|
||||||
|
streamList = []
|
||||||
|
zomstream = Zomstream()
|
||||||
|
|
||||||
|
frontend = flask.Blueprint('frontend', __name__)
|
||||||
|
|
||||||
|
@frontend.route("/")
|
||||||
|
def start():
|
||||||
|
mainTemplate = '%s/main.html.j2' % zomstream.configuration['template_folder']
|
||||||
|
streamList = zomstream.getStreamNames()
|
||||||
|
page = flask.render_template(
|
||||||
|
mainTemplate,
|
||||||
|
items=streamList,
|
||||||
|
configuration=zomstream.configuration
|
||||||
|
)
|
||||||
|
return page
|
||||||
|
|
||||||
|
@frontend.route("/player/<appname>/<streamname>")
|
||||||
|
def show_player(appname, streamname):
|
||||||
|
playerTemplate = '%s/player.html.j2' % zomstream.configuration['template_folder']
|
||||||
|
page = flask.render_template(
|
||||||
|
playerTemplate,
|
||||||
|
streamname=streamname,
|
||||||
|
appname=appname,
|
||||||
|
configuration=zomstream.configuration
|
||||||
|
)
|
||||||
|
return page
|
26
frontend/app/locust-tests.py
Normal file
26
frontend/app/locust-tests.py
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
from locust import HttpLocust, TaskSet, task
|
||||||
|
import resource
|
||||||
|
|
||||||
|
resource.setrlimit(resource.RLIMIT_NOFILE, (15000, 15000))
|
||||||
|
|
||||||
|
class ZomstreamTests(TaskSet):
|
||||||
|
@task
|
||||||
|
def index(self):
|
||||||
|
self.client.get("/")
|
||||||
|
|
||||||
|
@task
|
||||||
|
def static_css(self):
|
||||||
|
self.client.get("/static/style.default.css")
|
||||||
|
|
||||||
|
@task
|
||||||
|
def api_list(self):
|
||||||
|
self.client.get("/api/streams/")
|
||||||
|
|
||||||
|
@task
|
||||||
|
def videoplayer(self):
|
||||||
|
self.client.get("/player/bsod")
|
||||||
|
|
||||||
|
class WebsiteUser(HttpLocust):
|
||||||
|
task_set = ZomstreamTests
|
||||||
|
min_wait = 5000
|
||||||
|
max_wait = 20000
|
222
frontend/app/static/style.default.css
Normal file
222
frontend/app/static/style.default.css
Normal file
|
@ -0,0 +1,222 @@
|
||||||
|
body {
|
||||||
|
background: #ffffff;
|
||||||
|
margin: 0px;
|
||||||
|
padding: 0px;
|
||||||
|
font-family: sans-serif;
|
||||||
|
font-size: 10pt;
|
||||||
|
color: #333333;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
background: #eeeeee;
|
||||||
|
margin: none;
|
||||||
|
padding: 4px;
|
||||||
|
box-shadow: 0px 0px 3px rgba(0, 0, 0, .5);
|
||||||
|
}
|
||||||
|
|
||||||
|
header a {
|
||||||
|
text-decoration: none;
|
||||||
|
color: #333333;
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1 {
|
||||||
|
margin: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header h2 {
|
||||||
|
text-align: right;
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: normal;
|
||||||
|
font-size: 12pt;
|
||||||
|
color: #888;
|
||||||
|
margin: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
clear: both;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
max-width: 750px;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
text-align: center;
|
||||||
|
color: #888;
|
||||||
|
font-size: 8pt;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
article {
|
||||||
|
border-radius: 5px;
|
||||||
|
border: 1px solid #888;
|
||||||
|
box-shadow: 0px 0px 2px rgba(0, 0, 0, .5);
|
||||||
|
margin: 15px 0px 15px 0px;
|
||||||
|
padding: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
article h1 {
|
||||||
|
font-size: 13pt;
|
||||||
|
font-weight: normal;
|
||||||
|
color: #444;
|
||||||
|
background: #eee;
|
||||||
|
border-bottom: 1px solid #888;
|
||||||
|
border-radius: 5px 5px 0px 0px;
|
||||||
|
padding-top: 4px;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
padding-left: 6px;
|
||||||
|
margin: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
article table {
|
||||||
|
width: 100%;
|
||||||
|
border-style: collapse;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
article table th {
|
||||||
|
font-weight: bold;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #ccc;
|
||||||
|
padding-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
article table td {
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
article table td em.url {
|
||||||
|
font-style: normal;
|
||||||
|
font-family: monospace;
|
||||||
|
background: #eee;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
article table td.btn {
|
||||||
|
width: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
article table th.btn {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.btn {
|
||||||
|
color: #eee;
|
||||||
|
display: block;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 4px 10px 4px 10px;
|
||||||
|
box-shadow: 0px 0px 2px rgba(0, 0, 0, .5);
|
||||||
|
text-shadow: 1px 1px 1px rgba(0, 0, 0, .5);
|
||||||
|
text-decoration: none;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.btn-green {
|
||||||
|
background-color: rgb(44, 57, 75);
|
||||||
|
/*background: linear-gradient(#4caf50, #2b622d);*/
|
||||||
|
}
|
||||||
|
a.btn-green:hover {
|
||||||
|
background-color: rgb(60, 78, 104);
|
||||||
|
box-shadow: 0px 0px 5px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
a.btn-large {
|
||||||
|
color: #eee;
|
||||||
|
font-size: 16pt;
|
||||||
|
display: block;
|
||||||
|
padding: 10px;
|
||||||
|
margin: 10px;
|
||||||
|
text-shadow: 1px 2px 1px rgba(0, 0, 0, .5);
|
||||||
|
border-top: 1px solid #888;
|
||||||
|
text-decoration: none;
|
||||||
|
text-align: center;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
div#videocontainer {
|
||||||
|
border-radius: 5px;
|
||||||
|
border: 1px solid #888;
|
||||||
|
background: #000;
|
||||||
|
box-shadow: 0px 0px 2px rgba(0, 0, 0, .5);
|
||||||
|
margin-top: 15px;
|
||||||
|
padding: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (min-width: 1501px) {
|
||||||
|
div#videocontainer {
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
max-width: 80vw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 1500px) {
|
||||||
|
div#videocontainer {
|
||||||
|
margin-left: 20px;
|
||||||
|
margin-right: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
div#videocontainer h1 {
|
||||||
|
font-size: 13pt;
|
||||||
|
font-weight: normal;
|
||||||
|
color: #444;
|
||||||
|
background: #eee;
|
||||||
|
border-bottom: 1px solid #888;
|
||||||
|
border-radius: 5px 5px 0px 0px;
|
||||||
|
padding-top: 4px;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
padding-left: 6px;
|
||||||
|
margin: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
video#player {
|
||||||
|
width: 100%;
|
||||||
|
max-height: calc(100vh - 150px);
|
||||||
|
background-color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* dark mode */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
body {
|
||||||
|
background: #202020;
|
||||||
|
color: #999999;
|
||||||
|
}
|
||||||
|
header {
|
||||||
|
background: #444444;
|
||||||
|
color: #999999;
|
||||||
|
}
|
||||||
|
header a {
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
header h2 {
|
||||||
|
color: #777;
|
||||||
|
}
|
||||||
|
footer {
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
article {
|
||||||
|
border: 1px solid #888;
|
||||||
|
box-shadow: 0px 0px 5px rgba(150, 150, 150, .5);
|
||||||
|
}
|
||||||
|
article h1 {
|
||||||
|
color: #888;
|
||||||
|
background: #121212;
|
||||||
|
}
|
||||||
|
article table th {
|
||||||
|
border-bottom: 1px solid #666;
|
||||||
|
}
|
||||||
|
article table td {
|
||||||
|
border-bottom: 1px solid #333;
|
||||||
|
}
|
||||||
|
article table td em.url {
|
||||||
|
color: #eee;
|
||||||
|
background: #121212;
|
||||||
|
}
|
||||||
|
div#videocontainer {
|
||||||
|
box-shadow: 0px 0px 5px rgba(150, 150, 150, .5);
|
||||||
|
}
|
||||||
|
div#videocontainer h1 {
|
||||||
|
color: #888;
|
||||||
|
background: #121212;
|
||||||
|
}
|
||||||
|
}
|
52
frontend/app/templates/default/main.html.j2
Normal file
52
frontend/app/templates/default/main.html.j2
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta name='viewport' content='width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0' />
|
||||||
|
<title>{{ configuration['pagetitle'] }} - Stream Overview</title>
|
||||||
|
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='style.default.css') }}">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>{{ configuration["pagetitle"] }}</h1>
|
||||||
|
<h2>{{ configuration["subtitle"] }}</h2>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
{% if items == [] %}
|
||||||
|
<p style="margin-top: 20px; margin-bottom: 150px;">
|
||||||
|
<span style="color: #888; font-size: 14pt;">There are currently no streams running</span>
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
{% for item in items %}
|
||||||
|
<article>
|
||||||
|
<h1>{{ item[1] }}</h1>
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<th>Protocol</th>
|
||||||
|
<th>URL</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>RTMP</td>
|
||||||
|
<td><em class="url">rtmp://{{ configuration["rtmp_base"] }}/{{ item[0] }}/{{ item[1] }}</em></td>
|
||||||
|
<td class="btn"><a href="rtmp://{{ configuration["rtmp_base"] }}/{{ item[0] }}/{{ item[1] }}" class="btn btn-green">▶<br/>RTMP</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>HTTP-FLV</td>
|
||||||
|
<td><em class="url">{{ configuration["web_proto"] }}://{{ configuration["base_url"] }}/flv?app={{ item[0] }}&stream={{ item[1] }}</em></td>
|
||||||
|
<td class="btn"><a href="{{ configuration["web_proto"] }}://{{ configuration["base_url"] }}/flv?app={{ item[0] }}&stream={{ item[1] }}" class="btn btn-green">▶<br/>HTTP-FLV</a></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<a href="{{ url_for('frontend.show_player', streamname=item[1], appname=item[0]) }}"
|
||||||
|
class="btn-large btn-green">
|
||||||
|
▷ Web Player
|
||||||
|
</a>
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
</main>
|
||||||
|
<footer>
|
||||||
|
{{ configuration["footer"] }}
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
32
frontend/app/templates/default/player.html.j2
Normal file
32
frontend/app/templates/default/player.html.j2
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta name='viewport' content='width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0' />
|
||||||
|
<title>{{ configuration['pagetitle'] }} - {{ appname }}: {{ streamname }}</title>
|
||||||
|
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='style.default.css') }}">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<a href="{{ url_for('frontend.start') }}"><h1>{{ configuration["pagetitle"] }}</h1></a>
|
||||||
|
<h2>{{ configuration["subtitle"] }}</h2>
|
||||||
|
</header>
|
||||||
|
<div id="videocontainer">
|
||||||
|
<h1>{{ streamname }}</h1>
|
||||||
|
<video id="player" controls>
|
||||||
|
</video>
|
||||||
|
</div>
|
||||||
|
<script src="{{ url_for('static', filename='flv.min.js') }}"></script>
|
||||||
|
<script type="application/javascript">
|
||||||
|
if (flvjs.isSupported()) {
|
||||||
|
var videoElement = document.getElementById('player');
|
||||||
|
var flvPlayer = flvjs.createPlayer({
|
||||||
|
type: 'flv',
|
||||||
|
url: '{{ configuration["web_proto"] }}://{{ configuration["base_url"] }}/flv?app={{ appname }}&stream={{ streamname }}'
|
||||||
|
});
|
||||||
|
flvPlayer.attachMediaElement(videoElement);
|
||||||
|
flvPlayer.load();
|
||||||
|
flvPlayer.play();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
76
frontend/app/zomstream.py
Normal file
76
frontend/app/zomstream.py
Normal file
|
@ -0,0 +1,76 @@
|
||||||
|
import pathlib
|
||||||
|
import xml.etree.ElementTree as etree
|
||||||
|
import sys
|
||||||
|
import yaml
|
||||||
|
import urllib
|
||||||
|
|
||||||
|
|
||||||
|
class Stream:
|
||||||
|
def __init__(self, app, name, urls):
|
||||||
|
self.name = name # String
|
||||||
|
self.app = app # String
|
||||||
|
self.urls = urls # List of Dictionaries with the keys url and type
|
||||||
|
|
||||||
|
|
||||||
|
class Zomstream:
|
||||||
|
def __init__(self):
|
||||||
|
# load configuration from config.yml file
|
||||||
|
if pathlib.Path("config.yml").is_file():
|
||||||
|
stream = open('config.yml', 'r')
|
||||||
|
self.configuration = yaml.load(stream)
|
||||||
|
stream.close()
|
||||||
|
else:
|
||||||
|
print('missing configuration.')
|
||||||
|
sys.exit(1)
|
||||||
|
self.streamnames = []
|
||||||
|
|
||||||
|
def getStreamNames(self):
|
||||||
|
self.streamnames = []
|
||||||
|
# get data from the streaming server
|
||||||
|
response = urllib.request.urlopen(self.configuration['stat_url'])
|
||||||
|
content = response.read().decode('utf-8')
|
||||||
|
# parse the xml / walk the tree
|
||||||
|
tree = etree.fromstring(content)
|
||||||
|
server = tree.find('server')
|
||||||
|
applications = server.findall('application')
|
||||||
|
for application in applications:
|
||||||
|
appname = application.find('name')
|
||||||
|
if appname.text == "live" or appname.text == "rec":
|
||||||
|
streams = application.find('live').findall('stream')
|
||||||
|
for stream in streams:
|
||||||
|
name = stream.find('name')
|
||||||
|
rate = stream.find('bw_video')
|
||||||
|
if rate.text != "0":
|
||||||
|
self.streamnames.append( [appname.text, name.text] )
|
||||||
|
|
||||||
|
return self.streamnames
|
||||||
|
|
||||||
|
|
||||||
|
def getStreams(self):
|
||||||
|
streams = []
|
||||||
|
for streamName in self.getStreamNames():
|
||||||
|
urls = []
|
||||||
|
app = streamName[0]
|
||||||
|
name = streamName[1]
|
||||||
|
|
||||||
|
flv_url = self.getFlvUrl (app,name)
|
||||||
|
rtmp_url = self.getRtmpUrl(app,name)
|
||||||
|
|
||||||
|
urls.append({'url': flv_url, 'type':'http_flv'})
|
||||||
|
urls.append({'url': rtmp_url,'type':'rtmp'})
|
||||||
|
|
||||||
|
stream = Stream(app=app, name=name, urls=urls)
|
||||||
|
streams.append(stream.__dict__)
|
||||||
|
return streams
|
||||||
|
|
||||||
|
def getFlvUrl(self,app_name,stream_name):
|
||||||
|
return '%s://%s/flv?app=%s&stream=%s' % (
|
||||||
|
self.configuration['web_proto'],
|
||||||
|
self.configuration['base_url'],
|
||||||
|
app_name,
|
||||||
|
stream_name)
|
||||||
|
def getRtmpUrl(self,app_name,stream_name):
|
||||||
|
return "rtmp://%s/%s/%s" % (
|
||||||
|
self.configuration['rtmp_base'],
|
||||||
|
app_name,
|
||||||
|
stream_name)
|
Loading…
Reference in a new issue