Skip to content

Commit

Permalink
Ad-hoc Recordings
Browse files Browse the repository at this point in the history
This patch adds a simple user interface to schedule a new event starting
right now on the pyCA capture agent.

This easily allows users to quickly schedule test or last-minute events
with no need for going through Opencast's admin interface.
  • Loading branch information
lkiesow committed Mar 7, 2022
1 parent 0050805 commit cfa40ce
Show file tree
Hide file tree
Showing 8 changed files with 262 additions and 0 deletions.
36 changes: 36 additions & 0 deletions pyca/ui/jsonapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from pyca.db import with_session, Status, ServiceStates
from pyca.ui import app
from pyca.ui.utils import requires_auth, jsonapi_mediatype
from pyca.ui.opencast_commands import schedule
from pyca.utils import get_service_status, ensurelist, timestamp
import logging
import os
Expand Down Expand Up @@ -257,3 +258,38 @@ def logs():
'type': 'logs',
'attributes': {
'lines': logs}})


@app.route('/api/schedule', methods=['POST'])
@requires_auth
@jsonapi_mediatype
def schedule_event():
try:
# We only allow one schedule at a time
print(0, request)
print(1, request.data)
print(2, request.get_json())
data = request.get_json()['data']
if len(data) != 1:
return make_error_response('Invalid data', 400)
data = data[0]

# Check attributes
for key in data.keys():
if key not in ('title', 'duration', 'creator'):
return make_error_response('Invalid data', 400)

# Check duration
if type(data['duration']) != int:
return make_error_response('Duration must be an integer', 400)
except Exception as e:
logger.debug('bad request', e)
return make_error_response('Invalid data', 400)

try:
schedule(title=data.get('title', 'pyCA Recording'),
duration=data['duration'],
creator=data.get('creator', config('ui', 'username')))
except Exception:
return make_error_response('Scheduling conflict', 409)
return make_data_response('Event scheduled')
79 changes: 79 additions & 0 deletions pyca/ui/opencast_commands.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# -*- coding: utf-8 -*-
'''
python-capture-agent
~~~~~~~~~~~~~~~~~~~~
:copyright: 2014-2022, Lars Kiesow <lkiesow@uos.de>
:license: LGPL – see license.lgpl for more details.
'''

from xml.sax.saxutils import escape as xml_escape
from pyca.config import config
from pyca.utils import http_request, service
from datetime import datetime, timedelta
import logging
import random

logger = logging.getLogger(__name__)

DUBLINCORE = '''<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<dublincore xmlns="http://www.opencastproject.org/xsd/1.0/dublincore/"
xmlns:dcterms="http://purl.org/dc/terms/"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<dcterms:creator>{creator}</dcterms:creator>
<dcterms:created xsi:type="dcterms:W3CDTF">{start}</dcterms:created>
<dcterms:temporal xsi:type="dcterms:Period">start={start}; end={end}; scheme=W3C-DTF;</dcterms:temporal>
<dcterms:language>demo</dcterms:language>
<dcterms:spatial>{agent_name}</dcterms:spatial>
<dcterms:title>{title}</dcterms:title>
</dublincore>''' # noqa


def schedule(title='pyCA Recording', duration=60, creator=None):
'''Schedule a recording for this capture agent with the given title,
creator and duration starting 10 seconds from now.
:param title: Title of the event to schedule
:type title: string
:param creator: Creator of the event to schedule
:type creator: string
:param duration: Duration of the event to schedule in seconds
:type creator: int
'''
if not creator:
creator = config('ui', 'username')

# Select ingest service
# The ingest service to use is selected at random from the available
# ingest services to ensure that not every capture agent uses the same
# service at the same time
service_url = service('ingest', force_update=True)
service_url = service_url[random.randrange(0, len(service_url))]
logger.info('Selecting ingest service for scheduling: ' + service_url)

# create media package
logger.info('Creating new media package')
mediapackage = http_request(service_url + '/createMediaPackage')

# add dublin core catalog
start = datetime.utcnow() + timedelta(seconds=10)
end = start + timedelta(seconds=duration)
dublincore = DUBLINCORE.format(
agent_name=xml_escape(config('agent', 'name')),
start=start.strftime('%Y-%m-%dT%H:%M:%SZ'),
end=end.strftime('%Y-%m-%dT%H:%M:%SZ'),
title=xml_escape(title),
creator=xml_escape(creator))
logger.info('Adding Dublin Core catalog for scheduling')
fields = [('mediaPackage', mediapackage),
('flavor', 'dublincore/episode'),
('dublinCore', dublincore)]
mediapackage = http_request(service_url + '/addDCCatalog', fields)

# schedule event
logger.info('Scheduling recording')
fields = [('mediaPackage', mediapackage)]
mediapackage = http_request(service_url + '/schedule', fields)

