diff --git a/make_dev_container.sh b/make_dev_container.sh index 829c5269..519ffca8 100755 --- a/make_dev_container.sh +++ b/make_dev_container.sh @@ -14,29 +14,34 @@ if ! docker info >/dev/null 2>&1; then exit 1 fi +SQLITEDB=$(pwd)/docker/symposion.sqlite + docker image build -f docker/Dockerfile -t ${IMAGE_NAME} --target symposion_dev . docker container stop 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 -## 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 - ./fixture_to_docker.sh fixtures/dev_dummy_superuser.json - ./fixture_to_docker.sh fixtures/????_*.json - popd -else - echo Now creating a Django superuser. Please enter a - docker exec -it symposion ./manage.py createsuperuser --username admin1 --email root@example.com +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 + ./fixture_to_docker.sh fixtures/dev_dummy_superuser.json + ./fixture_to_docker.sh fixtures/????_*.json + popd + else + echo Now creating a Django superuser. Please enter a + docker exec -it symposion ./manage.py createsuperuser --username admin1 --email root@example.com + fi fi set +x diff --git a/pinaxcon/settings.py b/pinaxcon/settings.py index 9b587928..1947acf5 100644 --- a/pinaxcon/settings.py +++ b/pinaxcon/settings.py @@ -259,6 +259,7 @@ INSTALLED_APPS = [ "pinaxcon.proposals", "pinaxcon.registrasion", "pinaxcon.raffle", + "pinaxcon.streaming", "jquery", "djangoformsetjs", @@ -580,3 +581,8 @@ VENUELESS_URL = os.environ.get('VENUELESS_URL', None) VENUELESS_AUDIENCE = os.environ.get('VENUELESS_AUDIENCE', "venueless") VENUELESS_TOKEN_ISSUER = os.environ.get('VENUELESS_TOKEN_ISSUER', "any") 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') diff --git a/pinaxcon/streaming/__init__.py b/pinaxcon/streaming/__init__.py new file mode 100644 index 00000000..03f86ecf --- /dev/null +++ b/pinaxcon/streaming/__init__.py @@ -0,0 +1 @@ +default_app_config = 'pinaxcon.streaming.apps.StreamingConfig' diff --git a/pinaxcon/streaming/admin.py b/pinaxcon/streaming/admin.py new file mode 100644 index 00000000..7769b5f4 --- /dev/null +++ b/pinaxcon/streaming/admin.py @@ -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) diff --git a/pinaxcon/streaming/apps.py b/pinaxcon/streaming/apps.py new file mode 100644 index 00000000..43c09446 --- /dev/null +++ b/pinaxcon/streaming/apps.py @@ -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 diff --git a/pinaxcon/streaming/migrations/0001_initial.py b/pinaxcon/streaming/migrations/0001_initial.py new file mode 100644 index 00000000..2bc90368 --- /dev/null +++ b/pinaxcon/streaming/migrations/0001_initial.py @@ -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')}, + }, + ), + ] diff --git a/pinaxcon/streaming/migrations/__init__.py b/pinaxcon/streaming/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pinaxcon/streaming/models.py b/pinaxcon/streaming/models.py new file mode 100644 index 00000000..9addf3b0 --- /dev/null +++ b/pinaxcon/streaming/models.py @@ -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") diff --git a/pinaxcon/streaming/urls.py b/pinaxcon/streaming/urls.py new file mode 100644 index 00000000..c4e30f5a --- /dev/null +++ b/pinaxcon/streaming/urls.py @@ -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"), +] diff --git a/pinaxcon/streaming/views.py b/pinaxcon/streaming/views.py new file mode 100644 index 00000000..43fd3b8f --- /dev/null +++ b/pinaxcon/streaming/views.py @@ -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 diff --git a/pinaxcon/templates/streaming/overview.html b/pinaxcon/templates/streaming/overview.html new file mode 100644 index 00000000..74f6c99d --- /dev/null +++ b/pinaxcon/templates/streaming/overview.html @@ -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 %} +

Live Streaming is available to everyone attending the conference.

+

We encourage you to join in the chat via the Everything Open Matrix Space

+ +
+

There are currently no live streams available. Please check back soon.

+
+
+ +
+{% endblock %} + +{% block extra_script %} + + +{% endblock %} diff --git a/pinaxcon/templates/symposion/dashboard/_categories.html b/pinaxcon/templates/symposion/dashboard/_categories.html index 1dc96b01..2377d36a 100644 --- a/pinaxcon/templates/symposion/dashboard/_categories.html +++ b/pinaxcon/templates/symposion/dashboard/_categories.html @@ -57,6 +57,18 @@ {% endif %} {% endflag %} + {% flag "streaming_dashboard" %} +
+
+

Join the Conference

+

The conference stream is now open. Please join us to watch talks.

+
+ Launch Conference +
+
+
+ {% endflag %} +

Attendee Profile

diff --git a/pinaxcon/urls.py b/pinaxcon/urls.py index 4818128b..d4321f93 100644 --- a/pinaxcon/urls.py +++ b/pinaxcon/urls.py @@ -23,6 +23,7 @@ urlpatterns = [ path("teams/", include("symposion.teams.urls")), path('raffle/', include("pinaxcon.raffle.urls")), + path('streaming/', include("pinaxcon.streaming.urls")), # Required by registrasion path('tickets/payments/', include('registripe.urls')), diff --git a/static/src/js/streaming.js b/static/src/js/streaming.js new file mode 100644 index 00000000..416869db --- /dev/null +++ b/static/src/js/streaming.js @@ -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); + +});