OpenVeo Publish server

API Docs for: 8.0.0
Show:

File: app/server/controllers/VideoController.js

'use strict';

/**
 * @module controllers
 */

var util = require('util');
var path = require('path');
var fs = require('fs');
var async = require('async');
var openVeoApi = require('@openveo/api');
var coreApi = process.api.getCoreApi();
var fileSystemApi = openVeoApi.fileSystem;
var configDir = fileSystemApi.getConfDir();
var HTTP_ERRORS = process.requirePublish('app/server/controllers/httpErrors.js');
var VideoProvider = process.requirePublish('app/server/providers/VideoProvider.js');
var PropertyProvider = process.requirePublish('app/server/providers/PropertyProvider.js');
var STATES = process.requirePublish('app/server/packages/states.js');
var PublishManager = process.requirePublish('app/server/PublishManager.js');
var mediaPlatformFactory = process.requirePublish('app/server/providers/mediaPlatforms/factory.js');
var TYPES = process.requirePublish('app/server/providers/mediaPlatforms/types.js');
var platforms = require(path.join(configDir, 'publish/videoPlatformConf.json'));
var publishConf = require(path.join(configDir, 'publish/publishConf.json'));
var MultipartParser = openVeoApi.multipart.MultipartParser;
var ContentController = openVeoApi.controllers.ContentController;
var ResourceFilter = openVeoApi.storages.ResourceFilter;

var env = (process.env.NODE_ENV === 'production') ? 'prod' : 'dev';

/**
 * Defines a controller to handle actions relative to videos' routes.
 *
 * @class VideoController
 * @extends ContentController
 * @constructor
 */
function VideoController() {
  VideoController.super_.call(this);
}

module.exports = VideoController;
util.inherits(VideoController, ContentController);

/**
 * Resolves medias resources urls using CDN url.
 *
 * Medias may have attached resources like files associated to tags, timecodes images, thumbnail image and
 * so on. These resources must be accessible through an url. As all resources must, in the future, reside in
 * a CDN, resolveResourcesUrls transforms all resources URIs to URLs based on CDN.
 *
 * @param {Array} medias The list of medias
 */
