Files
edx-platform/common/lib/xmodule/xmodule/video_module/video_module.py
Valera Rozuvan 1c8eed853e Moved all YouTube related URLs to a separate namespace.
All YouTube urls are defined on backend side.
2014-03-24 18:40:28 +02:00

807 lines
32 KiB
Python

# pylint: disable=W0223
"""Video is ungraded Xmodule for support video content.
It's new improved video module, which support additional feature:
- Can play non-YouTube video sources via in-browser HTML5 video player.
- YouTube defaults to HTML5 mode from the start.
- Speed changes in both YouTube and non-YouTube videos happen via
in-browser HTML5 video method (when in HTML5 mode).
- Navigational subtitles can be disabled altogether via an attribute
in XML.
"""
import os
import json
import logging
from operator import itemgetter
from lxml import etree
from pkg_resources import resource_string
import datetime
import copy
from webob import Response
from collections import OrderedDict
from django.conf import settings
from xmodule.x_module import XModule, module_attr
from xmodule.editing_module import TabsEditingDescriptor
from xmodule.raw_module import EmptyDataRawDescriptor
from xmodule.xml_module import is_pointer_tag, name_to_pathname, deserialize_field
from xmodule.exceptions import NotFoundError
from xblock.core import XBlock
from xblock.fields import Scope, String, Float, Boolean, List, Dict, ScopeIds
from xmodule.fields import RelativeTime
from .transcripts_utils import (
get_or_create_sjson,
TranscriptException,
generate_sjson_for_all_speeds,
youtube_speed_dict,
Transcript,
)
from .video_utils import create_youtube_string
from xmodule.modulestore.inheritance import InheritanceKeyValueStore
from xblock.runtime import KvsFieldData
from urlparse import urlparse
def get_ext(filename):
# Prevent incorrectly parsing urls like 'http://abc.com/path/video.mp4?xxxx'.
path = urlparse(filename).path
return path.rpartition('.')[-1]
log = logging.getLogger(__name__)
class VideoFields(object):
"""Fields for `VideoModule` and `VideoDescriptor`."""
display_name = String(
display_name="Display Name", help="Display name for this module.",
default="Video",
scope=Scope.settings
)
saved_video_position = RelativeTime(
help="Current position in the video",
scope=Scope.user_state,
default=datetime.timedelta(seconds=0)
)
# TODO: This should be moved to Scope.content, but this will
# require data migration to support the old video module.
youtube_id_1_0 = String(
help="This is the Youtube ID reference for the normal speed video.",
display_name="Youtube ID",
scope=Scope.settings,
default="OEoXaMPEzfM"
)
youtube_id_0_75 = String(
help="Optional, for older browsers: the Youtube ID for the .75x speed video.",
display_name="Youtube ID for .75x speed",
scope=Scope.settings,
default=""
)
youtube_id_1_25 = String(
help="Optional, for older browsers: the Youtube ID for the 1.25x speed video.",
display_name="Youtube ID for 1.25x speed",
scope=Scope.settings,
default=""
)
youtube_id_1_5 = String(
help="Optional, for older browsers: the Youtube ID for the 1.5x speed video.",
display_name="Youtube ID for 1.5x speed",
scope=Scope.settings,
default=""
)
start_time = RelativeTime( # datetime.timedelta object
help="Start time for the video (HH:MM:SS). Max value is 23:59:59.",
display_name="Start Time",
scope=Scope.settings,
default=datetime.timedelta(seconds=0)
)
end_time = RelativeTime( # datetime.timedelta object
help="End time for the video (HH:MM:SS). Max value is 23:59:59.",
display_name="End Time",
scope=Scope.settings,
default=datetime.timedelta(seconds=0)
)
#front-end code of video player checks logical validity of (start_time, end_time) pair.
# `source` is deprecated field and should not be used in future.
# `download_video` is used instead.
source = String(
help="The external URL to download the video.",
display_name="Download Video",
scope=Scope.settings,
default=""
)
download_video = Boolean(
help="Show a link beneath the video to allow students to download the video. Note: You must add at least one video source below.",
display_name="Video Download Allowed",
scope=Scope.settings,
default=False
)
html5_sources = List(
help="A list of filenames to be used with HTML5 video. The first supported filetype will be displayed.",
display_name="Video Sources",
scope=Scope.settings,
)
track = String(
help="The external URL to download the timed transcript track. This appears as a link beneath the video.",
display_name="Download Transcript",
scope=Scope.settings,
default=''
)
download_track = Boolean(
help="Show a link beneath the video to allow students to download the transcript. Note: You must add a link to the HTML5 Transcript field above.",
display_name="Transcript Download Allowed",
scope=Scope.settings,
default=False
)
sub = String(
help="The name of the timed transcript track (for non-Youtube videos).",
display_name="Transcript (primary)",
scope=Scope.settings,
default=""
)
show_captions = Boolean(
help="This controls whether or not captions are shown by default.",
display_name="Transcript Display",
scope=Scope.settings,
default=True
)
# Data format: {'de': 'german_translation', 'uk': 'ukrainian_translation'}
transcripts = Dict(
help="Add additional transcripts in other languages",
display_name="Transcript Translations",
scope=Scope.settings,
default={}
)
transcript_language = String(
help="Preferred language for transcript",
display_name="Preferred language for transcript",
scope=Scope.preferences,
default="en"
)
transcript_download_format = String(
help="Transcript file format to download by user.",
scope=Scope.preferences,
values=[
{"display_name": "SubRip (.srt) file", "value": "srt"},
{"display_name": "Text (.txt) file", "value": "txt"}
],
default='srt',
)
speed = Float(
help="The last speed that was explicitly set by user for the video.",
scope=Scope.user_state,
)
global_speed = Float(
help="Default speed in cases when speed wasn't explicitly for specific video",
scope=Scope.preferences,
default=1.0
)
youtube_is_available = Boolean(
help="The availaibility of YouTube API for the user",
scope=Scope.user_info,
default=True
)
class VideoModule(VideoFields, XModule):
"""
XML source example:
<video show_captions="true"
youtube="0.75:jNCf2gIqpeE,1.0:ZwkTiUPN0mg,1.25:rsq9auxASqI,1.50:kMyNdzVHHgg"
url_name="lecture_21_3" display_name="S19V3: Vacancies"
>
<source src=".../mit-3091x/M-3091X-FA12-L21-3_100.mp4"/>
<source src=".../mit-3091x/M-3091X-FA12-L21-3_100.webm"/>
<source src=".../mit-3091x/M-3091X-FA12-L21-3_100.ogv"/>
</video>
"""
video_time = 0
icon_class = 'video'
# To make sure that js files are called in proper order we use numerical
# index. We do that to avoid issues that occurs in tests.
module = __name__.replace('.video_module', '', 2)
js = {
'js': [
resource_string(module, 'js/src/video/00_video_storage.js'),
resource_string(module, 'js/src/video/00_resizer.js'),
resource_string(module, 'js/src/video/01_initialize.js'),
resource_string(module, 'js/src/video/025_focus_grabber.js'),
resource_string(module, 'js/src/video/02_html5_video.js'),
resource_string(module, 'js/src/video/03_video_player.js'),
resource_string(module, 'js/src/video/035_video_accessible_menu.js'),
resource_string(module, 'js/src/video/04_video_control.js'),
resource_string(module, 'js/src/video/05_video_quality_control.js'),
resource_string(module, 'js/src/video/06_video_progress_slider.js'),
resource_string(module, 'js/src/video/07_video_volume_control.js'),
resource_string(module, 'js/src/video/08_video_speed_control.js'),
resource_string(module, 'js/src/video/09_video_caption.js'),
resource_string(module, 'js/src/video/10_main.js')
]
}
css = {'scss': [
resource_string(module, 'css/video/display.scss'),
resource_string(module, 'css/video/accessible_menu.scss'),
]}
js_module_name = "Video"
def handle_ajax(self, dispatch, data):
accepted_keys = [
'speed', 'saved_video_position', 'transcript_language',
'transcript_download_format', 'youtube_is_available'
]
conversions = {
'speed': json.loads,
'saved_video_position': lambda v: RelativeTime.isotime_to_timedelta(v),
'youtube_is_available': json.loads,
}
if dispatch == 'save_user_state':
for key in data:
if hasattr(self, key) and key in accepted_keys:
if key in conversions:
value = conversions[key](data[key])
else:
value = data[key]
setattr(self, key, value)
if key == 'speed':
self.global_speed = self.speed
return json.dumps({'success': True})
log.debug(u"GET {0}".format(data))
log.debug(u"DISPATCH {0}".format(dispatch))
raise NotFoundError('Unexpected dispatch type')
def get_html(self):
track_url = None
transcript_download_format = self.transcript_download_format
sources = {get_ext(src): src for src in self.html5_sources}
if self.download_video:
if self.source:
sources['main'] = self.source
elif self.html5_sources:
sources['main'] = self.html5_sources[0]
if self.download_track:
if self.track:
track_url = self.track
transcript_download_format = None
elif self.sub or self.transcripts:
track_url = self.runtime.handler_url(self, 'transcript').rstrip('/?') + '/download'
if not self.transcripts:
transcript_language = u'en'
languages = {'en': 'English'}
else:
if self.transcript_language in self.transcripts:
transcript_language = self.transcript_language
elif self.sub:
transcript_language = u'en'
else:
transcript_language = sorted(self.transcripts.keys())[0]
languages = {
lang: display
for lang, display in settings.ALL_LANGUAGES
if lang in self.transcripts
}
if self.sub:
languages['en'] = 'English'
# OrderedDict for easy testing of rendered context in tests
sorted_languages = OrderedDict(sorted(languages.items(), key=itemgetter(1)))
return self.system.render_template('video.html', {
'ajax_url': self.system.ajax_url + '/save_user_state',
'autoplay': settings.FEATURES.get('AUTOPLAY_VIDEOS', False),
# This won't work when we move to data that
# isn't on the filesystem
'data_dir': getattr(self, 'data_dir', None),
'display_name': self.display_name_with_default,
'end': self.end_time.total_seconds(),
'id': self.location.html_id(),
'show_captions': json.dumps(self.show_captions),
'sources': sources,
'speed': json.dumps(self.speed),
'general_speed': self.global_speed,
'saved_video_position': self.saved_video_position.total_seconds(),
'start': self.start_time.total_seconds(),
'sub': self.sub,
'track': track_url,
'youtube_streams': create_youtube_string(self),
# TODO: Later on the value 1500 should be taken from some global
# configuration setting field.
'yt_test_timeout': 1500,
'yt_api_url': settings.YOUTUBE['API'],
'yt_test_url': settings.YOUTUBE['TEST_URL'],
'transcript_download_format': transcript_download_format,
'transcript_download_formats_list': self.descriptor.fields['transcript_download_format'].values,
'transcript_language': transcript_language,
'transcript_languages': json.dumps(sorted_languages),
'transcript_translation_url': self.runtime.handler_url(self, 'transcript').rstrip('/?') + '/translation',
'transcript_available_translations_url': self.runtime.handler_url(self, 'transcript').rstrip('/?') + '/available_translations',
})
def get_transcript(self, transcript_format='srt'):
"""
Returns transcript, filename and MIME type.
Raises:
- NotFoundError if cannot find transcript file in storage.
- ValueError if transcript file is empty or incorrect JSON.
- KeyError if transcript file has incorrect format.
If language is 'en', self.sub should be correct subtitles name.
If language is 'en', but if self.sub is not defined, this means that we
should search for video name in order to get proper transcript (old style courses).
If language is not 'en', give back transcript in proper language and format.
"""
lang = self.transcript_language
if lang == 'en':
if self.sub: # HTML5 case and (Youtube case for new style videos)
transcript_name = self.sub
elif self.youtube_id_1_0: # old courses
transcript_name = self.youtube_id_1_0
else:
log.debug("No subtitles for 'en' language")
raise ValueError
data = Transcript.asset(self.location, transcript_name, lang).data
filename = '{}.{}'.format(transcript_name, transcript_format)
content = Transcript.convert(data, 'sjson', transcript_format)
else:
data = Transcript.asset(self.location, None, None, self.transcripts[lang]).data
filename = '{}.{}'.format(os.path.splitext(self.transcripts[lang])[0], transcript_format)
content = Transcript.convert(data, 'srt', transcript_format)
if not content:
log.debug('no subtitles produced in get_transcript')
raise ValueError
mime_type = 'text/plain' if transcript_format == 'txt' else 'application/x-subrip'
return content, filename, mime_type
@XBlock.handler
def transcript(self, request, dispatch):
"""
Entry point for transcript handlers.
Request GET should contains 2-char language code for `download`
and additionally `videoId` for `translation`.
Dispatches:
`download`: returns SRT file.
`translation`: returns jsoned translation text.
`available_translations`: returns list of languages, for which SRT files exist. For 'en' check if SJSON exists.
"""
if dispatch == 'translation':
if 'language' not in request.GET:
log.info("Invalid /transcript GET parameters.")
return Response(status=400)
lang = request.GET.get('language')
if lang not in ['en'] + self.transcripts.keys():
log.info("Video: transcript facilities are not available for given language.")
return Response(status=404)
if lang != self.transcript_language:
self.transcript_language = lang
try:
transcript = self.translation(request.GET.get('videoId', None))
except (TranscriptException, NotFoundError) as ex:
log.info(ex.message)
response = Response(status=404)
else:
response = Response(transcript)
response.content_type = 'application/json'
elif dispatch == 'download':
try:
transcript_content, transcript_filename, transcript_mime_type = self.get_transcript(self.transcript_download_format)
except (NotFoundError, ValueError, KeyError):
log.debug("Video@download exception")
response = Response(status=404)
else:
response = Response(
transcript_content,
headerlist=[
('Content-Disposition', 'attachment; filename="{}"'.format(transcript_filename)),
]
)
response.content_type = transcript_mime_type
elif dispatch == 'available_translations':
available_translations = []
if self.sub: # check if sjson exists for 'en'.
try:
Transcript.asset(self.location, self.sub, 'en')
except NotFoundError:
pass
else:
available_translations = ['en']
for lang in self.transcripts:
try:
Transcript.asset(self.location, None, None, self.transcripts[lang])
except NotFoundError:
continue
available_translations.append(lang)
if available_translations:
response = Response(json.dumps(available_translations))
response.content_type = 'application/json'
else:
response = Response(status=404)
else: # unknown dispatch
log.debug("Dispatch is not allowed")
response = Response(status=404)
return response
def translation(self, youtube_id):
"""
This is called to get transcript file for specific language.
youtube_id: str: must be one of youtube_ids or None if HTML video
Logic flow:
If youtube_id doesn't exist, we have a video in HTML5 mode. Otherwise,
video video in Youtube or Flash modes.
if youtube:
If english -> give back youtube_id subtitles:
Return what we have in contentstore for given youtube_id.
If non-english:
a) extract youtube_id from srt file name.
b) try to find sjson by youtube_id and return if successful.
c) generate sjson from srt for all youtube speeds.
if non-youtube:
If english -> give back `sub` subtitles:
Return what we have in contentstore for given subs_if that is stored in self.sub.
If non-english:
a) try to find previously generated sjson.
b) otherwise generate sjson from srt and return it.
Filenames naming:
en: subs_videoid.srt.sjson
non_en: uk_subs_videoid.srt.sjson
Raises:
NotFoundError if for 'en' subtitles no asset is uploaded.
"""
if youtube_id:
# Youtube case:
if self.transcript_language == 'en':
return Transcript.asset(self.location, youtube_id).data
youtube_ids = youtube_speed_dict(self)
assert youtube_id in youtube_ids
try:
sjson_transcript = Transcript.asset(self.location, youtube_id, self.transcript_language).data
except (NotFoundError):
log.info("Can't find content in storage for %s transcript: generating.", youtube_id)
generate_sjson_for_all_speeds(
self,
self.transcripts[self.transcript_language],
{speed: youtube_id for youtube_id, speed in youtube_ids.iteritems()},
self.transcript_language
)
sjson_transcript = Transcript.asset(self.location, youtube_id, self.transcript_language).data
return sjson_transcript
else:
# HTML5 case
if self.transcript_language == 'en':
return Transcript.asset(self.location, self.sub).data
else:
return get_or_create_sjson(self)
class VideoDescriptor(VideoFields, TabsEditingDescriptor, EmptyDataRawDescriptor):
"""Descriptor for `VideoModule`."""
module_class = VideoModule
transcript = module_attr('transcript')
tabs = [
{
'name': "Basic",
'template': "video/transcripts.html",
'current': True
},
{
'name': "Advanced",
'template': "tabs/metadata-edit-tab.html"
}
]
def __init__(self, *args, **kwargs):
"""
Mostly handles backward compatibility issues.
`source` is deprecated field.
a) If `source` exists and `source` is not `html5_sources`: show `source`
field on front-end as not-editable but clearable. Dropdown is a new
field `download_video` and it has value True.
b) If `source` is cleared it is not shown anymore.
c) If `source` exists and `source` in `html5_sources`, do not show `source`
field. `download_video` field has value True.
"""
super(VideoDescriptor, self).__init__(*args, **kwargs)
# For backwards compatibility -- if we've got XML data, parse
# it out and set the metadata fields
if self.data:
field_data = self._parse_video_xml(self.data)
self._field_data.set_many(self, field_data)
del self.data
editable_fields = self.editable_metadata_fields
self.source_visible = False
if self.source:
# If `source` field value exist in the `html5_sources` field values,
# then delete `source` field value and use value from `html5_sources` field.
if self.source in self.html5_sources:
self.source = '' # Delete source field value.
self.download_video = True
else: # Otherwise, `source` field value will be used.
self.source_visible = True
download_video = editable_fields['download_video']
if not download_video['explicitly_set']:
self.download_video = True
# for backward compatibility.
# If course was existed and was not re-imported by the moment of adding `download_track` field,
# we should enable `download_track` if following is true:
download_track = editable_fields['download_track']
if not download_track['explicitly_set'] and self.track:
self.download_track = True
def save_with_metadata(self, user):
"""
Save module with updated metadata to database."
"""
self.save()
self.runtime.modulestore.update_item(self, user.id if user else None)
@property
def editable_metadata_fields(self):
editable_fields = super(VideoDescriptor, self).editable_metadata_fields
if hasattr(self, 'source_visible'):
if self.source_visible:
editable_fields['source']['non_editable'] = True
else:
editable_fields.pop('source')
return editable_fields
@classmethod
def from_xml(cls, xml_data, system, id_generator):
"""
Creates an instance of this descriptor from the supplied xml_data.
This may be overridden by subclasses
xml_data: A string of xml that will be translated into data and children for
this module
system: A DescriptorSystem for interacting with external resources
org and course are optional strings that will be used in the generated modules
url identifiers
"""
xml_object = etree.fromstring(xml_data)
url_name = xml_object.get('url_name', xml_object.get('slug'))
block_type = 'video'
definition_id = id_generator.create_definition(block_type, url_name)
usage_id = id_generator.create_usage(definition_id)
if is_pointer_tag(xml_object):
filepath = cls._format_filepath(xml_object.tag, name_to_pathname(url_name))
xml_data = etree.tostring(cls.load_file(filepath, system.resources_fs, usage_id))
field_data = cls._parse_video_xml(xml_data)
kvs = InheritanceKeyValueStore(initial_values=field_data)
field_data = KvsFieldData(kvs)
video = system.construct_xblock_from_class(
cls,
# We're loading a descriptor, so student_id is meaningless
# We also don't have separate notions of definition and usage ids yet,
# so we use the location for both
ScopeIds(None, block_type, definition_id, usage_id),
field_data,
)
return video
def definition_to_xml(self, resource_fs):
"""
Returns an xml string representing this module.
"""
xml = etree.Element('video')
youtube_string = create_youtube_string(self)
# Mild workaround to ensure that tests pass -- if a field
# is set to its default value, we don't need to write it out.
if youtube_string and youtube_string != '1.00:OEoXaMPEzfM':
xml.set('youtube', unicode(youtube_string))
xml.set('url_name', self.url_name)
attrs = {
'display_name': self.display_name,
'show_captions': json.dumps(self.show_captions),
'start_time': self.start_time,
'end_time': self.end_time,
'sub': self.sub,
'download_track': json.dumps(self.download_track),
'download_video': json.dumps(self.download_video),
}
for key, value in attrs.items():
# Mild workaround to ensure that tests pass -- if a field
# is set to its default value, we don't write it out.
if value:
if key in self.fields and self.fields[key].is_set_on(self):
xml.set(key, unicode(value))
for source in self.html5_sources:
ele = etree.Element('source')
ele.set('src', source)
xml.append(ele)
if self.track:
ele = etree.Element('track')
ele.set('src', self.track)
xml.append(ele)
# sorting for easy testing of resulting xml
for transcript_language in sorted(self.transcripts.keys()):
ele = etree.Element('transcript')
ele.set('language', transcript_language)
ele.set('src', self.transcripts[transcript_language])
xml.append(ele)
return xml
def get_context(self):
"""
Extend context by data for transcript basic tab.
"""
_context = super(VideoDescriptor, self).get_context()
metadata_fields = copy.deepcopy(self.editable_metadata_fields)
display_name = metadata_fields['display_name']
video_url = metadata_fields['html5_sources']
youtube_id_1_0 = metadata_fields['youtube_id_1_0']
def get_youtube_link(video_id):
if video_id:
return 'http://youtu.be/{0}'.format(video_id)
else:
return ''
_ = self.runtime.service(self, "i18n").ugettext
video_url.update({
'help': _('A YouTube URL or a link to a file hosted anywhere on the web.'),
'display_name': 'Video URL',
'field_name': 'video_url',
'type': 'VideoList',
'default_value': [get_youtube_link(youtube_id_1_0['default_value'])]
})
youtube_id_1_0_value = get_youtube_link(youtube_id_1_0['value'])
if youtube_id_1_0_value:
video_url['value'].insert(0, youtube_id_1_0_value)
metadata = {
'display_name': display_name,
'video_url': video_url
}
_context.update({'transcripts_basic_tab_metadata': metadata})
return _context
@classmethod
def _parse_youtube(cls, data):
"""
Parses a string of Youtube IDs such as "1.0:AXdE34_U,1.5:VO3SxfeD"
into a dictionary. Necessary for backwards compatibility with
XML-based courses.
"""
ret = {'0.75': '', '1.00': '', '1.25': '', '1.50': ''}
videos = data.split(',')
for video in videos:
pieces = video.split(':')
try:
speed = '%.2f' % float(pieces[0]) # normalize speed
# Handle the fact that youtube IDs got double-quoted for a period of time.
# Note: we pass in "VideoFields.youtube_id_1_0" so we deserialize as a String--
# it doesn't matter what the actual speed is for the purposes of deserializing.
youtube_id = deserialize_field(cls.youtube_id_1_0, pieces[1])
ret[speed] = youtube_id
except (ValueError, IndexError):
log.warning('Invalid YouTube ID: %s', video)
return ret
@classmethod
def _parse_video_xml(cls, xml_data):
"""
Parse video fields out of xml_data. The fields are set if they are
present in the XML.
"""
xml = etree.fromstring(xml_data)
field_data = {}
# Convert between key types for certain attributes --
# necessary for backwards compatibility.
conversions = {
# example: 'start_time': cls._example_convert_start_time
}
# Convert between key names for certain attributes --
# necessary for backwards compatibility.
compat_keys = {
'from': 'start_time',
'to': 'end_time'
}
sources = xml.findall('source')
if sources:
field_data['html5_sources'] = [ele.get('src') for ele in sources]
track = xml.find('track')
if track is not None:
field_data['track'] = track.get('src')
transcripts = xml.findall('transcript')
if transcripts:
field_data['transcripts'] = {tr.get('language'): tr.get('src') for tr in transcripts}
for attr, value in xml.items():
if attr in compat_keys:
attr = compat_keys[attr]
if attr in cls.metadata_to_strip + ('url_name', 'name'):
continue
if attr == 'youtube':
speeds = cls._parse_youtube(value)
for speed, youtube_id in speeds.items():
# should have made these youtube_id_1_00 for
# cleanliness, but hindsight doesn't need glasses
normalized_speed = speed[:-1] if speed.endswith('0') else speed
# If the user has specified html5 sources, make sure we don't use the default video
if youtube_id != '' or 'html5_sources' in field_data:
field_data['youtube_id_{0}'.format(normalized_speed.replace('.', '_'))] = youtube_id
else:
# Convert XML attrs into Python values.
if attr in conversions:
value = conversions[attr](value)
else:
# We export values with json.dumps (well, except for Strings, but
# for about a month we did it for Strings also).
value = deserialize_field(cls.fields[attr], value)
field_data[attr] = value
# For backwards compatibility: Add `source` if XML doesn't have `download_video`
# attribute.
if 'download_video' not in field_data and sources:
field_data['source'] = field_data['html5_sources'][0]
# For backwards compatibility: if XML doesn't have `download_track` attribute,
# it means that it is an old format. So, if `track` has some value,
# `download_track` needs to have value `True`.
if 'download_track' not in field_data and track is not None:
field_data['download_track'] = True
return field_data