OpenVeo Publish server

API Docs for: 8.0.0
Show:

File: app/server/packages/TarPackage.js

'use strict';

/**
 * @module packages
 */

var path = require('path');
var fs = require('fs');
var util = require('util');
var async = require('async');
var xml2js = require('xml2js');
var openVeoApi = require('@openveo/api');
var Package = process.requirePublish('app/server/packages/Package.js');
var VideoPackage = process.requirePublish('app/server/packages/VideoPackage.js');
var ERRORS = process.requirePublish('app/server/packages/errors.js');
var STATES = process.requirePublish('app/server/packages/states.js');
var TarPackageError = process.requirePublish('app/server/packages/TarPackageError.js');
var ResourceFilter = openVeoApi.storages.ResourceFilter;

var shortid = require('shortid');

/**
 * Defines a TarPackage to manage publication of a tar file.
 *
 * A tar file may contain :
 *  - A video file
 *  - A list of image files
 *  - A .session file describing the package content
 *
 * @example
 *     // tar package object example
 *     {
 *       "id": "13465465", // Id of the package
 *       "type": "vimeo", // Platform type
 *       "title": "2015-03-09_16-53-10_rich-media", // Package title
 *       "originalPackagePath": "/tmp/2015-03-09_16-53-10_rich-media.tar" // Package file
 *     }
 *
 * @example
 *     // ".session" file example contained in a tar package
 *     {
 *       "date": 1425916390, // Unix epoch time of the video record
 *       "rich-media": true, // true if package contains presentation images
 *       "filename": "video.mp4", // The name of the video file in the package
 *       "duration": 30, // Duration of the video in seconds
 *       "indexes": [ // The list of indexes in the video
 *         {
 *           "type": "image", // Index type (could be "image" or "tag")
 *           "timecode": 0, // Index time (in ms) from the beginning of the video
 *           "data": { // Index data (only for "image" type)
 *             "filename": "slide_00000.jpeg" // The name of the image file in the tar
 *           }
 *         },
 *         {
 *           "type": "tag", // Index type (could be "image" or "tag")
 *           "timecode": 3208 // Index time (in ms) from the beginning of the video
 *         },
 *         ...
 *       ]
 *     }
 *
 * @class TarPackage
 * @extends Package
 * @constructor
 * @param {Object} mediaPackage The media description object
 * @param {VideoProvider} videoProvider A video provider
 */
function TarPackage(mediaPackage, videoProvider) {
  TarPackage.super_.call(this, mediaPackage, videoProvider);

  // Validate package metadata file name
  if (!this.publishConf.metadataFileName || (typeof this.publishConf.metadataFileName !== 'string'))
    this.emit('error', new TarPackageError('metadataFileName in publishConf.json must be a String'),
      ERRORS.INVALID_CONFIGURATION);

}

module.exports = TarPackage;
util.inherits(TarPackage, VideoPackage);

/**
 * Process states for tar packages.
 *
 * @property STATES
 * @type Object
 * @static
 * @final
 */
TarPackage.STATES = {
  PACKAGE_EXTRACTED: 'packageExtracted',
  PACKAGE_VALIDATED: 'packageValidated',
  PUBLIC_DIR_PREPARED: 'publicDirectoryPrepared',
  TIMECODES_SAVED: 'timecodesSaved'
};
Object.freeze(TarPackage.STATES);

/**
 * Tar package process transitions (from one state to another).
 *
 * @property TRANSITIONS
 * @type Object
 * @static
 * @final
 */
TarPackage.TRANSITIONS = {
  EXTRACT_PACKAGE: 'extractPackage',
  VALIDATE_PACKAGE: 'validatePackage',
  PREPARE_PACKAGE: 'preparePublicDirectory',
  SAVE_TIMECODES: 'saveTimecodes'
};
Object.freeze(TarPackage.TRANSITIONS);

/**
 * Define the order in which transitions will be executed for a TarPackage.
 *
 * @property stateTransitions
 * @type Array
 * @static
 * @final
 */
