$ = 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"