diff --git a/db_schema.sql b/db_schema.sql index 7086fa1400379d43c491c3868dd56d53ad0c6f3d..67e27877972aede0c3055bf21dcc65753883ffba 100644 --- a/db_schema.sql +++ b/db_schema.sql @@ -179,6 +179,21 @@ CREATE TABLE IF NOT EXISTS `streams` ( `poster` text NOT NULL, `job_id` INTEGER ); +CREATE TABLE IF NOT EXISTS `live_sources` ( + `id` INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + `key` varchar(32) UNIQUE, + `preview_key` varchar(32), + `name` text NOT NULL, + `description` text NOT NULL DEFAULT '', + `options` text NOT NULL DEFAULT '', + `server` varchar(32), + `clientid` INTEGER, + `last_active` datetime, + `time_created` datetime NOT NULL, + `time_updated` datetime NOT NULL, + `created_by` INTEGER NOT NULL, + `deleted` INTEGER NOT NULL DEFAULT '0' +); CREATE TABLE IF NOT EXISTS `stream_stats` ( `id` INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, `handle` varchar(32) NOT NULL, diff --git a/edit.py b/edit.py index 6a676cdea3966b8e812a0acf01ca8879e787354c..6cee1ed6d4ff96bf65388abec8adac9a3ae43110 100644 --- a/edit.py +++ b/edit.py @@ -111,7 +111,15 @@ editable_tables = { 'notify_new_video': {'type': 'boolean'}, 'notify_edit': {'type': 'boolean'} }, - 'creationtime_fields': [] } + 'creationtime_fields': [] }, + 'live_sources': { + 'table': 'live_sources', + 'idcolumn': 'id', + 'editable_fields': { + 'name': {'type': 'shortstring'}, + 'description': {'type': 'text'}, + }, + 'creationtime_fields': ['created_by', 'time_created', 'time_updated'] } } #parses the path to a dict, containing the table, id, field and field type diff --git a/livestreams.py b/livestreams.py index 2c8acf6dcfbf8fc524291b014d107fdbc75aac6d..c079dc4fa00f40a5486a3cfeb6346064ec619bab 100644 --- a/livestreams.py +++ b/livestreams.py @@ -1,5 +1,10 @@ from server import * +import requests +from xml.etree import ElementTree +import random +import string + @sched_func(120) def livestream_thumbnail(): livestreams = query('SELECT streams.lecture_id, streams.handle AS livehandle FROM streams WHERE streams.active') @@ -8,7 +13,7 @@ def livestream_thumbnail(): @app.route('/internal/streaming/legacy_auth', methods=['GET', 'POST']) @app.route('/internal/streaming/legacy_auth/<server>', methods=['GET', 'POST']) -def streamauth(server=None): +def streamauth_legacy(server=None): internal = False if 'X-Real-IP' in request.headers: for net in config.get('FSMPI_IP_RANGES', []): @@ -57,3 +62,82 @@ def streamauth(server=None): def restart_failed_live_transcode(id, type, data, state, status): restart_job(id) +@app.route('/internal/streaming') +#@register_navbar('Streaming', icon='transfer') +@mod_required +def streaming(): + sources = query('SELECT * FROM live_sources WHERE NOT deleted') + for source in sources: + if not source['clientid']: + continue + r = requests.get('http://%s:8080/stats'%source['server']) + if r.status_code != 200: + continue + source['stat'] = {} + tree = ElementTree.fromstring(r.text) + if not tree: + continue + s = tree.find("./server/application/[name='src']/live/stream/[name='%i']"%source['id']) + for e in s.find("client/[publishing='']").getchildren(): + source['stat'][e.tag] = e.text + source['video'] = {} + for e in s.find('meta/video').getchildren(): + source['video'][e.tag] = e.text + source['audio'] = {} + for e in s.find('meta/audio').getchildren(): + source['audio'][e.tag] = e.text + return render_template("streaming.html", sources=sources) + +def gentoken(): + return ''.join(random.SystemRandom().choice(string.ascii_letters + string.digits) for _ in range(16)) + +@app.route('/internal/streaming/rekey/<int:id>') +@mod_required +def streamrekey(id): + modify('UPDATE live_sources SET key = ? WHERE id = ? AND NOT deleted', gentoken(), id) + source = query('SELECT * FROM live_sources WHERE NOT deleted AND id = ?', id)[0] + flash('Der Streamkey von <strong>'+source['name']+'</strong> wurde neu generiert: <span><input readonly type="text" style="width: 15em" value="'+source['key']+'"></span>') + return redirect(url_for('streaming')) + +@app.route('/internal/streaming/drop/<int:id>') +@mod_required +def streamdrop(id): + source = (query('SELECT * FROM live_sources WHERE NOT deleted AND id = ?', id) or [None])[0] + if not source: + if 'ref' in request.values: + flash('Streamquelle nicht gefunden') + return redirect(request.values['ref']) + else: + return 'Not found', 404 + requests.get('http://%s:8080/control/drop/publisher?clientid=%i'%(source['server'], source['clientid'])) + if 'ref' in request.values: + return redirect(request.values['ref']) + return 'Ok', 200 + +@app.route('/internal/streaming/auth/<server>', methods=['GET', 'POST']) +def streamauth(server): + internal = False + for net in config.get('INTERNAL_IP_RANGES', []): + if ip_address(request.headers['X-Real-IP']) in ip_network(net): + internal = True + if not internal: + return 'Forbidden', 403 + if request.values['call'] == 'publish': + sources = query('SELECT * FROM live_sources WHERE NOT deleted AND key = ?', request.values['name']) + if not sources: + return 'Not found', 404 + modify('UPDATE live_sources SET server = ?, clientid = ?, last_active = ?, preview_key = ? WHERE id = ?', server, request.values['clientid'], datetime.now(), gentoken(), sources[0]['id']) + ret = Response('Redirect', 301, {'Location': '%i'%sources[0]['id']}) + ret.autocorrect_location_header = False + return ret + if request.values['call'] == 'play': + source = (query('SELECT * FROM live_sources WHERE NOT deleted AND id = ?', request.values['name']) or [None])[0] + if not source: + return 'Not found', 404 + if source['preview_key'] != request.values.get('preview_key'): + return 'Forbidden', 403 + return 'Ok', 200 + elif request.values['call'] == 'publish_done': + modify('UPDATE live_sources SET server = NULL, clientid = NULL, preview_key = NULL WHERE server = ? AND clientid = ?', server, request.values['clientid']) + return 'Ok', 200 + return 'Bad request', 400 diff --git a/templates/streaming.html b/templates/streaming.html new file mode 100644 index 0000000000000000000000000000000000000000..6a385d040c50dc9758ee4a1c494838841cfe914d --- /dev/null +++ b/templates/streaming.html @@ -0,0 +1,89 @@ +{% extends "base.html" %} +{% block content %} +<div class="panel-group"> + <div class="panel panel-default"> + <div class="panel-heading"> + <h1 class="panel-title">Streamquellen</h1> + </div> + <div class="panel-body"> + <form action="{{ url_for('create', table='live_sources', ref=request.url) }}" method="post"> + <div class="input-group pull-right" style="width: 300px;"> + <input type="text" class="form-control" name="name" placeholder="Quellenname" width="100px"> + <span class="input-group-btn"> + <button class="btn btn-default" type="submit">Quelle anlegen</button> + </span> + </div> + </form> + </div> + <ul class="list-group"> + {% for source in sources %} + <li class="list-group-item{% if source.clientid %} list-group-item-danger{% endif %}"> + <div class="row"> + <div style="background-image: url('{{ config.VIDEOPREFIX }}/hls/preview/{{ source.id }}.jpg')" class="col-sm-2 col-xs-12 thumbnailimg"> + {% if source.clientid %} + <a href="#" data-toggle="modal" data-target="#preview-player" data-srcname="{{ source.name }}" data-srcid="{{ source.id }}"> + <span class="glyphicon glyphicon-play-circle playpreviewbtn"></span> + </a> + {% endif %} + </div> + <ul class="list-unstyled col-sm-3 col-xs-12"> + <li>{{ moderator_editor(['live_sources',source.id,'name'], source.name) }}</li> + <li>{{ moderator_editor(['live_sources',source.id,'description'], source.description) }}</li> + </ul> + <ul class="list-unstyled col-sm-3 col-xs-12"> + {% if source.clientid %} + <li><a href="rtmp://{{ source.server }}/src/{{ source.id }}?preview_key={{ source.preview_key }}">rtmp://{{ source.server }}/src/{{ source.id }}</a></li> + {% if source.stat and source.video and source.audio %} + <li>Quelladresse: {{ source.stat.address }}</li> + <li>Framedrops: {{ source.stat.dropped }}</li> + <li>Auflösung: {{ source.video.width }}x{{ source.video.height }}</li> + <li>Framerate: {{ source.video.frame_rate }} fps</li> + <li>Audiokanäle: {{ source.audio.channels }}</li> + <li>Audiorate: {{ source.audio.sample_rate }} Hz</li> + <li>Codecs: {{ source.video.codec }}/{{ source.audio.codec }}</li> + {% endif %} + {% else %} + {% if source.last_active %} + <li>Zuletzt aktiv: {{ source.last_active|date }}, {{ source.last_active|time }} Uhr</li> + {% else %} + <li>Noch nie aktiv</li> + {% endif %} + {% endif %} + </ul> + <ul class="list-unstyled col-sm-4 col-xs-12"> + <li class="pull-right">{{ moderator_delete(['live_sources',source.id,'deleted']) }}</li> + <a href="{{ url_for('streamrekey', id=source.id) }}" class="btn btn-{{ 'primary' if not source.key else 'default' }}">Streamkey erneuern</a> + <a href="{{ url_for('streamdrop', id=source.id, ref=request.url) }}" class="btn btn-danger {% if not source.clientid %} disabled{% endif %}">Trennen</a> + </ul> + </div> + </li> + {% endfor %} + </ul> + </div> +</div> +<div id="preview-player" class="modal fade" tabindex="-1" role="dialog"> + <div class="modal-dialog" role="document"> + <div class="modal-content"> + <div class="modal-header"> + <button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">×</span></button> + <h4 class="modal-title">Vorschau von</h4> + </div> + <div class="modal-body"> + </div> + </div> + </div> +</div> +<script> +$('#preview-player').on('show.bs.modal', function (e) { + var btn = $(e.relatedTarget); + $(this).find('.modal-title').text('Vorschau von '+btn.data('srcname')); + $(this).find('.modal-body').html('<video id="previewplayer" style="width: 100%" class="video-js vjs-default-skin vjs-big-play-centered" width="640" height="320" controls><source type="application/x-mpegURL" src="{{config.VIDEOPREFIX}}/hls/preview/'+btn.data('srcid')+'.m3u8"/></video>'); + var player = videojs('previewplayer'); + player.play(); +}); +$('#preview-player').on('hidden.bs.modal', function (e) { + videojs('previewplayer').dispose(); + $(this).find('.modal-body').text(''); +}); +</script> +{% endblock %}