OpenVeo Publish server

API Docs for: 8.0.0
Show:

File: app/server/providers/VideoProvider.js

'use strict';

/**
 * @module providers
 */

var util = require('util');
var path = require('path');
var async = require('async');
var openVeoApi = require('@openveo/api');
var shortid = require('shortid');
var mediaPlatformFactory = process.requirePublish('app/server/providers/mediaPlatforms/factory.js');
var configDir = openVeoApi.fileSystem.getConfDir();
var videoPlatformConf = require(path.join(configDir, 'publish/videoPlatformConf.json'));
var publishConf = require(path.join(configDir, 'publish/publishConf.json'));
var fileSystemApi = openVeoApi.fileSystem;
var ResourceFilter = openVeoApi.storages.ResourceFilter;
var NotFoundError = openVeoApi.errors.NotFoundError;

/**
 * Defines a VideoProvider to get and save videos.
 *
 * @class VideoProvider
 * @extends EntityProvider
 * @constructor
 * @param {Database} database The database to interact with
 */
function VideoProvider(database) {
  VideoProvider.super_.call(this, database, 'publish_videos');

  Object.defineProperties(this, {

    /**
     * List of pending updates.
     *
     * @property updateQueue
     * @type Array
     * @final
     */
    updateQueue: {value: []},

    /**
     * Indicates if an update is actually running.
     *
     * @property pendingUpdate
     * @type Boolean
     * @final
     */
    pendingUpdate: {value: false, writable: true}

  });

}

module.exports = VideoProvider;
util.inherits(VideoProvider, openVeoApi.providers.EntityProvider);

/**
 * Removes a list of directories.
 *
 * @method removeDirectories
 * @private
 * @async
 * @param {Array} directories The list of directory paths
 * @param {Function} callback The function to call when it's done
 *   - **Error** The error if an error occurred, null otherwise
 */
function removeDirectories(directories, callback) {

  /**
   * Gets the closure to remove the given directory.
   *
   * @param {String} directory The path of the directory to remove
   * @return {Function} A dedicated function to remove the directory
   */
  function removeDirClosure(directory) {
    return function(callback) {
      openVeoApi.fileSystem.rmdir(directory, function(error) {
        callback(error);
      });
    };
  }

  var actions = [];
  for (var i = 0; i < directories.length; i++)
    actions.push(removeDirClosure(directories[i]));

  async.parallel(actions, function(error) {
    callback(error);
  });
}

/**
 * Removes all data related to a list of videos.
 *
 * @method removeAllDataRelatedToVideo
 * @private
 * @async
 * @param {Array} videosToRemove The list of videos to remove
 * @param {Function} callback The function to call when it's done
 *   - **Error** The error if an error occurred, null otherwise
 */
function removeAllDataRelatedToVideo(videosToRemove, callback) {
  var parallel = [];

  // Remove videos public directories
  parallel.push(function(callback) {
    var directories = [];

    for (var i = 0; i < videosToRemove.length; i++)
      directories.push(path.normalize(process.rootPublish + '/assets/player/videos/' + videosToRemove[i].id));

    removeDirectories(directories, function(error) {
      callback(error);
    });
  });

  // Remove videos temporary directories
  parallel.push(function(callback) {
    var directories = [];

    for (var i = 0; i < videosToRemove.length; i++)
      directories.push(path.join(publishConf.videoTmpDir, videosToRemove[i].id));

    removeDirectories(directories, function(error) {
      callback(error);
    });
  });

  videosToRemove.forEach(function(video) {
    parallel.push(
      function(callback) {
        var mediaId = [];

        // compatibility with old mediaId format
        if (video.mediaId) {
          mediaId = !Array.isArray(video.mediaId) ? [video.mediaId] : video.mediaId;
        }

        // verify that media is uploaded before retreiving platformProvider
        if (mediaId.length) {
          var mediaPlatformProvider = mediaPlatformFactory.get(video.type, videoPlatformConf[video.type]);

          if (mediaPlatformProvider)
            mediaPlatformProvider.remove(mediaId, function(error) {
              if (error) {
                callback(error);
                return;
              }
              callback();
            });
          else callback();
        } else callback();
      });
  });

  async.parallel(parallel, function(error) {
    if (error)
      callback(error);
    else
      callback(null);
  });
}

