Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -1510,7 +1510,7 @@ export const File = new Resource({
tableName: TABLE_NAMES.FILE,
urlName: 'file',
indexFields: ['contentnode'],
uploadUrl({ checksum, size, type, name, file_format, preset }) {
uploadUrl({ checksum, size, type, name, file_format, preset, duration = null }) {
return client
.post(this.getUrlFunction('upload_url')(), {
checksum,
Expand All @@ -1519,6 +1519,7 @@ export const File = new Resource({
name,
file_format,
preset,
duration,
})
.then(response => {
if (!response) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { getHash, inferPreset, storageUrl } from './utils';
import { getHash, extractMetadata, storageUrl } from './utils';
import { File } from 'shared/data/resources';
import client from 'shared/client';
import { fileErrors, NOVALUE } from 'shared/constants';
Expand Down Expand Up @@ -166,8 +166,8 @@ export function uploadFileToStorage(
export function uploadFile(context, { file, preset = null } = {}) {
return new Promise((resolve, reject) => {
// 1. Get the checksum of the file
Promise.all([getHash(file), preset ? Promise.resolve() : inferPreset(file)])
.then(([checksum, presetId]) => {
Promise.all([getHash(file), extractMetadata(file, preset)])
.then(([checksum, metadata]) => {
const file_format = file.name
.split('.')
.pop()
Expand All @@ -179,7 +179,7 @@ export function uploadFile(context, { file, preset = null } = {}) {
type: file.type,
name: file.name,
file_format,
preset: preset || presetId,
...metadata,
})
.then(data => {
const fileObject = {
Expand Down Expand Up @@ -235,7 +235,7 @@ export function uploadFile(context, { file, preset = null } = {}) {
file_size: file.size,
original_filename: file.name,
file_format,
preset: presetId,
preset: metadata.preset,
error: errorType,
};
context.commit('ADD_FILE', fileObject);
Expand Down
76 changes: 45 additions & 31 deletions contentcuration/contentcuration/frontend/shared/vuex/file/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@ import { FormatPresetsList, FormatPresetsNames } from 'shared/leUtils/FormatPres

const BLOB_SLICE = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice;
const CHUNK_SIZE = 2097152;
const MEDIA_PRESETS = [
FormatPresetsNames.AUDIO,
FormatPresetsNames.HIGH_RES_VIDEO,
FormatPresetsNames.LOW_RES_VIDEO,
];
const VIDEO_PRESETS = [FormatPresetsNames.HIGH_RES_VIDEO, FormatPresetsNames.LOW_RES_VIDEO];

export function getHash(file) {
return new Promise((resolve, reject) => {
Expand Down Expand Up @@ -55,40 +61,48 @@ export function storageUrl(checksum, file_format) {
return `${window.storageBaseUrl}${checksum[0]}/${checksum[1]}/${checksum}.${file_format}`;
}

export function inferPreset(file) {
return new Promise(resolve => {
if (file.preset) {
resolve(file.preset);
}
const file_format = file.name
/**
* @param {{name: String, preset: String}} file
* @param {String|null} preset
* @return {Promise<{preset: String, duration:Number|null}>}
*/
export function extractMetadata(file, preset = null) {
const metadata = {
preset: file.preset || preset,
};

if (!metadata.preset) {
const fileFormat = file.name
.split('.')
.pop()
.toLowerCase();
const inferredPresets = extensionPresetMap[file_format];
if (inferredPresets && inferredPresets.length > 1) {
// Special processing for inferring preset of videos
if (
inferredPresets.length === 2 &&
inferredPresets.includes(FormatPresetsNames.HIGH_RES_VIDEO) &&
inferredPresets.includes(FormatPresetsNames.LOW_RES_VIDEO)
) {
const videoElement = document.createElement('video');
const videoSource = URL.createObjectURL(file);
// Add a listener to read the height from the video once
// the metadata has loaded.
videoElement.addEventListener('loadedmetadata', () => {
if (videoElement.videoHeight >= 720) {
resolve(FormatPresetsNames.HIGH_RES_VIDEO);
} else {
resolve(FormatPresetsNames.LOW_RES_VIDEO);
}
});
// Set the src url on the video element
videoElement.src = videoSource;
// Return here to prevent subsequent processing
return;
// Default to whatever the first preset is
metadata.preset = extensionPresetMap[fileFormat][0];
}

// End here if not audio or video
if (!MEDIA_PRESETS.includes(metadata.preset)) {
return Promise.resolve(metadata);
}

// Extract additional media metadata
const isVideo = VIDEO_PRESETS.includes(metadata.preset);

return new Promise(resolve => {
const mediaElement = document.createElement(isVideo ? 'video' : 'audio');
// Add a listener to read the metadata once it has loaded.
mediaElement.addEventListener('loadedmetadata', () => {
metadata.duration = mediaElement.duration;
// Override preset based off video resolution
if (isVideo) {
metadata.preset =
mediaElement.videoHeight >= 720
? FormatPresetsNames.HIGH_RES_VIDEO
: FormatPresetsNames.LOW_RES_VIDEO;
}
}
resolve(inferredPresets && inferredPresets[0]);
resolve(metadata);
});
// Set the src url on the media element
mediaElement.src = URL.createObjectURL(file);
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Generated by Django 3.2.5 on 2022-01-24 21:49
from django.db import migrations
from django.db import models


class Migration(migrations.Migration):

dependencies = [
('contentcuration', '0132_auto_20210708_0011'),
]

operations = [
migrations.AddField(
model_name='file',
name='duration',
field=models.IntegerField(blank=True, null=True),
),
migrations.AddConstraint(
model_name='file',
constraint=models.CheckConstraint(check=models.Q(models.Q(('duration__gt', 0), ('preset__in', ['audio', 'high_res_video', 'low_res_video'])), ('duration__isnull', True), _connector='OR'), name='file_media_duration_int'),
),
]
10 changes: 8 additions & 2 deletions contentcuration/contentcuration/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1922,6 +1922,8 @@ class StagedFile(models.Model):

FILE_DISTINCT_INDEX_NAME = "file_checksum_file_size_idx"
FILE_MODIFIED_DESC_INDEX_NAME = "file_modified_desc_idx"
FILE_DURATION_CONSTRAINT = "file_media_duration_int"
MEDIA_PRESETS = [format_presets.AUDIO, format_presets.VIDEO_HIGH_RES, format_presets.VIDEO_LOW_RES]


class File(models.Model):
Expand All @@ -1945,6 +1947,7 @@ class File(models.Model):
uploaded_by = models.ForeignKey(User, related_name='files', blank=True, null=True, on_delete=models.SET_NULL)

modified = models.DateTimeField(auto_now=True, verbose_name="modified", null=True)
duration = models.IntegerField(blank=True, null=True)

objects = CustomManager()

Expand Down Expand Up @@ -2056,6 +2059,9 @@ class Meta:
models.Index(fields=['checksum', 'file_size'], name=FILE_DISTINCT_INDEX_NAME),
models.Index(fields=["-modified"], name=FILE_MODIFIED_DESC_INDEX_NAME),
]
constraints = [
models.CheckConstraint(check=(Q(preset__in=MEDIA_PRESETS, duration__gt=0) | Q(duration__isnull=True)), name=FILE_DURATION_CONSTRAINT)
]


@receiver(models.signals.post_delete, sender=File)
Expand Down Expand Up @@ -2095,7 +2101,7 @@ def clean(self, *args, **kwargs):
raise IntegrityError('Cannot self reference as prerequisite.')
# immediate cyclic exception
if PrerequisiteContentRelationship.objects.using(self._state.db) \
.filter(target_node=self.prerequisite, prerequisite=self.target_node):
.filter(target_node=self.prerequisite, prerequisite=self.target_node):
raise IntegrityError(
'Note: Prerequisite relationship is directional! %s and %s cannot be prerequisite of each other!'
% (self.target_node, self.prerequisite))
Expand Down Expand Up @@ -2128,7 +2134,7 @@ def save(self, *args, **kwargs):
raise IntegrityError('Cannot self reference as related.')
# handle immediate cyclic
if RelatedContentRelationship.objects.using(self._state.db) \
.filter(contentnode_1=self.contentnode_2, contentnode_2=self.contentnode_1):
.filter(contentnode_1=self.contentnode_2, contentnode_2=self.contentnode_1):
return # silently cancel the save
super(RelatedContentRelationship, self).save(*args, **kwargs)

Expand Down
33 changes: 33 additions & 0 deletions contentcuration/contentcuration/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@
from django.conf import settings
from django.db.utils import IntegrityError
from le_utils.constants import content_kinds
from le_utils.constants import format_presets

from contentcuration.models import AssessmentItem
from contentcuration.models import Channel
from contentcuration.models import ContentNode
from contentcuration.models import File
from contentcuration.models import FILE_DURATION_CONSTRAINT
from contentcuration.models import generate_object_storage_name
from contentcuration.models import Invitation
from contentcuration.models import object_storage_name
Expand Down Expand Up @@ -569,6 +571,37 @@ def test_filter_edit_queryset__uploaded_by(self):
queryset = File.filter_edit_queryset(self.base_queryset, user=user)
self.assertQuerysetContains(queryset, pk=node_file.id)

def test_duration_check_constraint__acceptable(self):
channel = testdata.channel()
File.objects.create(
contentnode=create_contentnode(channel.main_tree_id),
preset_id=format_presets.AUDIO,
duration=10,
)
File.objects.create(
contentnode=create_contentnode(channel.main_tree_id),
preset_id=format_presets.VIDEO_HIGH_RES,
duration=1123123,
)

def test_duration_check_constraint__negative(self):
channel = testdata.channel()
with self.assertRaises(IntegrityError, msg=FILE_DURATION_CONSTRAINT):
File.objects.create(
contentnode=create_contentnode(channel.main_tree_id),
preset_id=format_presets.AUDIO,
duration=-10,
)

def test_duration_check_constraint__not_media(self):
channel = testdata.channel()
with self.assertRaises(IntegrityError, msg=FILE_DURATION_CONSTRAINT):
File.objects.create(
contentnode=create_contentnode(channel.main_tree_id),
preset_id=format_presets.EPUB,
duration=10,
)


class AssessmentItemFilePermissionTestCase(PermissionQuerysetTestCase):
@property
Expand Down
7 changes: 7 additions & 0 deletions contentcuration/contentcuration/utils/publish.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from django.core.management import call_command
from django.db import transaction
from django.db.models import Count
from django.db.models import Max
from django.db.models import Q
from django.db.models import Sum
from django.db.utils import IntegrityError
Expand Down Expand Up @@ -225,6 +226,11 @@ def create_bare_contentnode(ccnode, default_language, channel_id, channel_name):
if ccnode.language or default_language:
language, _new = get_or_create_language(ccnode.language or default_language)

duration = None
if ccnode.kind_id in [content_kinds.AUDIO, content_kinds.VIDEO]:
# aggregate duration from associated files, choosing maximum if there are multiple, like hi and lo res videos
duration = ccnode.files.aggregate(duration=Max("duration")).get("duration")

options = {}
if ccnode.extra_fields and 'options' in ccnode.extra_fields:
options = ccnode.extra_fields['options']
Expand All @@ -247,6 +253,7 @@ def create_bare_contentnode(ccnode, default_language, channel_id, channel_name):
'license_name': kolibri_license.license_name if kolibri_license is not None else None,
'license_description': kolibri_license.license_description if kolibri_license is not None else None,
'coach_content': ccnode.role_visibility == roles.COACH,
'duration': duration,
'options': json.dumps(options)
}
)
Expand Down
3 changes: 3 additions & 0 deletions contentcuration/contentcuration/viewsets/file.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ class Meta:
"contentnode",
"assessment_item",
"preset",
"duration"
)
list_serializer_class = BulkListSerializer

Expand Down Expand Up @@ -98,6 +99,7 @@ class FileViewSet(BulkDeleteMixin, BulkUpdateMixin, ReadOnlyValuesViewset):
"language_id",
"original_filename",
"uploaded_by",
"duration"
)

field_map = {
Expand Down Expand Up @@ -164,6 +166,7 @@ def upload_url(self, request):
file_format_id=file_format,
preset_id=preset,
uploaded_by=request.user,
duration=request.data.get("duration"),
)

# Avoid using our file_on_disk attribute for checks
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 3.2.5 on 2022-01-27 18:56
from django.db import migrations
from django.db import models


class Migration(migrations.Migration):

dependencies = [
('content', '0015_auto_20210707_1606'),
]

operations = [
migrations.AddField(
model_name='contentnode',
name='duration',
field=models.IntegerField(blank=True, null=True),
),
]
3 changes: 3 additions & 0 deletions contentcuration/kolibri_content/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,9 @@ class ContentNode(MPTTModel):
# A JSON Dictionary of properties to configure loading, rendering, etc. the file
options = models.TextField(default="{}")

# If media, the duration in seconds
duration = models.IntegerField(null=True, blank=True)

class Meta:
ordering = ("lft",)
index_together = [
Expand Down