TarPackage.stateTransitions = [
  Package.TRANSITIONS.INIT,
  Package.TRANSITIONS.COPY_PACKAGE,
  Package.TRANSITIONS.REMOVE_ORIGINAL_PACKAGE,
  TarPackage.TRANSITIONS.EXTRACT_PACKAGE,
  TarPackage.TRANSITIONS.VALIDATE_PACKAGE,
  VideoPackage.TRANSITIONS.DEFRAGMENT_MP4,
  VideoPackage.TRANSITIONS.GENERATE_THUMB,
  VideoPackage.TRANSITIONS.GET_METADATA,
  TarPackage.TRANSITIONS.PREPARE_PACKAGE,
  Package.TRANSITIONS.UPLOAD_MEDIA,
  Package.TRANSITIONS.SYNCHRONIZE_MEDIA,
  TarPackage.TRANSITIONS.SAVE_TIMECODES,
  VideoPackage.TRANSITIONS.COPY_IMAGES,
  Package.TRANSITIONS.CLEAN_DIRECTORY
];
Object.freeze(TarPackage.stateTransitions);

/**
 * Define machine state authorized transitions depending on previous and next states.
 *
 * @property stateMachine
 * @type Array
 * @static
 * @final
 */
TarPackage.stateMachine = VideoPackage.stateMachine.concat([
  {
    name: TarPackage.TRANSITIONS.EXTRACT_PACKAGE,
    from: Package.ORIGINAL_PACKAGE_REMOVED_STATE,
    to: TarPackage.STATES.PACKAGE_EXTRACTED
  },
  {
    name: VideoPackage.TRANSITIONS.DEFRAGMENT_MP4,
    from: TarPackage.STATES.PACKAGE_VALIDATED,
    to: VideoPackage.STATES.MP4_DEFRAGMENTED
  },
  {
    name: VideoPackage.TRANSITIONS.GENERATE_THUMB,
    from: VideoPackage.STATES.MP4_DEFRAGMENTED,
    to: VideoPackage.STATES.THUMB_GENERATED
  },
  {
    name: TarPackage.TRANSITIONS.PREPARE_PACKAGE,
    from: VideoPackage.STATES.METADATA_RETRIEVED,
    to: TarPackage.STATES.PUBLIC_DIR_PREPARED
  },
  {
    name: TarPackage.TRANSITIONS.VALIDATE_PACKAGE,
    from: TarPackage.STATES.PACKAGE_EXTRACTED,
    to: TarPackage.STATES.PACKAGE_VALIDATED
  },
  {
    name: TarPackage.TRANSITIONS.PREPARE_PACKAGE,
    from: TarPackage.STATES.PACKAGE_VALIDATED,
    to: TarPackage.STATES.PUBLIC_DIR_PREPARED
  },
  {
    name: Package.TRANSITIONS.UPLOAD_MEDIA,
    from: TarPackage.STATES.PUBLIC_DIR_PREPARED,
    to: Package.STATES.MEDIA_UPLOADED
  },
  {
    name: TarPackage.TRANSITIONS.SAVE_TIMECODES,
    from: Package.STATES.MEDIA_SYNCHRONIZED,
    to: TarPackage.STATES.TIMECODES_SAVED
  },
  {
    name: VideoPackage.TRANSITIONS.COPY_IMAGES,
    from: TarPackage.STATES.TIMECODES_SAVED,
    to: VideoPackage.STATES.COPIED_IMAGES
  }
]);
Object.freeze(TarPackage.stateMachine);

/**
 * Validates package content.
 *
 * A video package must contain, at least a valid package information
 * file and a video file.
 *
 * @example
 *     // mediaPackage example
 *     {
 *       "id" : 1422731934859, // Internal video id
 *       "type" : "vimeo", // The video platform to use
 *       "path" : "C:/Temp/", // The path of the hot folder
 *       "originalPackagePath" : "C:/Temp/video-package.tar", // The original package path in hot folder
 *     }
 *
 * @method validatePackage
 * @async
 * @private
 * @param {Function} callback The function to call when done
 *   - **Error** The error if an error occurred, null otherwise
 *   - **Object** The package information object
 */
function validatePackage(callback) {
  var extractDirectory = path.join(this.publishConf.videoTmpDir, String(this.mediaPackage.id));

  // Read package information file
  openVeoApi.fileSystem.getJSONFileContent(path.join(extractDirectory, this.publishConf.metadataFileName),
    function(error, packageInformation) {

      // Failed reading file or parsing JSON
      if (error) {
        callback(new Error(error.message));
      } else if (packageInformation.filename) {

        // Got the name of the video file
        // Test if video file really exists in package
        fs.exists(path.join(extractDirectory, packageInformation.filename), function(exists) {

          if (exists)
            callback(null, packageInformation);
          else
            callback(new Error('Missing file ' + packageInformation.filename));

        });

      } else {

        // No video file name in metadata, package is not valid
        callback(new Error('No video file name found in metadata file'));

      }

    });

}

