528 lines
19 KiB
CoffeeScript
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"
|
|
|