# Update status
logger.info('Event successfully scheduled')
37 changes: 37 additions & 0 deletions tests/test_restapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -282,3 +282,40 @@ def test_modify_event(self):
self.assertEqual(jsonevent.get('id'), event.uid)
self.assertEqual(jsonevent['attributes'].get('start'), 1000)
self.assertEqual(jsonevent['attributes'].get('end'), 2000)

def test_schedule_event(self):
# Mock scheduling
ui.jsonapi.schedule = lambda title, duration, creator: True

# Without authentication
with ui.app.test_request_context():
self.assertEqual(ui.jsonapi.schedule_event().status_code, 401)

args = dict(headers=self.headers, method='POST')

# With authentication but no or invalid data
for data in (
'null',
'{"data":[{}, {}]}',
'{"id":0}',
'{"data":[{"invalid":"test"}]}',
'{"data":[{"duration":"invalid"}]}'):
args['data'] = data
with ui.app.test_request_context(**args):
response = ui.jsonapi.schedule_event()
self.assertEqual(response.status_code, 400)
self.assertEqual(
response.headers['Content-Type'], self.content_type)
error = json.loads(response.data.decode('utf-8'))['errors'][0]
self.assertEqual(error['status'], 400)

# With authentication and valid uid
args['data'] = json.dumps({'data': [{
'title': 'a',
'creator': 'b',
'duration': 1}]})
with ui.app.test_request_context(**args):
response = ui.jsonapi.schedule_event()
self.assertEqual(response.status_code, 200)
self.assertEqual(
response.headers['Content-Type'], self.content_type)
19 changes: 19 additions & 0 deletions tests/test_ui_opencast_commands.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-

'''
Tests for basic capturing
'''

import unittest

from pyca.ui import opencast_commands


class TestPycaIngest(unittest.TestCase):

def setUp(self):
opencast_commands.http_request = lambda x, y=False: b'xxx'
opencast_commands.service = lambda x, force_update=False: ['']

def test_schedule_defaults(self):
opencast_commands.schedule()
63 changes: 63 additions & 0 deletions ui/Schedule.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<template>
<div>
<form v-if='!this.active' id=schedule v-on:submit=schedule>
<label for=title>Title</label>
<input id=title type=text placeholder='pyCA Recording' v-model=title required />
<br />
<label for=creator>Creator</label>
<input id=creator type=text placeholder=Administrator v-model=creator required />
<br />
<label for=duration>Duration (min)</label>
<input id=duration type=number placeholder=30 v-model.number=duration required />
<br />
<input type=submit value=Start />
</form>
<div v-if='this.active' style='color: green;padding: 15px'>
Event is being scheduled…
</div>
</div>
</template>

<script>
export default {
data() {
return {
active: false,
title: 'pyCA Recording',
creator: 'Administrator',
duration: 5,
}},
methods: {
schedule: function(event) {
event.preventDefault();
this.active = true;
let requestOptions = {
method: 'POST',
headers: { 'Content-Type': 'application/vnd.api+json' },
body: JSON.stringify(
{'data': [{
'title': this.title,
'creator': this.creator,
'duration': this.duration * 60
}]})
};
fetch('/api/schedule', requestOptions)
.then(response => {
if (response.status == 409) {
alert('Conflict: A scheduled recording exists during this time.');
throw 'Error: Scheduling conflict';
} else if (response.status != 200) {
throw 'Error: request failed';
}
})
.catch(function(error) {
console.log(error);
})
.finally(() => {
this.active = false;
})
}
}
};
</script>
3 changes: 3 additions & 0 deletions ui/func.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import Event from './Event.vue'
import Metrics from './Metrics.vue'
import Preview from './Preview.vue'
import Schedule from './Schedule.vue'

library.add(faExclamationTriangle)
library.add(faSync)
Expand Down Expand Up @@ -173,6 +174,7 @@ var update_data = function () {
});
};


window.onload = function () {
// Vue App
new Vue({
Expand All @@ -182,6 +184,7 @@ window.onload = function () {
Preview,
Event,
Metrics,
Schedule,
},
created: update_data,
});
Expand Down
11 changes: 11 additions & 0 deletions ui/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,17 @@ <h2>Logs</h2>
</pre>
</div>
</section>

<section>
<h2 id=_schedule>Start Recording</h2>
<div>
Schedule a recording on this capture agent for right now.
The maximum time before the recording actually starts
depends on the capture agent's update frequency. It should
start with the next update.
</div>
<Schedule />
</section>
</main>
</div>

Expand Down
14 changes: 14 additions & 0 deletions ui/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -131,3 +131,17 @@ div.logs pre {
padding: 10px;
font-family: sans;
}

form#schedule {
padding: 20px;
}

form#schedule label {
display: inline-block;
width: 120px;
margin: 10px;
}

form#schedule input[type=submit] {
margin-left: 143px;
}

0 comments on commit cfa40ce

Please sign in to comment.