Saving & restoring video watching progress

Video progress updates:

- Mark as 'done' when 90% or more is watched.
- Keep 'done' flag when re-watching.

The video progress is stored on three events, whichever comes first:

- Every 30 seconds of video.
- Every 10% of the video.
- Every pause/stop/navigation to another page.
- When we detect the video is looping.
This commit is contained in:
Sybren A. Stüvel 2018-08-30 18:30:38 +02:00
parent 3f8e0396cf
commit 2698be3e12
7 changed files with 590 additions and 4 deletions

View File

@ -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.

View File

@ -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/<video_id>/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.<video_id>.
"""
# 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/<video_id>/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%
# Elephants 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

View File

@ -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():

View File

@ -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);

View File

@ -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) {

View File

@ -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 %}

View File

@ -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)