/**
 * Executes an update operation on the given media.
 *
 * Only one update operation can be performed at a time. Pending operations
 * are added to the queue and executed sequentially.
 *
 * @method updateMedia
 * @private
 * @param {String} id The media id
 * @param {Object} modifier Database modifier
 * @param {Function} [callback] Function to call when it's done
 *   - **Error** The error if an error occurred, null otherwise
 *   - **Number** The number of updated items
 */
function updateMedia(id, modifier, callback) {
  var self = this;

  /**
   * Executes oldest update in the queue.
   */
  function executeOperation() {
    if (!self.updateQueue.length)
      return;

    // Retrieve oldest operation
    var update = self.updateQueue.shift();
    self.pendingUpdate = true;

    // Execute operation
    self.updateOne(
      new ResourceFilter().equal('id', update.id),
      update.modifier,
      function() {
        self.pendingUpdate = false;

        // Execute update callback
        if (update.callback)
          update.callback.apply(null, arguments);

        // Execute next operation in the queue
        executeOperation();

      }
    );
  }

  // Add update operation to queue
  this.updateQueue.push(
    {
      id: id,
      modifier: modifier,
      callback: callback
    }
  );

  // If no update operation is running
  // execute the oldest operation in the queue
  if (!this.pendingUpdate)
    executeOperation();
}

/**
 * Resolves media tag file path.
 *
 * @method getTagFilePath
 * @private
 * @param {String} mediaId The media id
 * @param {Object} file The file information
 * @param {String} file.mimetype The file MIME type
 * @param {String} file.filename The file name to resolve
 * @return {String} The resolved file path
 */
function getTagFilePath(mediaId, file) {
  if (file.mimetype.substr(0, 'image'.length) != 'image')
    return '/publish/player/videos/' + mediaId + '/uploads/' + file.filename;
  else
    return '/publish/' + mediaId + '/uploads/' + file.filename;
}

/**
 * Fetches a media.
 *
 * If filter corresponds to more than one media, the first found media will be the returned one.
 * If the media point of interests are in percents, needPointsOfInterestUnitConversion property will be added
 * to the media.
 *
 * @method getOne
 * @async
 * @param {ResourceFilter} [filter] Rules to filter medias
 * @param {Object} [fields] Fields to be included or excluded from the response, by default all
 * fields are returned. Only "exclude" or "include" can be specified, not both
 * @param {Array} [fields.include] The list of fields to include in the response, all other fields are excluded
 * @param {Array} [fields.exclude] The list of fields to exclude from response, all other fields are included. Ignored
 * if include is also specified.
 * @param {Function} callback The function to call when it's done
 *   - **Error** The error if an error occurred, null otherwise
 *   - **Object** The media
 */
VideoProvider.prototype.getOne = function(filter, fields, callback) {
  VideoProvider.super_.prototype.getOne.call(this, filter, fields, function(getOneError, media) {
    if (getOneError) return callback(getOneError);
    if (!media) return callback();

    var last;

    // Order points of interest by (time) value
    // instead of creation order
    var ordering = function(a, b) {
      switch (true) {
        case a.value < b.value:
          return -1;

        case a.value > b.value:
          return 1;

        default:
          return 0;
      }
    };

    var pointsOfInterest = [media.chapters, media.tags, media.cut];
    var poi;
    for (var i = 0; i < pointsOfInterest.length; i++) {
      poi = pointsOfInterest[i];

      if (!poi || poi.length === 0) {
        continue;
      }

      poi.sort(ordering);
      last = poi[poi.length - 1];

      media.needPointsOfInterestUnitConversion = last.value <= 1;
      break;
    }

    callback(null, media);
  });
};

