]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blobdiff - server/models/video/video.ts
Lazy description and previews to video form
[github/Chocobozzz/PeerTube.git] / server / models / video / video.ts
index fb18a485a4ed0bc1a5359761f434436469a6009e..1877c506ae63bf07acbf85e5f359df39eb85c816 100644 (file)
@@ -6,7 +6,7 @@ import * as parseTorrent from 'parse-torrent'
 import { join } from 'path'
 import * as Sequelize from 'sequelize'
 import * as Promise from 'bluebird'
-import { maxBy } from 'lodash'
+import { maxBy, truncate } from 'lodash'
 
 import { TagInstance } from './tag-interface'
 import {
@@ -35,7 +35,10 @@ import {
   VIDEO_CATEGORIES,
   VIDEO_LICENCES,
   VIDEO_LANGUAGES,
-  THUMBNAILS_SIZE
+  THUMBNAILS_SIZE,
+  PREVIEWS_SIZE,
+  CONSTRAINTS_FIELDS,
+  API_VERSION
 } from '../../initializers'
 import { removeVideoToFriends } from '../../lib'
 import { VideoResolution } from '../../../shared'
@@ -48,11 +51,9 @@ import {
 
   VideoMethods
 } from './video-interface'
-import { PREVIEWS_SIZE } from '../../initializers/constants'
 
 let Video: Sequelize.Model<VideoInstance, VideoAttributes>
 let getOriginalFile: VideoMethods.GetOriginalFile
-let generateMagnetUri: VideoMethods.GenerateMagnetUri
 let getVideoFilename: VideoMethods.GetVideoFilename
 let getThumbnailName: VideoMethods.GetThumbnailName
 let getThumbnailPath: VideoMethods.GetThumbnailPath
@@ -61,6 +62,7 @@ let getPreviewPath: VideoMethods.GetPreviewPath
 let getTorrentFileName: VideoMethods.GetTorrentFileName
 let isOwned: VideoMethods.IsOwned
 let toFormattedJSON: VideoMethods.ToFormattedJSON
+let toFormattedDetailsJSON: VideoMethods.ToFormattedDetailsJSON
 let toAddRemoteJSON: VideoMethods.ToAddRemoteJSON
 let toUpdateRemoteJSON: VideoMethods.ToUpdateRemoteJSON
 let optimizeOriginalVideofile: VideoMethods.OptimizeOriginalVideofile
@@ -71,6 +73,8 @@ let getVideoFilePath: VideoMethods.GetVideoFilePath
 let createTorrentAndSetInfoHash: VideoMethods.CreateTorrentAndSetInfoHash
 let getOriginalFileHeight: VideoMethods.GetOriginalFileHeight
 let getEmbedPath: VideoMethods.GetEmbedPath
+let getDescriptionPath: VideoMethods.GetDescriptionPath
+let getTruncatedDescription: VideoMethods.GetTruncatedDescription
 
 let generateThumbnailFromData: VideoMethods.GenerateThumbnailFromData
 let list: VideoMethods.List
@@ -80,6 +84,7 @@ let listOwnedAndPopulateAuthorAndTags: VideoMethods.ListOwnedAndPopulateAuthorAn
 let listOwnedByAuthor: VideoMethods.ListOwnedByAuthor
 let load: VideoMethods.Load
 let loadByUUID: VideoMethods.LoadByUUID
+let loadLocalVideoByUUID: VideoMethods.LoadLocalVideoByUUID
 let loadAndPopulateAuthor: VideoMethods.LoadAndPopulateAuthor
 let loadAndPopulateAuthorAndPodAndTags: VideoMethods.LoadAndPopulateAuthorAndPodAndTags
 let loadByUUIDAndPopulateAuthorAndPodAndTags: VideoMethods.LoadByUUIDAndPopulateAuthorAndPodAndTags
@@ -152,7 +157,7 @@ export default function (sequelize: Sequelize.Sequelize, DataTypes: Sequelize.Da
         }
       },
       description: {
-        type: DataTypes.STRING,
+        type: DataTypes.STRING(CONSTRAINTS_FIELDS.VIDEOS.DESCRIPTION.max),
         allowNull: false,
         validate: {
           descriptionValid: value => {
@@ -206,9 +211,6 @@ export default function (sequelize: Sequelize.Sequelize, DataTypes: Sequelize.Da
     },
     {
       indexes: [
-        {
-          fields: [ 'authorId' ]
-        },
         {
           fields: [ 'name' ]
         },
@@ -226,6 +228,9 @@ export default function (sequelize: Sequelize.Sequelize, DataTypes: Sequelize.Da
         },
         {
           fields: [ 'uuid' ]
+        },
+        {
+          fields: [ 'channelId' ]
         }
       ],
       hooks: {
@@ -247,6 +252,7 @@ export default function (sequelize: Sequelize.Sequelize, DataTypes: Sequelize.Da
     loadAndPopulateAuthorAndPodAndTags,
     loadByHostAndUUID,
     loadByUUID,
+    loadLocalVideoByUUID,
     loadByUUIDAndPopulateAuthorAndPodAndTags,
     searchAndPopulateAuthorAndPodAndTags
   ]
@@ -254,7 +260,6 @@ export default function (sequelize: Sequelize.Sequelize, DataTypes: Sequelize.Da
     createPreview,
     createThumbnail,
     createTorrentAndSetInfoHash,
-    generateMagnetUri,
     getPreviewName,
     getPreviewPath,
     getThumbnailName,
@@ -270,11 +275,14 @@ export default function (sequelize: Sequelize.Sequelize, DataTypes: Sequelize.Da
     removeTorrent,
     toAddRemoteJSON,
     toFormattedJSON,
+    toFormattedDetailsJSON,
     toUpdateRemoteJSON,
     optimizeOriginalVideofile,
     transcodeOriginalVideofile,
     getOriginalFileHeight,
-    getEmbedPath
+    getEmbedPath,
+    getTruncatedDescription,
+    getDescriptionPath
   ]
   addMethodsToModel(Video, classMethods, instanceMethods)
 
@@ -284,9 +292,9 @@ export default function (sequelize: Sequelize.Sequelize, DataTypes: Sequelize.Da
 // ------------------------------ METHODS ------------------------------
 
 function associate (models) {
-  Video.belongsTo(models.Author, {
+  Video.belongsTo(models.VideoChannel, {
     foreignKey: {
-      name: 'authorId',
+      name: 'channelId',
       allowNull: false
     },
     onDelete: 'cascade'
@@ -315,7 +323,7 @@ function associate (models) {
   })
 }
 
-function afterDestroy (video: VideoInstance, options: { transaction: Sequelize.Transaction }) {
+function afterDestroy (video: VideoInstance) {
   const tasks = []
 
   tasks.push(
@@ -329,17 +337,20 @@ function afterDestroy (video: VideoInstance, options: { transaction: Sequelize.T
 
     tasks.push(
       video.removePreview(),
-      removeVideoToFriends(removeVideoToFriendsParams, options.transaction)
+      removeVideoToFriends(removeVideoToFriendsParams)
     )
 
     // Remove physical files and torrents
     video.VideoFiles.forEach(file => {
-      video.removeFile(file),
-      video.removeTorrent(file)
+      tasks.push(video.removeFile(file))
+      tasks.push(video.removeTorrent(file))
     })
   }
 
   return Promise.all(tasks)
+    .catch(err => {
+      logger.error('Some errors when removing files of video %s in after destroy hook.', video.uuid, err)
+    })
 }
 
 getOriginalFile = function (this: VideoInstance) {
@@ -423,33 +434,6 @@ createTorrentAndSetInfoHash = function (this: VideoInstance, videoFile: VideoFil
     })
 }
 
-generateMagnetUri = function (this: VideoInstance, videoFile: VideoFileInstance) {
-  let baseUrlHttp
-  let baseUrlWs
-
-  if (this.isOwned()) {
-    baseUrlHttp = CONFIG.WEBSERVER.URL
-    baseUrlWs = CONFIG.WEBSERVER.WS + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT
-  } else {
-    baseUrlHttp = REMOTE_SCHEME.HTTP + '://' + this.Author.Pod.host
-    baseUrlWs = REMOTE_SCHEME.WS + '://' + this.Author.Pod.host
-  }
-
-  const xs = baseUrlHttp + STATIC_PATHS.TORRENTS + this.getTorrentFileName(videoFile)
-  const announce = [ baseUrlWs + '/tracker/socket' ]
-  const urlList = [ baseUrlHttp + STATIC_PATHS.WEBSEED + this.getVideoFilename(videoFile) ]
-
-  const magnetHash = {
-    xs,
-    announce,
-    urlList,
-    infoHash: videoFile.infoHash,
-    name: this.name
-  }
-
-  return magnetUtil.encode(magnetHash)
-}
-
 getEmbedPath = function (this: VideoInstance) {
   return '/videos/embed/' + this.uuid
 }
@@ -465,8 +449,8 @@ getPreviewPath = function (this: VideoInstance) {
 toFormattedJSON = function (this: VideoInstance) {
   let podHost
 
-  if (this.Author.Pod) {
-    podHost = this.Author.Pod.host
+  if (this.VideoChannel.Author.Pod) {
+    podHost = this.VideoChannel.Author.Pod.host
   } else {
     // It means it's our video
     podHost = CONFIG.WEBSERVER.HOST
@@ -495,10 +479,10 @@ toFormattedJSON = function (this: VideoInstance) {
     language: this.language,
     languageLabel,
     nsfw: this.nsfw,
-    description: this.description,
+    description: this.getTruncatedDescription(),
     podHost,
     isLocal: this.isOwned(),
-    author: this.Author.name,
+    author: this.VideoChannel.Author.name,
     duration: this.duration,
     views: this.views,
     likes: this.likes,
@@ -508,20 +492,34 @@ toFormattedJSON = function (this: VideoInstance) {
     previewPath: this.getPreviewPath(),
     embedPath: this.getEmbedPath(),
     createdAt: this.createdAt,
-    updatedAt: this.updatedAt,
+    updatedAt: this.updatedAt
+  }
+
+  return json
+}
+
+toFormattedDetailsJSON = function (this: VideoInstance) {
+  const formattedJson = this.toFormattedJSON()
+
+  const detailsJson = {
+    descriptionPath: this.getDescriptionPath(),
+    channel: this.VideoChannel.toFormattedJSON(),
     files: []
   }
 
   // Format and sort video files
-  json.files = this.VideoFiles
+  const { baseUrlHttp, baseUrlWs } = getBaseUrls(this)
+  detailsJson.files = this.VideoFiles
                    .map(videoFile => {
                      let resolutionLabel = videoFile.resolution + 'p'
 
                      const videoFileJson = {
                        resolution: videoFile.resolution,
                        resolutionLabel,
-                       magnetUri: this.generateMagnetUri(videoFile),
-                       size: videoFile.size
+                       magnetUri: generateMagnetUri(this, videoFile, baseUrlHttp, baseUrlWs),
+                       size: videoFile.size,
+                       torrentUrl: getTorrentUrl(this, videoFile, baseUrlHttp),
+                       fileUrl: getVideoFileUrl(this, videoFile, baseUrlHttp)
                      }
 
                      return videoFileJson
@@ -532,7 +530,7 @@ toFormattedJSON = function (this: VideoInstance) {
                      return -1
                    })
 
-  return json
+  return Object.assign(formattedJson, detailsJson)
 }
 
 toAddRemoteJSON = function (this: VideoInstance) {
@@ -547,8 +545,8 @@ toAddRemoteJSON = function (this: VideoInstance) {
       licence: this.licence,
       language: this.language,
       nsfw: this.nsfw,
-      description: this.description,
-      author: this.Author.name,
+      truncatedDescription: this.getTruncatedDescription(),
+      channelUUID: this.VideoChannel.uuid,
       duration: this.duration,
       thumbnailData: thumbnailData.toString('binary'),
       tags: map<TagInstance, string>(this.Tags, 'name'),
@@ -581,8 +579,7 @@ toUpdateRemoteJSON = function (this: VideoInstance) {
     licence: this.licence,
     language: this.language,
     nsfw: this.nsfw,
-    description: this.description,
-    author: this.Author.name,
+    truncatedDescription: this.getTruncatedDescription(),
     duration: this.duration,
     tags: map<TagInstance, string>(this.Tags, 'name'),
     createdAt: this.createdAt,
@@ -605,6 +602,14 @@ toUpdateRemoteJSON = function (this: VideoInstance) {
   return json
 }
 
+getTruncatedDescription = function (this: VideoInstance) {
+  const options = {
+    length: CONSTRAINTS_FIELDS.VIDEOS.TRUNCATED_DESCRIPTION.max
+  }
+
+  return truncate(this.description, options)
+}
+
 optimizeOriginalVideofile = function (this: VideoInstance) {
   const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
   const newExtname = '.mp4'
@@ -697,6 +702,10 @@ getOriginalFileHeight = function (this: VideoInstance) {
   return getVideoFileHeight(originalFilePath)
 }
 
+getDescriptionPath = function (this: VideoInstance) {
+  return `/api/${API_VERSION}/videos/${this.uuid}/description`
+}
+
 removeThumbnail = function (this: VideoInstance) {
   const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName())
   return unlinkPromise(thumbnailPath)
@@ -746,8 +755,18 @@ listForApi = function (start: number, count: number, sort: string) {
     order: [ getSort(sort), [ Video['sequelize'].models.Tag, 'name', 'ASC' ] ],
     include: [
       {
-        model: Video['sequelize'].models.Author,
-        include: [ { model: Video['sequelize'].models.Pod, required: false } ]
+        model: Video['sequelize'].models.VideoChannel,
+        include: [
+          {
+            model: Video['sequelize'].models.Author,
+            include: [
+              {
+                model: Video['sequelize'].models.Pod,
+                required: false
+              }
+            ]
+          }
+        ]
       },
       Video['sequelize'].models.Tag,
       Video['sequelize'].models.VideoFile
@@ -763,8 +782,8 @@ listForApi = function (start: number, count: number, sort: string) {
   })
 }
 
-loadByHostAndUUID = function (fromHost: string, uuid: string) {
-  const query = {
+loadByHostAndUUID = function (fromHost: string, uuid: string, t?: Sequelize.Transaction) {
+  const query: Sequelize.FindOptions<VideoAttributes> = {
     where: {
       uuid
     },
@@ -773,20 +792,27 @@ loadByHostAndUUID = function (fromHost: string, uuid: string) {
         model: Video['sequelize'].models.VideoFile
       },
       {
-        model: Video['sequelize'].models.Author,
+        model: Video['sequelize'].models.VideoChannel,
         include: [
           {
-            model: Video['sequelize'].models.Pod,
-            required: true,
-            where: {
-              host: fromHost
-            }
+            model: Video['sequelize'].models.Author,
+            include: [
+              {
+                model: Video['sequelize'].models.Pod,
+                required: true,
+                where: {
+                  host: fromHost
+                }
+              }
+            ]
           }
         ]
       }
     ]
   }
 
+  if (t !== undefined) query.transaction = t
+
   return Video.findOne(query)
 }
 
@@ -797,7 +823,10 @@ listOwnedAndPopulateAuthorAndTags = function () {
     },
     include: [
       Video['sequelize'].models.VideoFile,
-      Video['sequelize'].models.Author,
+      {
+        model: Video['sequelize'].models.VideoChannel,
+        include: [ Video['sequelize'].models.Author ]
+      },
       Video['sequelize'].models.Tag
     ]
   }
@@ -815,10 +844,15 @@ listOwnedByAuthor = function (author: string) {
         model: Video['sequelize'].models.VideoFile
       },
       {
-        model: Video['sequelize'].models.Author,
-        where: {
-          name: author
-        }
+        model: Video['sequelize'].models.VideoChannel,
+        include: [
+          {
+            model: Video['sequelize'].models.Author,
+            where: {
+              name: author
+            }
+          }
+        ]
       }
     ]
   }
@@ -830,19 +864,42 @@ load = function (id: number) {
   return Video.findById(id)
 }
 
-loadByUUID = function (uuid: string) {
-  const query = {
+loadByUUID = function (uuid: string, t?: Sequelize.Transaction) {
+  const query: Sequelize.FindOptions<VideoAttributes> = {
     where: {
       uuid
     },
     include: [ Video['sequelize'].models.VideoFile ]
   }
+
+  if (t !== undefined) query.transaction = t
+
+  return Video.findOne(query)
+}
+
+loadLocalVideoByUUID = function (uuid: string, t?: Sequelize.Transaction) {
+  const query: Sequelize.FindOptions<VideoAttributes> = {
+    where: {
+      uuid,
+      remote: false
+    },
+    include: [ Video['sequelize'].models.VideoFile ]
+  }
+
+  if (t !== undefined) query.transaction = t
+
   return Video.findOne(query)
 }
 
 loadAndPopulateAuthor = function (id: number) {
   const options = {
-    include: [ Video['sequelize'].models.VideoFile, Video['sequelize'].models.Author ]
+    include: [
+      Video['sequelize'].models.VideoFile,
+      {
+        model: Video['sequelize'].models.VideoChannel,
+        include: [ Video['sequelize'].models.Author ]
+      }
+    ]
   }
 
   return Video.findById(id, options)
@@ -852,8 +909,13 @@ loadAndPopulateAuthorAndPodAndTags = function (id: number) {
   const options = {
     include: [
       {
-        model: Video['sequelize'].models.Author,
-        include: [ { model: Video['sequelize'].models.Pod, required: false } ]
+        model: Video['sequelize'].models.VideoChannel,
+        include: [
+          {
+            model: Video['sequelize'].models.Author,
+            include: [ { model: Video['sequelize'].models.Pod, required: false } ]
+          }
+        ]
       },
       Video['sequelize'].models.Tag,
       Video['sequelize'].models.VideoFile
@@ -870,8 +932,13 @@ loadByUUIDAndPopulateAuthorAndPodAndTags = function (uuid: string) {
     },
     include: [
       {
-        model: Video['sequelize'].models.Author,
-        include: [ { model: Video['sequelize'].models.Pod, required: false } ]
+        model: Video['sequelize'].models.VideoChannel,
+        include: [
+          {
+            model: Video['sequelize'].models.Author,
+            include: [ { model: Video['sequelize'].models.Pod, required: false } ]
+          }
+        ]
       },
       Video['sequelize'].models.Tag,
       Video['sequelize'].models.VideoFile
@@ -889,9 +956,13 @@ searchAndPopulateAuthorAndPodAndTags = function (value: string, field: string, s
 
   const authorInclude: Sequelize.IncludeOptions = {
     model: Video['sequelize'].models.Author,
-    include: [
-      podInclude
-    ]
+    include: [ podInclude ]
+  }
+
+  const videoChannelInclude: Sequelize.IncludeOptions = {
+    model: Video['sequelize'].models.VideoChannel,
+    include: [ authorInclude ],
+    required: true
   }
 
   const tagInclude: Sequelize.IncludeOptions = {
@@ -917,7 +988,7 @@ searchAndPopulateAuthorAndPodAndTags = function (value: string, field: string, s
     }
   } else if (field === 'tags') {
     const escapedValue = Video['sequelize'].escape('%' + value + '%')
-    query.where['id'].$in = Video['sequelize'].literal(
+    query.where['id'][Sequelize.Op.in] = Video['sequelize'].literal(
       `(SELECT "VideoTags"."videoId"
         FROM "Tags"
         INNER JOIN "VideoTags" ON "Tags"."id" = "VideoTags"."tagId"
@@ -928,26 +999,24 @@ searchAndPopulateAuthorAndPodAndTags = function (value: string, field: string, s
     // FIXME: Include our pod? (not stored in the database)
     podInclude.where = {
       host: {
-        $iLike: '%' + value + '%'
+        [Sequelize.Op.iLike]: '%' + value + '%'
       }
     }
     podInclude.required = true
   } else if (field === 'author') {
     authorInclude.where = {
       name: {
-        $iLike: '%' + value + '%'
+        [Sequelize.Op.iLike]: '%' + value + '%'
       }
     }
-
-    // authorInclude.or = true
   } else {
     query.where[field] = {
-      $iLike: '%' + value + '%'
+      [Sequelize.Op.iLike]: '%' + value + '%'
     }
   }
 
   query.include = [
-    authorInclude, tagInclude, videoFileInclude
+    videoChannelInclude, tagInclude, videoFileInclude
   ]
 
   return Video.findAndCountAll(query).then(({ rows, count }) => {
@@ -963,9 +1032,48 @@ searchAndPopulateAuthorAndPodAndTags = function (value: string, field: string, s
 function createBaseVideosWhere () {
   return {
     id: {
-      $notIn: Video['sequelize'].literal(
+      [Sequelize.Op.notIn]: Video['sequelize'].literal(
         '(SELECT "BlacklistedVideos"."videoId" FROM "BlacklistedVideos")'
       )
     }
   }
 }
+
+function getBaseUrls (video: VideoInstance) {
+  let baseUrlHttp
+  let baseUrlWs
+
+  if (video.isOwned()) {
+    baseUrlHttp = CONFIG.WEBSERVER.URL
+    baseUrlWs = CONFIG.WEBSERVER.WS + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT
+  } else {
+    baseUrlHttp = REMOTE_SCHEME.HTTP + '://' + video.VideoChannel.Author.Pod.host
+    baseUrlWs = REMOTE_SCHEME.WS + '://' + video.VideoChannel.Author.Pod.host
+  }
+
+  return { baseUrlHttp, baseUrlWs }
+}
+
+function getTorrentUrl (video: VideoInstance, videoFile: VideoFileInstance, baseUrlHttp: string) {
+  return baseUrlHttp + STATIC_PATHS.TORRENTS + video.getTorrentFileName(videoFile)
+}
+
+function getVideoFileUrl (video: VideoInstance, videoFile: VideoFileInstance, baseUrlHttp: string) {
+  return baseUrlHttp + STATIC_PATHS.WEBSEED + video.getVideoFilename(videoFile)
+}
+
+function generateMagnetUri (video: VideoInstance, videoFile: VideoFileInstance, baseUrlHttp: string, baseUrlWs: string) {
+  const xs = getTorrentUrl(video, videoFile, baseUrlHttp)
+  const announce = [ baseUrlWs + '/tracker/socket', baseUrlHttp + '/tracker/announce' ]
+  const urlList = [ getVideoFileUrl(video, videoFile, baseUrlHttp) ]
+
+  const magnetHash = {
+    xs,
+    announce,
+    urlList,
+    infoHash: videoFile.infoHash,
+    name: video.name
+  }
+
+  return magnetUtil.encode(magnetHash)
+}