]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blobdiff - server/models/video.js
Server: use binary data instead of base64 to send thumbnails
[github/Chocobozzz/PeerTube.git] / server / models / video.js
index 8ef07c9e68bda59383b6b6f8f77c6656d2e2c243..0e84e89867f722d88e0c6d236986b4faa6646d15 100644 (file)
 'use strict'
 
+const Buffer = require('safe-buffer').Buffer
 const createTorrent = require('create-torrent')
 const ffmpeg = require('fluent-ffmpeg')
 const fs = require('fs')
 const magnetUtil = require('magnet-uri')
+const map = require('lodash/map')
 const parallel = require('async/parallel')
 const parseTorrent = require('parse-torrent')
 const pathUtils = require('path')
+const values = require('lodash/values')
 
 const constants = require('../initializers/constants')
 const logger = require('../helpers/logger')
+const friends = require('../lib/friends')
 const modelUtils = require('./utils')
+const customVideosValidators = require('../helpers/custom-validators').videos
 
 // ---------------------------------------------------------------------------
 
 module.exports = function (sequelize, DataTypes) {
-// TODO: add indexes on searchable columns
+  // TODO: add indexes on searchable columns
   const Video = sequelize.define('Video',
     {
       id: {
         type: DataTypes.UUID,
         defaultValue: DataTypes.UUIDV4,
-        primaryKey: true
+        primaryKey: true,
+        validate: {
+          isUUID: 4
+        }
       },
       name: {
-        type: DataTypes.STRING
+        type: DataTypes.STRING,
+        allowNull: false,
+        validate: {
+          nameValid: function (value) {
+            const res = customVideosValidators.isVideoNameValid(value)
+            if (res === false) throw new Error('Video name is not valid.')
+          }
+        }
       },
       extname: {
-        // TODO: enum?
-        type: DataTypes.STRING
+        type: DataTypes.ENUM(values(constants.CONSTRAINTS_FIELDS.VIDEOS.EXTNAME)),
+        allowNull: false
       },
       remoteId: {
-        type: DataTypes.UUID
+        type: DataTypes.UUID,
+        allowNull: true,
+        validate: {
+          isUUID: 4
+        }
       },
       description: {
-        type: DataTypes.STRING
+        type: DataTypes.STRING,
+        allowNull: false,
+        validate: {
+          descriptionValid: function (value) {
+            const res = customVideosValidators.isVideoDescriptionValid(value)
+            if (res === false) throw new Error('Video description is not valid.')
+          }
+        }
       },
       infoHash: {
-        type: DataTypes.STRING
+        type: DataTypes.STRING,
+        allowNull: false,
+        validate: {
+          infoHashValid: function (value) {
+            const res = customVideosValidators.isVideoInfoHashValid(value)
+            if (res === false) throw new Error('Video info hash is not valid.')
+          }
+        }
       },
       duration: {
-        type: DataTypes.INTEGER
-      },
-      tags: {
-        type: DataTypes.ARRAY(DataTypes.STRING)
+        type: DataTypes.INTEGER,
+        allowNull: false,
+        validate: {
+          durationValid: function (value) {
+            const res = customVideosValidators.isVideoDurationValid(value)
+            if (res === false) throw new Error('Video duration is not valid.')
+          }
+        }
       }
     },
     {
+      indexes: [
+        {
+          fields: [ 'authorId' ]
+        },
+        {
+          fields: [ 'remoteId' ]
+        },
+        {
+          fields: [ 'name' ]
+        },
+        {
+          fields: [ 'createdAt' ]
+        },
+        {
+          fields: [ 'duration' ]
+        },
+        {
+          fields: [ 'infoHash' ]
+        }
+      ],
       classMethods: {
         associate,
 
-        generateThumbnailFromBase64,
+        generateThumbnailFromData,
         getDurationFromFile,
+        list,
         listForApi,
         listByHostAndRemoteId,
-        listOwnedAndPopulateAuthor,
+        listOwnedAndPopulateAuthorAndTags,
         listOwnedByAuthor,
         load,
         loadAndPopulateAuthor,
-        loadAndPopulateAuthorAndPod,
-        searchAndPopulateAuthorAndPod
+        loadAndPopulateAuthorAndPodAndTags,
+        searchAndPopulateAuthorAndPodAndTags
       },
       instanceMethods: {
         generateMagnetUri,
@@ -72,6 +130,7 @@ module.exports = function (sequelize, DataTypes) {
         toRemoteJSON
       },
       hooks: {
+        beforeValidate,
         beforeCreate,
         afterDestroy
       }
@@ -81,13 +140,14 @@ module.exports = function (sequelize, DataTypes) {
   return Video
 }
 
-// TODO: Validation
-// VideoSchema.path('name').validate(customVideosValidators.isVideoNameValid)
-// VideoSchema.path('description').validate(customVideosValidators.isVideoDescriptionValid)
-// VideoSchema.path('podHost').validate(customVideosValidators.isVideoPodHostValid)
-// VideoSchema.path('author').validate(customVideosValidators.isVideoAuthorValid)
-// VideoSchema.path('duration').validate(customVideosValidators.isVideoDurationValid)
-// VideoSchema.path('tags').validate(customVideosValidators.isVideoTagsValid)
+function beforeValidate (video, options, next) {
+  if (video.isOwned()) {
+    // 40 hexa length
+    video.infoHash = '0123456789abcdef0123456789abcdef01234567'
+  }
+
+  return next(null)
+}
 
 function beforeCreate (video, options, next) {
   const tasks = []
@@ -114,9 +174,8 @@ function beforeCreate (video, options, next) {
             if (err) return callback(err)
 
             const parsedTorrent = parseTorrent(torrent)
-            video.infoHash = parsedTorrent.infoHash
-
-            callback(null)
+            video.set('infoHash', parsedTorrent.infoHash)
+            video.validate().asCallback(callback)
           })
         })
       },
@@ -148,11 +207,24 @@ function afterDestroy (video, options, next) {
       function (callback) {
         removeFile(video, callback)
       },
+
       function (callback) {
         removeTorrent(video, callback)
       },
+
       function (callback) {
         removePreview(video, callback)
+      },
+
+      function (callback) {
+        const params = {
+          name: video.name,
+          remoteId: video.id
+        }
+
+        friends.removeVideoToFriends(params)
+
+        return callback()
       }
     )
   }