/**
 * Saves the XML timecode file into a JSON equivalent.
 * This will check if the file exists first.
 *
 * 1. Test if timecode xml file exists
 * 2. Transcode XML file to a JSON equivalent
 *    e.g.
 * 3. Format JSON
 *    e.g.
 *
 * @example
 *     // Transform XML timecodes into JSON
 *     // From :
 *     {
 *       "player": {
 *         "synchro":
 *         [
 *           {
 *             "id": ["slide_00000.jpeg"],
 *             "timecode": ["0"]
 *           }, {
 *             "id": ["slide_00001.jpeg"],
 *             "timecode": ["1200"]
 *           }
 *         ]
 *       }
 *     }
 *
 *     // To :
 *     [
 *       {
 *         "timecode": 0,
 *         "type": "image"
 *         "data": {
 *           "filename": "slide_00000.jpeg"
 *         }
 *       },
 *       {
 *         "timecode": 1200,
 *         "type": "image"
 *         "data": {
 *           "filename": "slide_00001.jpeg"
 *         }
 *       }
 *     ]
 *
 * @method saveTimecodes
 * @private
 * @async
 * @param {String} xmlTimecodeFilePath The timecode file to save
 * @param {String} destinationFilePath The JSON timecode file path
 * @param {Function} callback The function to call when done
 *   - **Error** The error if an error occurred, null otherwise
 */
function saveTimecodes(xmlTimecodeFilePath, callback) {
  var self = this;
  var formattedTimecodes = [];
  async.series([
    function(callback) {

      // Check if XML file exists
      fs.exists(xmlTimecodeFilePath, function(exists) {

        if (exists)
          callback();
        else
          callback(new Error('Missing timecode file ' + xmlTimecodeFilePath));

      });
    },
    function(callback) {

      // Transcode XML to JSON
      fs.readFile(xmlTimecodeFilePath, function(error, data) {

        if (error)
          callback(error);
        else {
          xml2js.parseString(data, {
            mergeAttrs: true
          },
          function(error, timecodes) {

            // Transform timecode format to
            if (timecodes && timecodes.player && timecodes.player.synchro) {

              // Iterate through the list of timecodes
              // Change JSON organization to be more accessible
              timecodes.player.synchro.forEach(function(timecodeInfo) {
                if (timecodeInfo['id'] && timecodeInfo['id'].length) {
                  formattedTimecodes.push({
                    timecode: parseInt(timecodeInfo['timecode'][0]),
                    type: 'image',
                    data: {
                      filename: timecodeInfo['id'][0]
                    }
                  });
                }
              });
            }
            callback(error);
          });
        }

      });

    }
  ], function(error) {

    if (error) {
      callback(error);
    } else {
      if (!self.mediaPackage.metadata) self.mediaPackage.metadata = {};
      openVeoApi.util.merge(self.mediaPackage.metadata, {indexes: formattedTimecodes});

      self.videoProvider.updateMetadata(self.mediaPackage.id, self.mediaPackage.metadata, function(error) {
        callback(error, formattedTimecodes);
      });
    }
  });

}

/**
 * Gets the stack of transitions corresponding to the package.
 *
 * @method getTransitions
 * @return {Array} The stack of transitions
 */
TarPackage.prototype.getTransitions = function() {
  return TarPackage.stateTransitions;
};

/**
 * Gets the list of transitions states corresponding to the package.
 *
 * @return {Array} The list of states/transitions
 * @method getStateMachine
 */
TarPackage.prototype.getStateMachine = function() {
  return TarPackage.stateMachine;
};

/**
 * Extracts package into temporary directory.
 *
 * This is a transition.
 *
 * @method extractPackage
 */
TarPackage.prototype.extractPackage = function() {
  var self = this;
  var extractDirectory = path.join(this.publishConf.videoTmpDir, String(this.mediaPackage.id));

  // Extract package
  this.updateState(this.mediaPackage.id, STATES.EXTRACTING, function() {

    // Copy destination
    var packagePath = path.join(extractDirectory, self.mediaPackage.id + '.tar');

    process.logger.debug('Extract package ' + packagePath + ' to ' + extractDirectory);
    openVeoApi.fileSystem.extract(packagePath, extractDirectory, function(error) {

      // Extraction failed
      if (error) {
        self.setError(new TarPackageError(error.message, ERRORS.EXTRACT));
      } else {

        // Extraction done
        self.fsm.transition();

      }

    });

  });
};

/**
 * Validates the package by analyzing its content.
 *
 * This is a transition.
 *
 * @method validatePackage
 */
