From 15be84948a3f5b5f071c8597aff01ed35a2b594d Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Sat, 5 Mar 2022 01:05:02 +0100 Subject: [PATCH] Ad-hoc Recordings 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. --- pyca/ui/jsonapi.py | 36 ++++++++++++++ pyca/ui/opencast_commands.py | 79 ++++++++++++++++++++++++++++++ tests/test_restapi.py | 37 ++++++++++++++ tests/test_ui_opencast_commands.py | 19 +++++++ ui/Schedule.vue | 63 ++++++++++++++++++++++++ ui/func.js | 3 ++ ui/index.html | 11 +++++ ui/style.css | 14 ++++++ 8 files changed, 262 insertions(+) create mode 100644 pyca/ui/opencast_commands.py create mode 100644 tests/test_ui_opencast_commands.py create mode 100644 ui/Schedule.vue diff --git a/pyca/ui/jsonapi.py b/pyca/ui/jsonapi.py index 0990c246..07094f40 100644 --- a/pyca/ui/jsonapi.py +++ b/pyca/ui/jsonapi.py @@ -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 @@ -260,3 +261,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') diff --git a/pyca/ui/opencast_commands.py b/pyca/ui/opencast_commands.py new file mode 100644 index 00000000..13078abf --- /dev/null +++ b/pyca/ui/opencast_commands.py @@ -0,0 +1,79 @@ +# -*- coding: utf-8 -*- +''' + python-capture-agent + ~~~~~~~~~~~~~~~~~~~~ + + :copyright: 2014-2022, Lars Kiesow + :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 = ''' + + {creator} + {start} + start={start}; end={end}; scheme=W3C-DTF; + demo + {agent_name} + {title} +''' # 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') diff --git a/tests/test_restapi.py b/tests/test_restapi.py index 0a147a0a..3928a796 100644 --- a/tests/test_restapi.py +++ b/tests/test_restapi.py @@ -290,3 +290,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) diff --git a/tests/test_ui_opencast_commands.py b/tests/test_ui_opencast_commands.py new file mode 100644 index 00000000..730c4e9f --- /dev/null +++ b/tests/test_ui_opencast_commands.py @@ -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() diff --git a/ui/Schedule.vue b/ui/Schedule.vue new file mode 100644 index 00000000..6c050398 --- /dev/null +++ b/ui/Schedule.vue @@ -0,0 +1,63 @@ + + + diff --git a/ui/func.js b/ui/func.js index 544c0240..fd74f4c3 100644 --- a/ui/func.js +++ b/ui/func.js @@ -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) @@ -173,6 +174,7 @@ var update_data = function () { }); }; + window.onload = function () { // Vue App new Vue({ @@ -182,6 +184,7 @@ window.onload = function () { Preview, Event, Metrics, + Schedule, }, created: update_data, }); diff --git a/ui/index.html b/ui/index.html index 829dd257..6bf27d15 100644 --- a/ui/index.html +++ b/ui/index.html @@ -113,6 +113,17 @@

Logs

+ +
+

Start Recording

+
+ 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. +
+ +
diff --git a/ui/style.css b/ui/style.css index 1252148b..6cc9e4cf 100644 --- a/ui/style.css +++ b/ui/style.css @@ -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; +}