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

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"