diff --git a/pillar/api/eve_settings.py b/pillar/api/eve_settings.py index 2c5dc52e..0fcb9200 100644 --- a/pillar/api/eve_settings.py +++ b/pillar/api/eve_settings.py @@ -123,6 +123,35 @@ users_schema = { 'allow_unknown': True, }, + # Node-specific information for this user. + 'nodes': { + 'type': 'dict', + 'schema': { + # Per watched video info about where the user left off, both in time and in percent. + 'view_progress': { + 'type': 'dict', + # Keyed by Node ID of the video asset. MongoDB doesn't support using + # ObjectIds as key, so we cast them to string instead. + 'keyschema': {'type': 'string'}, + 'valueschema': { + 'type': 'dict', + 'schema': { + 'progress_in_sec': {'type': 'float', 'min': 0}, + 'progress_in_percent': {'type': 'integer', 'min': 0, 'max': 100}, + + # When the progress was last updated, so we can limit this history to + # the last-watched N videos if we want, or show stuff in chrono order. + 'last_watched': {'type': 'datetime'}, + + # True means progress_in_percent = 100, for easy querying + 'done': {'type': 'boolean', 'default': False}, + }, + }, + }, + + }, + }, + # Properties defined by extensions. Extensions should use their name (see the # PillarExtension.name property) as the key, and are free to use whatever they want as value, # but we suggest a dict for future extendability. diff --git a/pillar/api/users/routes.py b/pillar/api/users/routes.py index 7f0e79c2..d4e1d690 100644 --- a/pillar/api/users/routes.py +++ b/pillar/api/users/routes.py @@ -1,9 +1,11 @@ import logging from eve.methods.get import get -from flask import Blueprint +from flask import Blueprint, request +import werkzeug.exceptions as wz_exceptions -from pillar.api.utils import jsonify +from pillar import current_app +from pillar.api import utils from pillar.api.utils.authorization import require_login from pillar.auth import current_user @@ -15,7 +17,128 @@ blueprint_api = Blueprint('users_api', __name__) @require_login() def my_info(): eve_resp, _, _, status, _ = get('users', {'_id': current_user.user_id}) - resp = jsonify(eve_resp['_items'][0], status=status) + resp = utils.jsonify(eve_resp['_items'][0], status=status) return resp +@blueprint_api.route('/video//progress') +@require_login() +def get_video_progress(video_id: str): + """Return video progress information. + + Either a `204 No Content` is returned (no information stored), + or a `200 Ok` with JSON from Eve's 'users' schema, from the key + video.view_progress.. + """ + + # Validation of the video ID; raises a BadRequest when it's not an ObjectID. + # This isn't strictly necessary, but it makes this function behave symmetrical + # to the set_video_progress() function. + utils.str2id(video_id) + + users_coll = current_app.db('users') + user_doc = users_coll.find_one(current_user.user_id, projection={'nodes.view_progress': True}) + try: + progress = user_doc['nodes']['view_progress'][video_id] + except KeyError: + return '', 204 + if not progress: + return '', 204 + + return utils.jsonify(progress) + + +@blueprint_api.route('/video//progress', methods=['POST']) +@require_login() +def set_video_progress(video_id: str): + """Save progress information about a certain video. + + Expected parameters: + - progress_in_sec: float number of seconds + - progress_in_perc: integer percentage of video watched (interval [0-100]) + """ + my_log = log.getChild('set_video_progress') + my_log.debug('Setting video progress for user %r video %r', current_user.user_id, video_id) + + # Constructing this response requires an active app, and thus can't be done on module load. + no_video_response = utils.jsonify({'_message': 'No such video'}, status=404) + + try: + progress_in_sec = float(request.form['progress_in_sec']) + progress_in_perc = int(request.form['progress_in_perc']) + except KeyError as ex: + my_log.debug('Missing POST field in request: %s', ex) + raise wz_exceptions.BadRequest(f'missing a form field') + except ValueError as ex: + my_log.debug('Invalid value for POST field in request: %s', ex) + raise wz_exceptions.BadRequest(f'Invalid value for field: {ex}') + + users_coll = current_app.db('users') + nodes_coll = current_app.db('nodes') + + # First check whether this is actually an existing video + video_oid = utils.str2id(video_id) + video_doc = nodes_coll.find_one(video_oid, projection={ + 'node_type': True, + 'properties.content_type': True, + 'properties.file': True, + }) + if not video_doc: + my_log.debug('Node %r not found, unable to set progress for user %r', + video_oid, current_user.user_id) + return no_video_response + + try: + is_video = (video_doc['node_type'] == 'asset' + and video_doc['properties']['content_type'] == 'video') + except KeyError: + is_video = False + + if not is_video: + my_log.info('Node %r is not a video, unable to set progress for user %r', + video_oid, current_user.user_id) + # There is no video found at this URL, so act as if it doesn't even exist. + return no_video_response + + # Compute the progress + percent = min(100, max(0, progress_in_perc)) + progress = { + 'progress_in_sec': progress_in_sec, + 'progress_in_percent': percent, + 'last_watched': utils.utcnow(), + } + + # After watching a certain percentage of the video, we consider it 'done' + # + # Total Credit start Total Credit Percent + # HH:MM:SS HH:MM:SS sec sec of duration + # Sintel 00:14:48 00:12:24 888 744 83.78% + # Tears of Steel 00:12:14 00:09:49 734 589 80.25% + # Cosmos Laundro 00:12:10 00:10:05 730 605 82.88% + # Agent 327 00:03:51 00:03:26 231 206 89.18% + # Caminandes 3 00:02:30 00:02:18 150 138 92.00% + # Glass Half 00:03:13 00:02:52 193 172 89.12% + # Big Buck Bunny 00:09:56 00:08:11 596 491 82.38% + # Elephant’s Drea 00:10:54 00:09:25 654 565 86.39% + # + # Median 85.09% + # Average 85.75% + # + # For training videos marking at done at 85% of the video may be a bit + # early, since those probably won't have (long) credits. This is why we + # stick to 90% here. + if percent >= 90: + progress['done'] = True + + # Setting each property individually prevents us from overwriting any + # existing {done: true} fields. + updates = {f'nodes.view_progress.{video_id}.{k}': v + for k, v in progress.items()} + result = users_coll.update_one({'_id': current_user.user_id}, + {'$set': updates}) + + if result.matched_count == 0: + my_log.error('Current user %r could not be updated', current_user.user_id) + raise wz_exceptions.InternalServerError('Unable to find logged-in user') + + return '', 204 diff --git a/pillar/tests/__init__.py b/pillar/tests/__init__.py index 1baae624..61914561 100644 --- a/pillar/tests/__init__.py +++ b/pillar/tests/__init__.py @@ -391,7 +391,7 @@ class AbstractPillarTest(TestMinimal): return user_id - def create_node(self, node_doc): + def create_node(self, node_doc) -> ObjectId: """Creates a node, returning its ObjectId. """ with self.app.test_request_context(): diff --git a/src/scripts/video_plugins.js b/src/scripts/video_plugins.js new file mode 100644 index 00000000..cd0c20a3 --- /dev/null +++ b/src/scripts/video_plugins.js @@ -0,0 +1,182 @@ +/* Video.JS plugin for keeping track of user's viewing progress. + Also registers the analytics plugin. + +Progress is reported after a number of seconds or a percentage +of the duration of the video, whichever comes first. + +Example usage: + +videojs(videoPlayerElement, options).ready(function() { + let report_url = '{{ url_for("users_api.set_video_progress", video_id=node._id) }}'; + this.progressPlugin({'report_url': report_url}); +}); + +*/ + +// Report after progressing this many seconds video-time. +let PROGRESS_REPORT_INTERVAL_SEC = 30; + +// Report after progressing this percentage of the entire video (scale 0-100). +let PROGRESS_REPORT_INTERVAL_PERC = 10; + +// Don't report within this many milliseconds of wall-clock time of the previous report. +let PROGRESS_RELAXING_TIME_MSEC = 500; + + +var Plugin = videojs.getPlugin('plugin'); +var VideoProgressPlugin = videojs.extend(Plugin, { + constructor: function(player, options) { + Plugin.call(this, player, options); + + this.last_wallclock_time_ms = 0; + this.last_inspected_progress_in_sec = 0; + this.last_reported_progress_in_sec = 0; + this.last_reported_progress_in_perc = 0; + this.report_url = options.report_url; + this.fetch_progress_url = options.fetch_progress_url; + this.reported_error = false; + this.reported_looping = false; + + if (typeof this.report_url === 'undefined' || !this.report_url) { + /* If we can't report anything, don't bother registering event handlers. */ + videojs.log('VideoProgressPlugin: no report_url option given. Not storing video progress.'); + } else { + /* Those events will have 'this' bound to the player, + * which is why we explicitly re-bind to 'this''. */ + player.on('timeupdate', this.on_timeupdate.bind(this)); + player.on('pause', this.on_pause.bind(this)); + } + + if (typeof this.fetch_progress_url === 'undefined' || !this.fetch_progress_url) { + /* If we can't report anything, don't bother registering event handlers. */ + videojs.log('VideoProgressPlugin: no fetch_progress_url option given. Not restoring video progress.'); + } else { + this.resume_playback(); + } + }, + + resume_playback: function() { + let on_done = function(progress, status, xhr) { + /* 'progress' is an object like: + {"progress_in_sec": 3, + "progress_in_percent": 51, + "last_watched": "Fri, 31 Aug 2018 13:53:06 GMT", + "done": true} + */ + switch (xhr.status) { + case 204: return; // no info found. + case 200: + /* Don't do anything when the progress is at 100%. + * Moving the current time to the end makes no sense then. */ + if (progress.progress_in_percent >= 100) return; + + /* Set the 'last reported' props before manipulating the + * player, so that the manipulation doesn't trigger more + * API calls to remember what we just restored. */ + this.last_reported_progress_in_sec = progress.progress_in_sec; + this.last_reported_progress_in_perc = progress.progress_in_perc; + + console.log("Continuing playback at ", progress.progress_in_percent, "% from", progress.last_watched); + this.player.currentTime(progress.progress_in_sec); + this.player.play(); + return; + default: + console.log("Unknown code", xhr.status, "getting video progress information."); + } + }; + + $.get(this.fetch_progress_url) + .fail(function(error) { + console.log("Unable to fetch video progress information:", xhrErrorResponseMessage(error)); + }) + .done(on_done.bind(this)); + }, + + /* Pausing playback should report the progress. + * This function is also called when playback stops at the end of the video, + * so it's important to report in this case; otherwise progress will never + * reach 100%. */ + on_pause: function(event) { + this.inspect_progress(true); + }, + + on_timeupdate: function() { + this.inspect_progress(false); + }, + + inspect_progress: function(force_report) { + // Don't report seeking when paused, only report actual playback. + if (this.player.paused()) return; + + let now_in_ms = new Date().getTime(); + if (!force_report && now_in_ms - this.last_wallclock_time_ms < PROGRESS_RELAXING_TIME_MSEC) { + // We're trying too fast, don't bother doing any other calculation. + // console.log('skipping, already reported', now_in_ms - this.last_wallclock_time_ms, 'ms ago.'); + return; + } + + let progress_in_sec = this.player.currentTime(); + let duration_in_sec = this.player.duration(); + + /* Instead of reporting the current time, report reaching the end + * of the video. This ensures that it's properly marked as 'done'. */ + if (!this.reported_looping) { + let margin = 1.25 * PROGRESS_RELAXING_TIME_MSEC / 1000.0; + let is_looping = progress_in_sec == 0 && duration_in_sec - this.last_inspected_progress_in_sec < margin; + this.last_inspected_progress_in_sec = progress_in_sec; + if (is_looping) { + this.reported_looping = true; + this.report(this.player.duration(), 100, now_in_ms); + return; + } + } + + if (Math.abs(progress_in_sec - this.last_reported_progress_in_sec) < 0.01) { + // Already reported this, don't bother doing it again. + return; + } + let progress_in_perc = 100 * progress_in_sec / duration_in_sec; + let diff_sec = progress_in_sec - this.last_reported_progress_in_sec; + let diff_perc = progress_in_perc - this.last_reported_progress_in_perc; + + if (!force_report + && Math.abs(diff_perc) < PROGRESS_REPORT_INTERVAL_PERC + && Math.abs(diff_sec) < PROGRESS_REPORT_INTERVAL_SEC) { + return; + } + + this.report(progress_in_sec, progress_in_perc, now_in_ms); + }, + + report: function(progress_in_sec, progress_in_perc, now_in_ms) { + /* Store when we tried, not when we succeeded. This function can be + * called every 15-250 milliseconds, so we don't want to retry with + * that frequency. */ + this.last_wallclock_time_ms = now_in_ms; + + let on_fail = function(error) { + /* Don't show (as in: a toastr popup) the error to the user, + * as it doesn't impact their ability to play the video. + * Also show the error only once, instead of spamming. */ + if (this.reported_error) return; + + let msg = xhrErrorResponseMessage(error); + console.log('Unable to report viewing progress:', msg); + this.reported_error = true; + }; + let on_done = function() { + this.last_reported_progress_in_sec = progress_in_sec; + this.last_reported_progress_in_perc = progress_in_perc; + }; + + $.post(this.report_url, { + progress_in_sec: progress_in_sec, + progress_in_perc: Math.round(progress_in_perc), + }) + .fail(on_fail.bind(this)) + .done(on_done.bind(this)); + }, +}); + +// Register our watch-progress-bookkeeping plugin. +videojs.registerPlugin('progressPlugin', VideoProgressPlugin); diff --git a/src/templates/nodes/custom/asset/video/view_embed.pug b/src/templates/nodes/custom/asset/video/view_embed.pug index a6ab2860..55c938c0 100644 --- a/src/templates/nodes/custom/asset/video/view_embed.pug +++ b/src/templates/nodes/custom/asset/video/view_embed.pug @@ -67,6 +67,14 @@ script(type="text/javascript"). }); this.hotkeys(); + {% if current_user.is_authenticated %} + let fetch_progress_url = '{{ url_for("users_api.get_video_progress", video_id=node._id) }}'; + let report_url = '{{ url_for("users_api.set_video_progress", video_id=node._id) }}'; + this.progressPlugin({ + 'report_url': report_url, + 'fetch_progress_url': fetch_progress_url, + }); + {% endif %} }); function addVideoPlayerButton(data) { diff --git a/src/templates/projects/view.pug b/src/templates/projects/view.pug index 6626636a..a216bc20 100644 --- a/src/templates/projects/view.pug +++ b/src/templates/projects/view.pug @@ -74,6 +74,7 @@ link(rel="amphtml", href="{{ url_for('nodes.view', node_id=node._id, _external=T script(src="{{ url_for('static_pillar', filename='assets/js/vendor/videojs-6.2.8.min.js') }}") script(src="{{ url_for('static_pillar', filename='assets/js/vendor/videojs-ga-0.4.2.min.js') }}") script(src="{{ url_for('static_pillar', filename='assets/js/vendor/videojs-hotkeys-0.2.20.min.js') }}") +script(src="{{ url_for('static_pillar', filename='assets/js/video_plugins.min.js') }}") | {% endblock %} | {% block css %} diff --git a/tests/test_api/test_user_video_progress.py b/tests/test_api/test_user_video_progress.py new file mode 100644 index 00000000..b5e9af68 --- /dev/null +++ b/tests/test_api/test_user_video_progress.py @@ -0,0 +1,243 @@ +from unittest import mock + +import bson +from eve import RFC1123_DATE_FORMAT +import flask + +from pillar.tests import AbstractPillarTest + + +class AbstractVideoProgressTest(AbstractPillarTest): + def setUp(self, **kwargs): + super().setUp(**kwargs) + + self.pid, _ = self.ensure_project_exists() + + self.admin_uid = self.create_user(24 * 'a', roles={'admin'}) + self.uid = self.create_user(24 * 'b', roles={'subscriber'}) + + from pillar.api.utils import utcnow + self.fake_now = utcnow() + self.fake_now_str = self.fake_now.strftime(RFC1123_DATE_FORMAT) + + def create_video_node(self) -> bson.ObjectId: + return self.create_node({ + 'description': '', + 'node_type': 'asset', + 'user': self.admin_uid, + 'properties': { + 'status': 'published', + 'content_type': 'video', + 'file': bson.ObjectId()}, + 'name': 'Image test', + 'project': self.pid, + }) + + def set_progress(self, + progress_in_sec: float = 413.0, + progress_in_perc: int = 65, + expected_status: int = 204) -> None: + with self.login_as(self.uid): + url = flask.url_for('users_api.set_video_progress', video_id=str(self.video_id)) + self.post(url, + data={'progress_in_sec': progress_in_sec, + 'progress_in_perc': progress_in_perc}, + expected_status=expected_status) + + def get_progress(self, expected_status: int = 200) -> dict: + with self.login_as(self.uid): + url = flask.url_for('users_api.get_video_progress', video_id=str(self.video_id)) + progress = self.get(url, expected_status=expected_status) + return progress.json + + def create_video_and_set_progress(self, progress_in_sec=413.0, progress_in_perc=65): + self.video_id = self.create_video_node() + + # Check that we can get the progress after setting it. + with mock.patch('pillar.api.utils.utcnow') as utcnow: + utcnow.return_value = self.fake_now + self.set_progress(progress_in_sec, progress_in_perc) + + +class HappyFlowVideoProgressTest(AbstractVideoProgressTest): + + def test_video_progress_known_video(self): + self.create_video_and_set_progress() + progress = self.get_progress() + + expected_progress = { + 'progress_in_sec': 413.0, + 'progress_in_percent': 65, + 'last_watched': self.fake_now, + } + self.assertEqual({**expected_progress, 'last_watched': self.fake_now_str}, + progress) + + # Check that the database has been updated correctly. + self.db_user = self.fetch_user_from_db(self.uid) + self.assertEqual({str(self.video_id): expected_progress}, + self.db_user['nodes']['view_progress']) + + def test_user_adheres_to_schema(self): + from pillar.api.utils import remove_private_keys + # This check is necessary because the API code uses direct MongoDB manipulation, + # which means that the user can end up not matching the Cerberus schema. + self.create_video_and_set_progress() + db_user = self.fetch_user_from_db(self.uid) + + r, _, _, status = self.app.put_internal( + 'users', + payload=remove_private_keys(db_user), + _id=db_user['_id']) + self.assertEqual(200, status, r) + + def test_video_progress_is_private(self): + self.create_video_and_set_progress() + + with self.login_as(self.uid): + resp = self.get(f'/api/users/{self.uid}') + self.assertIn('nodes', resp.json) + + other_uid = self.create_user(24 * 'c', roles={'subscriber'}) + with self.login_as(other_uid): + resp = self.get(f'/api/users/{self.uid}') + self.assertIn('username', resp.json) # just to be sure this is a real user response + self.assertNotIn('nodes', resp.json) + + def test_done_at_100_percent(self): + self.create_video_and_set_progress(630, 100) + progress = self.get_progress() + self.assertEqual({'progress_in_sec': 630.0, + 'progress_in_percent': 100, + 'last_watched': self.fake_now_str, + 'done': True}, + progress) + + def test_done_at_95_percent(self): + self.create_video_and_set_progress(599, 95) + progress = self.get_progress() + self.assertEqual({'progress_in_sec': 599.0, + 'progress_in_percent': 95, + 'last_watched': self.fake_now_str, + 'done': True}, + progress) + + def test_rewatch_after_done(self): + from pillar.api.utils import utcnow + + self.create_video_and_set_progress(630, 100) + + # Re-watching should keep the 'done' key. + another_fake_now = utcnow() + with mock.patch('pillar.api.utils.utcnow') as mock_utcnow: + mock_utcnow.return_value = another_fake_now + self.set_progress(444, 70) + + progress = self.get_progress() + self.assertEqual({'progress_in_sec': 444, + 'progress_in_percent': 70, + 'done': True, + 'last_watched': another_fake_now.strftime(RFC1123_DATE_FORMAT)}, + progress) + + def test_inconsistent_progress(self): + # Send a percentage that's incorrect. It should just be copied. + self.create_video_and_set_progress(413.557, 30) + progress = self.get_progress() + + expected_progress = { + 'progress_in_sec': 413.557, + 'progress_in_percent': 30, + 'last_watched': self.fake_now, + } + self.assertEqual({**expected_progress, 'last_watched': self.fake_now_str}, + progress) + + # Check that the database has been updated correctly. + self.db_user = self.fetch_user_from_db(self.uid) + self.assertEqual({str(self.video_id): expected_progress}, + self.db_user['nodes']['view_progress']) + + +class UnhappyFlowVideoProgressTest(AbstractVideoProgressTest): + def test_get_video_progress_invalid_video_id(self): + with self.login_as(self.uid): + url = flask.url_for('users_api.get_video_progress', video_id='jemoeder') + self.get(url, expected_status=400) + + def test_get_video_progress_unknown_video(self): + with self.login_as(self.uid): + url = flask.url_for('users_api.get_video_progress', video_id=24 * 'f') + self.get(url, expected_status=204) + + def test_set_video_progress_unknown_video(self): + with self.login_as(self.uid): + url = flask.url_for('users_api.set_video_progress', video_id=24 * 'f') + self.post(url, + data={'progress_in_sec': 16, 'progress_in_perc': 10}, + expected_status=404) + + def test_set_video_progress_invalid_video_id(self): + with self.login_as(self.uid): + url = flask.url_for('users_api.set_video_progress', video_id='jemoeder') + self.post(url, + data={'progress_in_sec': 16, 'progress_in_perc': 10}, + expected_status=400) + + def test_get_video_empty_dict(self): + self.video_id = bson.ObjectId(24 * 'f') + with self.app.app_context(): + users_coll = self.app.db('users') + # The progress dict for that video is there, but empty. + users_coll.update_one( + {'_id': self.uid}, + {'$set': {f'nodes.view_progress.{self.video_id}': {}}}) + + progress = self.get_progress(expected_status=204) + self.assertIsNone(progress) + + def test_missing_post_field(self): + with self.login_as(self.uid): + url = flask.url_for('users_api.set_video_progress', video_id=24 * 'f') + self.post(url, data={'progress_in_ms': 1000}, expected_status=400) + + def test_nonint_progress(self): + with self.login_as(self.uid): + url = flask.url_for('users_api.set_video_progress', video_id=24 * 'f') + self.post(url, data={'progress_in_sec': 'je moeder'}, expected_status=400) + + def test_asset_is_valid_but_not_video(self): + self.video_id = self.create_node({ + 'description': '', + 'node_type': 'asset', + 'user': self.admin_uid, + 'properties': { + 'status': 'published', + 'content_type': 'image', # instead of video + 'file': bson.ObjectId()}, + 'name': 'Image test', + 'project': self.pid, + }) + + with mock.patch('pillar.api.utils.utcnow') as utcnow: + utcnow.return_value = self.fake_now + self.set_progress(expected_status=404) + self.get_progress(expected_status=204) + + def test_asset_malformed(self): + self.video_id = self.create_node({ + 'description': '', + 'node_type': 'asset', + 'user': self.admin_uid, + 'properties': { + 'status': 'published', + # Note the lack of a 'content_type' key. + 'file': bson.ObjectId()}, + 'name': 'Missing content_type test', + 'project': self.pid, + }) + + with mock.patch('pillar.api.utils.utcnow') as utcnow: + utcnow.return_value = self.fake_now + self.set_progress(expected_status=404) + self.get_progress(expected_status=204)