TarPackage.prototype.validatePackage = function() {
  var self = this;

  process.logger.debug('Validate package ' + this.mediaPackage.originalPackagePath);
  this.updateState(this.mediaPackage.id, STATES.VALIDATING, function() {

    // Validate package content
    if (self.mediaPackage.metadata && self.mediaPackage.metadata.indexes)
      self.fsm.transition();
    else validatePackage.call(self, function(error, metadata) {
      if (error)
        self.setError(new TarPackageError(error.message, ERRORS.VALIDATION));
      else {
        if (!self.mediaPackage.metadata) self.mediaPackage.metadata = {};

        openVeoApi.util.merge(self.mediaPackage.metadata, metadata);

        async.parallel([
          function(callback) {
            self.videoProvider.updateMetadata(self.mediaPackage.id, self.mediaPackage.metadata, callback);
          },
          function(callback) {
            if (self.mediaPackage.metadata.date)
              self.videoProvider.updateDate(self.mediaPackage.id, self.mediaPackage.metadata.date * 1000, callback);
            else callback();
          },
          function(callback) {
            var pathDescriptor = path.parse(self.mediaPackage.originalPackagePath);
            if (self.mediaPackage.metadata.name && self.mediaPackage.title === pathDescriptor.name)
              self.videoProvider.updateTitle(self.mediaPackage.id, self.mediaPackage.metadata.name, callback);
            else callback();
          }
        ], function() {
          self.fsm.transition();
        });
      }
    });

  });
};

/**
 * Saves package timecodes into a JSON file.
 *
 * This is a transition.
 *
 * @method saveTimecodes
 */
TarPackage.prototype.saveTimecodes = function() {
  var self = this;
  var extractDirectory = path.join(this.publishConf.videoTmpDir, String(this.mediaPackage.id));
  var videoFinalDir = path.normalize(process.rootPublish + '/assets/player/videos/' + this.mediaPackage.id);

  process.logger.debug('Save timecodes to ' + videoFinalDir);

  var timecodes;
  async.series([

    // Update state
    function(callback) {
      self.updateState(self.mediaPackage.id, STATES.SAVING_TIMECODES, callback);
    },

    // save timecode in metadata from XML if they are not in metadata
    function(callback) {
      if (self.mediaPackage.metadata && self.mediaPackage.metadata.indexes) {
        timecodes = self.mediaPackage.metadata.indexes;
        callback();
      } else saveTimecodes.call(self, path.join(extractDirectory, 'synchro.xml'), function(error, formatedTimecodes) {
        if (error && self.mediaPackage.metadata['rich-media'])
          callback(new TarPackageError(error.message, ERRORS.SAVE_TIMECODE));
        else {
          timecodes = formatedTimecodes;
          callback();
        }
      });
    },

    // parse meatadata to save timecodes and tags
    function(callback) {
      var videoInfo = {};

      // Got timecodes for this video
      if (timecodes) {
        videoInfo.timecodes = [];
        videoInfo.tags = [];

        for (var i = 0; i < timecodes.length; i++) {
          var currentTc = timecodes[i];
          var timecodeType = currentTc.type;

          switch (timecodeType) {
            case 'image':
              var style = 'publish-thumb-200';
              videoInfo.timecodes.push({
                id: shortid.generate(),
                timecode: currentTc.timecode,
                image: {
                  small: '/publish/' + self.mediaPackage.id + '/' + currentTc.data.filename + '?style=' + style,
                  large: '/publish/' + self.mediaPackage.id + '/' + currentTc.data.filename
                }
              });
              break;

            case 'tag':
              videoInfo.tags.push({
                id: shortid.generate(),
                value: currentTc.timecode,
                name: currentTc.data && currentTc.data.tagname ?
                  currentTc.data.tagname : 'Tag' + (videoInfo.tags.length + 1)
              });
              break;
            default:
          }
        }
        self.videoProvider.updateOne(
          new ResourceFilter().equal('id', self.mediaPackage.id),
          videoInfo,
          function(error) {
            return callback(error);
          }
        );
      } else {
        callback();
      }
    }
  ], function(error) {
    if (error)
      self.setError(error);
    else
      self.fsm.transition();
  });
};


/**
 * Gets the media file path of the package.
 *
 * @method getMediaFilePath
 * @return {String} System path of the media file
 */
TarPackage.prototype.getMediaFilePath = function() {
  return path.join(this.publishConf.videoTmpDir,
     String(this.mediaPackage.id), this.mediaPackage.metadata.filename);
};