/**
 * Adds medias.
 *
 * @method add
 * @async
 * @param {Array} medias The list of medias to store with for each media:
 *   - **String** id The media id
 *   - **Boolean** [available] true if the media is available, false otherwise
 *   - **String** [title] The media title
 *   - **String** [leadParagraph] The media lead paragraph
 *   - **String** [description] The media description
 *   - **Number** [state] The media state (see STATES class from module packages)
 *   - **Date** [date] The media date
 *   - **String** [type] The id of the associated media platform
 *   - **Object** [metadata] Information about the media as a content
 *   - **String** [metadata.user] The id of the user the media belongs to
 *   - **Array** [metadata.groups] The list of groups the media belongs to
 *   - **Number** [errorCode] The media error code (see ERRORS class from module packages)
 *   - **String** [category] The id of the category the media belongs to
 *   - **Array** [properties] The list of properties values for this media
 *   - **String** [packageType] The type of package
 *   - **String** [lastState] The last media state in publication process
 *   - **String** [lastTransition] The last media transition in publication process
 *   - **String** [originalPackagePath] Absolute path of the original package
 *   - **String** [originalFileName] Original package name without the extension
 *   - **Array** [mediaId] The list of medias in the media platform. Could have several media ids if media has
 *     multiple sources
 *   - **Array** [timecodes] The list of media timecodes
 *   - **Array** [chapters] The list of media chapters
 *   - **Array** [tags] The list of media tags
 *   - **Array** [cut] Media begin and end cuts
 *   - **Array** [sources] The list of media sources
 *   - **Number** [views=0] The statistic number of views
 *   - **String** [thumbnail] The media thumbnail URI
 *   - **String** [link] The media link in OpenVeo
 * @param {Function} [callback] The function to call when it's done
 *   - **Error** The error if an error occurred, null otherwise
 *   - **Number** The total amount of medias inserted
 *   - **Array** The list of added medias
 */
VideoProvider.prototype.add = function(medias, callback) {
  var mediasToAdd = [];
  var anonymousId = process.api.getCoreApi().getAnonymousUserId();

  for (var i = 0; i < medias.length; i++) {
    var media = medias[i];

    var data = {
      id: media.id ? String(media.id) : shortid.generate(),
      available: media.available,
      title: media.title,
      leadParagraph: media.leadParagraph,
      description: media.description,
      state: media.state,
      date: media.date,
      type: media.type,
      metadata: media.metadata || {},
      errorCode: media.errorCode,
      category: media.category,
      properties: media.properties || {},
      packageType: media.packageType,
      lastState: media.lastState,
      lastTransition: media.lastTransition,
      originalPackagePath: media.originalPackagePath,
      originalFileName: media.originalFileName,
      mediaId: media.mediaId,
      timecodes: media.timecodes,
      chapters: media.chapters,
      tags: media.tags,
      cut: media.cut || [],
      sources: media.sources || [],
      views: media.views || 0,
      thumbnail: media.thumbnail,
      link: media.link
    };

    data.metadata.user = media.user || anonymousId;
    data.metadata.groups = media.groups || [];

    mediasToAdd.push(data);
  }

  VideoProvider.super_.prototype.add.call(this, mediasToAdd, callback);
};

/**
 * Updates video state.
 *
 * @method updateState
 * @async
 * @param {Number} id The id of the video to update
 * @param {String} state The state of the video
 * @param {Function} callback The function to call when it's done
 *   - **Error** The error if an error occurred, null otherwise
 *   - **Number** The number of updated items
 */
VideoProvider.prototype.updateState = function(id, state, callback) {
  updateMedia.call(this, id, {state: state}, callback);
};

/**
 * Updates last video state.
 *
 * @method updateLastState
 * @async
 * @param {Number} id The id of the video to update
 * @param {String} state The last state of the video
 * @param {Function} callback The function to call when it's done
 *   - **Error** The error if an error occurred, null otherwise
 *   - **Number** The number of updated items
 */
VideoProvider.prototype.updateLastState = function(id, state, callback) {
  updateMedia.call(this, id, {lastState: state}, callback);
};

/**
 * Updates last video transition.
 *
 * @method updateLastTransition
 * @async
 * @param {Number} id The id of the video to update
 * @param {String} state The last transition of the video
 * @param {Function} callback The function to call when it's done
 *   - **Error** The error if an error occurred, null otherwise
 *   - **Number** The number of updated items
 */
VideoProvider.prototype.updateLastTransition = function(id, state, callback) {
  updateMedia.call(this, id, {lastTransition: state}, callback);
};

