Add initial version of live streaming
This commit is contained in:
parent
84c3a48626
commit
ae8e8f7d93
14 changed files with 317 additions and 18 deletions
|
|
@ -14,29 +14,34 @@ if ! docker info >/dev/null 2>&1; then
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
SQLITEDB=$(pwd)/docker/symposion.sqlite
|
||||||
|
|
||||||
docker image build -f docker/Dockerfile -t ${IMAGE_NAME} --target symposion_dev .
|
docker image build -f docker/Dockerfile -t ${IMAGE_NAME} --target symposion_dev .
|
||||||
docker container stop symposion
|
docker container stop symposion
|
||||||
docker container rm symposion
|
docker container rm symposion
|
||||||
docker container create --env-file docker/laptop-mode-env -p 28000:8000 -v $(pwd):/app/symposion_app --name symposion ${IMAGE_NAME}
|
docker container create --env-file docker/laptop-mode-env -p 28000:8000 -v $(pwd):/app/symposion_app -v $SQLITEDB:/tmp/symposion.sqlite --name symposion ${IMAGE_NAME}
|
||||||
docker container start symposion
|
docker container start symposion
|
||||||
## When we started the container and mounted . into /app/symposion_app, it hides the static/build directory
|
|
||||||
## As a kludge, re-run collectstatic to recreate it
|
|
||||||
## Possible alternative here: don't mount all of ., just mount the bits that we'd live to have update live
|
|
||||||
docker exec symposion ./manage.py collectstatic --noinput -v 0
|
|
||||||
docker exec symposion ./manage.py migrate
|
|
||||||
docker exec symposion ./manage.py loaddata ./fixtures/{conference,sites,sitetree,flatpages}.json
|
|
||||||
docker exec symposion ./manage.py create_review_permissions
|
|
||||||
docker exec symposion ./manage.py loaddata ./fixtures/sessions/*.json
|
|
||||||
docker exec symposion ./manage.py populate_inventory
|
|
||||||
|
|
||||||
if [ -e ./symposion-tools ]; then
|
if [ ! -e $SQLITEDB ]; then
|
||||||
|
## When we started the container and mounted . into /app/symposion_app, it hides the static/build directory
|
||||||
|
## As a kludge, re-run collectstatic to recreate it
|
||||||
|
## Possible alternative here: don't mount all of ., just mount the bits that we'd live to have update live
|
||||||
|
docker exec symposion ./manage.py collectstatic --noinput -v 0
|
||||||
|
docker exec symposion ./manage.py migrate
|
||||||
|
docker exec symposion ./manage.py loaddata ./fixtures/{conference,sites,sitetree,flatpages}.json
|
||||||
|
docker exec symposion ./manage.py create_review_permissions
|
||||||
|
docker exec symposion ./manage.py loaddata ./fixtures/sessions/*.json
|
||||||
|
docker exec symposion ./manage.py populate_inventory
|
||||||
|
|
||||||
|
if [ -e ./symposion-tools ]; then
|
||||||
pushd ./symposion-tools
|
pushd ./symposion-tools
|
||||||
./fixture_to_docker.sh fixtures/dev_dummy_superuser.json
|
./fixture_to_docker.sh fixtures/dev_dummy_superuser.json
|
||||||
./fixture_to_docker.sh fixtures/????_*.json
|
./fixture_to_docker.sh fixtures/????_*.json
|
||||||
popd
|
popd
|
||||||
else
|
else
|
||||||
echo Now creating a Django superuser. Please enter a
|
echo Now creating a Django superuser. Please enter a
|
||||||
docker exec -it symposion ./manage.py createsuperuser --username admin1 --email root@example.com
|
docker exec -it symposion ./manage.py createsuperuser --username admin1 --email root@example.com
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
set +x
|
set +x
|
||||||
|
|
|
||||||
|
|
@ -259,6 +259,7 @@ INSTALLED_APPS = [
|
||||||
"pinaxcon.proposals",
|
"pinaxcon.proposals",
|
||||||
"pinaxcon.registrasion",
|
"pinaxcon.registrasion",
|
||||||
"pinaxcon.raffle",
|
"pinaxcon.raffle",
|
||||||
|
"pinaxcon.streaming",
|
||||||
"jquery",
|
"jquery",
|
||||||
"djangoformsetjs",
|
"djangoformsetjs",
|
||||||
|
|
||||||
|
|
@ -580,3 +581,8 @@ VENUELESS_URL = os.environ.get('VENUELESS_URL', None)
|
||||||
VENUELESS_AUDIENCE = os.environ.get('VENUELESS_AUDIENCE', "venueless")
|
VENUELESS_AUDIENCE = os.environ.get('VENUELESS_AUDIENCE', "venueless")
|
||||||
VENUELESS_TOKEN_ISSUER = os.environ.get('VENUELESS_TOKEN_ISSUER', "any")
|
VENUELESS_TOKEN_ISSUER = os.environ.get('VENUELESS_TOKEN_ISSUER', "any")
|
||||||
VENUELESS_SECRET = os.environ.get('VENUELESS_SECRET', SECRET_KEY)
|
VENUELESS_SECRET = os.environ.get('VENUELESS_SECRET', SECRET_KEY)
|
||||||
|
|
||||||
|
# Mux integration
|
||||||
|
MUX_PRIVATE_KEY_BASE64 = os.environ.get('MUX_PRIVATE_KEY_BASE64', None)
|
||||||
|
MUX_SIGNING_KEY_ID = os.environ.get('MUX_SIGNING_KEY_ID', None)
|
||||||
|
STREAMING_UI_VERSION = os.environ.get('STREAMING_UI_VERSION', 'test')
|
||||||
|
|
|
||||||
1
pinaxcon/streaming/__init__.py
Normal file
1
pinaxcon/streaming/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
default_app_config = 'pinaxcon.streaming.apps.StreamingConfig'
|
||||||
11
pinaxcon/streaming/admin.py
Normal file
11
pinaxcon/streaming/admin.py
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from pinaxcon.streaming import models
|
||||||
|
|
||||||
|
|
||||||
|
class RoomStreamAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('room', 'day', 'stream_id', 'chat_url', 'published',)
|
||||||
|
list_filter = ('room','day','published',)
|
||||||
|
|
||||||
|
|
||||||
|
admin.site.register(models.RoomStream, RoomStreamAdmin)
|
||||||
15
pinaxcon/streaming/apps.py
Normal file
15
pinaxcon/streaming/apps.py
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
from django.apps import AppConfig
|
||||||
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
|
|
||||||
|
class StreamingConfig(AppConfig):
|
||||||
|
name = "pinaxcon.streaming"
|
||||||
|
label = "pinaxcon_streaming"
|
||||||
|
verbose_name = _("Pinaxcon Streaming")
|
||||||
|
admin_group_name = "Streaming Admins"
|
||||||
|
|
||||||
|
def get_admin_group(self):
|
||||||
|
from django.contrib.auth.models import Group
|
||||||
|
|
||||||
|
group, created = Group.objects.get_or_create(name=self.admin_group_name)
|
||||||
|
return group
|
||||||
33
pinaxcon/streaming/migrations/0001_initial.py
Normal file
33
pinaxcon/streaming/migrations/0001_initial.py
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
# Generated by Django 2.2.28 on 2024-04-15 21:17
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('symposion_schedule', '0008_auto_20190122_0815'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='RoomStream',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('stream_id', models.CharField(max_length=255, null=True, verbose_name='Stream Identifier')),
|
||||||
|
('playback_id', models.CharField(max_length=255, null=True, verbose_name='Playback Identifier')),
|
||||||
|
('chat_url', models.CharField(max_length=255, null=True, verbose_name='Chat URL')),
|
||||||
|
('published', models.BooleanField(default=True, verbose_name='Published')),
|
||||||
|
('day', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='symposion_schedule.Day')),
|
||||||
|
('room', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='symposion_schedule.Room')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Room Stream',
|
||||||
|
'verbose_name_plural': 'Room Streams',
|
||||||
|
'unique_together': {('room', 'day')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
0
pinaxcon/streaming/migrations/__init__.py
Normal file
0
pinaxcon/streaming/migrations/__init__.py
Normal file
24
pinaxcon/streaming/models.py
Normal file
24
pinaxcon/streaming/models.py
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
from django.db import models
|
||||||
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
from symposion.schedule.models import Day, Room
|
||||||
|
|
||||||
|
|
||||||
|
class RoomStream(models.Model):
|
||||||
|
"""Video/Chat stream information for a room at the conference."""
|
||||||
|
|
||||||
|
room = models.ForeignKey(Room, on_delete=models.CASCADE)
|
||||||
|
day = models.ForeignKey(Day, on_delete=models.CASCADE)
|
||||||
|
stream_id = models.CharField(max_length=255, verbose_name=_("Stream Identifier"), null=True)
|
||||||
|
playback_id = models.CharField(max_length=255, verbose_name=_("Playback Identifier"), null=True)
|
||||||
|
chat_url= models.CharField(max_length=255, verbose_name=_("Chat URL"), null=True)
|
||||||
|
published = models.BooleanField(default=True, verbose_name=_("Published"))
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return "%s - %s" % (self.room.name, self.day.date.strftime("%a"))
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = [('room', 'day')]
|
||||||
|
verbose_name = _("Room Stream")
|
||||||
|
verbose_name_plural = _("Room Streams")
|
||||||
8
pinaxcon/streaming/urls.py
Normal file
8
pinaxcon/streaming/urls.py
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
from django.conf.urls import url
|
||||||
|
from pinaxcon.streaming import views
|
||||||
|
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
url(r"^$", views.streaming_view, name="streaming-home"),
|
||||||
|
url(r'^feeds$', views.streaming_feeds, name="streaming-feeds"),
|
||||||
|
]
|
||||||
104
pinaxcon/streaming/views.py
Normal file
104
pinaxcon/streaming/views.py
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
import base64
|
||||||
|
import datetime
|
||||||
|
import json
|
||||||
|
import jwt
|
||||||
|
|
||||||
|
from django.http import Http404, HttpResponse
|
||||||
|
from django.shortcuts import render, get_object_or_404, redirect
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
from django.contrib.auth.decorators import login_required
|
||||||
|
|
||||||
|
from registrasion.controllers.item import ItemController
|
||||||
|
from regidesk.models import CheckIn
|
||||||
|
from pinaxcon.streaming.models import RoomStream
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def streaming_view(request):
|
||||||
|
ctx = {}
|
||||||
|
return render(request, 'streaming/overview.html', ctx)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def streaming_feeds(request):
|
||||||
|
"""Details of the currently available live streams."""
|
||||||
|
stream_data = []
|
||||||
|
user_data = {}
|
||||||
|
|
||||||
|
# Find the checkin for the current user.
|
||||||
|
checkin = _current_checkin(request)
|
||||||
|
|
||||||
|
if checkin:
|
||||||
|
user_data["code"] = checkin.code
|
||||||
|
|
||||||
|
# Find all streams for the current day
|
||||||
|
today = datetime.datetime.now().date()
|
||||||
|
streams = RoomStream.objects.filter(
|
||||||
|
day__date = today,
|
||||||
|
published = True,
|
||||||
|
)
|
||||||
|
|
||||||
|
for stream in streams:
|
||||||
|
stream_details = {
|
||||||
|
"room_id": stream.room.id,
|
||||||
|
"room_name": stream.room.name,
|
||||||
|
#"track_name": stream.room.track.name,
|
||||||
|
"stream_name": str(stream),
|
||||||
|
"playback_id": stream.playback_id,
|
||||||
|
"video_token": _mux_token(checkin, stream.playback_id, 'v'),
|
||||||
|
"thumbnail_token": _mux_token(checkin, stream.playback_id, 't'),
|
||||||
|
"chat_url": stream.chat_url,
|
||||||
|
}
|
||||||
|
stream_data.append(stream_details)
|
||||||
|
|
||||||
|
live_streams_data = {
|
||||||
|
"ui_version": settings.STREAMING_UI_VERSION,
|
||||||
|
"streams": stream_data,
|
||||||
|
"user": user_data,
|
||||||
|
}
|
||||||
|
|
||||||
|
return HttpResponse(
|
||||||
|
json.dumps(live_streams_data, indent=2),
|
||||||
|
content_type="application/json"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _current_checkin(request):
|
||||||
|
# Check that they have a ticket first (only item from category 1)
|
||||||
|
TICKET_CATEGORY = 1
|
||||||
|
items = ItemController(request.user).items_purchased(
|
||||||
|
category=TICKET_CATEGORY
|
||||||
|
)
|
||||||
|
|
||||||
|
if not items:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Get token from checkin
|
||||||
|
checkin = CheckIn.objects.get_or_create(user=request.user)[0]
|
||||||
|
return checkin
|
||||||
|
|
||||||
|
|
||||||
|
def _mux_token(checkin, playback_id, audience):
|
||||||
|
"""Returns the Mux JSON Web Token (JWT) for a given video (playback_id)
|
||||||
|
that is linked to this checkin."""
|
||||||
|
if not checkin.code or not settings.MUX_PRIVATE_KEY_BASE64 or not settings.MUX_SIGNING_KEY_ID:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Based on example at https://docs.mux.com/guides/secure-video-playback
|
||||||
|
private_key = base64.b64decode(settings.MUX_PRIVATE_KEY_BASE64)
|
||||||
|
expiry_date = settings.CONF_END + datetime.timedelta(days=1)
|
||||||
|
|
||||||
|
token = {
|
||||||
|
'sub': playback_id,
|
||||||
|
'exp': expiry_date.timestamp(),
|
||||||
|
'aud': audience,
|
||||||
|
'custom': {
|
||||||
|
'checkin': checkin.code,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
headers = {
|
||||||
|
'kid': settings.MUX_SIGNING_KEY_ID
|
||||||
|
}
|
||||||
|
json_web_token = jwt.encode(token, private_key, algorithm="RS256", headers=headers)
|
||||||
|
return json_web_token
|
||||||
28
pinaxcon/templates/streaming/overview.html
Normal file
28
pinaxcon/templates/streaming/overview.html
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
{% extends "site_base.html" %}
|
||||||
|
{% load registrasion_tags %}
|
||||||
|
{% load lca2018_tags %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block head_title %}Live Streaming{% endblock %}
|
||||||
|
{% block page_title %}Live Streaming{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<p>Live Streaming is available to everyone attending the conference.</p>
|
||||||
|
<p>We encourage you to join in the chat via the <a href="https://matrix.to/#/#everything-open:matrix.org" target="_blank">Everything Open Matrix Space</a></p>
|
||||||
|
|
||||||
|
<div id="holding-frame">
|
||||||
|
<p>There are currently no live streams available. Please check back soon.</p>
|
||||||
|
</div>
|
||||||
|
<div id="streaming-frame">
|
||||||
|
<mux-player
|
||||||
|
playback-id="EcHgOK9coz5K4rjSwOkoE7Y7O01201YMIC200RI6lNxnhs"
|
||||||
|
metadata-video-title="Test VOD"
|
||||||
|
metadata-viewer-user-id="user-id-007"
|
||||||
|
></mux-player>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_script %}
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@mux/mux-player"></script>
|
||||||
|
<script src="{% static 'js/streaming.js' %}" type="text/javascript"></script>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -57,6 +57,18 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endflag %}
|
{% endflag %}
|
||||||
|
|
||||||
|
{% flag "streaming_dashboard" %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12 mb-3">
|
||||||
|
<h3>Join the Conference</h3>
|
||||||
|
<p>The conference stream is now open. Please join us to watch talks.</p>
|
||||||
|
<div>
|
||||||
|
<a class="btn btn-lg btn-primary" role="button" href="/streaming">Launch Conference</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endflag %}
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-6 mb-3 mb-md-0">
|
<div class="col-md-6 mb-3 mb-md-0">
|
||||||
<h3>Attendee Profile</h3>
|
<h3>Attendee Profile</h3>
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ urlpatterns = [
|
||||||
|
|
||||||
path("teams/", include("symposion.teams.urls")),
|
path("teams/", include("symposion.teams.urls")),
|
||||||
path('raffle/', include("pinaxcon.raffle.urls")),
|
path('raffle/', include("pinaxcon.raffle.urls")),
|
||||||
|
path('streaming/', include("pinaxcon.streaming.urls")),
|
||||||
|
|
||||||
# Required by registrasion
|
# Required by registrasion
|
||||||
path('tickets/payments/', include('registripe.urls')),
|
path('tickets/payments/', include('registripe.urls')),
|
||||||
|
|
|
||||||
51
static/src/js/streaming.js
Normal file
51
static/src/js/streaming.js
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
$(function() {
|
||||||
|
const SLOT_REFRESH_INTERVAL_MS = 10 * 60 * 1000;
|
||||||
|
var lastVersion = '';
|
||||||
|
|
||||||
|
function update_streams(data) {
|
||||||
|
|
||||||
|
if (lastVersion && data.ui_version != lastVersion) {
|
||||||
|
window.location.reload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastVersion = data.ui_version;
|
||||||
|
|
||||||
|
// If no streams, display holding message.
|
||||||
|
if (data.streams.length == 0) {
|
||||||
|
$('#holding-frame').show();
|
||||||
|
$('#streaming-frame').hide();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let streamingFrame = $('#streaming-frame');
|
||||||
|
$('#holding-frame').hide();
|
||||||
|
streamingFrame.show();
|
||||||
|
|
||||||
|
var stream = data.streams[0];
|
||||||
|
|
||||||
|
const muxPlayer = document.createElement("mux-player");
|
||||||
|
muxPlayer.setAttribute("playback-id", stream.playback_id);
|
||||||
|
muxPlayer.setAttribute("metadata-video-title", stream.stream_name);
|
||||||
|
muxPlayer.setAttribute("metadata-viewer-user-id", data.user.code);
|
||||||
|
muxPlayer.tokens = {
|
||||||
|
playback: stream.video_token,
|
||||||
|
thumbnail: stream.thumbnail_token,
|
||||||
|
};
|
||||||
|
streamingFrame.empty();
|
||||||
|
streamingFrame.append(muxPlayer);
|
||||||
|
}
|
||||||
|
|
||||||
|
function load_streams() {
|
||||||
|
$.ajax({
|
||||||
|
dataType: "json",
|
||||||
|
method: "GET",
|
||||||
|
url: "/streaming/feeds",
|
||||||
|
success: update_streams
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
load_streams();
|
||||||
|
setInterval(load_streams, SLOT_REFRESH_INTERVAL_MS);
|
||||||
|
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue