// Copyright 2008 Google, Inc. // All Rights Reserved. /** * @fileoverview Implementation of the Scotty API in Javascript using only * Gears. * * Depends on gears_init.js, gears_common.js, gears_request.js, * gears_database.js and lang.js. * * @author Chris Monson * @author Charles Fry * @author Brian McBarron */ up.LOGIN_PATH = '/login'; up.STAT_PATH = '/stat'; up.UPLOAD_PATH = '/upload'; up.SID_COOKIE = 'SID'; up.CHUNK_SIZE = 1024 * 1024; // 1MB up.MINIMUM_RETRY_DELAY = 5; // 5 seconds up.MAXIMUM_RETRY_DELAY = 30 * 60; // 30 minutes up.MAXIMUM_LIFETIME = 72 * 60 * 60; // 72 hours up.RETRY_DELAY_VARIANCE = 25; // +/- 25% up.RECOVERY_THRESHOLD = 2; /** * Creates a file object wrapper that clients of this API see instead of the * native gears file type. * * @constructor */ up.File = function(manager) { this.database_ = manager.database_; // The following members are set by subclass constructors. //this.id_ = ...; //this.name_ = ...; //this.size_ = ...; //this.state_ = ...; //this.blob_ = ...; this.metaData_ = {}; this.uploader_ = null; this.bytesTransferred_ = 0; this.statusMessage_ = null; this.boundary_ = null; this.sessionId_ = null; }; /** @constructor */ up.FileFromHistory = function(manager, id, attributes) { up.File.call(this, manager); this.id_ = id; this.blob_ = null; this.database_.deserialize(this, attributes); // Perform consistency checks on deserialized object. if (!this.name_) { up.debug('File has no name: ' + id); return; } this.size_ = parseInt(this.size_, 10); if (isNaN(this.size_)) { up.debug('File has no size: ' + id); return; } var legalState = false; for (var key in up.File.State) { if (this.state_ == up.File.State[key]) { up.debug('found state: ' + this.state_); legalState = true; break; } } if (!legalState) { up.debug('File has invalid state: ' + id); return; } this.valid_ = true; }; up.FileFromHistory.inherits(up.File); /** @constructor */ up.FileFromUser = function(manager, file) { up.File.call(this, manager); this.name_ = file.name; try { // length can throw if the file has changed on disk. this.size_ = file.blob.length; this.state_ = up.File.State.DEFAULT; this.blob_ = file.blob; } catch (e) { up.debug('unable to read file size'); this.size_ = 0; this.state_ = up.File.State.UPLOAD_ERROR; this.blob_ = null; } this.id_ = this.database_.createUpload(this.serialize_()); }; up.FileFromUser.inherits(up.File); /* Note: If this is changed, you will likely need to increment up.DB_VERSION. */ up.File.State = { /** File has been selected, no transfer related events have occured. */ DEFAULT: 'default', /** File has been queued for upload, still no transfer related events. */ IN_QUEUE: 'inQueue', /** File is being transferred to a server. */ UPLOADING: 'uploading', /** A non-fatal error */ TRANSIENT_ERROR: 'transientError', /** File transfer has completed. */ UPLOAD_COMPLETED: 'uploadComplete', /** File transfer has been cancelled. */ UPLOAD_CANCELLED: 'uploadCancelled', /** A fatal error has occured with a file transfer. */ UPLOAD_ERROR: 'uploadError', /** File is being uploaded in another page. */ REMOTE: 'remoteUpload' }; up.File.EventType = { /** Files were added */ FILES_ADDED: 'filesAdded', /** Files were removed */ FILES_REMOVED: 'filesRemoved', /** File progress update */ UPLOAD_PROGRESS: 'uploadProgress', /** File state change */ STATE_CHANGE: 'stateChange' }; up.File.prototype.getId = function() { return this.id_; }; up.File.prototype.getName = function() { return this.name_; }; up.File.prototype.getSize = function() { return this.size_; }; up.File.prototype.setState_ = function(state, opt_message) { var statusMessage = opt_message || null; if ((this.state_ == state) && (this.statusMessage_ == statusMessage)) { return false; } if (!this.isActive()) { up.debug('setState_: ' + state + ' called while in state: ' + this.state_); return false; } up.debug('setState_: ' + state + (statusMessage ? (', ' + statusMessage) : '')); this.state_ = state; this.statusMessage_ = statusMessage; if (this.isActive()) { this.database_.updateUpload(this.id_, this.serialize_()); } else { this.database_.completeUpload(this.id_, this.serialize_()); } return true; }; up.File.prototype.getState = function() { return this.state_; }; up.File.prototype.getStatusMessage = function() { return this.statusMessage_; }; up.File.prototype.isActive = function() { return this.state_ == up.File.State.DEFAULT || this.state_ == up.File.State.IN_QUEUE || this.state_ == up.File.State.UPLOADING || this.state_ == up.File.State.TRANSIENT_ERROR; }; up.File.prototype.setBytesTransferred = function(bytes) { // never let the progress clock run backwards if (bytes <= this.bytesTransferred_) return false; this.bytesTransferred_ = bytes; return true; }; up.File.prototype.getBytesTransferred = function() { return this.bytesTransferred_; }; /** * Add a new key/value pair to the metadata to be sent on upload. (Like form * elements, and can even come from form elements, but this is not required). * * @param {String} key The name of the form element * @param {String} val The value of the form element */ up.File.prototype.setMetaData = function(key, val) { if (this.state_ != up.File.State.DEFAULT) return false; if (!isString(key) || !isString(val)) return false; up.debug('setMetaData: ' + key + '=' + val); this.metaData_[key] = val; this.database_.updateUpload(this.id_, this.serialize_()); return true; }; up.File.prototype.getMetaData = function(key) { return this.metaData_[key] || ''; }; up.File.prototype.setUploader_ = function(uploader) { this.uploader_ = uploader; }; up.File.prototype.setBoundary_ = function(boundary) { if (this.boundary_) return false; up.debug('setBoundary_: ' + boundary); this.boundary_ = boundary; this.database_.updateUpload(this.id_, this.serialize_()); return true; }; up.File.prototype.setSessionId_ = function(id) { up.debug('setSessionId_: ' + id); this.sessionId_ = id; this.database_.updateUpload(this.id_, this.serialize_()); return true; }; /** File attributes which can be persisted between sessions. A value of * SET_ONCE indicates the attribute is constant for the lifetime of the * upload. A value of RESETTABLE indicates that the attribute may change * multiple times. */ up.File.SET_ONCE = 1; up.File.RESETTABLE = 2; up.File.Serializable = { name_: up.File.SET_ONCE, size_: up.File.SET_ONCE, state_: up.File.RESETTABLE, statusMessage_: up.File.RESETTABLE, metaData_: up.File.RESETTABLE, sessionId_: up.File.RESETTABLE, boundary_: up.File.SET_ONCE }; up.File.prototype.serialize_ = function() { return this.database_.serialize(this, up.File.Serializable); }; /** * Creates an uploader object that follows the Scotty client API. * * @param {String} uploadUrl The url to which the file should be uploaded. * @param {String} xhrWorkerUrl The url containing the worker pool script that * will process XHR request. * @param {String} username The username of the current user; used to create a * unique database name * @param {Object} opt_params Optional parameters: * - disableHistory: if true, prevents the use of the database to store * information about uploads across page reloads. * * @constructor */ up.Uploader = function(uploadUrl, xhrWorkerUrl, username, opt_params) { if (!opt_params) { opt_params = {}; } // Remove the trailing / if there is one on the URL. We'll add it when // making path targets. if (uploadUrl.charAt(uploadUrl.length - 1) == '/') { uploadUrl = uploadUrl.slice(0, -1); } this.uploadUrl_ = uploadUrl; this.xhrWorkerUrl_ = xhrWorkerUrl; this.desktop_ = null; this.xhrWorker_ = null; this.listeners_ = {}; this.files_ = {}; this.uploadQueue_ = []; // Note: fileArray_ is only used by this.getFiles(). this.fileArray_ = []; this.database_ = new up.Database(username); this.use_database_ = !opt_params.disableHistory; this.database_.addListener('update', this.onDbUpdate_.bind(this)); if (google.gears.factory.hasPermission) { this.initialize_(); } }; /** * Initializes the uploader. No gears apis that require permission are touched * until this is called. */ up.Uploader.prototype.initialize_ = function() { up.debug("initializing uploader"); if (!this.desktop_) { this.desktop_ = google.gears.factory.create('beta.desktop'); } if (this.use_database_) { this.database_.initialize(); } if (!this.xhrWorker_) { this.xhrWorker_ = new up.XhrWorker(this.xhrWorkerUrl_); } return true; }; /** * Returns the Xhr worker shared by this instance. */ up.Uploader.prototype.getXhrWorker = function() { return this.xhrWorker_; }; up.Uploader.prototype.onDbUpdate_ = function(info) { up.debug('onDbUpdate(' + info.uploads.length + ')'); var addedFiles = []; var removedFileIds = []; var numUploads = info.uploads.length; for (var i = 0; i < numUploads; ++i) { var upload = info.uploads[i]; if (isNull(upload.attributes)) { // Null attributes indicates a removed upload. if (this.removeFile_(upload.id)) { removedFileIds.push(upload.id); } continue; } var file = new up.FileFromHistory(this, upload.id, upload.attributes); if (!file.valid_) { up.debug('Invalid attributes: ' + upload.attributes); this.database_.removeUpload(upload.id); if (this.removeFile_(upload.id)) { removedFileIds.push(upload.id); } continue; } // Perform consistency checks based on File.State and Database.State. switch (upload.state) { case up.Database.State.LOCAL: if (!file.isActive()) { up.debug('Local file is inactive: ' + file.id_); this.database_.completeUpload(file.id_, file.serialize_()); } break; case up.Database.State.REMOTE: if (!file.isActive()) { up.debug('Remote file is inactive: ' + file.id_); // Strange, but not our problem since we're not the owner. } file.state_ = up.File.State.REMOTE; break; case up.Database.State.COMPLETE: if (file.isActive()) { up.debug('Complete file is active: ' + file.id_); this.database_.removeUpload(file.id_); if (this.removeFile_(file.id_)) { removedFileIds.push(file.id_); } continue; } break; default: up.debug('Unexpected database state: ' + upload.state); continue; } var result = this.addFile_(file); if (result.isNew) { addedFiles.push(result.file); } else { this.broadcast_(up.File.EventType.STATE_CHANGE, { file: result.file.id_ }); } } if (addedFiles.length) { this.broadcast_(up.File.EventType.FILES_ADDED, { files: addedFiles }); } if (removedFileIds.length) { this.broadcast_(up.File.EventType.FILES_REMOVED, { ids: removedFileIds }); } }; /** * Calls all listeners associated with the given event type * * Note that this augments eventInfo to include the "type" field, which is * inferred from the eventType parameter, e.g., "selected" => "onSelected" * * @param {String} eventType Type of even to trigger * @param {Object} opt_eventInfo Event object to be passed to the listeners */ up.Uploader.prototype.broadcast_ = function(eventType, opt_eventInfo) { if (!opt_eventInfo) { opt_eventInfo = {}; } if (!opt_eventInfo.type) { opt_eventInfo.type = ('on' + eventType.charAt(0).toUpperCase() + eventType.slice(1)); } up.debug('broadcast_(' + eventType + ', ' + up.printObject(opt_eventInfo) + ')'); var listeners = this.listeners_[eventType]; if (listeners) { var num = listeners.length; for (var i = 0; i < num; ++i) { listeners[i](opt_eventInfo); } } }; /** * Opens up a file browser so the user can pick files * * @param {Boolean} multiSelect Turn on multiple selection mode * @param {Array} fileTypes Array of file types * Example: ['text/html', '.txt', 'image/jpeg'] */ up.Uploader.prototype.browse = function(multiSelect, fileTypes) { if (!this.desktop_ && !this.initialize_()) { return; } this.desktop_.openFiles(this.openFilesCallback_.bind(this), { singleFile: !multiSelect, filter: fileTypes }); }; up.Uploader.prototype.openFilesCallback_ = function(files) { var numSelected = files.length; if (numSelected == 0) { return; } var addedFiles = []; for (var i = 0; i < numSelected; ++i) { var file = new up.FileFromUser(this, files[i]); var result = this.addFile_(file); if (result.isNew) { addedFiles.push(result.file); } else { up.debug('Error: Newly selected file conflicts with existing file.'); } } this.broadcast_(up.File.EventType.FILES_ADDED, { files: addedFiles }); }; /** * Gets a file by the given ID (sent to the file select listener) * * @param {String} fileId The ID of the file to retrieve * * @returns {up.File} can be null */ up.Uploader.prototype.getFile = function(fileId) { return this.files_[fileId]; }; /** * Gets all of the files. * * @returns {Array} */ up.Uploader.prototype.getFiles = function() { return this.fileArray_; }; /** * Adds or merges the provided file object with our managed file list. * * @param {Object} file The file to add. */ up.Uploader.prototype.addFile_ = function(file) { up.debug('addFile_(' + up.printObject(file) + ')'); var current = this.files_[file.id_]; if (current) { // Ensure that none of the set-once attributes are being changed. for (var key in up.File.Serializable) { if ((up.File.Serializable[key] == up.File.SET_ONCE) && !isNull(current[key]) && (current[key] != file[key])) { up.debug('Rejecting change of File.' + key + ' from ' + current[key] + ' to ' + file[key]); return { file: current, isNew: false }; } } // Merge the serializable fields of the new file state with our current // object. for (var key in up.File.Serializable) { current[key] = file[key]; } file = current; } else { this.files_[file.id_] = file; var result = this.fileArrayIndexOf_(file.id_) if (result.isFound) { up.debug('Error: fileArray_ is out of date'); this.fileArray_.splice(result.index, 1, file); } else { up.debug('splice index=' + result.index); this.fileArray_.splice(result.index, 0, file); } } if (file.isActive() && !file.uploader_) { file.setUploader_(new up.SingleUploader(this, file)); if (!file.blob_ && (file.state_ == up.File.State.DEFAULT || file.state_ == up.File.State.IN_QUEUE)) { // Don't bother letting the user edit metadata, or leave an upload in // the queue, if we don't have a blob. file.setState_(up.File.State.UPLOAD_ERROR, up.FILE_ERROR); } else if (file.state_ != up.File.State.DEFAULT) { this.startUpload(file.id_); } } return { file: file, isNew: (current ? false : true) }; }; /** * Returns the index of the element in fileArray_ with index fileId if such an * element is present; otherwise returns the index where fileId should be * inserted. */ up.Uploader.prototype.fileArrayIndexOf_ = function(fileId) { var bot = 0; var top = this.fileArray_.length - 1; var mid; while (top >= bot) { mid = Math.floor((top + bot) / 2); if (this.fileArray_[mid].id_ > fileId) { top = mid - 1; } else if (this.fileArray_[mid].id_ < fileId) { bot = mid + 1; } else { return {isFound: true, index: mid}; } } // not found, so calculate insertion point if (bot > top) { // if new element is larger than mid, place it after mid mid = bot; } return {isFound: false, index: mid}; }; /** * Removes an upload from the internal data structures. * * @param {number} fileId The ID of the file to remove. * * @returns {Boolean} */ up.Uploader.prototype.removeFile_ = function(fileId) { var file = this.files_[fileId]; if (!file) { return false; } if (file.uploader_) { file.uploader_.cancelUpload(); delete file.uploader_; } var result = this.fileArrayIndexOf_(fileId); if (result.isFound) { this.fileArray_.splice(result.index, 1); } delete this.files_[fileId]; return true; }; /** * Removes the specified file from the list of files selected for upload. * * @param {number} fileId The ID of the file to remove. * * @returns {Boolean} */ up.Uploader.prototype.removeFile = function(fileId) { if (!this.removeFile_(fileId)) { up.debug('removeFile(' + fileId + '): File not found'); return false; } // Remove upload from database. this.database_.removeUpload(fileId); return true; }; /** * Starts the upload of a file. If another upload is in progress this one will * be enqueued. * * @param {String} fileId The ID of the file to start uploading */ up.Uploader.prototype.startUpload = function(fileId) { // let this call return before we start sending error messages this.startUpload_.callAsync(this, fileId); }; up.Uploader.prototype.startUpload_ = function(fileId) { var file = this.files_[fileId]; if (!file) { up.debug('startUpload_(' + fileId + '): File not found'); return false; } var uploader = file.uploader_; if (!uploader) { up.debug('Error: started upload without uploader_'); return false; } up.debug('Enqueuing file=' + fileId); uploader.start(); // start calls enqueueUploader }; /** * Enqueues an uploader that is ready for upload, and start uploading it if it * is alone in the queue. * * @param {up.SingleUploader} uploader The uploader to enqueue */ up.Uploader.prototype.enqueueUploader = function(uploader) { this.uploadQueue_.push(uploader); // start this upload if it is next (and alone) in the queue if (this.uploadQueue_[0] == uploader) { uploader.startUpload(); } }; /** * Dequeues an uploader that has completed uploading, and start uploading the * next uploader in the queue. * * @param {up.SingleUploader} uploader The uploader to dequeue */ up.Uploader.prototype.dequeueUploader = function(uploader) { if (this.uploadQueue_[0] == uploader) { this.uploadQueue_.shift(); // start the next upload if (this.uploadQueue_.length > 0) { this.uploadQueue_[0].startUpload(); } } else { up.debug("dequeueUploader with incorrect uploader"); } }; /** * A callback that is passed as callback parameter to a single uploader, with * the intention of it passing through to registered listeners of the main * uploader. * * In other words, if the single uploader can have a callback registered, we * can register this function as that callback, and when it is called, it will * broadcast to the corresponding callbacks registered on this object. * * When passing info objects through, it merges their fields into a single * object. * * @param {String} type The type of the outgoing callbacks to be called * @param {Object} info The info fields to be overridden or added * @param {Object} singleInfo The info object originating with the single * Uploader */ up.Uploader.prototype.singleBroadcastPassthrough = function(type, info, singleInfo) { var completeInfo = {}; for (var key in singleInfo) { completeInfo[key] = singleInfo[key]; } for (var key in info) { completeInfo[key] = info[key]; } return this.broadcast_(type, completeInfo); }; /** * Cancels all outstanding uploads. */ up.Uploader.prototype.cancelAll = function() { // cancel all outstanding uploads var uploaders = this.uploadQueue_; this.uploadQueue_ = []; var numUploaders = uploaders.length; for (var i = 0; i < numUploaders; ++i) { var uploader = uploaders[i]; uploader.cancelUpload(); } // and cancel all unstarted for (var fileId in this.files_) { var file = this.files_[fileId]; if (file.uploader_ && (file.state_ == up.File.State.DEFAULT)) { file.uploader_.cancelUpload(); } } }; /** * Cancels the upload of the given file, setting the state to cancelled. * * @param {String} fileId The ID of the file to cancel */ up.Uploader.prototype.cancelUpload = function(fileId) { var file = this.files_[fileId]; if (!file) { up.debug('cancelUpload(' + fileId + '): File not found'); return false; } if (!file.uploader_) return false; file.uploader_.cancelUpload(); return true; }; /** * Adds an event listener to the uploader * * @param {String} eventType The type of event to be attached. One of * selected * cancel * open * progress * complete * httperror * securityerror * ioerror * * @param {Function} callback The name of the callback that will be called */ up.Uploader.prototype.addListener = function(eventType, callback) { if (!this.listeners_[eventType]) { this.listeners_[eventType] = []; } this.listeners_[eventType].push(callback); }; /** * Creates an object that handles the upload of a single file to a given URL. * It does not do status checking or any of that, just attempts to upload a * single file. * * All metadata must be provided, including header and footer for the * transmission. With that, all this has to do is calculate sizes of stuff. * * @param {up.Uploader} manager The parent uploader object * @param {up.File} file The file to upload (an object with a name and a blob} * @constructor */ up.SingleUploader = function(manager, file) { this.manager_ = manager; this.file_ = file; this.started_ = false; if (!this.file_.boundary_) { this.file_.setBoundary_('--' + this.createBoundary_()); } this.header_ = null; // will be calculated in start this.footer_ = '\r\n' + this.file_.boundary_ + '--\r\n'; this.footerLength_ = this.footer_.length; // The following length fields will all be set in start, as the metadata // fields have not yet been set, and thus the header length is unknown. this.headerLength_ = 0; this.totalLength_ = 0; this.canceled_ = false; this.needsLogin_ = !this.file_.sessionId_; // login if lacking session id this.needsStat_ = true; this.offset_ = 0; this.retries_ = 0; this.consecutivePosts_ = 0; this.startTime_ = new Date().getTime(); this.listeners_ = {}; this.addListeners_(manager); }; /** * Gets this uploader's file object * * @returns {up.File} */ up.SingleUploader.prototype.getFile = function() { return this.file_; }; /** * Creates a random MIME boundary for use with the youtube internal file * protocol */ up.SingleUploader.prototype.createBoundary_ = function() { // Base64 valid characters (minus =, which is special) var b64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; var boundary = ''; for (var i = 0; i < 64; ++i) { var index = Math.floor(Math.random() * b64.length); boundary += b64.charAt(index); } return boundary; }; /** * Adds all listeners * * @param {up.Uploader} uploader The uploader instance to register callbacks */ up.SingleUploader.prototype.addListeners_ = function(uploader) { this.addListener_(up.File.EventType.UPLOAD_PROGRESS, uploader); this.addListener_(up.File.EventType.STATE_CHANGE, uploader); }; /** * Add a listener for the given event, one of * * start * progress * complete * cancel * error * * @param {String} eventType Event to listen for. * @param {up.Uploader} uploader */ up.SingleUploader.prototype.addListener_ = function(eventType, uploader) { if (!this.listeners_[eventType]) { this.listeners_[eventType] = []; } var callback = uploader.singleBroadcastPassthrough.bind( uploader, eventType, {file: this.file_.id_}); this.listeners_[eventType].push(callback); }; /** * Calls the given listeners with the given info objects (these come in as * events) * * Changes the info passed in to add a type field. * * @param {String} eventType The type of even to trigger * @param {Object} opt_eventInfo The information for the event */ up.SingleUploader.prototype.broadcast_ = function(eventType, opt_eventInfo) { if (!opt_eventInfo) { opt_eventInfo = {}; } var listenerList = this.listeners_[eventType]; if (listenerList) { if (!opt_eventInfo.type) { opt_eventInfo.type = eventType; } var numListeners = listenerList.length; for (var i = 0; i < numListeners; ++i) { listenerList[i](opt_eventInfo); } } }; /** * Broadcasts a transient error message, and then retries this upload. Note * that the error message is displayed to the user. */ up.SingleUploader.prototype.transientError_ = function(message) { if (this.file_.setState_(up.File.State.TRANSIENT_ERROR, message)) { this.broadcast_(up.File.EventType.STATE_CHANGE, {message: message}); } this.backoff_(); }; /** * Broadcasts a fatal error message, and then aborts this upload. Note that the * error message is displayed to the user. */ up.SingleUploader.prototype.fatalError_ = function(message) { if (this.file_.setState_(up.File.State.UPLOAD_ERROR, message)) { this.broadcast_(up.File.EventType.STATE_CHANGE, {message: message}); } this.manager_.dequeueUploader(this); }; /** * Broadcast a completion message, and complete this upload. */ up.SingleUploader.prototype.finishUpload_ = function() { if (this.file_.setState_(up.File.State.UPLOAD_COMPLETED)) { this.broadcast_(up.File.EventType.STATE_CHANGE); } this.manager_.dequeueUploader(this); }; up.SingleUploader.prototype.elapsedTime_ = function() { return new Date().getTime() - this.startTime_; }; up.SingleUploader.prototype.login_ = function() { var xhrInfo = { headers: { 'X-GUploader-Client-Version': up.VERSION, 'X-GUploader-Gears-Version': google.gears.factory.getBuildInfo(), 'Content-Type': 'application/octet-stream' }, data: 'login=uploader%40youtube.com&password=', onStateChange: this.onStateChange_.bind(this, this.onLoginComplete_.bind(this)), onDebug: this.onDebug_.bind(this), onError: this.onError_.bind(this) }; up.debug('sending login'); this.xhrRequest_(this.manager_.uploadUrl_ + up.LOGIN_PATH, 'POST', xhrInfo); }; up.SingleUploader.prototype.stat_ = function() { // TODO(fry): have scotty-team remove this antiquated Content-Disposition var xhrInfo = { headers: { 'X-GUploader-Client-Version': up.VERSION, 'X-GUploader-Gears-Version': google.gears.factory.getBuildInfo(), 'Cookie': up.SID_COOKIE + '=' + this.file_.sessionId_, 'Content-Disposition': 'attachment; filename="' + this.escapeDoubleQuotes_(this.file_.sessionId_) + '"' }, onStateChange: this.onStateChange_.bind(this, this.onStatComplete_.bind(this)), onDebug: this.onDebug_.bind(this), onError: this.onError_.bind(this) }; up.debug('sending stat'); this.xhrRequest_(this.manager_.uploadUrl_ + up.STAT_PATH, 'POST', xhrInfo); }; up.SingleUploader.prototype.stringLengthUTF8_ = function(text) { var length = 0; for (i = 0; i < text.length; ++i) { var value = text.charCodeAt(i); if (value < 0x80) length += 1; else if (value < 0x800) length += 2; else if (value < 0x10000) length += 3; else if (value < 0x110000) length += 4; // handle overflow like ConvertUTF16toUTF8 from Unicode consortium else length += 3; } return length; } /** * Starts the upload of a file to the specified URL. If another upload is in * progress this one will be enqueued. * * @param {String} url The URL to which the file should be sent */ up.SingleUploader.prototype.start = function(url) { // start uploading at most once if (this.started_) { return; } this.started_ = true; // calculate header_ var headerLines = []; var numMeta = this.file_.metaData_.length; for (var key in this.file_.metaData_) { var val = this.file_.metaData_[key]; headerLines.push(this.file_.boundary_); headerLines.push('Content-Disposition: form-data; ' + 'name="' + this.escapeDoubleQuotes_(key) + '"'); headerLines.push(''); headerLines.push(val); } headerLines.push(this.file_.boundary_); headerLines.push('Content-Disposition: form-data; ' + 'name="Filedata"; filename="' + this.escapeDoubleQuotes_(this.file_.name_) + '"'); headerLines.push('Content-Type: application/octet-stream'); headerLines.push(''); this.header_ = headerLines.join('\r\n') + '\r\n'; this.headerLength_ = this.stringLengthUTF8_(this.header_); this.totalLength_ = this.headerLength_ + this.file_.size_ + this.footerLength_; if (this.file_.setState_(up.File.State.IN_QUEUE)) { this.broadcast_(up.File.EventType.STATE_CHANGE); } this.manager_.enqueueUploader(this); }; /** * Starts uploading this file. This should only be called a single time, when * Uploader is begining the next serial upload. When uploading is complete * Uploader.dequeueUploader must be called to pass control to the next uploader. */ up.SingleUploader.prototype.startUpload = function() { up.debug('Uploading file: ' + this.file_.id_); if (this.file_.setState_(up.File.State.UPLOADING)) { this.broadcast_(up.File.EventType.STATE_CHANGE); } this.upload_(); }; up.SingleUploader.prototype.escapeDoubleQuotes_ = function(text) { return text.replace(/(["\\])/g, '\\$1'); } /** * Performs the next step in the upload process. This should be called * repeatedly everytime an asynchronous request completes until the entire file * has been uploaded. */ up.SingleUploader.prototype.upload_ = function() { if (this.canceled_) { return; } // check completion before attempting login if (this.offset_ == this.totalLength_) { // we're done! this.finishUpload_(); return; } if (this.needsLogin_) { this.login_(); return; } if (this.needsStat_) { this.stat_(); return; } if ((this.offset_ < this.headerLength_ + this.file_.size_) && !this.file_.blob_) { // We'll never be able to finish this upload, so fail now. this.fatalError_(up.FILE_ERROR); return; } // upload the next chunk var data; var dataLength; // use stringLengthUTF8_ for string data try { if (this.offset_ < this.headerLength_) { data = this.header_.slice(this.offset_); dataLength = this.stringLengthUTF8_(data); } else if (this.offset_ < this.headerLength_ + this.file_.size_) { var fileOffset = this.offset_ - this.headerLength_; var length = up.CHUNK_SIZE; if (fileOffset + length > this.file_.size_) { length = this.file_.size_ - fileOffset; } data = this.file_.blob_.slice(fileOffset, length); dataLength = data.length; } else if (this.offset_ < this.totalLength_) { var footerOffset = this.offset_ - this.headerLength_ - this.file_.size_; data = this.footer_.slice(footerOffset); dataLength = this.stringLengthUTF8_(data); } else { up.debug('offset is out of range: offset=' + this.offset_ + ', totalLength=' + this.totalLength_); this.fatalError_(up.INTERNAL_ERROR); return; } } catch (e) { // This could be an error with slice if the file changed out from under us. up.debug(e.message); this.fatalError_(up.FILE_ERROR); return; } var range = this.offset_ + '-' + (this.offset_ + dataLength - 1) + '/' + this.totalLength_; up.debug('sending chunk ' + range); // Set up info for a request var xhrInfo = { headers: { 'X-GUploader-Client-Version': up.VERSION, 'X-GUploader-Gears-Version': google.gears.factory.getBuildInfo(), 'Cookie': up.SID_COOKIE + '=' + this.file_.sessionId_, 'Content-Disposition': 'attachment; filename="' + this.escapeDoubleQuotes_(this.file_.sessionId_) + '"', 'Content-Type': 'application/octet-stream', 'Content-Range': 'bytes ' + range, 'X-GUploader-Metadata': 'filename="' + this.escapeDoubleQuotes_(this.file_.name_) + '"; title="' + this.escapeDoubleQuotes_(this.file_.metaData_['field_myvideo_title']) + '"; token="' + this.escapeDoubleQuotes_(this.file_.metaData_['s']) + '"' }, data: data, onStateChange: this.onStateChange_.bind( this, this.onUploadComplete_.bind(this, this.offset_ + dataLength)), onDebug: this.onDebug_.bind(this), onProgress: this.onProgress_.bind(this), onError: this.onError_.bind(this) }; this.xhrRequest_(this.manager_.uploadUrl_ + up.UPLOAD_PATH, 'POST', xhrInfo); }; up.SingleUploader.prototype.cancelUpload = function() { if (!this.canceled_) { this.canceled_ = true; // if there is an outstanding request, abort it if (this.abort_) { this.abort_(); } if (this.file_.setState_(up.File.State.UPLOAD_CANCELLED)) { this.broadcast_(up.File.EventType.STATE_CHANGE); } this.manager_.dequeueUploader(this); } }; /** * Function to be called to make requests * * @param {String} url URL to request * @param {String} method Request method: 'POST' or 'GET' * @param {Object} opt_params Parameters to send in: * - headers: headers object (name: value pairs), e.g. * {'Content-Type': 'application/octet-stream'} * - data: data for POST requests (string or blob) * - onDebug: callback to log debug information to console (if available) * - onStateChange: callback for state changes * - onProgress: callback for progress updates * - onErrr: callback for non-HTTP errors */ up.SingleUploader.prototype.xhrRequest_ = function(url, method, opt_params) { // prevent new requests if we've been cancelled if (this.canceled_) { return; } this.abort_ = this.manager_.getXhrWorker().startRequest(url, method, opt_params); }; /** * Called when any request completes. Calls the completeCallback when a state * of 4 is achieved. * * @param {Function} completeCallback A function to call on reaching state 4 * @param {Object} requestInfo The request object */ up.SingleUploader.prototype.onStateChange_ = function(completeCallback, requestInfo) { if (this.canceled_) { return; } // clear transient error messages if (this.file_.setState_(up.File.State.UPLOADING)) { this.broadcast_(up.File.EventType.STATE_CHANGE); } // complete switch (requestInfo.state) { case 1: // Open up.debug('request opened'); break; case 2: // Sent up.debug('request sent'); break; case 3: // Interactive up.debug('request interactive'); break; case 4: // Complete if (requestInfo.responseStatus) { var statusText = requestInfo.responseStatus + ': ' + requestInfo.responseStatusText; up.debug('request complete: status "' + statusText + '"'); } else { up.debug('request complete: network error'); } completeCallback(requestInfo); break; default: // Unknown - we should never get here up.debug('unexpected request state: ' + requestInfo.state); this.transientError_(up.SERVER_ERROR); break; } }; up.SingleUploader.prototype.progress_ = function(loaded) { // clear transient error messages if (this.file_.setState_(up.File.State.UPLOADING)) { this.broadcast_(up.File.EventType.STATE_CHANGE); } // only report progress on uploading the body, not the header/footer loaded -= this.headerLength_; if (loaded < 0) { return; } if (loaded > this.file_.size_) { loaded = this.file_.size_; } if (this.file_.setBytesTransferred(loaded)) { this.broadcast_(up.File.EventType.UPLOAD_PROGRESS, { loaded: this.file_.bytesTransferred_, total: this.file_.size_, lengthComputable: true }); } }; up.SingleUploader.prototype.onProgress_ = function(progressInfo) { if (progressInfo.lengthComputable) { // this progress is relative to the chunk offset this.progress_(this.offset_ + progressInfo.loaded); } }; up.SingleUploader.prototype.onDebug_ = function(type, debugInfo) { up.debug(debugInfo.message); }; up.SingleUploader.prototype.onError_ = function(info) { this.transientError_(info.message); }; up.SingleUploader.prototype.getCookie_ = function(requestInfo, cookie) { var cookies = requestInfo.responseHeaders['Set-Cookie'].split(';'); var numCookies = cookies.length; for (var i = 0; i < numCookies; ++i) { if (cookies[i].substring(0, cookie.length + 1) == (cookie + "=")) { return cookies[i].substring(cookie.length + 1); } } return null; }; /** * Called when the state changes for the request that posted the header * information. * * @param {Object} requestInfo A request/response info object */ up.SingleUploader.prototype.onLoginComplete_ = function(requestInfo) { if (requestInfo.responseStatus == 200) { // Capture the SID cookie so we can set it optimistically on new uploads. var sid = this.getCookie_(requestInfo, up.SID_COOKIE); if (sid) { this.file_.setSessionId_(sid); } else { up.debug('no SID cookie set'); this.transientError_(up.SERVER_ERROR); return; } this.needsLogin_ = false; this.needsStat_ = true; this.upload_(); } else if (requestInfo.responseStatus) { up.debug('unexpected login response: ' + requestInfo.responseStatus); this.transientError_(up.SERVER_ERROR); } else { up.debug('login request aborted'); this.transientError_(up.NETWORK_ERROR); } }; /** * Called when the state changes for the request that posted the header * information. * * @param {Object} requestInfo A request/response info object */ up.SingleUploader.prototype.onStatComplete_ = function(requestInfo) { if (requestInfo.responseStatus == 404) { // this datacenter hasn't seen this file yet; // proceed uploading from the beginning this.offset_ = 0; this.progress_(this.offset_); this.needsStat_ = false; this.upload_(); } else if (requestInfo.responseStatus == 200) { var lines = requestInfo.responseText.split(/\r\n|\n|\r/); if (lines.length >= 1 && lines[0] == 'complete') { up.debug('server has entire file'); this.offset_ = this.totalLength_; } else if (lines.length >= 2 && lines[0] == 'partial') { var bytes = parseInt(lines[1], 10); up.debug('server has ' + bytes + ' bytes'); // if bytes == this.totalLength_ then complete should have been sent if (!isNaN(bytes) && bytes >= 0 && bytes < this.totalLength_) { this.offset_ = bytes; } else { up.debug('server has too many bytes: bytes=' + bytes + ', totalLength=' + this.totalLength_); this.fatalError_(up.SERVER_ERROR); return; } } else { up.debug('unrecognized stat response: ' + requestInfo.responseText); this.transientError_(up.SERVER_ERROR); return; } this.progress_(this.offset_); this.needsStat_ = false; this.upload_(); } else if (requestInfo.responseStatus) { // Probably an invalid cookie after changing datacenters, requiring login. this.needsLogin_ = true; up.debug('unexpected stat response: ' + requestInfo.responseStatus); this.transientError_(up.SERVER_ERROR); } else { up.debug('stat request aborted'); this.transientError_(up.NETWORK_ERROR); } }; /** * Called when a request completes. * * @param {Object} offset The total number of bytes sent so far * @param {Object} requestInfo The request/response information */ up.SingleUploader.prototype.onUploadComplete_ = function(offset, requestInfo) { if (requestInfo.responseStatus == 200) { // This chunk was sent successfully, so now send the next chunk or footer. if (offset < this.offset_) { up.debug('Received offset ' + offset + ' after offset ' + this.offset_); // NOTE: unsure what to do in this unexpected case; if we were sure that // another request was outstanding we could just kill this one } this.offset_ = offset; this.progress_(offset); // Reset exponential backoff timer after succesfully uploading enough // consecutive chunks. if (++this.consecutivePosts_ >= up.RECOVERY_THRESHOLD) { up.debug("Retries reset"); this.retries_ = 0; } this.upload_(); } else if (requestInfo.responseStatus) { up.debug('unexpected upload response: ' + requestInfo.responseStatus); this.transientError_(up.SERVER_ERROR); } else { // check length to distinguish file error from network error try { var length = this.file_.blob_.length; up.debug('upload request aborted'); this.transientError_(up.NETWORK_ERROR); } catch (e) { up.debug('error reading file'); this.fatalError_(up.FILE_ERROR); } } }; /** * Backoff with exponentially increasing delay, and schedule a retry. Fail * fatally if too much time has elapsed. */ up.SingleUploader.prototype.backoff_ = function() { // exponential backoff var delay = up.MINIMUM_RETRY_DELAY; for (var i = 0; i < this.retries_; ++i) { delay *= 2; if (delay > up.MAXIMUM_RETRY_DELAY) { delay = up.MAXIMUM_RETRY_DELAY; break; } } // add some random variance if (up.RETRY_DELAY_VARIANCE < 100 && up.RETRY_DELAY_VARIANCE >= 0) { var percentVariance = 100 - up.RETRY_DELAY_VARIANCE + 2 * Math.random() * up.RETRY_DELAY_VARIANCE; delay *= percentVariance / 100; } if ((this.elapsedTime_() / 1000) + delay <= up.MAXIMUM_LIFETIME) { up.debug('retry in ' + delay + ' seconds'); this.consecutivePosts_ = 0; this.retries_++; this.needsStat_ = true; window.setTimeout(this.upload_.bind(this), delay * 1000); } else { // give up eventually up.debug('backoff expired'); this.fatalError_(up.TIMEOUT_ERROR); } };