/**
 * Updates video error code.
 *
 * @method updateErrorCode
 * @async
 * @param {Number} id The id of the video to update
 * @param {Number} errorCode The error code of the video
 * @param {Function} callback The function to call when it's done
 *   - **Error** The error if an error occurred, null otherwise
 *   - **Number** The number of updated items
 */
VideoProvider.prototype.updateErrorCode = function(id, errorCode, callback) {
  updateMedia.call(this, id, {errorCode: errorCode}, callback);
};

/**
 * Updates video link.
 *
 * @method updateLink
 * @async
 * @param {Number} id The id of the video to update
 * @param {String} link The link of the video
 * @param {Function} callback The function to call when it's done
 *   - **Error** The error if an error occurred, null otherwise
 *   - **Number** The number of updated items
 */
VideoProvider.prototype.updateLink = function(id, link, callback) {
  updateMedia.call(this, id, {link: link}, callback);
};

/**
 * Updates media id for media platform.
 *
 * @method updateMediaId
 * @async
 * @param {String} id The id of the media to update
 * @param {String} idMediaPlatform The id of the media in the video platform
 * @param {Function} callback The function to call when it's done
 *   - **Error** The error if an error occurred, null otherwise
 *   - **Number** The number of updated items
 */
VideoProvider.prototype.updateMediaId = function(id, idMediaPlatform, callback) {
  updateMedia.call(this, id, {mediaId: idMediaPlatform}, callback);
};

/**
 * Updates video metadata for video platform.
 *
 * @method updateMetadata
 * @async
 * @param {Number} id The id of the video to update
 * @param {Object} metadata The metadata of the video in the video platform
 * @param {Function} callback The function to call when it's done
 *   - **Error** The error if an error occurred, null otherwise
 *   - **Number** The number of updated items
 */
VideoProvider.prototype.updateMetadata = function(id, metadata, callback) {
  updateMedia.call(this, id, {metadata: metadata}, callback);
};

/**
 * Updates video date timestamp.
 *
 * @method updateDate
 * @async
 * @param {Number} id The id of the video to update
 * @param {Number} date The date of the video
 * @param {Function} callback The function to call when it's done
 *   - **Error** The error if an error occurred, null otherwise
 *   - **Number** The number of updated items
 */
VideoProvider.prototype.updateDate = function(id, date, callback) {
  updateMedia.call(this, id, {date: date}, callback);
};

/**
 * Updates video category for video platform.
 *
 * @method updateCategory
 * @async
 * @param {Number} id The id of the video to update
 * @param {String} category The category id of the video in the video platform
 * @param {Function} callback The function to call when it's done
 *   - **Error** The error if an error occurred, null otherwise
 *   - **Number** The number of updated items
 */
VideoProvider.prototype.updateCategory = function(id, categoryId, callback) {
  updateMedia.call(this, id, {category: categoryId}, callback);
};

/**
 * Updates video platform type.
 *
 * @method updateType
 * @async
 * @param {Number} id The id of the video to update
 * @param {String} type The type of the video platform
 * @param {Function} callback The function to call when it's done
 *   - **Error** The error if an error occurred, null otherwise
 *   - **Number** The number of updated items
 */
VideoProvider.prototype.updateType = function(id, type, callback) {
  updateMedia.call(this, id, {type: type}, callback);
};

/**
 * Updates video thumbnail.
 *
 * @method updateThumbnail
 * @async
 * @param {Number} id The id of the video to update
 * @param {String} path The path of the thumbnail file
 * @param {Function} callback The function to call when it's done
 *   - **Error** The error if an error occurred, null otherwise
 *   - **Number** The number of updated items
 */
VideoProvider.prototype.updateThumbnail = function(id, path, callback) {
  updateMedia.call(this, id, {thumbnail: path}, callback);
};

/**
 * Updates video title.
 *
 * @method updateTitle
 * @async
 * @param {Number} id The id of the video to update
 * @param {String} title The video title
 * @param {Function} callback The function to call when it's done
 *   - **Error** The error if an error occurred, null otherwise
 *   - **Number** The number of updated items
 */
VideoProvider.prototype.updateTitle = function(id, title, callback) {
  updateMedia.call(this, id, {title: title}, callback);
};

