jam-cloud/web/app/assets/javascripts/download_jamtrack.js.coffee

528 lines
19 KiB
CoffeeScript

$ = jQuery
context = window
context.JK ||= {};
# This is the sequence of how this widget works:
# checkState() is the heart of the state machine; it is called to get things going, and is called whenevr a state ends
# checkState() checks first against what the client thinks about the state of the JamTrack;
# if it on the disk then the state machine may enter one of:
# * synchronized
# * keying
#
# if it's still on the server, then the state machine may be:
# * packaging
# * downloading
#
# errored state can be entered from @jamTrack.jam_track_right_id
#
# other state; you augment the error to the user by suppling @errorMessage before transitioning
#
# no-client is the way the widget behaves when you are in a normal browser (i.e., nothing happens other than tell the user to use the client)
#
# Discussion of the different states:
# There are different states that a JamTrack can be in.
# The final success state is that the JamTrack is on disk and loadable. (show synchronized state)
# But there are others until you get there:
# The JamTrack does not exist on the server, so we will create it (packaging state)
# The JamTrack exists on the server, but not on disk, so we will download it (downloading state)
# The JamTrack is on the disk, but does not yet have keys, so we will fetch them (keying)
context.JK.DownloadJamTracks = {}
context.JK.DownloadJamTrack = class DownloadJamTrack
constructor: (@app, jamTrack, size = 'large') ->
@EVENTS = context.JK.EVENTS
@rest = context.JK.Rest()
@logger = context.JK.logger
@jamTrack = jamTrack
@size = size
@attemptedEnqueue = false
@errorReason = null
@errorMessage = null
@transitionTimer = null
@downloadTimer = null
@trackDetail = null
@stateHolder = null
@active = false
@startTime = null
@attempts = 0
@tracked = false
@ajaxEnqueueAborted = false
@ajaxGetJamTrackRightAborted = false
@currentPackagingStep = null
@totalSteps = null
throw "no JamTrack specified" unless @jamTrack?
throw "invalid size" if @size != 'large' && @size != 'small'
throw "no JamTrack version" unless @jamTrack.version?
@path = []
@states = {
no_client: { name: 'no-client', show: @showNoClient, leaf: true },
synchronized: { name: 'synchronized', show: @showSynchronized, leaf: true},
packaging: { name: 'packaging', show: @showPackaging },
downloading: { name: 'downloading', show: @showDownloading },
keying: { name: 'keying', show: @showKeying, max_time: 10000 },
initial: { name: 'initial', show: @showInitial },
quiet: { name: 'quiet', show: @showQuiet },
errored: { name: 'errored', show: @showError, leaf: true}
}
context.JK.DownloadJamTracks[@jamTrack.id] = this
downloadJamTrackTemplate = $('#template-download-jamtrack')
throw "no download jamtrack template" if not downloadJamTrackTemplate.exists()
@root = $(downloadJamTrackTemplate.html())
@stateHolder = @root.find('.state')
@root.on('remove', this.destroy) # automatically destroy self when removed from DOM
# populate in template and visual transition functions
for state, data of @states
data.template = $("#template-download-jamtrack-state-#{data.name}")
# start off in quiet state, but don't do it through transition system. The transition system expects a change, not initial state
@state = @states.quiet
this.showState()
# after you've created the DownloadJamTrack widget, call synchronize which will begin ensuring that the jamtrack
# is downloaded and ready to open
init: () =>
@active = true
@root.addClass('active')
this.reset()
# check if we are in a browser or client
if !gon.isNativeClient
this.transition(@states.no_client)
else
this.transition(@states.initial)
# when done with the widget, call destroy; this ensures it's not still active, and tracks final metrics
destroy: () =>
$(this).off()
@active = false
@root.removeClass('active')
this.trackProgress()
# since we are not in a leave node, we need to report a state since this is effectively our end state
this.reset()
clear: () =>
# reset attemptedEnqueue to false, to allow one attempt to enqueue
@attemptedEnqueue = false
this.clearDownloadTimer()
this.clearTransitionTimer()
this.abortEnqueue()
this.abortGetJamTrackRight()
for state, data of @states
if data.timer?
clearInterval(data.timer)
data.timer = null
reset: () =>
@path = []
@attempts = 0
@tracked = false
@startTime = new Date()
this.clear()
abortEnqueue: () =>
if @ajaxEnqueueAborted
@logger.debug("DownloadJamTrack: aborting ajax enqueue")
# we need to clear out @ajaxEnqueue *before* calling abort(), because the .fail callback fires inline
ajax = @ajaxEnqueueAborted
@ajaxEnqueueAborted = true
ajax.abort()
abortGetJamTrackRight: () =>
if @ajaxGetJamTrackRightAborted
@logger.debug("DownloadJamTrack: aborting ajax GetJamTrackRight")
# we need to clear out @ajaxEnqueue *before* calling abort(), because the .fail callback fires inline
ajax = @ajaxGetJamTrackRightAborted
@ajaxGetJamTrackRightAborted = true
ajax.abort()
showState: () =>
@state.stateStartTime = new Date();
@stateHolder.children().remove()
@stateHolder.append(context._.template(@state.template.html(), @jamTrack, { variable: 'data' }))
@stateHolder.find('.' + @size).removeClass('hidden')
@state.show()
# report a stat now that we've reached the end of this widget's journey
trackProgress: () =>
# do not double-report
if @tracked
return
if @path.length == 0
return
unless @state.leaf
# we've been asked to report at a non-leaf node, meaning the user must have cancelled
@path.push('user-cancelled')
flattened_path = @path.join('-')
data = {
value: 1,
path: flattened_path,
duration: (new Date().getTime() - @startTime.getTime()) / 1000,
attempts: @attempts,
user_id: context.JK.currentUserId,
user_name: context.JK.currentUserName}
if @state == @states.errored
data.result = 'error'
data.detail = @errorReason
@rest.createAlert("JamTrack Sync failed for #{context.JK.currentUserName}", data)
else
data.result = 'success'
context.stats.write('web.jamtrack.downloader', data)
@tracked = true
showPackaging: () =>
@logger.debug("showing #{@state.name}")
this.expectTransition()
showDownloading: () =>
@logger.debug("showing #{@state.name}")
# while downloading, we don't run the transition timer, because the download API is guaranteed to call success, or failure, eventually
context.jamClient.JamTrackDownload(@jamTrack.id, context.JK.currentUserId,
this.makeDownloadProgressCallback(),
this.makeDownloadSuccessCallback(),
this.makeDownloadFailureCallback())
showKeying: () =>
@logger.debug("showing #{@state.name}")
context.jamClient.JamTrackKeysRequest()
this.waitForState()
showQuiet: () =>
@logger.debug("showing #{@state.name}")
showInitial: () =>
@logger.debug("showing #{@state.name}")
@sampleRate = context.jamClient.GetSampleRate()
@sampleRateForFilename = if @sampleRate == 48 then '48' else '44'
@attempts = @attempts + 1
this.expectTransition()
context.JK.SubscriptionUtils.subscribe('jam_track_right', @jamTrack.jam_track_right_id).on(context.JK.EVENTS.SUBSCRIBE_NOTIFICATION, this.onJamTrackRightEvent)
this.checkState()
showError: () =>
@logger.debug("showing #{@state.name}")
context.JK.SubscriptionUtils.unsubscribe('jam_track_right', @jamTrack.jam_track_right_id)
if @size == 'large'
@stateHolder.find('.msg').text(@errorMessage)
@stateHolder.find('.retry-button').click(this.retry)
else
@stateHolder.find('.msg').text(@jamTrack.name + ' (error)')
@stateHolder.find('.errormsg').text(@errorMessage)
@stateHolder.find('.retry-button').on('click', this.retry)
retryMsg = ''
if @attempts > 1
retryMsg = 'Continue retrying or contact support@jamkazam.com'
@stateHolder.find('.retry').text(retryMsg)
showSynchronized: () =>
@logger.debug("showing #{@state.name}")
context.JK.SubscriptionUtils.unsubscribe('jam_track_right', @jamTrack.jam_track_right_id)
showNoClient: () =>
@logger.debug("showing #{@state.name}")
downloadCheck: () =>
@logger.debug "downloadCheck"
retry: () =>
@path = []
@path.push('retry')
this.clear()
# just switch to the initial state again, causing the loop to start again
this.transition(@states.initial)
return false
clearStateTimer: () =>
if @state.timer?
clearInterval(@state.timer)
@state.timer = null
stateIntervalCheck: () =>
this.checkState()
# if the timer is null now, then it must have been whacked due to a state change
# if not, then let's see if we have timed out
if @state.timer?
if (new Date()).getTime() - @state.stateStartTime.getTime() > @state.max_time
@logger.debug("The current step (#{@state.name}) took too long")
if @state == @states.keying
# specific message
this.transitionError("#{@state.name}-timeout", "It took too long for the JamTrack to be keyed.")
else
# generic message
this.transitionError("#{@state.name}-timeout", "The current step (#{@state.name}) took too long")
# sets an interval timer for every second, waiting for the status to change
waitForState: () =>
unless @active
@logger.error("DownloadJamTrack: ignoring waitForState because we are not active")
@state.timer = setInterval(this.stateIntervalCheck, 1000)
# unused atm; the backend is good about always signalling. we still should though
expectDownload: () =>
unless @active
@logger.error("DownloadJamTrack: ignoring expectDownload because we are not active")
# every 10 seconds, wake up and check the server and see if we missed a state transition
this.clearDownloadTimer()
@downloadTimer = setTimeout(this.downloadCheck, 10000)
clearDownloadTimer: () =>
if @downloadTimer?
clearTimeout(@downloadTimer)
@downloadTimer = null
transitionError: (reasonCode, errorMessage) =>
@errorReason = reasonCode
@errorMessage = errorMessage
this.transition(@states.errored)
transitionCheck: () =>
this.checkState()
# this should be called every time something changes statefully, to restart a 12 second timer to hit the server for update.
# if everything is moving snappily, we won't have to query the server much, because we are also getting subscription events
# about any changes to the status of the jam track. But, we could miss a message or there could be a path in the server where
# we don't get an event, so that's why, after 12 seconds, we'll still go to the server and check.
# exception: this should not be runngi
expectTransition: () =>
unless @active
@logger.error("DownloadJamTrack: ignoring expectTransition because we are not active")
# every 12 seconds, wake up and check the server and see if we missed a state transition
this.clearTransitionTimer()
@transitionTimer = setTimeout(this.transitionCheck, 12000)
clearTransitionTimer: () =>
if @transitionTimer?
clearTimeout(@transitionTimer)
@transitionTimer = null
transition: (newState) =>
unless @active
@logger.error("DownloadJamTrack: ignoring state change because we are not active")
return
if newState == @state
@logger.debug("DownloadJamTrack: ignoring state change #{@state.name}")
return
if @state?
@logger.debug("DownloadJamTrack: state change: #{@state.name} => #{newState.name}")
# make sure there is no timer running on the old state
this.clearTransitionTimer()
this.clearStateTimer()
this.abortEnqueue()
@logger.debug("aborting getJamTrack right on state change")
this.abortGetJamTrackRight()
else
@logger.debug("DownloadJamTrack: initial state: #{newState.name}")
@state = newState
# track which states were taken
@path.push(@state.name)
if @state.leaf
this.trackProgress()
this.showState()
$(this).triggerHandler(@EVENTS.JAMTRACK_DOWNLOADER_STATE_CHANGED, {state: @state})
checkState: () =>
# check for the success state against the local state of the client... if it's playable, then we should be OK
fqId = "#{@jamTrack.id}-#{@sampleRateForFilename}"
@trackDetail = context.jamClient.JamTrackGetTrackDetail (fqId)
@logger.debug("DownloadJamTrack: JamTrackGetTrackDetail(#{fqId}).key_state: " + @trackDetail.key_state, @trackDetail)
# first check if the version is not the same; if so, invalidate.
if @trackDetail.version?
if @jamTrack.version != @trackDetail.version
@logger.info("DownloadJamTrack: JamTrack on disk is different version (stored: #{@trackDetail.version}, server: #{@jamTrack.version}. Invalidating")
context.jamClient.InvalidateJamTrack("#{@jamTrack.id}-#{@sampleRateForFilename}")
@trackDetail = context.jamClient.JamTrackGetTrackDetail ("#{@jamTrack.id}-#{@sampleRateForFilename}")
if @trackDetail.version?
@logger.error("after invalidating package, the version is still wrong!")
throw "after invalidating package, the version is still wrong!"
switch @trackDetail.key_state
when 'pending'
this.transition(@states.keying)
when 'not authorized'
# TODO: if not authorized, do we need to re-initiate a keying attempt?
this.transition(@states.keying)
when 'ready'
this.transition(@states.synchronized)
when 'unknown'
@ajaxGetJamTrackRightAborted = false
@rest.getJamTrackRight({id: @jamTrack.id})
.done(this.processJamTrackRight)
.fail(this.processJamTrackRightFail)
# update progress indicator for packaging step
updateSteps: () =>
if @currentPackagingStep? and @totalSteps?
progress = "#{Math.round(@currentPackagingStep/@totalSteps * 100)}%"
else
progress = '...'
@root.find('.state-packaging .progress').text(progress)
processSigningState: (jamTrackRight) =>
signingState = jamTrackRight.signing_state
@totalSteps = jamTrackRight.packaging_steps
@currentPackagingStep = jamTrackRight.current_packaging_step
@updateSteps()
@logger.debug("DownloadJamTrack: processSigningState: " + signingState)
switch signingState
when 'QUIET'
if @attemptedEnqueue
# this means we've already tried to poke the server. something is wrong
this.transitionError("enqueue-timeout", "The server has not begun building your JamTrack.")
else
this.expectTransition()
this.attemptToEnqueue()
when 'QUEUED'
# when it's queued, there is nothing to do except wait.
this.transition(@states.packaging)
when 'QUEUED_TIMEOUT'
if @attemptedEnqueue
# this means we've already tried to poke the server. something is wrong
this.transitionError("queued-timeout", "The server took too long to begin processing your JamTrack.")
else
this.expectTransition()
this.attemptToEnqueue()
when 'SIGNING'
this.expectTransition()
this.transition(@states.packaging)
when 'SIGNING_TIMEOUT'
if @attemptedEnqueue
# this means we've already tried to poke the server. something is wrong
this.transitionError("signing-timeout", "The server took too long to create your JamTrack.")
else
this.expectTransition()
this.attemptToEnqueue()
when 'SIGNED'
this.transition(@states.downloading)
when 'ERROR'
if @attemptedEnqueue
# this means we've already tried to poke the server. something is wrong
this.transitionError("package-error", "The server failed to create your package.")
else
this.expectTransition()
this.attemptToEnqueue()
else
@logger.error("unknown state: " + signingState)
this.transitionError("unknown-state-#{signingState}", "The server sent an unknown state message: " + signingState)
attemptToEnqueue: () =>
@attemptedEnqueue = true
@ajaxEnqueueAborted = false
@rest.enqueueJamTrack({id: @jamTrack.id, sample_rate: @sampleRate})
.done(this.processEnqueueJamTrack)
.fail(this.processEnqueueJamTrackFail)
processJamTrackRight: (myJamTrack) =>
@logger.debug("processJamTrackRight", myJamTrack)
unless @ajaxGetJamTrackRightAborted
this.processSigningState(myJamTrack)
else
@logger.debug("DownloadJamTrack: ignoring processJamTrackRight response")
processJamTrackRightFail: () =>
unless @ajaxGetJamTrackRightAborted?
this.transitionError("status-check-error", "Unable to check with the server on the status of your JamTrack.")
else
@logger.debug("DownloadJamTrack: ignoring processJamTrackRightFail response")
processEnqueueJamTrack: (enqueueResponse) =>
unless @ajaxEnqueueAborted
this.expectTransition() # the act of enqueuing should send down events to the client. we wait...
else
@logger.debug("DownloadJamTrack: ignoring processEnqueueJamTrack response")
processEnqueueJamTrackFail: () =>
unless @ajaxEnqueueAborted
this.transitionError("enqueue-error", "Unable to ask the server to build your JamTrack.")
else
@logger.debug("DownloadJamTrack: ignoring processEnqueueJamTrackFail response")
onJamTrackRightEvent: (e, data) =>
@logger.debug("DownloadJamTrack: subscription notification received: type:" + data.type, data)
this.expectTransition()
this.processSigningState(data.body)
updateDownloadProgress: () =>
if @bytesReceived? and @bytesTotal?
progress = "#{Math.round(@bytesReceived/@bytesTotal * 100)}%"
else
progress = '0%'
@root.find('.state-downloading .progress').text(progress)
downloadProgressCallback: (bytesReceived, bytesTotal) =>
@logger.debug("download #{bytesReceived}/#{bytesTotal}")
@bytesReceived = Number(bytesReceived)
@bytesTotal = Number(bytesTotal)
# the reason this timeout is set is because, without it,
# we observe that the client will hang. So, if you remove this timeout, make sure to test with real client
setTimeout(this.updateDownloadProgress, 100)
downloadSuccessCallback: (updateLocation) =>
# is the package loadable yet?
@logger.debug("DownloadJamTrack: download complete - on to keying")
this.transition(@states.keying)
downloadFailureCallback: (errorMsg) =>
this.transitionError("download-error", errorMsg)
# makes a function name for the backend
makeDownloadProgressCallback: () =>
"JK.DownloadJamTracks['#{@jamTrack.id}'].downloadProgressCallback"
# makes a function name for the backend
makeDownloadSuccessCallback: () =>
"JK.DownloadJamTracks['#{@jamTrack.id}'].downloadSuccessCallback"
# makes a function name for the backend
makeDownloadFailureCallback: () =>
"JK.DownloadJamTracks['#{@jamTrack.id}'].downloadFailureCallback"