@@ -170,6 +242,12 @@ function associate (models) {
     },
     onDelete: 'cascade'
   })
+
+  this.belongsToMany(models.Tag, {
+    foreignKey: 'videoId',
+    through: models.VideoTag,
+    onDelete: 'cascade'
+  })
 }
 
 function generateMagnetUri () {
@@ -248,7 +326,7 @@ function toFormatedJSON () {
     magnetUri: this.generateMagnetUri(),
     author: this.Author.name,
     duration: this.duration,
-    tags: this.tags,
+    tags: map(this.Tags, 'name'),
     thumbnailPath: constants.STATIC_PATHS.THUMBNAILS + '/' + this.getThumbnailName(),
     createdAt: this.createdAt
   }
@@ -259,7 +337,7 @@ function toFormatedJSON () {
 function toRemoteJSON (callback) {
   const self = this
 
-  // Convert thumbnail to base64
+  // Get thumbnail data to send to the other pod
   const thumbnailPath = pathUtils.join(constants.CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName())
   fs.readFile(thumbnailPath, function (err, thumbnailData) {
     if (err) {
@@ -274,8 +352,8 @@ function toRemoteJSON (callback) {
       remoteId: self.id,
       author: self.Author.name,
       duration: self.duration,
-      thumbnailBase64: new Buffer(thumbnailData).toString('base64'),
-      tags: self.tags,
+      thumbnailData: thumbnailData.toString('binary'),
+      tags: map(self.Tags, 'name'),
       createdAt: self.createdAt,
       extname: self.extname
     }
@@ -286,12 +364,12 @@ function toRemoteJSON (callback) {
 
 // ------------------------------ STATICS ------------------------------
 
-function generateThumbnailFromBase64 (video, thumbnailData, callback) {
+function generateThumbnailFromData (video, thumbnailData, callback) {
   // Creating the thumbnail for a remote video
 
   const thumbnailName = video.getThumbnailName()
   const thumbnailPath = constants.CONFIG.STORAGE.THUMBNAILS_DIR + thumbnailName
-  fs.writeFile(thumbnailPath, thumbnailData, { encoding: 'base64' }, function (err) {
+  fs.writeFile(thumbnailPath, Buffer.from(thumbnailData, 'binary'), function (err) {
     if (err) return callback(err)
 
     return callback(null, thumbnailName)
@@ -306,16 +384,23 @@ function getDurationFromFile (videoPath, callback) {
   })
 }
 
+function list (callback) {
+  return this.find().asCallback()
+}
+
 function listForApi (start, count, sort, callback) {
   const query = {
     offset: start,
     limit: count,
-    order: [ modelUtils.getSort(sort) ],
+    distinct: true, // For the count, a video can have many tags
+    order: [ modelUtils.getSort(sort), [ this.sequelize.models.Tag, 'name', 'ASC' ] ],
     include: [
       {
         model: this.sequelize.models.Author,
-        include: [ this.sequelize.models.Pod ]
-      }
+        include: [ { model: this.sequelize.models.Pod, required: false } ]
+      },
+
+      this.sequelize.models.Tag
     ]
   }
 
@@ -337,6 +422,7 @@ function listByHostAndRemoteId (fromHost, remoteId, callback) {
         include: [
           {
             model: this.sequelize.models.Pod,
+            required: true,
             where: {
               host: fromHost
             }
@@ -349,13 +435,13 @@ function listByHostAndRemoteId (fromHost, remoteId, callback) {
   return this.findAll(query).asCallback(callback)
 }
 
-function listOwnedAndPopulateAuthor (callback) {
+function listOwnedAndPopulateAuthorAndTags (callback) {
   // If remoteId is null this is *our* video
   const query = {
     where: {
       remoteId: null
     },
-    include: [ this.sequelize.models.Author ]
+    include: [ this.sequelize.models.Author, this.sequelize.models.Tag ]
   }
 
   return this.findAll(query).asCallback(callback)
@@ -391,23 +477,26 @@ function loadAndPopulateAuthor (id, callback) {
   return this.findById(id, options).asCallback(callback)
 }
 
-function loadAndPopulateAuthorAndPod (id, callback) {
+function loadAndPopulateAuthorAndPodAndTags (id, callback) {
   const options = {
     include: [
       {
         model: this.sequelize.models.Author,
-        include: [ this.sequelize.models.Pod ]
-      }
+        include: [ { model: this.sequelize.models.Pod, required: false } ]
+      },
+      this.sequelize.models.Tag
     ]
   }
 
   return this.findById(id, options).asCallback(callback)
 }
 
-function searchAndPopulateAuthorAndPod (value, field, start, count, sort, callback) {
+function searchAndPopulateAuthorAndPodAndTags (value, field, start, count, sort, callback) {
   const podInclude = {
-    model: this.sequelize.models.Pod
+    model: this.sequelize.models.Pod,
+    required: false
   }
+
   const authorInclude = {
     model: this.sequelize.models.Author,
     include: [
@@ -415,55 +504,61 @@ function searchAndPopulateAuthorAndPod (value, field, start, count, sort, callba
     ]
   }
 
+  const tagInclude = {
+    model: this.sequelize.models.Tag
+  }
+
   const query = {
     where: {},
-    include: [
-      authorInclude
-    ],
     offset: start,
     limit: count,
-    order: [ modelUtils.getSort(sort) ]
+    distinct: true, // For the count, a video can have many tags
+    order: [ modelUtils.getSort(sort), [ this.sequelize.models.Tag, 'name', 'ASC' ] ]
   }
 
-  // TODO: include our pod for podHost searches (we are not stored in the database)
   // Make an exact search with the magnet
   if (field === 'magnetUri') {
     const infoHash = magnetUtil.decode(value).infoHash
     query.where.infoHash = infoHash
   } else if (field === 'tags') {
-    query.where[field] = value
-  } else if (field === 'host') {
-    const whereQuery = {
-      '$Author.Pod.host$': {
-        $like: '%' + value + '%'
+    const escapedValue = this.sequelize.escape('%' + value + '%')
+    query.where = {
+      id: {
+        $in: this.sequelize.literal(
+          '(SELECT "VideoTags"."videoId" FROM "Tags" INNER JOIN "VideoTags" ON "Tags"."id" = "VideoTags"."tagId" WHERE name LIKE ' + escapedValue + ')'
+        )
       }
     }
-
-    // Include our pod? (not stored in the database)
-    if (constants.CONFIG.WEBSERVER.HOST.indexOf(value) !== -1) {
-      query.where = {
-        $or: [
-          whereQuery,
-          {
-            remoteId: null
-          }
-        ]
+  } else if (field === 'host') {
+    // FIXME: Include our pod? (not stored in the database)
+    podInclude.where = {
+      host: {
+        $like: '%' + value + '%'
       }
-    } else {
-      query.where = whereQuery
     }
+    podInclude.required = true
   } else if (field === 'author') {
-    query.where = {
-      '$Author.name$': {
+    authorInclude.where = {
+      name: {
         $like: '%' + value + '%'
       }
     }
+
+    // authorInclude.or = true
   } else {
     query.where[field] = {
       $like: '%' + value + '%'
     }
   }
 
+  query.include = [
+    authorInclude, tagInclude
+  ]
+
+  if (tagInclude.where) {
+    // query.include.push([ this.sequelize.models.Tag ])
+  }
+
   return this.findAndCountAll(query).asCallback(function (err, result) {
     if (err) return callback(err)