/**
 * Removes medias.
 *
 * All datas associated to the deleted medias will also be deleted.
 *
 * @method remove
 * @async
 * @param {ResourceFilter} [filter] Rules to filter medias to remove
 * @param {Function} [callback] The function to call when it's done
 *   - **Error** The error if an error occurred, null otherwise
 *   - **Number** The number of removed medias
 */
VideoProvider.prototype.remove = function(filter, callback) {
  var self = this;

  // Find medias
  this.getAll(
    filter,
    {
      include: ['id', 'mediaId', 'type']
    },
    {
      id: 'desc'
    },
    function(getAllError, medias) {
      if (getAllError) return self.executeCallback(callback, getAllError);
      if (!medias || !medias.length) return self.executeCallback(callback);

      // Remove medias
      VideoProvider.super_.prototype.remove.call(self, filter, function(removeError, total) {
        if (removeError) return self.executeCallback(callback, removeError);

        // Remove related datas
        removeAllDataRelatedToVideo(medias, function(error) {
          if (error) return callback(error);

          callback(null, total);
        });
      });
    }
  );
};

/**
 * Updates a media.
 *
 * @method updateOne
 * @async
 * @param {ResourceFilter} [filter] Rules to filter the media to update
 * @param {Object} data The modifications to perform
 *   - **String** [data.title] The media title
 *   - **Date** [data.date] The media date
 *   - **String** [data.leadParagraph] The media lead paragraph
 *   - **String** [data.description] The media description
 *   - **Array** [data.properties] The list of properties values for this media
 *   - **String** [data.category] The id of the category the media belongs to
 *   - **Array** [data.cut] Media begin and end cuts
 *   - **Array** [data.timecodes] The list of media timecodes
 *   - **Array** [data.chapters] The list of media chapters
 *   - **Array** [data.tags] The list of media tags
 *   - **Number** [data.views=0] The statistic number of views
 *   - **String** [data.thumbnail] The media thumbnail URI
 *   - **Array** [data.sources] The list of media sources
 *   - **Array** [data.groups] The list of groups ids the media belongs to
 *   - **String** [data.user] The user id the media belongs to
 *   - **Boolean** [available] true if the media is available, false otherwise
 *   - **Number** [state] The media state (see STATES class from module packages)
 *   - **Date** [date] The media date
 *   - **String** [type] The id of the associated media platform
 *   - **Object** [metadata] Information about the media as a content
 *   - **Number** [errorCode] The media error code (see ERRORS class from module packages)
 *   - **String** [packageType] The type of package
 *   - **String** [lastState] The last media state in publication process
 *   - **String** [lastTransition] The last media transition in publication process
 *   - **Array** [mediaId] The list of medias in the media platform. Could have several media ids if media has
 *     multiple sources
 *   - **String** [link] The media link in OpenVeo
 * @param {Function} [callback] The function to call when it's done
 *   - **Error** The error if an error occurred, null otherwise
 *   - **Number** 1 if everything went fine
 */
VideoProvider.prototype.updateOne = function(filter, data, callback) {
  var self = this;
  var modifications = {};

  if (data.title) modifications.title = data.title;
  if (data.date) modifications.date = data.date;
  if (data.properties) modifications.properties = data.properties;
  if (data.cut) modifications.cut = data.cut;
  if (data.timecodes) modifications.timecodes = data.timecodes;
  if (data.chapters) modifications.chapters = data.chapters;
  if (data.tags) modifications.tags = data.tags;
  if (data.thumbnail) modifications.thumbnail = data.thumbnail;
  if (data.sources) modifications.sources = data.sources;
  if (data.lastTransition) modifications.lastTransition = data.lastTransition;
  if (data.type) modifications.type = data.type;
  if (data.metadata) modifications.metadata = data.metadata;
  if (data.packageType) modifications.packageType = data.packageType;
  if (data.mediaId) modifications.mediaId = data.mediaId;
  if (data.link) modifications.link = data.link;
  if (data.hasOwnProperty('lastState')) modifications.lastState = data.lastState;
  if (data.hasOwnProperty('views')) modifications.views = parseInt(data.views);
  if (data.hasOwnProperty('category')) modifications.category = data.category;
  if (data.hasOwnProperty('leadParagraph')) modifications.leadParagraph = data.leadParagraph;
  if (data.hasOwnProperty('description')) modifications.description = data.description;
  if (data.hasOwnProperty('state')) modifications.state = data.state;
  if (data.hasOwnProperty('errorCode')) modifications.errorCode = data.errorCode;
  if (data.hasOwnProperty('available')) modifications.available = Boolean(data.available);
  if (data.groups) {
    modifications['metadata.groups'] = data.groups.filter(function(group) {
      return group ? true : false;
    });
  }
  if (data.hasOwnProperty('user'))
    modifications['metadata.user'] = data.user || process.api.getCoreApi().getAnonymousUserId();

  VideoProvider.super_.prototype.updateOne.call(self, filter, modifications, function(updateError, total) {
    self.executeCallback(callback, updateError, total);
  });
};

