546 lines
21 KiB
CoffeeScript
546 lines
21 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, null, 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()
|
|
@fingerprint = context.jamClient.SessionGetMacHash()
|
|
logger.debug("fingerprint: ", @fingerprint)
|
|
@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: () =>
|
|
@logger.debug "user initiated 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!", @trackDetail)
|
|
throw "after invalidating package, the version is still wrong! #{@trackDetail.version}"
|
|
|
|
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, fingerprint: @fingerprint})
|
|
.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")
|
|
|
|
displayUIForGuard:(response) =>
|
|
display = switch response.message
|
|
when 'no user specified' then 'Please log back in.'
|
|
when 'no fingerprint specified' then 'There was a problem communicating between client and server. Please restart JamKazam.'
|
|
when 'no all fingerprint specified' then 'There was a problem communicating between client and server. Please restart JamKazam.'
|
|
when 'no running fingerprint specified' then 'There was a problem communicating between client and server. Please restart JamKazam.'
|
|
when 'already redeemed another' then "It appears you have already redeemed your one free JamTrack for your household. We are sorry, but we cannot let you download this JamTrack free. If you believe this is an error, please contact us at support@jamkazam.com."
|
|
when "other user has 'all' fingerprint" then "It appears you have already redeemed your one free JamTrack for your household. We are sorry, but we cannot let you download this JamTrack free. If you believe this is an error, please contact us at support@jamkazam.com."
|
|
when "other user has 'running' fingerprint" then "It appears you have already redeemed your one free JamTrack for your household. We are sorry, but we cannot let you download this JamTrack free. If you believe this is an error, please contact us at support@jamkazam.com."
|
|
else "Something went wrong #{response.message}. Please restart JamKazam"
|
|
|
|
processEnqueueJamTrackFail: (jqXHR) =>
|
|
unless @ajaxEnqueueAborted
|
|
if jqXHR.status == 403
|
|
display = this.displayUIForGuard(JSON.parse(jqXHR.responseText))
|
|
this.transitionError("enqueue-error", display)
|
|
else
|
|
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"
|
|
|