function resolveResourcesUrls(medias) {
  var cdnUrl = coreApi.getCdnUrl();
  var removeFirstSlashRegExp = new RegExp(/^\//);

  if (medias && medias.length) {
    medias.forEach(function(media) {

      // Timecodes
      if (media.timecodes) {
        media.timecodes.forEach(function(timecode) {
          if (timecode.image) {

            if (timecode.image.small)
              timecode.image.small = cdnUrl + timecode.image.small.replace(removeFirstSlashRegExp, '');

            if (timecode.image.large)
              timecode.image.large = cdnUrl + timecode.image.large.replace(removeFirstSlashRegExp, '');
          }
        });
      }

      // Tags
      if (media.tags) {
        media.tags.forEach(function(tag) {
          if (tag.file && tag.file.basePath)
            tag.file.basePath = cdnUrl + tag.file.basePath.replace(removeFirstSlashRegExp, '');
        });
      }

      // Thumbnail
      if (media.thumbnail)
        media.thumbnail = cdnUrl + media.thumbnail.replace(removeFirstSlashRegExp, '');

      // Local videos are hosted in local and consequently delivered by OpenVeo HTTP server
      if (media.type === TYPES.LOCAL && media.sources) {
        media.sources.forEach(function(source) {
          if (source.files) {
            source.files.forEach(function(file) {
              if (file.link)
                file.link = cdnUrl + file.link.replace(removeFirstSlashRegExp, '');
            });
          }
        });
      }

    });
  }
}

/**
 * Displays video player template.
 *
 * Checks first if the video id is valid and if the video is published
 * before returning the template.
 *
 * @method displayVideoAction
 * @async
 * @param {Request} request ExpressJS HTTP Request
 * @param {Response} response ExpressJS HTTP Response
 * @param {Function} next Function to defer execution to the next registered middleware
 */
VideoController.prototype.displayVideoAction = function(request, response, next) {
  var publishPlugin;
  var plugins = process.api.getPlugins();
  response.locals.scripts = [];
  response.locals.css = [];

  plugins.forEach(function(subPlugin) {
    if (subPlugin.name === 'publish')
      publishPlugin = subPlugin;
  });

  if (publishPlugin) {
    if (publishPlugin.custom) {
      var customScripts = publishPlugin.custom.scriptFiles;
      var playerScripts = customScripts.publishPlayer;
      response.locals.scripts = response.locals.scripts.concat(
        (customScripts.base || []),
        ((playerScripts && playerScripts[env]) ? playerScripts[env] : [])
      );
      response.locals.css = response.locals.css.concat(publishPlugin.custom.cssFiles || []);
    }
    response.render('player', response.locals);
  } else
    next();
};

/**
 * Gets all media platforms available.
 *
 * @example
 *     {
 *       "platforms" : [
 *         ...
 *       ]
 *     }
 *
 * @method getPlatformsAction
 * @async
 * @param {Request} request ExpressJS HTTP Request
 * @param {Response} response ExpressJS HTTP Response
 * @param {Function} next Function to defer execution to the next registered middleware
 */
VideoController.prototype.getPlatformsAction = function(request, response) {
  response.send({
    platforms: Object.keys(platforms) ? Object.keys(platforms).filter(function(value) {
      return platforms[value];
    }) : []
  });
};

/**
 * Gets a ready media.
 *
 * A ready media is a media with a state set to ready or published.
 * Connected users may have access to ready medias but unconnected users can only access published medias.
 *
 * @example
 *
 *     // Response example
 *     {
 *       "entity" : {
 *         "id": ..., // The media id
 *         "state": ..., // The media state
 *         "date": ..., // The media published date as a timestamp
 *         "type": ..., // The video associated platform
 *         "errorCode": ..., // The media error code or -1 if no error
 *         "category": ..., // The media category
 *         "properties": {...}, // The media custom properties
 *         "link": ..., // The media URL
 *         "mediaId": [...], // The media id on the video platform
 *         "available": ..., // The media availability on the video platform
 *         "thumbnail": ..., // The media thumbnail URL
 *         "title": ..., // The media title
 *         "leadParagraph": ..., // The media lead paragraph
 *         "description": ..., // The media description
 *         "chapters": [...], // The media chapters
 *         "tags": [...], // The media tags
 *         "cut": [...], // The media begin and end cuts
 *         "timecodes": [...], // The media associated images
 *       }
 *     }
 *
 * @method getVideoReadyAction
 * @async
 * @param {Request} request ExpressJS HTTP Request
 * @param {Object} request.params Request's parameters
 * @param {String} request.params.id The media id
 * @param {Response} response ExpressJS HTTP Response
 * @param {Function} next Function to defer execution to the next registered middleware
 */
VideoController.prototype.getVideoReadyAction = function(request, response, next) {
  if (!request.params.id) return next(HTTP_ERRORS.GET_VIDEO_READY_MISSING_PARAMETERS);

  var params;
  var self = this;
  var provider = this.getProvider();

  try {
    params = openVeoApi.util.shallowValidateObject(request.params, {
      id: {type: 'string', required: true}
    });
  } catch (error) {
    return next(HTTP_ERRORS.GET_VIDEO_READY_WRONG_PARAMETERS);
  }

  // Get video
  provider.getOne(
    new ResourceFilter().equal('id', params.id),
    null,
    function(getOneError, media) {
      if (getOneError) {
        process.logger.error(getOneError.message, {error: getOneError, method: 'getVideoReadyAction'});
        return next(HTTP_ERRORS.GET_VIDEO_READY_ERROR);
      }

      if (!media) {
        process.logger.warn('Not found', {method: 'getVideoReadyAction', entity: params.id});
        return next(HTTP_ERRORS.GET_VIDEO_READY_NOT_FOUND);
      }

      // Media not ready
      if (media.state !== STATES.READY && media.state !== STATES.PUBLISHED)
        return next(HTTP_ERRORS.GET_VIDEO_READY_NOT_READY_ERROR);

      // User without enough privilege to read the media in ready state
      if (media.state === STATES.READY &&
          !self.isUserAuthorized(request.user, media, ContentController.OPERATIONS.READ)
         ) {
        return next(HTTP_ERRORS.GET_VIDEO_READY_FORBIDDEN);
      }

      // Video from video platform already retrieved
      if (!media.type || !media.mediaId || (media.available && media.sources.length == media.mediaId.length)) {
        resolveResourcesUrls([media]);
        return response.send({
          entity: media
        });
      }

      // Get information about the media from the medias platform
      var mediaPlatformProvider = mediaPlatformFactory.get(media.type, platforms[media.type]);
      var expectedDefinition = media.metadata['profile-settings']['video-height'];

      // Compatibility with old mediaId format
      var mediaId = !Array.isArray(media.mediaId) ? [media.mediaId] : media.mediaId;

      // Get media availability and sources
      mediaPlatformProvider.getMediaInfo(mediaId, expectedDefinition, function(getInfoError, info) {
        if (getInfoError) {
          process.logger.error(getInfoError.message, {error: getInfoError, method: 'getVideoReadyAction'});
          return next(HTTP_ERRORS.GET_VIDEO_READY_GET_INFO_ERROR);
        }

        media.available = info.available;
        media.sources = info.sources;

        provider.updateOne(new ResourceFilter().equal('id', media.id), info);

        resolveResourcesUrls([media]);

        return response.send({
          entity: media
        });
      });

    }
  );
};

/**
 * Gets a media.
 *
 * @example
 *
 *     // Response example
 *     {
 *       "entity" : {
 *         "id": ..., // The media id
 *         "state": ..., // The media state
 *         "date": ..., // The media published date as a timestamp
 *         "type": ..., // The video associated platform
 *         "errorCode": ..., // The media error code or -1 if no error
 *         "category": ..., // The media category
 *         "properties": {...}, // The media custom properties
 *         "link": ..., // The media URL
 *         "mediaId": [...], // The media id on the video platform
 *         "available": ..., // The media availability on the video platform
 *         "thumbnail": ..., // The media thumbnail URL
 *         "title": ..., // The media title
 *         "leadParagraph": ..., // The media lead paragraph
 *         "description": ..., // The media description
 *         "chapters": [...], // The media chapters
 *         "tags": [...], // The media tags
 *         "cut": [...], // The media begin and end cuts
 *         "timecodes": [...], // The media associated images
 *       }
 *     }
 *
 * @method getEntityAction
 * @async
 * @param {Request} request ExpressJS HTTP Request
 * @param {Object} request.params Request parameters
 * @param {String} request.params.id The id of the media to retrieve
 * @param {Object} request.query Request query
 * @param {String|Array} [request.query.include] The list of fields to include from returned media
 * @param {String|Array} [request.query.exclude] The list of fields to exclude from returned media. Ignored if
 * include is also specified.
 * @param {Response} response ExpressJS HTTP Response
 * @param {Function} next Function to defer execution to the next registered middleware
 */
VideoController.prototype.getEntityAction = function(request, response, next) {
  if (request.params.id) {
    var entityId = request.params.id;
    var provider = this.getProvider();
    var self = this;
    var query;
    var fields;
    request.query = request.query || {};

    try {
      query = openVeoApi.util.shallowValidateObject(request.query, {
        include: {type: 'array<string>'},
        exclude: {type: 'array<string>'}
      });
    } catch (error) {
      return next(HTTP_ERRORS.GET_MEDIA_WRONG_PARAMETERS);
    }

    // Make sure "metadata" field is not excluded
    fields = this.removeMetatadaFromFields({
      exclude: query.exclude,
      include: query.include
    });

    provider.getOne(
      new ResourceFilter().equal('id', entityId),
      fields,
      function(error, media) {
        if (error) {
          process.logger.error(error.message, {error: error, method: 'getEntityAction', entity: entityId});
          return next(HTTP_ERRORS.GET_MEDIA_ERROR);
        }

        if (!media) {
          process.logger.warn('Not found', {method: 'getEntityAction', entity: entityId});
          return next(HTTP_ERRORS.GET_MEDIA_NOT_FOUND);
        }

        // User without enough privilege to read the media
        if (!self.isUserAuthorized(request.user, media, ContentController.OPERATIONS.READ)) {
          return next(HTTP_ERRORS.GET_MEDIA_FORBIDDEN);
        }

        // Video from video platform already retrieved
        if (!media.type || !media.mediaId || (media.available && media.sources.length == media.mediaId.length)) {
          resolveResourcesUrls([media]);
          return response.send({
            entity: media
          });
        }

        // Get information about the media from the medias platform
        var mediaPlatformProvider = mediaPlatformFactory.get(media.type, platforms[media.type]);
        var expectedDefinition = media.metadata['profile-settings']['video-height'];

        // Compatibility with old mediaId format
        var mediaId = !Array.isArray(media.mediaId) ? [media.mediaId] : media.mediaId;

        // Get media availability and sources
        mediaPlatformProvider.getMediaInfo(mediaId, expectedDefinition, function(getInfoError, info) {
          if (getInfoError) {
            process.logger.error(getInfoError.message, {error: getInfoError, method: 'getEntityAction'});
            return next(HTTP_ERRORS.GET_MEDIA_GET_INFO_ERROR);
          }

          media.available = info.available;
          media.sources = info.sources;

          provider.updateOne(new ResourceFilter().equal('id', media.id), info);

          resolveResourcesUrls([media]);

          return response.send({
            entity: media
          });
        });
      }
    );
  } else {

    // Missing id of the media
    next(HTTP_ERRORS.GET_MEDIA_MISSING_PARAMETERS);

  }
};

/**
 * Adds a media.
 *
 * @method addEntityAction
 * @async
 * @param {Request} request ExpressJS HTTP Request
 * @param {Object} request.body The media information as multipart body
 * @param {Object} [request.body.file] The media file as multipart data
 * @param {Object} [request.body.thumbnail] The media thumbnail as multipart data
 * @param {Object} request.body.info The media information
 * @param {String} request.body.info.title The media title
 * @param {Object} [request.body.info.properties] The media custom properties values with property id as keys
 * @param {String} [request.body.info.category] The media category id it belongs to
 * @param {Date|Number|String} [request.body.info.date] The media date
 * @param {String} [request.body.info.leadParagraph] The media lead paragraph
 * @param {String} [request.body.info.description] The media description
 * @param {Array} [request.body.info.groups] The media content groups it belongs to
 * @param {String} [request.body.info.platform] The platform to upload the file to
 * @param {Response} response ExpressJS HTTP Response
 * @param {Function} next Function to defer execution to the next registered middleware
 */
VideoController.prototype.addEntityAction = function(request, response, next) {
  if (!request.body) return next(HTTP_ERRORS.ADD_MEDIA_MISSING_PARAMETERS);

  var self = this;
  var mediaId;
  var categoriesIds;
  var groupsIds;
  var customProperties;
  var params;
  var mediaPackageType;
  var parser = new MultipartParser(request, [
    {
      name: 'file',
      destinationPath: publishConf.videoTmpDir,
      maxCount: 1,
      unique: true
    },
    {
      name: 'thumbnail',
      destinationPath: publishConf.videoTmpDir,
      maxCount: 1
    }
  ]);

  async.parallel([

    // Get the list of categories
    function(callback) {
      coreApi.taxonomyProvider.getTaxonomyTerms('categories', function(error, terms) {
        if (error) {
          process.logger.error(error.message, {error: error, method: 'addEntityAction'});
          categoriesIds = [];
        } else
          categoriesIds = openVeoApi.util.getPropertyFromArray('id', terms, 'items');

        callback();
      });
    },

    // Get the list of groups
    function(callback) {
      coreApi.groupProvider.getAll(null, null, {id: 'desc'}, function(error, groups) {
        if (error) {
          process.logger.error(error.message, {error: error, method: 'addEntityAction'});
          return callback(HTTP_ERRORS.ADD_MEDIA_GROUPS_ERROR);
        }

        groupsIds = openVeoApi.util.getPropertyFromArray('id', groups);
        callback();
      });
    },

    // Get the list of custom properties
    function(callback) {
      var database = coreApi.getDatabase();
      var propertyProvider = new PropertyProvider(database);

      propertyProvider.getAll(null, null, {id: 'desc'}, function(error, properties) {
        if (error) {
          process.logger.error(error.message, {error: error, method: 'addEntityAction'});
          return callback(HTTP_ERRORS.ADD_MEDIA_CUSTOM_PROPERTIES_ERROR);
        }

        customProperties = properties;
        callback();
      });
    }

  ], function(error) {
    if (error) return next(error);

    async.series([

      // Parse multipart body
      function(callback) {
        parser.parse(function(error) {
          if (error) {
            process.logger.error(error.message, {error: error, method: 'addEntityAction'});
            return callback(HTTP_ERRORS.ADD_MEDIA_PARSE_ERROR);
          }
          if (!request.body.info) return callback(HTTP_ERRORS.ADD_MEDIA_MISSING_INFO_PARAMETERS);

          request.body.info = JSON.parse(request.body.info);
          callback();
        });
      },

      // Validate file
      function(callback) {
        if (!request.files || !request.files.file || !request.files.file.length)
          return callback(HTTP_ERRORS.ADD_MEDIA_MISSING_FILE_PARAMETER);

        openVeoApi.util.validateFiles({
          file: request.files.file[0].path,
          validateExtension: true
        }, {
          file: {in: [fileSystemApi.FILE_TYPES.MP4, fileSystemApi.FILE_TYPES.TAR]}
        }, function(validateError, files) {
          if (validateError || (files.file && !files.file.isValid)) {
            if (validateError)
              process.logger.error(validateError.message, {error: validateError, method: 'addEntityAction'});

            callback(HTTP_ERRORS.ADD_MEDIA_WRONG_FILE_PARAMETER);
          } else {
            mediaPackageType = files.file.type;
            callback();
          }
        });
      },

      // Validate custom properties
      function(callback) {
        var validationDescriptor = {};

        // Iterate through custom properties values
        for (var id in request.body.info.properties) {
          var value = request.body.info.properties[id];

          // Iterate through custom properties descriptors
          for (var i = 0; i < customProperties.length; i++) {
            var customProperty = customProperties[i];
            if (customProperties[i].id === id) {

              // Found custom property description corresponding to the custom property from request
              // Add its validation descriptor

              if (customProperty.type === PropertyProvider.TYPES.BOOLEAN)
                validationDescriptor[id] = {type: 'boolean'};

              else if (customProperty.type === PropertyProvider.TYPES.LIST && value !== null)
                validationDescriptor[id] = {type: 'string'};

              else if (customProperty.type === PropertyProvider.TYPES.TEXT)
                validationDescriptor[id] = {type: 'string'};

              else if (customProperty.type === PropertyProvider.TYPES.DATE_TIME)
                validationDescriptor[id] = {type: 'number'};

              break;
            }
          }
        }

        try {
          request.body.info.properties = openVeoApi.util.shallowValidateObject(
            request.body.info.properties,
            validationDescriptor
          );
        } catch (validationError) {
          process.logger.error(validationError.message, {error: validationError, method: 'addEntityAction'});
          return callback(HTTP_ERRORS.ADD_MEDIA_WRONG_PROPERTIES_PARAMETER);
        }

        callback();
      },

      // Validate other parameters
      function(callback) {
        try {
          var validationDescriptor = {
            title: {type: 'string', required: true},
            date: {type: 'number', default: Date.now()},
            leadParagraph: {type: 'string'},
            description: {type: 'string'},
            groups: {type: 'array<string>', in: groupsIds}
          };

          if (request.body.info.category)
            validationDescriptor.category = {type: 'string', in: categoriesIds};

          if (request.body.info.platform)
            validationDescriptor.platform = {type: 'string', in: Object.keys(platforms)};

          params = openVeoApi.util.shallowValidateObject(request.body.info, validationDescriptor);

        } catch (validationError) {
          process.logger.error(validationError.message, {error: validationError, method: 'addEntityAction'});
          return callback(HTTP_ERRORS.ADD_MEDIA_WRONG_PARAMETERS);
        }

        callback();
      },

      // Add new media
      function(callback) {
        var pathDescriptor = path.parse(request.files.file[0].path);
        var publishManager = self.getPublishManager();

        var listener = function(mediaPackage) {
          if (mediaPackage.originalPackagePath === request.files.file[0].path) {
            mediaId = mediaPackage.id;
            publishManager.removeListener('stateChanged', listener);
            callback();
          }
        };

        // Make sure process has started before sending back response to the client
        publishManager.on('stateChanged', listener);

        publishManager.publish({
          originalPackagePath: request.files.file[0].path,
          originalThumbnailPath: request.files.thumbnail ? request.files.thumbnail[0].path : undefined,
          originalFileName: pathDescriptor.name,
          title: params.title,
          date: params.date,
          leadParagraph: params.leadParagraph,
          description: params.description,
          category: params.category,
          groups: params.groups,
          user: request.user.type === 'oAuthClient' ? coreApi.getSuperAdminId() : request.user.id,
          properties: request.body.info.properties,
          packageType: mediaPackageType,
          type: params.platform
        });
      }

    ], function(error) {
      if (error) {
        if (request.files && request.files.file && request.files.file.length) {

          // Remove temporary file
          fs.unlink(request.files.file[0].path, function(unlinkError) {
            if (unlinkError) {
              process.logger.error(unlinkError.message, {error: unlinkError, method: 'addEntityAction'});
              return next(HTTP_ERRORS.ADD_MEDIA_REMOVE_FILE_ERROR);
            }
            next(error);
          });

        } else
          next(error);
      } else response.send({id: mediaId});
    });
  });
};

/**
 * Updates a media.
 *
 * @example
 *
 *     // Response example
 *     {
 *       "total": 1
 *     }
 *
 * @method updateEntityAction
 * @async
 * @param {Request} request ExpressJS HTTP Request
 * @param {String} request.params.id Id of the media to update
 * @param {Object} request.body The media information as multipart body
 * @param {Object} [request.body.thumbnail] The media thumbnail as multipart data
 * @param {Object} request.body.info The media information
 * @param {String} [request.body.info.title] The media title
 * @param {Object} [request.body.info.properties] The media custom properties values with property id as keys
 * @param {String} [request.body.info.category] The media category id it belongs to
 * @param {Date|Number|String} [request.body.info.date] The media date
 * @param {String} [request.body.info.leadParagraph] The media lead paragraph
 * @param {String} [request.body.info.description] The media description
 * @param {Array} [request.body.info.groups] The media content groups it belongs to
 * @param {Response} response ExpressJS HTTP Response
 * @param {Function} next Function to defer execution to the next registered middleware
 */
VideoController.prototype.updateEntityAction = function(request, response, next) {
  if (!request.body || !request.params.id) return next(HTTP_ERRORS.UPDATE_MEDIA_MISSING_PARAMETERS);

  var media;
  var totalUpdated;
  var self = this;
  var mediaId = request.params.id;
  var provider = this.getProvider();
  var parser = new MultipartParser(request, [
    {
      name: 'thumbnail',
      destinationPath: publishConf.videoTmpDir,
      maxCount: 1
    }
  ]);

  parser.parse(function(error) {
    if (error) {
      process.logger.error(error.message, {error: error, method: 'updateEntityAction'});
      next(HTTP_ERRORS.UPDATE_MEDIA_PARSE_ERROR);
    }

    var info = JSON.parse(request.body.info);
    var files = request.files;
    var thumbnail = files.thumbnail ? files.thumbnail[0] : undefined;
    var imageDir = path.normalize(process.rootPublish + '/assets/player/videos/' + mediaId);

    async.series([

      // Verify that user has enough privilege to update the media
      function(callback) {
        provider.getOne(
          new ResourceFilter().equal('id', mediaId), null, function(error, fetchedMedia) {
            if (error) {
              process.logger.error(error.message, {error: error, method: 'updateEntityAction'});
              return callback(HTTP_ERRORS.UPDATE_MEDIA_GET_ONE_ERROR);
            }

            if (!fetchedMedia) return callback(HTTP_ERRORS.UPDATE_MEDIA_NOT_FOUND_ERROR);

            media = fetchedMedia;

            if (self.isUserAuthorized(request.user, media, ContentController.OPERATIONS.UPDATE)) {

              // User is authorized to update but he must be owner to update the owner
              if (!self.isUserOwner(media, request.user) &&
                  !self.isUserAdmin(request.user) &&
                  !self.isUserManager(request.user)) {
                delete info['user'];
              }

              callback();
            } else
              callback(HTTP_ERRORS.UPDATE_MEDIA_FORBIDDEN);
          }
        );
      },

      // Validate the file
      function(callback) {
        if (!thumbnail) return callback();

        openVeoApi.util.validateFiles(
          {thumbnail: thumbnail.path},
          {thumbnail: {in: [fileSystemApi.FILE_TYPES.JPG]}},
          function(error, files) {
            if (error)
              process.logger.warn(error.message, {error: error, action: 'updateEntity', mediaId: mediaId});

            if (!files.thumbnail.isValid) return callback(HTTP_ERRORS.INVALID_VIDEO_THUMBNAIL);

            callback();
          }
        );
      },

      // Copy the file
      function(callback) {
        if (!thumbnail) return callback();

        fileSystemApi.copy(thumbnail.path, path.join(imageDir, 'thumbnail.jpg'), function(error) {
          if (error) {
            process.logger.warn(
              error.message,
              {error: error, action: 'updateEntityAction', mediaId: mediaId, thumbnail: thumbnail.path}
            );
          }

          fileSystemApi.rm(thumbnail.path, function(error) {
            if (error) {
              process.logger.warn(
                error.message,
                {error: error, action: 'updateEntityAction', mediaId: mediaId, thumbnail: thumbnail.path}
              );
            }
            callback();
          });
        });
      },

      // Clear image thumbnail cache
      function(callback) {
        if (!thumbnail) return callback();

        coreApi.clearImageCache(path.join(mediaId, 'thumbnail.jpg'), 'publish', function(error) {
          if (error) {
            process.logger.warn(
              error.message,
              {error: error, action: 'updateEntityAction', mediaId: mediaId}
            );
          }
          callback();
        });
      },

      // Update the media
      function(callback) {
        if (thumbnail) info.thumbnail = '/publish/' + mediaId + '/thumbnail.jpg';

        provider.updateOne(
          new ResourceFilter().equal('id', mediaId),
          info,
          function(error, total) {
            if (error) {
              process.logger.error(error.message, {error: error, method: 'updateEntityAction', entity: mediaId});
              return callback(HTTP_ERRORS.UPDATE_MEDIA_ERROR);
            }

            totalUpdated = total;
            callback();
          }
        );
      },

      // Synchronize the media with the media platform
      function(callback) {
        if (!media.type || !media.mediaId) return callback();

        var mediaPlatformProvider = mediaPlatformFactory.get(media.type, platforms[media.type]);

        mediaPlatformProvider.update(media, info, false, function(error) {
          if (error) {
            process.logger.error(error.message, {error: error, method: 'updateEntityAction', entity: mediaId});
            return callback(HTTP_ERRORS.UPDATE_MEDIA_SYNCHRONIZE_ERROR);
          }

          callback();
        });
      }

    ], function(error) {
      if (error) return next(error);
      response.send({total: totalUpdated});
    });
  });
};

/**
 * Gets medias.
 *
 * @example
 *
 *     // Response example
 *     {
 *       "entities" : [ ... ],
 *       "pagination" : {
 *         "limit": ..., // The limit number of medias by page
 *         "page": ..., // The actual page
 *         "pages": ..., // The total number of pages
 *         "size": ... // The total number of medias
 *     }
 *
 * @method getEntitiesAction
 * @async
 * @param {Request} request ExpressJS HTTP Request
 * @param {Object} request.query Request's query parameters
 * @param {String} [request.query.query] To search on both medias title and description
 * @param {String|Array} [request.query.include] The list of fields to include from returned medias
 * @param {String|Array} [request.query.exclude] The list of fields to exclude from returned medias. Ignored if
 * include is also specified.
 * @param {String|Array} [request.query.states] To filter medias by state
 * @param {String} [request.query.dateStart] To filter medias after or equal to a date (in format mm/dd/yyyy)
 * @param {String} [request.query.dateEnd] To get medias before a date (in format mm/dd/yyyy)
 * @param {String|Array} [request.query.categories] To filter medias by category
 * @param {String|Array} [request.query.groups] To filter medias by group
 * @param {String|Array} [request.query.user] To filter medias by user
 * @param {String} [request.query.sortBy="date"] To sort medias by either **title**, **description**, **date**,
 * **state**, **views** or **category**
 * @param {String} [request.query.sortOrder="desc"] Sort order (either **asc** or **desc**)
 * @param {String} [request.query.page=0] The expected page
 * @param {String} [request.query.limit=10] To limit the number of medias per page
 * @param {Object} [request.query.properties] A list of properties with the property id as the key and the expected
 * property value as the value
 * @param {Response} response ExpressJS HTTP Response
 * @param {Function} next Function to defer execution to the next registered middleware
 */
VideoController.prototype.getEntitiesAction = function(request, response, next) {
  var params;
  var fields;
  var self = this;
  var medias = [];
  var properties = [];
  var pagination = {};
  var provider = this.getProvider();
  var orderedProperties = ['title', 'description', 'date', 'state', 'views', 'category'];

  try {
    params = openVeoApi.util.shallowValidateObject(request.query, {
      query: {type: 'string'},
      include: {type: 'array<string>'},
      exclude: {type: 'array<string>'},
      states: {type: 'array<number>'},
      dateStart: {type: 'date'},
      dateEnd: {type: 'date'},
      categories: {type: 'array<string>'},
      groups: {type: 'array<string>'},
      user: {type: 'array<string>'},
      properties: {type: 'object', default: {}},
      limit: {type: 'number', gt: 0},
      page: {type: 'number', gte: 0, default: 0},
      sortBy: {type: 'string', in: orderedProperties, default: 'date'},
      sortOrder: {type: 'string', in: ['asc', 'desc'], default: 'desc'}
    });
  } catch (error) {
    return next(HTTP_ERRORS.GET_VIDEOS_WRONG_PARAMETERS);
  }

  // Build sort
  var sort = {};
  sort[params.sortBy] = params.sortOrder;

  // Build filter
  var filter = new ResourceFilter();

  // Add search query
  if (params.query) filter.search('"' + params.query + '"');

  // Add states
  if (params.states && params.states.length) filter.in('state', params.states);

  // Add categories
  if (params.categories && params.categories.length) filter.in('category', params.categories);

  // Add groups
  if (params.groups && params.groups.length) filter.in('metadata.groups', params.groups);

  // Add owner
  if (params.user && params.user.length) filter.in('metadata.user', params.user);

  // Add date
  if (params.dateStart) filter.greaterThanEqual('date', params.dateStart);
  if (params.dateEnd) filter.lesserThanEqual('date', params.dateEnd);

  // Make sure "metadata" field is not excluded
  fields = this.removeMetatadaFromFields({
    exclude: params.exclude,
    include: params.include
  });

  async.series([

    // Get the list of custom properties
    function(callback) {
      var database = coreApi.getDatabase();
      var propertyProvider = new PropertyProvider(database);

      propertyProvider.getAll(null, null, {id: 'desc'}, function(error, propertiesList) {
        if (error) {
          process.logger.error(error.message, {error: error, method: 'getEntitiesAction'});
          return callback(HTTP_ERRORS.GET_VIDEOS_GET_PROPERTIES_ERROR);
        }
        properties = propertiesList;
        callback(error);
      });
    },

    // Validate custom properties
    function(callback) {
      if (params.properties) {
        var customPropertiesIds = Object.keys(params.properties);
        try {
          for (var i = 0; i < customPropertiesIds.length; i++) {
            for (var j = 0; j < properties.length; j++) {
              if (properties[j].id === customPropertiesIds[i]) {
                var validatedProperty;
                var validationDescriptor = {};

                if (properties[j].type === PropertyProvider.TYPES.BOOLEAN)
                  validationDescriptor[properties[j].id] = {type: 'boolean', required: true};

                else if (properties[j].type === PropertyProvider.TYPES.LIST)
                  validationDescriptor[properties[j].id] = {type: 'string', required: true};

                else if (properties[j].type === PropertyProvider.TYPES.TEXT)
                  validationDescriptor[properties[j].id] = {type: 'string', required: true};

                else if (properties[j].type === PropertyProvider.TYPES.DATE_TIME)
                  validationDescriptor[properties[j].id] = {type: 'date', required: true};

                validatedProperty = openVeoApi.util.shallowValidateObject(
                  params.properties,
                  validationDescriptor
                );

                if (validatedProperty[properties[j].id]) {
                  if (properties[j].type === PropertyProvider.TYPES.DATE_TIME) {
                    var startDate = new Date(validatedProperty[properties[j].id]);
                    startDate.setHours(0);
                    startDate.setMinutes(0);
                    startDate.setSeconds(0);
                    startDate.setMilliseconds(0);

                    var endDate = new Date(startDate);
                    endDate.setDate(startDate.getDate() + 1);
                    filter.greaterThanEqual('properties.' + properties[j].id, startDate.getTime());
                    filter.lesserThan('properties.' + properties[j].id, endDate.getTime());
                  } else
                    filter.equal('properties.' + properties[j].id, validatedProperty[properties[j].id]);
                }

                break;
              }
            }
          }
        } catch (validationError) {
          process.logger.error(validationError.message, {error: validationError, method: 'getEntitiesAction'});
          return callback(HTTP_ERRORS.GET_VIDEOS_CUSTOM_PROPERTIES_WRONG_PARAMETERS);
        }
      }

      callback();
    },

    // Get the list of medias
    function(callback) {
      provider.get(
        self.addAccessFilter(filter, request.user),
        fields,
        params.limit,
        params.page,
        sort,
        function(error, fetchedMedias, fetchedPagination) {
          if (error) {
            process.logger.error(error.message, {error: error, method: 'getEntitiesAction'});
            return callback(HTTP_ERRORS.GET_VIDEOS_ERROR);
          }
          medias = fetchedMedias;
          pagination = fetchedPagination;
          callback();
        }
      );
    }

  ], function(error) {
    if (error) return next(error);
    if (properties) {

      // Medias may not have custom properties or just some of them.
      // Furthermore only the id and value of properties are stored
      // within medias, not complete information about the properties
      // (no name, no description and no type).
      // Inject all custom properties information inside media objects

      medias.forEach(function(media) {
        if (!media.properties) return;
        var mediaProperties = {};

        properties.forEach(function(property) {

          // Make a copy of property object to add value
          mediaProperties[String(property.id)] = JSON.parse(JSON.stringify(property));
          if (!media.properties[String(property.id)])
            mediaProperties[String(property.id)]['value'] = '';
          else
            mediaProperties[String(property.id)]['value'] = media.properties[String(property.id)];

        });
        media.properties = mediaProperties;
      });

    }

    resolveResourcesUrls(medias);

    response.send({
      entities: medias,
      pagination: pagination
    });
  });
};

/**
 * Publishes medias.
 *
 * Change the state of medias to published.
 *
 * @example
 *
 *     // Response example
 *     {
 *       "total": 42
 *     }
 *
 * @method publishVideosAction
 * @async
 * @param {Request} request ExpressJS HTTP Request
 * @param {Object} request.params Request's parameters
 * @param {String} request.params.ids A comma separated list of media ids
 * @param {Response} response ExpressJS HTTP Response
 * @param {Function} next Function to defer execution to the next registered middleware
 */
VideoController.prototype.publishVideosAction = function(request, response, next) {
  if (!request.params.ids) return next(HTTP_ERRORS.PUBLISH_VIDEOS_MISSING_PARAMETERS);

  var params;
  var self = this;
  var asyncFunctions = [];
  var total = 0;

  try {
    params = openVeoApi.util.shallowValidateObject(request.params, {
      ids: {type: 'string', required: true}
    });
  } catch (error) {
    return next(HTTP_ERRORS.PUBLISH_VIDEOS_WRONG_PARAMETERS);
  }

  var ids = params.ids.split(',');
  var provider = this.getProvider();

  // Make sure user has enough privilege to update the medias
  provider.getAll(
    new ResourceFilter().in('id', ids),
    {
      include: ['id', 'metadata']
    },
    {
      id: 'desc'
    },
    function(getAllError, medias) {
      if (getAllError) {
        process.logger.error(getAllError.message, {error: getAllError, method: 'publishVideosAction'});
        return next(HTTP_ERRORS.PUBLISH_VIDEOS_GET_VIDEOS_ERROR);
      }

      medias.forEach(function(media) {
        if (!self.isUserAuthorized(request.user, media, ContentController.OPERATIONS.UPDATE)) return;
        asyncFunctions.push(function(callback) {
          provider.updateOne(
            new ResourceFilter()
            .equal('id', media.id)
            .equal('state', STATES.READY),
            {
              state: STATES.PUBLISHED
            },
            function(updateOneError) {
              total++;
              callback(updateOneError);
            }
          );
        });
      });

      async.parallel(asyncFunctions, function(error, results) {
        if (error) {
          process.logger.error(error.message, {error: error, method: 'publishVideosAction'});
          return next(HTTP_ERRORS.PUBLISH_VIDEOS_ERROR);
        }
        if (total !== ids.length) return next(HTTP_ERRORS.PUBLISH_VIDEOS_FORBIDDEN);

        response.send({
          total: total
        });
      });
    }
  );
};

/**
 * Unpublishes medias.
 *
 * Change the state of medias to unpublished.
 *
 * @example
 *
 *     // Response example
 *     {
 *       "total": 42
 *     }
 *
 * @method unpublishVideosAction
 * @async
 * @param {Request} request ExpressJS HTTP Request
 * @param {Object} request.params Request's parameters
 * @param {String} request.params.ids A comma separated list of media ids
 * @param {Response} response ExpressJS HTTP Response
 * @param {Function} next Function to defer execution to the next registered middleware
 */
VideoController.prototype.unpublishVideosAction = function(request, response, next) {
  if (!request.params.ids) return next(HTTP_ERRORS.UNPUBLISH_VIDEOS_MISSING_PARAMETERS);

  var params;
  var self = this;
  var asyncFunctions = [];
  var total = 0;

  try {
    params = openVeoApi.util.shallowValidateObject(request.params, {
      ids: {type: 'string', required: true}
    });
  } catch (error) {
    return next(HTTP_ERRORS.UNPUBLISH_VIDEOS_WRONG_PARAMETERS);
  }

  var ids = params.ids.split(',');
  var provider = this.getProvider();

  // Make sure user has enough privilege to update the medias
  provider.getAll(
    new ResourceFilter().in('id', ids),
    {
      include: ['id', 'metadata']
    },
    {
      id: 'desc'
    },
    function(getAllError, medias) {
      if (getAllError) {
        process.logger.error(getAllError.message, {error: getAllError, method: 'unpublishVideosAction'});
        return next(HTTP_ERRORS.UNPUBLISH_VIDEOS_GET_VIDEOS_ERROR);
      }

      medias.forEach(function(media) {
        if (!self.isUserAuthorized(request.user, media, ContentController.OPERATIONS.UPDATE)) return;
        asyncFunctions.push(function(callback) {
          provider.updateOne(
            new ResourceFilter()
            .equal('id', media.id)
            .equal('state', STATES.PUBLISHED),
            {
              state: STATES.READY
            },
            function(updateOneError) {
              total++;
              callback(updateOneError);
            }
          );
        });
      });

      async.parallel(asyncFunctions, function(error, results) {
        if (error) {
          process.logger.error(error.message, {error: error, method: 'unpublishVideosAction'});
          return next(HTTP_ERRORS.UNPUBLISH_VIDEOS_ERROR);
        }
        if (total !== ids.length) return next(HTTP_ERRORS.UNPUBLISH_VIDEOS_FORBIDDEN);

        response.send({
          total: total
        });
      });
    }
  );
};

/**
 * Retries to publish videos on error.
 *
 * @method retryVideosAction
 * @async
 * @param {Request} request ExpressJS HTTP Request
 * @param {Object} request.params Request's parameters
 * @param {String} request.params.ids Comma separated list of media ids
 * @param {Response} response ExpressJS HTTP Response
 * @param {Function} next Function to defer execution to the next registered middleware
 */
VideoController.prototype.retryVideosAction = function(request, response, next) {
  if (!request.params.ids) return next(HTTP_ERRORS.RETRY_VIDEOS_MISSING_PARAMETERS);

  var self = this;
  var params;

  try {
    params = openVeoApi.util.shallowValidateObject(request.params, {
      ids: {type: 'string', required: true}
    });
  } catch (error) {
    return next(HTTP_ERRORS.RETRY_VIDEOS_WRONG_PARAMETERS);
  }

  var ids = params.ids.split(',');
  var asyncFunctions = [];
  var retryAsyncFunction = function(id) {
    return function(callback) {
      var publishManager = self.getPublishManager();
      publishManager.once('retry', callback);
      publishManager.retry(id);
    };
  };

  for (var i = 0; i < ids.length; i++)
    asyncFunctions.push(retryAsyncFunction(ids[i]));

  async.parallel(asyncFunctions, function() {
    response.send();
  });
};

/**
 * Starts uploading videos to the media platform.
 *
 * @method startUploadAction
 * @async
 * @param {Request} request ExpressJS HTTP Request
 * @param {Object} request.params Request's parameters
 * @param {String} request.params.ids Comma separated list of media ids
 * @param {String} request.params.platform The id of the platform to upload to
 * @param {Response} response ExpressJS HTTP Response
 * @param {Function} next Function to defer execution to the next registered middleware
 */
VideoController.prototype.startUploadAction = function(request, response, next) {
  if (!request.params.ids || !request.params.platform) return next(HTTP_ERRORS.START_UPLOAD_VIDEOS_MISSING_PARAMETERS);

  var params;
  var self = this;

  try {
    params = openVeoApi.util.shallowValidateObject(request.params, {
      ids: {type: 'string', required: true},
      platform: {type: 'string', required: true}
    });
  } catch (error) {
    return next(HTTP_ERRORS.START_UPLOAD_VIDEOS_WRONG_PARAMETERS);
  }

  var ids = params.ids.split(',');
  var asyncFunctions = [];
  var uploadAsyncFunction = function(id, platform) {
    return function(callback) {
      var publishManager = self.getPublishManager();
      publishManager.once('upload', callback);
      publishManager.upload(id, platform);
    };
  };

  for (var i = 0; i < ids.length; i++)
    asyncFunctions.push(uploadAsyncFunction(ids[i], params.platform));

  async.parallel(asyncFunctions, function() {
    response.send();
  });
};

/**
 * Gets an instance of the controller associated provider.
 *
 * @method getProvider
 * @return {VideoProvider} The provider
 */
VideoController.prototype.getProvider = function() {
  return new VideoProvider(coreApi.getDatabase());
};

/**
 * Gets PublishManager singleton.
 *
 * @method getPublishManager
 * @return {PublishManager} The PublishManager singleton
 */
VideoController.prototype.getPublishManager = function() {
  return PublishManager.get();
};

/**
 * Updates a tag associated to the given media.
 *
 * If tag does not exist it is created.
 *
 * @example
 *
 *     // Response example
 *     {
 *       "total": 1,
 *       "tag": ...
 *     }
 *
 * @method updateTagAction
 * @async
 * @param {Request} request ExpressJS HTTP Request
 * @param {Object} [request.body] Request multipart body
 * @param {Object} [request.body.info] Modifications to perform on the tag
 * @param {Number} [request.body.info.value] The tag time in milliseconds
 * @param {String} [request.body.info.name] The tag name
 * @param {String} [request.body.info.description] The tag description
 * @param {String} [request.body.file] The multipart file associated to the tag
 * @param {Object} request.params Request's parameters
 * @param {String} request.params.id The media id the tag belongs to
 * @param {String} [request.params.tagid] The tag id
 * @param {Response} response ExpressJS HTTP Response
 * @param {Function} next Function to defer execution to the next registered middleware
 */
VideoController.prototype.updateTagAction = function(request, response, next) {
  if (!request.params.id) return next(HTTP_ERRORS.UPDATE_TAG_MISSING_PARAMETERS);

  var params;
  var self = this;

  try {
    params = openVeoApi.util.shallowValidateObject(request.params, {
      id: {type: 'string', required: true},
      tagid: {type: 'string'}
    });
  } catch (error) {
    return next(HTTP_ERRORS.UPDATE_TAG_WRONG_PARAMETERS);
  }

  var mediaId = params.id;
  var tagId = params.tagid;
  var provider = this.getProvider();

  var parser = new MultipartParser(request, [
    {
      name: 'file',
      destinationPath: process.rootPublish + '/assets/player/videos/' + mediaId + '/uploads/',
      maxCount: 1
    }
  ], {
    fileSize: 20 * 1000 * 1000
  });

  parser.parse(function(parseError) {
    if (parseError) {
      process.logger.error(parseError.message, {error: parseError, method: 'updateTagAction'});
      return next(HTTP_ERRORS.UPDATE_TAG_UPLOAD_ERROR);
    }

    if (!request.body.info) return next(HTTP_ERRORS.UPDATE_TAG_MISSING_PARAMETERS);

    var tag = JSON.parse(request.body.info);
    var file = request.files.file ? request.files.file[0] : null;
    var filter = new ResourceFilter().equal('id', mediaId);
    tag.id = tagId;

    // Make sure user has enough privilege to update the media
    provider.getOne(
      filter,
      {
        include: ['id', 'metadata']
      },
      function(getOneError, media) {
        if (getOneError) {
          process.logger.error(getOneError.message, {error: getOneError, method: 'updateTagAction'});
          return next(HTTP_ERRORS.UPDATE_TAG_GET_ONE_ERROR);
        }
        if (!self.isUserAuthorized(request.user, media, ContentController.OPERATIONS.UPDATE))
          return next(HTTP_ERRORS.UPDATE_TAG_FORBIDDEN);

        provider.updateOneTag(filter, tag, file, function(updateError, total, tag) {
          if (updateError) {
            process.logger.error(updateError.message, {error: updateError, method: 'updateTagAction'});
            return next(HTTP_ERRORS.UPDATE_TAG_ERROR);
          }

          response.send({total: total, tag: tag});
        });
      }
    );
  });
};

/**
 * Updates a chapter associated to the given media.
 *
 * If chapter does not exist it is created.
 *
 * @example
 *
 *     // Response example
 *     {
 *       "total": 1,
 *       "chapter": ...
 *     }
 *
 * @method updateChapterAction
 * @async
 * @param {Request} request ExpressJS HTTP Request
 * @param {Object} [request.body] Request body
 * @param {Object} [request.body.info] Modifications to perform on the chapter
 * @param {Number} [request.body.info.value] The chapter time in milliseconds
 * @param {String} [request.body.info.name] The chapter name
 * @param {String} [request.body.info.description] The chapter description
 * @param {Object} request.params Request parameters
 * @param {String} request.params.id The media id the chapter belongs to
 * @param {String} [request.params.chapterid] The chapter id
 * @param {Response} response ExpressJS HTTP Response
 * @param {Function} next Function to defer execution to the next registered middleware
 */
VideoController.prototype.updateChapterAction = function(request, response, next) {
  if (!request.params.id || !request.body) return next(HTTP_ERRORS.UPDATE_CHAPTER_MISSING_PARAMETERS);

  var params;
  var self = this;

  try {
    params = openVeoApi.util.shallowValidateObject(request.params, {
      id: {type: 'string', required: true},
      chapterid: {type: 'string'}
    });
  } catch (error) {
    return next(HTTP_ERRORS.UPDATE_CHAPTER_WRONG_PARAMETERS);
  }

  var mediaId = params.id;
  var chapterId = params.chapterid;
  var chapter = request.body;
  var provider = this.getProvider();
  var filter = new ResourceFilter().equal('id', mediaId);
  chapter.id = chapterId;

  // Make sure user has enough privilege to update the media
  provider.getOne(
    filter,
    {
      include: ['id', 'metadata']
    },
    function(getOneError, media) {
      if (getOneError) {
        process.logger.error(getOneError.message, {error: getOneError, method: 'updateChapterAction'});
        return next(HTTP_ERRORS.UPDATE_CHAPTER_GET_ONE_ERROR);
      }
      if (!self.isUserAuthorized(request.user, media, ContentController.OPERATIONS.UPDATE))
        return next(HTTP_ERRORS.UPDATE_CHAPTER_FORBIDDEN);

      provider.updateOneChapter(filter, chapter, function(updateError, total, chapter) {
        if (updateError) {
          process.logger.error(updateError.message, {error: updateError, method: 'updateChapterAction'});
          return next(HTTP_ERRORS.UPDATE_CHAPTER_ERROR);
        }

        response.send({total: total, chapter: chapter});
      });
    }
  );
};

/**
 * Removes tags from a media.
 *
 * @example
 *
 *     // Response example
 *     {
 *       "total": 1
 *     }
 *
 * @method removeTagsAction
 * @async
 * @param {Request} request ExpressJS HTTP Request
 * @param {Object} request.params Request parameters
 * @param {String} request.params.id The media id
 * @param {String} request.params.tagsids A comma separated list of tags ids to remove
 * @param {Response} response ExpressJS HTTP Response
 * @param {Function} next Function to defer execution to the next registered middleware
 */
VideoController.prototype.removeTagsAction = function(request, response, next) {
  if (!request.params.id || !request.params.tagsids) return next(HTTP_ERRORS.REMOVE_TAGS_MISSING_PARAMETERS);

  var self = this;
  var params;

  try {
    params = openVeoApi.util.shallowValidateObject(request.params, {
      id: {type: 'string', required: true},
      tagsids: {type: 'string', required: true}
    });
  } catch (error) {
    return next(HTTP_ERRORS.REMOVE_TAGS_WRONG_PARAMETERS);
  }

  var tagsIds = params.tagsids.split(',');
  var provider = this.getProvider(request);
  var filter = new ResourceFilter().equal('id', params.id);

  // Make sure user has enough privilege to update the media
  provider.getOne(
    filter,
    {
      include: ['id', 'metadata']
    },
    function(getOneError, media) {
      if (getOneError) {
        process.logger.error(getOneError.message, {error: getOneError, method: 'removeTagsAction'});
        return next(HTTP_ERRORS.REMOVE_TAGS_GET_ONE_ERROR);
      }
      if (!self.isUserAuthorized(request.user, media, ContentController.OPERATIONS.UPDATE))
        return next(HTTP_ERRORS.REMOVE_TAGS_FORBIDDEN);

      provider.removeTags(filter, tagsIds, function(updateError, total) {
        if (updateError) {
          process.logger.error(updateError.message, {error: updateError, method: 'removeTagsAction'});
          return next(HTTP_ERRORS.REMOVE_TAGS_ERROR);
        }

        response.send({total: total});
      });
    }
  );
};

/**
 * Removes chapters from a media.
 *
 * @example
 *
 *     // Response example
 *     {
 *       "total": 1
 *     }
 *
 * @method removeChaptersAction
 * @async
 * @param {Request} request ExpressJS HTTP Request
 * @param {Object} request.params Request parameters
 * @param {String} request.params.id The media id
 * @param {String} request.params.chaptersids A comma separated list of chapters ids to remove
 * @param {Response} response ExpressJS HTTP Response
 * @param {Function} next Function to defer execution to the next registered middleware
 */
VideoController.prototype.removeChaptersAction = function(request, response, next) {
  if (!request.params.id || !request.params.chaptersids) return next(HTTP_ERRORS.REMOVE_CHAPTERS_MISSING_PARAMETERS);

  var self = this;
  var params;

  try {
    params = openVeoApi.util.shallowValidateObject(request.params, {
      id: {type: 'string', required: true},
      chaptersids: {type: 'string', required: true}
    });
  } catch (error) {
    return next(HTTP_ERRORS.REMOVE_CHAPTERS_WRONG_PARAMETERS);
  }

  var chaptersIds = params.chaptersids.split(',');
  var provider = this.getProvider(request);
  var filter = new ResourceFilter().equal('id', params.id);

  // Make sure user has enough privilege to update the media
  provider.getOne(
    filter,
    {
      include: ['id', 'metadata']
    },
    function(getOneError, media) {
      if (getOneError) {
        process.logger.error(getOneError.message, {error: getOneError, method: 'removeChaptersAction'});
        return next(HTTP_ERRORS.REMOVE_CHAPTERS_GET_ONE_ERROR);
      }
      if (!self.isUserAuthorized(request.user, media, ContentController.OPERATIONS.UPDATE))
        return next(HTTP_ERRORS.REMOVE_CHAPTERS_FORBIDDEN);

      provider.removeChapters(filter, chaptersIds, function(updateError, total) {
        if (updateError) {
          process.logger.error(updateError.message, {error: updateError, method: 'removeChaptersAction'});
          return next(HTTP_ERRORS.REMOVE_CHAPTERS_ERROR);
        }

        response.send({total: total});
      });
    }
  );
};

/**
 * Converts points of interest (chapters, tags & cut) units
 * from percents to milliseconds (depending on the video
 * duration).
 *
 * @example
 *
 *     // Response example
 *     {
 *       "entity": ...
 *     }
 *
 * @method convertPoiAction
 * @async
 * @param {Request} request ExpressJS HTTP Request
 * @param {Object} request.params Request parameters
 * @param {String} request.params.id The media id
 * @param {String} request.params.chaptersIds A comma separated list of chapters ids to remove
 * @param {String} request.body Information to convert points of interest
 * @param {Number} request.body.duration The media duration in milliseconds
 * @param {Response} response ExpressJS HTTP Response
 * @param {Function} next Function to defer execution to the next registered middleware
 */
VideoController.prototype.convertPoiAction = function(request, response, next) {
  if (!request.params.id || !request.body || !request.body.duration)
    return next(HTTP_ERRORS.CONVERT_POINTS_OF_INTEREST_MISSING_PARAMETERS);

  var params;
  var body;
  var self = this;

  try {
    params = openVeoApi.util.shallowValidateObject(request.params, {
      id: {type: 'string', required: true}
    });
    body = openVeoApi.util.shallowValidateObject(request.body, {
      duration: {type: 'number', required: true}
    });
  } catch (error) {
    return next(HTTP_ERRORS.CONVERT_POINTS_OF_INTEREST_WRONG_PARAMETERS);
  }

  var provider = this.getProvider();
  var duration = body.duration;
  var filter = new ResourceFilter().equal('id', params.id);

  // Get media
  provider.getOne(
    filter,
    null,
    function(getOneError, media) {
      if (getOneError) {
        process.logger.error(getOneError.message, {error: getOneError, method: 'convertPoiAction'});
        return next(HTTP_ERRORS.CONVERT_POINTS_OF_INTEREST_GET_ONE_ERROR);
      }

      // Media not ready
      if (media.state !== STATES.READY && media.state !== STATES.PUBLISHED)
        return next(HTTP_ERRORS.CONVERT_POINTS_OF_INTEREST_NOT_READY_ERROR);

      // User without enough privilege to read the media in ready state
      if (media.state === STATES.READY &&
          !self.isUserAuthorized(request.user, media, ContentController.OPERATIONS.READ)
         ) {
        return next(HTTP_ERRORS.CONVERT_POINTS_OF_INTEREST_FORBIDDEN);
      }

      if (!media.needPointsOfInterestUnitConversion) {
        resolveResourcesUrls([media]);

        return response.send({
          entity: media
        });
      }

      var properties = ['chapters', 'tags', 'cut'];

      for (var i = 0; i < properties.length; i++) {
        if (Array.isArray(media[properties[i]])) {
          media[properties[i]].forEach(function(pointOfInterest) {
            pointOfInterest.value = Math.floor(pointOfInterest.value * duration);
          });
        } else {
          media[properties[i]] = [];
        }
      }

      delete media.needPointsOfInterestUnitConversion;

      provider.updateOne(
        filter,
        {
          chapters: media.chapters,
          cut: media.cut,
          tags: media.tags
        },
        function(updateError, total) {
          if (updateError) {
            process.logger.error(updateError.message, {error: updateError, method: 'convertPoiAction'});
            return next(HTTP_ERRORS.CONVERT_VIDEO_POI_ERROR);
          }

          resolveResourcesUrls([media]);

          response.send({
            entity: media
          });
        }
      );
    }
  );
};

/**
 * Gets the id of the super administrator.
 *
 * @method getSuperAdminId
 * @return {String} The id of the super admin
 */
VideoController.prototype.getSuperAdminId = function() {
  return process.api.getCoreApi().getSuperAdminId();
};

/**
 * Gets the id of the anonymous user.
 *
 * @method getAnonymousId
 * @return {String} The id of the anonymous user
 */
VideoController.prototype.getAnonymousId = function() {
  return process.api.getCoreApi().getAnonymousUserId();
};

/**
 * Tests if user is a contents manager.
 *
 * A contents manager can perform CRUD operations on medias.
 *
 * @method isUserManager
 * @param {Object} user The user to test
 * @param {Array} user.permissions The user's permissions
 * @return {Boolean} true if the user has permission to manage medias, false otherwise
 */
VideoController.prototype.isUserManager = function(user) {
  if (!user || !user.permissions) return false;

  for (var i = 0; i < user.permissions.length; i++) {
    if (user.permissions[i] === 'publish-manage-videos') return true;
  }
  return false;
};

/**
 * Adds medias.
 *
 * It is not possible to add several medias at a time.
 *
 * @method addEntitiesAction
 * @async
 * @param {Request} request ExpressJS HTTP Request
 * @param {Response} response ExpressJS HTTP Response
 * @param {Function} next Function to defer execution to the next registered middleware
 * @throws {Error} Function is not implemented for this controller
 */
VideoController.prototype.addEntitiesAction = function(request, response, next) {
  throw new Error('addEntitiesAction method not available for medias');
};

/**
 * Removes medias.
 *
 * User must have permission to remove the medias. If user doesn't have permission to remove a particular media an
 * HTTP forbidden error will be sent as response.
 * Only medias in a stable state can be removed.
 *
 * @example
 *
 *     // Response example
 *     {
 *       "total": 42
 *     }
 *
 * @method removeEntitiesAction
 * @async
 * @param {Request} request ExpressJS HTTP Request
 * @param {Object} request.params Request parameters
 * @param {String} request.params.id A comma separated list of media ids to remove
 * @param {Response} response ExpressJS HTTP Response
 * @param {Function} next Function to defer execution to the next registered middleware
 */
VideoController.prototype.removeEntitiesAction = function(request, response, next) {
  if (request.params.id) {
    var self = this;
    var mediaIds = request.params.id.split(',');
    var stableStates = [
      STATES.ERROR,
      STATES.WAITING_FOR_UPLOAD,
      STATES.READY,
      STATES.PUBLISHED
    ];
    var mediaIdsToRemove = [];
    var provider = this.getProvider();

    // Get information on medias which are about to be removed to validate that the user has enough permissions
    // to do it and that the media is on a stable state
    provider.get(
      new ResourceFilter().in('id', mediaIds),
      {
        include: ['id', 'metadata', 'state']
      },
      mediaIds.length,
      null,
      null,
      function(error, medias, pagination) {
        if (error) return next(HTTP_ERRORS.REMOVE_MEDIAS_GET_MEDIAS_ERROR);

        medias.forEach(function(media) {

          // Make sure user is authorized to modify the media
          if (!self.isUserAuthorized(request.user, media, ContentController.OPERATIONS.DELETE)) {
            process.logger.error(
              'User doesn\'t have enough privilege to remove the media',
              {method: 'removeEntitiesAction', media: media.id, user: request.user}
            );
            return next(HTTP_ERRORS.REMOVE_MEDIAS_FORBIDDEN);
          }

          // Make sure media is in a stable state
          if (stableStates.indexOf(media.state) < 0) {
            process.logger.error(
              'Media can\'t be removed, it is not in a stable state',
              {method: 'removeEntitiesAction', media: media.id, state: media.state}
            );
            return next(HTTP_ERRORS.REMOVE_MEDIAS_STATE_ERROR);
          }
          mediaIdsToRemove.push(media.id);
        });

        provider.remove(new ResourceFilter().in('id', mediaIdsToRemove), function(error, total) {
          if (error) {
            process.logger.error(error.message, {error: error, method: 'removeEntitiesAction'});
            next(HTTP_ERRORS.REMOVE_MEDIAS_ERROR);
          } else if (total != mediaIdsToRemove.length) {
            process.logger.error(total + '/' + mediaIds.length + ' removed',
                                 {method: 'removeEntitiesAction', medias: mediaIdsToRemove});
            next(HTTP_ERRORS.REMOVE_MEDIAS_ERROR);
          } else {
            response.send({total: total});
          }
        });

      }
    );

  } else {

    // Missing media ids
    next(HTTP_ERRORS.REMOVE_MEDIAS_MISSING_PARAMETERS);

  }
};