/**
 * Creates videos indexes.
 *
 * @method createIndexes
 * @async
 * @param {Function} callback Function to call when it's done with :
 *  - **Error** An error if something went wrong, null otherwise
 */
VideoProvider.prototype.createIndexes = function(callback) {
  this.storage.createIndexes(this.location, [
    {key: {title: 'text', description: 'text'}, weights: {title: 2}, name: 'querySearch'},
    {key: {'metadata.groups': 1}, name: 'byGroups'},
    {key: {'metadata.user': 1}, name: 'byOwner'}
  ], function(error, result) {
    if (result && result.note)
      process.logger.debug('Create videos indexes : ' + result.note);

    callback(error);
  });
};

/**
 * Updates a tag associated to a media.
 *
 * If tag does not exist for the media it is created.
 * The associated file replaces the old file.
 *
 * @method updateOneTag
 * @async
 * @param {ResourceFilter} [filter] Rules to filter the media to update
 * @param {Object} tag The tag description object
 * @param String [tag.id] The tag id
 * @param {Number} [tag.value] The tag time in milliseconds
 * @param {String} [tag.name] The tag name
 * @param {String} [tag.description] The tag description
 * @param {String} [tag.file] The tag file description object
 * @param {String} tag.file.originalname The tag file original name
 * @param {String} tag.file.mimetype The tag file MIME type
 * @param {String} tag.file.filename The tag file name
 * @param {Number} tag.file.size The tag file size
 * @param {String} tag.file.basePath The tag file URI
 * @param {Object} [file] The new file to associate to the tag
 * @param {String} file.originalname The tag file original name
 * @param {String} file.mimetype The tag file MIME type
 * @param {String} file.filename The tag file name
 * @param {Number} file.size The tag file size
 * @param {Function} [callback] The function to call when it's done
 *   - **Error** The error if an error occurred, null otherwise
 *   - **Number** 1 if everything went fine
 *   - **Object** The tag
 */
VideoProvider.prototype.updateOneTag = function(filter, tag, file, callback) {
  var self = this;
  var fileNameToRemove;
  var total;
  var found = false;
  var asyncFunctions = [];
  var tagToAdd;

  // Get media
  this.getOne(
    filter,
    {
      include: ['id', 'tags']
    },
    function(getOneError, media) {
      if (getOneError) return self.executeCallback(callback, getOneError);
      if (!media) return self.executeCallback(callback, new NotFoundError(JSON.stringify(filter)));

      if (!media.tags) media.tags = [];

      if (tag.id) {

        // Tag exists
        // Try to find it in media tags
        for (var i = 0; i < media.tags.length; i++) {
          var mediaTag = media.tags[i];

          if (mediaTag.id === tag.id) {

            // Tag found in media tags
            // Update it

            found = true;

            // No more file associated to the tag
            // Mark old file as "to be deleted"
            if (!tag.file && mediaTag.file)
              fileNameToRemove = mediaTag.file.filename;

            if (file) {

              // New file uploaded

              // Old file should be deleted
              // Mark old file as "to be deleted"
              if (mediaTag.file)
                fileNameToRemove = mediaTag.file.filename;

              mediaTag.file = {
                originalname: file.originalname,
                mimetype: file.mimetype,
                filename: file.filename,
                size: file.size,
                basePath: getTagFilePath(media.id, file)
              };

            } else if (!tag.file) {

              // No new file and no more file associated to the tag

              delete mediaTag.file;
            }

            if (tag.name) mediaTag.name = tag.name;
            if (tag.hasOwnProperty('description')) mediaTag.description = tag.description;
            if (tag.hasOwnProperty('value')) mediaTag.value = tag.value;

            tagToAdd = mediaTag;
            break;

          }
        }

        if (!found) {
          return self.executeCallback(
            callback,
            new Error('Tag with id "' + tag.id + '" was not found in media "' + media.id + '"')
          );
        }

      } else {

        // Tag does not exist
        // Add it

        var newTag = {
          id: shortid.generate(),
          name: tag.name,
          description: tag.description,
          value: tag.value
        };

        if (file) {
          newTag.file = {
            originalname: file.originalname,
            mimetype: file.mimetype,
            filename: file.filename,
            size: file.size,
            basePath: getTagFilePath(media.id, file)
          };
        }

        tagToAdd = newTag;
        media.tags.push(newTag);
      }

      // Remove file
      if (fileNameToRemove) {
        asyncFunctions.push(function(removeCallback) {
          var oldFilePath = process.rootPublish + '/assets/player/videos/' + media.id + '/uploads/' + fileNameToRemove;
          fileSystemApi.rm(oldFilePath, function(removeError) {
            removeCallback(removeError);
          });
        });
      }

      // Update media tags
      asyncFunctions.push(function(updateCallback) {
        self.updateOne(
          new ResourceFilter().equal('id', media.id),
          {
            tags: media.tags
          },
          function(updateError, updateTotal) {
            total = updateTotal;
            updateCallback(updateError);
          }
        );
      });

      async.parallel(asyncFunctions, function(error) {
        return self.executeCallback(callback, error, total, tagToAdd);
      });

    }
  );
};

/**
 * Updates a chapter associated to a media.
 *
 * If chapter does not exist for the media it is created.
 *
 * @method updateOneChapter
 * @async
 * @param {ResourceFilter} [filter] Rules to filter the media to update
 * @param {Object} chapter The chapter description object
 * @param String [chapter.id] The chapter id
 * @param {Number} [chapter.value] The chapter time in milliseconds
 * @param {String} [chapter.name] The chapter name
 * @param {String} [chapter.description] The chapter description
 * @param {Function} [callback] The function to call when it's done
 *   - **Error** The error if an error occurred, null otherwise
 *   - **Number** 1 if everything went fine
 *   - **Object** The chapter
 */
VideoProvider.prototype.updateOneChapter = function(filter, chapter, callback) {
  var self = this;
  var found = false;
  var chapterToAdd;

  // Get media
  this.getOne(
    filter,
    {
      include: ['id', 'chapters']
    },
    function(getOneError, media) {
      if (getOneError) return self.executeCallback(callback, getOneError);
      if (!media) return self.executeCallback(callback, new NotFoundError(JSON.stringify(filter)));

      if (!media.chapters) media.chapters = [];

      if (chapter.id) {

        // Chapter exists
        // Try to find it in media chapters
        for (var i = 0; i < media.chapters.length; i++) {
          var mediaChapter = media.chapters[i];

          if (mediaChapter.id === chapter.id) {

            // Chapter found in media chapters
            // Update it

            found = true;

            if (chapter.name) mediaChapter.name = chapter.name;
            if (chapter.hasOwnProperty('description')) mediaChapter.description = chapter.description;
            if (chapter.hasOwnProperty('value')) mediaChapter.value = chapter.value;

            chapterToAdd = chapter;
            break;

          }
        }

        if (!found) {
          return self.executeCallback(
            callback,
            new Error('Chapter with id "' + chapter.id + '" was not found in media "' + media.id + '"')
          );
        }

      } else {

        // Chapter does not exist
        // Add it

        chapterToAdd = {
          id: shortid.generate(),
          name: chapter.name,
          description: chapter.description,
          value: chapter.value
        };
        media.chapters.push(chapterToAdd);
      }

      // Update media chapters
      self.updateOne(
        new ResourceFilter().equal('id', media.id),
        {
          chapters: media.chapters
        },
        function(updateError, total) {
          return self.executeCallback(callback, updateError, total, chapterToAdd);
        }
      );

    }
  );
};

/**
 * Removes tags associated to a media.
 *
 * Files associated to deleted tags are also removed.
 *
 * @method removeTags
 * @async
 * @param {ResourceFilter} [filter] Rules to filter the media to update
 * @param {Array} tagsIds The list of tags ids
 * @param {Function} [callback] The function to call when it's done
 *   - **Error** The error if an error occurred, null otherwise
 *   - **Number** 1 if everything went fine
 */
VideoProvider.prototype.removeTags = function(filter, tagsIds, callback) {
  var self = this;
  var oldFilesNames = [];
  var filteredTags = [];
  var asyncFunctions = [];
  var total;

  // Get media
  this.getOne(
    filter,
    {
      include: ['id', 'tags']
    },
    function(getOneError, media) {
      if (getOneError) return self.executeCallback(callback, getOneError);
      if (!media) return self.executeCallback(callback, new NotFoundError(JSON.stringify(filter)));

      // Find tags to remove in media tags
      if (media.tags && media.tags.length) {
        filteredTags = media.tags.filter(function(mediaTag) {
          if (tagsIds.indexOf(mediaTag.id) >= 0) {

            // Found a tag to remove
            if (mediaTag.file) oldFilesNames.push(mediaTag.file.filename);
            return false;

          } else
            return true;
        });
      }

      if (!media.tags || filteredTags.length !== media.tags.length - tagsIds.length) {

        // At least one of the tag was not found in media tags
        return self.executeCallback(
          callback,
          new Error('One of the tags (' + tagsIds.join(',') + ') was not found in media ' + media.id)
        );

      }

      // Remove all files
      if (oldFilesNames.length) {
        oldFilesNames.forEach(function(oldFileName) {
          asyncFunctions.push(function(removeCallback) {
            var oldFilePath = process.rootPublish + '/assets/player/videos/' + media.id + '/uploads/' + oldFileName;
            fileSystemApi.rm(oldFilePath, function(removeError) {
              removeCallback(removeError);
            });
          });
        });
      }

      // Update media tags
      asyncFunctions.push(function(updateCallback) {
        self.updateOne(
          new ResourceFilter().equal('id', media.id),
          {
            tags: filteredTags
          },
          function(updateError, updateTotal) {
            total = updateTotal;
            updateCallback(updateError);
          }
        );
      });

      async.parallel(asyncFunctions, function(error) {
        return self.executeCallback(callback, error, total);
      });

    }
  );
};

/**
 * Removes chapters associated to a media.
 *
 * @method removeChapters
 * @async
 * @param {ResourceFilter} [filter] Rules to filter the media to update
 * @param {Array} chaptersIds The list of chapters ids
 * @param {Function} [callback] The function to call when it's done
 *   - **Error** The error if an error occurred, null otherwise
 *   - **Number** 1 if everything went fine
 */
VideoProvider.prototype.removeChapters = function(filter, chaptersIds, callback) {
  var self = this;
  var filteredChapters = [];
  var asyncFunctions = [];
  var total;

  // Get media
  this.getOne(
    filter,
    {
      include: ['id', 'chapters']
    },
    function(getOneError, media) {
      if (getOneError) return self.executeCallback(callback, getOneError);
      if (!media) return self.executeCallback(callback, new NotFoundError(JSON.stringify(filter)));

      // Find chapters to remove in media chapters
      if (media.chapters && media.chapters.length) {
        filteredChapters = media.chapters.filter(function(mediaChapter) {
          return (chaptersIds.indexOf(mediaChapter.id) === -1);
        });
      }

      if (!media.chapters || filteredChapters.length !== media.chapters.length - chaptersIds.length) {

        // At least one of the chapter was not found in media chapters
        return self.executeCallback(
          callback,
          new Error('One of the chapters (' + chaptersIds.join(',') + ') was not found in media ' + media.id)
        );

      }

      // Update media chapters
      asyncFunctions.push(function(updateCallback) {
        self.updateOne(
          new ResourceFilter().equal('id', media.id),
          {
            chapters: filteredChapters
          },
          function(updateError, updateTotal) {
            total = updateTotal;
            updateCallback(updateError);
          }
        );
      });

      async.parallel(asyncFunctions, function(error) {
        return self.executeCallback(callback, error, total);
      });

    }
  );
};