]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blobdiff - server/models/video/video.ts
Fetch outbox to grab old activities
[github/Chocobozzz/PeerTube.git] / server / models / video / video.ts
index 01a801da30cd4f6f726d92ea93f9980b91a7b5d7..3b7e83779a0437fdc3785a00ebac9fecab2b1998 100644 (file)
@@ -1,54 +1,53 @@
-import * as safeBuffer from 'safe-buffer'
-const Buffer = safeBuffer.Buffer
+import { map, maxBy, truncate } from 'lodash'
 import * as magnetUtil from 'magnet-uri'
-import { map } from 'lodash'
 import * as parseTorrent from 'parse-torrent'
 import { join } from 'path'
+import * as safeBuffer from 'safe-buffer'
 import * as Sequelize from 'sequelize'
-import * as Promise from 'bluebird'
-import { maxBy } from 'lodash'
-
-import { TagInstance } from './tag-interface'
+import { VideoPrivacy, VideoResolution } from '../../../shared'
+import { VideoTorrentObject } from '../../../shared/models/activitypub/objects/video-torrent-object'
 import {
-  logger,
-  isVideoNameValid,
+  createTorrentPromise,
+  generateImageFromVideoFile,
+  getVideoFileHeight,
   isVideoCategoryValid,
-  isVideoLicenceValid,
-  isVideoLanguageValid,
-  isVideoNSFWValid,
   isVideoDescriptionValid,
   isVideoDurationValid,
-  readFileBufferPromise,
-  unlinkPromise,
+  isVideoLanguageValid,
+  isVideoLicenceValid,
+  isVideoNameValid,
+  isVideoNSFWValid,
+  isVideoPrivacyValid,
+  logger,
   renamePromise,
-  writeFilePromise,
-  createTorrentPromise,
   statPromise,
-  generateImageFromVideoFile,
   transcode,
-  getVideoFileHeight
+  unlinkPromise,
+  writeFilePromise
 } from '../../helpers'
+import { isVideoUrlValid } from '../../helpers/custom-validators/videos'
 import {
+  API_VERSION,
   CONFIG,
+  CONSTRAINTS_FIELDS,
+  PREVIEWS_SIZE,
   REMOTE_SCHEME,
   STATIC_PATHS,
+  THUMBNAILS_SIZE,
   VIDEO_CATEGORIES,
-  VIDEO_LICENCES,
   VIDEO_LANGUAGES,
-  THUMBNAILS_SIZE
+  VIDEO_LICENCES,
+  VIDEO_PRIVACIES
 } from '../../initializers'
-import { removeVideoToFriends } from '../../lib'
-import { VideoResolution } from '../../../shared'
-import { VideoFileInstance, VideoFileModel } from './video-file-interface'
 
 import { addMethodsToModel, getSort } from '../utils'
-import {
-  VideoInstance,
-  VideoAttributes,
 
-  VideoMethods
-} from './video-interface'
-import { PREVIEWS_SIZE } from '../../initializers/constants'
+import { TagInstance } from './tag-interface'
+import { VideoFileInstance, VideoFileModel } from './video-file-interface'
+import { VideoAttributes, VideoInstance, VideoMethods } from './video-interface'
+import { sendDeleteVideo } from '../../lib/index'
+
+const Buffer = safeBuffer.Buffer
 
 let Video: Sequelize.Model<VideoInstance, VideoAttributes>
 let getOriginalFile: VideoMethods.GetOriginalFile
@@ -61,8 +60,7 @@ 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 toActivityPubObject: VideoMethods.ToActivityPubObject
 let optimizeOriginalVideofile: VideoMethods.OptimizeOriginalVideofile
 let transcodeOriginalVideofile: VideoMethods.TranscodeOriginalVideofile
 let createPreview: VideoMethods.CreatePreview
@@ -71,20 +69,29 @@ 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 getCategoryLabel: VideoMethods.GetCategoryLabel
+let getLicenceLabel: VideoMethods.GetLicenceLabel
+let getLanguageLabel: VideoMethods.GetLanguageLabel
 
 let generateThumbnailFromData: VideoMethods.GenerateThumbnailFromData
 let list: VideoMethods.List
 let listForApi: VideoMethods.ListForApi
+let listAllAndSharedByAccountForOutbox: VideoMethods.ListAllAndSharedByAccountForOutbox
+let listUserVideosForApi: VideoMethods.ListUserVideosForApi
 let loadByHostAndUUID: VideoMethods.LoadByHostAndUUID
-let listOwnedAndPopulateAuthorAndTags: VideoMethods.ListOwnedAndPopulateAuthorAndTags
-let listOwnedByAuthor: VideoMethods.ListOwnedByAuthor
+let listOwnedAndPopulateAccountAndTags: VideoMethods.ListOwnedAndPopulateAccountAndTags
+let listOwnedByAccount: VideoMethods.ListOwnedByAccount
 let load: VideoMethods.Load
+let loadByUrlAndPopulateAccount: VideoMethods.LoadByUrlAndPopulateAccount
 let loadByUUID: VideoMethods.LoadByUUID
+let loadByUUIDOrURL: VideoMethods.LoadByUUIDOrURL
 let loadLocalVideoByUUID: VideoMethods.LoadLocalVideoByUUID
-let loadAndPopulateAuthor: VideoMethods.LoadAndPopulateAuthor
-let loadAndPopulateAuthorAndPodAndTags: VideoMethods.LoadAndPopulateAuthorAndPodAndTags
-let loadByUUIDAndPopulateAuthorAndPodAndTags: VideoMethods.LoadByUUIDAndPopulateAuthorAndPodAndTags
-let searchAndPopulateAuthorAndPodAndTags: VideoMethods.SearchAndPopulateAuthorAndPodAndTags
+let loadAndPopulateAccount: VideoMethods.LoadAndPopulateAccount
+let loadAndPopulateAccountAndServerAndTags: VideoMethods.LoadAndPopulateAccountAndServerAndTags
+let loadByUUIDAndPopulateAccountAndServerAndTags: VideoMethods.LoadByUUIDAndPopulateAccountAndServerAndTags
+let searchAndPopulateAccountAndServerAndTags: VideoMethods.SearchAndPopulateAccountAndServerAndTags
 let removeThumbnail: VideoMethods.RemoveThumbnail
 let removePreview: VideoMethods.RemovePreview
 let removeFile: VideoMethods.RemoveFile
@@ -142,6 +149,16 @@ export default function (sequelize: Sequelize.Sequelize, DataTypes: Sequelize.Da
           }
         }
       },
+      privacy: {
+        type: DataTypes.INTEGER,
+        allowNull: false,
+        validate: {
+          privacyValid: value => {
+            const res = isVideoPrivacyValid(value)
+            if (res === false) throw new Error('Video privacy is not valid.')
+          }
+        }
+      },
       nsfw: {
         type: DataTypes.BOOLEAN,
         allowNull: false,
@@ -153,7 +170,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 => {
@@ -203,6 +220,16 @@ export default function (sequelize: Sequelize.Sequelize, DataTypes: Sequelize.Da
         type: DataTypes.BOOLEAN,
         allowNull: false,
         defaultValue: false
+      },
+      url: {
+        type: DataTypes.STRING(CONSTRAINTS_FIELDS.VIDEOS.URL.max),
+        allowNull: false,
+        validate: {
+          urlValid: value => {
+            const res = isVideoUrlValid(value)
+            if (res === false) throw new Error('Video URL is not valid.')
+          }
+        }
       }
     },
     {
@@ -240,17 +267,21 @@ export default function (sequelize: Sequelize.Sequelize, DataTypes: Sequelize.Da
 
     generateThumbnailFromData,
     list,
+    listAllAndSharedByAccountForOutbox,
     listForApi,
-    listOwnedAndPopulateAuthorAndTags,
-    listOwnedByAuthor,
+    listUserVideosForApi,
+    listOwnedAndPopulateAccountAndTags,
+    listOwnedByAccount,
     load,
-    loadAndPopulateAuthor,
-    loadAndPopulateAuthorAndPodAndTags,
+    loadByUrlAndPopulateAccount,
+    loadAndPopulateAccount,
+    loadAndPopulateAccountAndServerAndTags,
     loadByHostAndUUID,
+    loadByUUIDOrURL,
     loadByUUID,
     loadLocalVideoByUUID,
-    loadByUUIDAndPopulateAuthorAndPodAndTags,
-    searchAndPopulateAuthorAndPodAndTags
+    loadByUUIDAndPopulateAccountAndServerAndTags,
+    searchAndPopulateAccountAndServerAndTags
   ]
   const instanceMethods = [
     createPreview,
@@ -269,14 +300,18 @@ export default function (sequelize: Sequelize.Sequelize, DataTypes: Sequelize.Da
     removePreview,
     removeThumbnail,
     removeTorrent,
-    toAddRemoteJSON,
+    toActivityPubObject,
     toFormattedJSON,
     toFormattedDetailsJSON,
-    toUpdateRemoteJSON,
     optimizeOriginalVideofile,
     transcodeOriginalVideofile,
     getOriginalFileHeight,
-    getEmbedPath
+    getEmbedPath,
+    getTruncatedDescription,
+    getDescriptionPath,
+    getCategoryLabel,
+    getLicenceLabel,
+    getLanguageLabel
   ]
   addMethodsToModel(Video, classMethods, instanceMethods)
 
@@ -315,9 +350,17 @@ function associate (models) {
     },
     onDelete: 'cascade'
   })
+
+  Video.hasMany(models.VideoShare, {
+    foreignKey: {
+      name: 'videoId',
+      allowNull: false
+    },
+    onDelete: 'cascade'
+  })
 }
 
-function afterDestroy (video: VideoInstance, options: { transaction: Sequelize.Transaction }) {
+function afterDestroy (video: VideoInstance) {
   const tasks = []
 
   tasks.push(
@@ -325,13 +368,9 @@ function afterDestroy (video: VideoInstance, options: { transaction: Sequelize.T
   )
 
   if (video.isOwned()) {
-    const removeVideoToFriendsParams = {
-      uuid: video.uuid
-    }
-
     tasks.push(
       video.removePreview(),
-      removeVideoToFriends(removeVideoToFriendsParams, options.transaction)
+      sendDeleteVideo(video, undefined)
     )
 
     // Remove physical files and torrents
@@ -343,7 +382,7 @@ function afterDestroy (video: VideoInstance, options: { transaction: Sequelize.T
 
   return Promise.all(tasks)
     .catch(err => {
-      logger.error('Some errors when removing files of video %d in after destroy hook.', video.uuid, err)
+      logger.error('Some errors when removing files of video %s in after destroy hook.', video.uuid, err)
     })
 }
 
@@ -404,7 +443,7 @@ getVideoFilePath = function (this: VideoInstance, videoFile: VideoFileInstance)
   return join(CONFIG.STORAGE.VIDEOS_DIR, this.getVideoFilename(videoFile))
 }
 
-createTorrentAndSetInfoHash = function (this: VideoInstance, videoFile: VideoFileInstance) {
+createTorrentAndSetInfoHash = async function (this: VideoInstance, videoFile: VideoFileInstance) {
   const options = {
     announceList: [
       [ CONFIG.WEBSERVER.WS + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT + '/tracker/socket' ]
@@ -414,18 +453,15 @@ createTorrentAndSetInfoHash = function (this: VideoInstance, videoFile: VideoFil
     ]
   }
 
-  return createTorrentPromise(this.getVideoFilePath(videoFile), options)
-    .then(torrent => {
-      const filePath = join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
-      logger.info('Creating torrent %s.', filePath)
+  const torrent = await createTorrentPromise(this.getVideoFilePath(videoFile), options)
 
-      return writeFilePromise(filePath, torrent).then(() => torrent)
-    })
-    .then(torrent => {
-      const parsedTorrent = parseTorrent(torrent)
+  const filePath = join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
+  logger.info('Creating torrent %s.', filePath)
 
-      videoFile.infoHash = parsedTorrent.infoHash
-    })
+  await writeFilePromise(filePath, torrent)
+
+  const parsedTorrent = parseTorrent(torrent)
+  videoFile.infoHash = parsedTorrent.infoHash
 }
 
 getEmbedPath = function (this: VideoInstance) {
@@ -441,42 +477,30 @@ getPreviewPath = function (this: VideoInstance) {
 }
 
 toFormattedJSON = function (this: VideoInstance) {
-  let podHost
+  let serverHost
 
-  if (this.VideoChannel.Author.Pod) {
-    podHost = this.VideoChannel.Author.Pod.host
+  if (this.VideoChannel.Account.Server) {
+    serverHost = this.VideoChannel.Account.Server.host
   } else {
     // It means it's our video
-    podHost = CONFIG.WEBSERVER.HOST
+    serverHost = CONFIG.WEBSERVER.HOST
   }
 
-  // Maybe our pod is not up to date and there are new categories since our version
-  let categoryLabel = VIDEO_CATEGORIES[this.category]
-  if (!categoryLabel) categoryLabel = 'Misc'
-
-  // Maybe our pod is not up to date and there are new licences since our version
-  let licenceLabel = VIDEO_LICENCES[this.licence]
-  if (!licenceLabel) licenceLabel = 'Unknown'
-
-  // Language is an optional attribute
-  let languageLabel = VIDEO_LANGUAGES[this.language]
-  if (!languageLabel) languageLabel = 'Unknown'
-
   const json = {
     id: this.id,
     uuid: this.uuid,
     name: this.name,
     category: this.category,
-    categoryLabel,
+    categoryLabel: this.getCategoryLabel(),
     licence: this.licence,
-    licenceLabel,
+    licenceLabel: this.getLicenceLabel(),
     language: this.language,
-    languageLabel,
+    languageLabel: this.getLanguageLabel(),
     nsfw: this.nsfw,
-    description: this.description,
-    podHost,
+    description: this.getTruncatedDescription(),
+    serverHost,
     isLocal: this.isOwned(),
-    author: this.VideoChannel.Author.name,
+    account: this.VideoChannel.Account.name,
     duration: this.duration,
     views: this.views,
     likes: this.likes,
@@ -493,59 +517,23 @@ toFormattedJSON = function (this: VideoInstance) {
 }
 
 toFormattedDetailsJSON = function (this: VideoInstance) {
-  let podHost
-
-  if (this.VideoChannel.Author.Pod) {
-    podHost = this.VideoChannel.Author.Pod.host
-  } else {
-    // It means it's our video
-    podHost = CONFIG.WEBSERVER.HOST
-  }
+  const formattedJson = this.toFormattedJSON()
 
-  // Maybe our pod is not up to date and there are new categories since our version
-  let categoryLabel = VIDEO_CATEGORIES[this.category]
-  if (!categoryLabel) categoryLabel = 'Misc'
-
-  // Maybe our pod is not up to date and there are new licences since our version
-  let licenceLabel = VIDEO_LICENCES[this.licence]
-  if (!licenceLabel) licenceLabel = 'Unknown'
-
-  // Language is an optional attribute
-  let languageLabel = VIDEO_LANGUAGES[this.language]
-  if (!languageLabel) languageLabel = 'Unknown'
+  // Maybe our server is not up to date and there are new privacy settings since our version
+  let privacyLabel = VIDEO_PRIVACIES[this.privacy]
+  if (!privacyLabel) privacyLabel = 'Unknown'
 
-  const json = {
-    id: this.id,
-    uuid: this.uuid,
-    name: this.name,
-    category: this.category,
-    categoryLabel,
-    licence: this.licence,
-    licenceLabel,
-    language: this.language,
-    languageLabel,
-    nsfw: this.nsfw,
-    description: this.description,
-    podHost,
-    isLocal: this.isOwned(),
-    author: this.VideoChannel.Author.name,
-    duration: this.duration,
-    views: this.views,
-    likes: this.likes,
-    dislikes: this.dislikes,
-    tags: map<TagInstance, string>(this.Tags, 'name'),
-    thumbnailPath: this.getThumbnailPath(),
-    previewPath: this.getPreviewPath(),
-    embedPath: this.getEmbedPath(),
-    createdAt: this.createdAt,
-    updatedAt: this.updatedAt,
+  const detailsJson = {
+    privacyLabel,
+    privacy: this.privacy,
+    descriptionPath: this.getDescriptionPath(),
     channel: this.VideoChannel.toFormattedJSON(),
     files: []
   }
 
   // Format and sort video files
   const { baseUrlHttp, baseUrlWs } = getBaseUrls(this)
-  json.files = this.VideoFiles
+  detailsJson.files = this.VideoFiles
                    .map(videoFile => {
                      let resolutionLabel = videoFile.resolution + 'p'
 
@@ -566,79 +554,91 @@ toFormattedDetailsJSON = function (this: VideoInstance) {
                      return -1
                    })
 
-  return json
+  return Object.assign(formattedJson, detailsJson)
 }
 
-toAddRemoteJSON = function (this: VideoInstance) {
-  // Get thumbnail data to send to the other pod
-  const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName())
-
-  return readFileBufferPromise(thumbnailPath).then(thumbnailData => {
-    const remoteVideo = {
-      uuid: this.uuid,
-      name: this.name,
-      category: this.category,
-      licence: this.licence,
-      language: this.language,
-      nsfw: this.nsfw,
-      description: this.description,
-      channelUUID: this.VideoChannel.uuid,
-      duration: this.duration,
-      thumbnailData: thumbnailData.toString('binary'),
-      tags: map<TagInstance, string>(this.Tags, 'name'),
-      createdAt: this.createdAt,
-      updatedAt: this.updatedAt,
-      views: this.views,
-      likes: this.likes,
-      dislikes: this.dislikes,
-      files: []
-    }
+toActivityPubObject = function (this: VideoInstance) {
+  const { baseUrlHttp, baseUrlWs } = getBaseUrls(this)
+  if (!this.Tags) this.Tags = []
+
+  const tag = this.Tags.map(t => ({
+    type: 'Hashtag' as 'Hashtag',
+    name: t.name
+  }))
+
+  const url = []
+  for (const file of this.VideoFiles) {
+    url.push({
+      type: 'Link',
+      mimeType: 'video/' + file.extname.replace('.', ''),
+      url: getVideoFileUrl(this, file, baseUrlHttp),
+      width: file.resolution,
+      size: file.size
+    })
 
-    this.VideoFiles.forEach(videoFile => {
-      remoteVideo.files.push({
-        infoHash: videoFile.infoHash,
-        resolution: videoFile.resolution,
-        extname: videoFile.extname,
-        size: videoFile.size
-      })
+    url.push({
+      type: 'Link',
+      mimeType: 'application/x-bittorrent',
+      url: getTorrentUrl(this, file, baseUrlHttp),
+      width: file.resolution
     })
 
-    return remoteVideo
-  })
-}
+    url.push({
+      type: 'Link',
+      mimeType: 'application/x-bittorrent;x-scheme-handler/magnet',
+      url: generateMagnetUri(this, file, baseUrlHttp, baseUrlWs),
+      width: file.resolution
+    })
+  }
 
-toUpdateRemoteJSON = function (this: VideoInstance) {
-  const json = {
-    uuid: this.uuid,
+  const videoObject: VideoTorrentObject = {
+    type: 'Video' as 'Video',
+    id: this.url,
     name: this.name,
-    category: this.category,
-    licence: this.licence,
-    language: this.language,
-    nsfw: this.nsfw,
-    description: this.description,
-    duration: this.duration,
-    tags: map<TagInstance, string>(this.Tags, 'name'),
-    createdAt: this.createdAt,
-    updatedAt: this.updatedAt,
+    // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-duration
+    duration: 'PT' + this.duration + 'S',
+    uuid: this.uuid,
+    tag,
+    category: {
+      identifier: this.category + '',
+      name: this.getCategoryLabel()
+    },
+    licence: {
+      identifier: this.licence + '',
+      name: this.getLicenceLabel()
+    },
+    language: {
+      identifier: this.language + '',
+      name: this.getLanguageLabel()
+    },
     views: this.views,
-    likes: this.likes,
-    dislikes: this.dislikes,
-    files: []
+    nsfw: this.nsfw,
+    published: this.createdAt.toISOString(),
+    updated: this.updatedAt.toISOString(),
+    mediaType: 'text/markdown',
+    content: this.getTruncatedDescription(),
+    icon: {
+      type: 'Image',
+      url: getThumbnailUrl(this, baseUrlHttp),
+      mediaType: 'image/jpeg',
+      width: THUMBNAILS_SIZE.width,
+      height: THUMBNAILS_SIZE.height
+    },
+    url // FIXME: needed?
   }
 
-  this.VideoFiles.forEach(videoFile => {
-    json.files.push({
-      infoHash: videoFile.infoHash,
-      resolution: videoFile.resolution,
-      extname: videoFile.extname,
-      size: videoFile.size
-    })
-  })
+  return videoObject
+}
 
-  return json
+getTruncatedDescription = function (this: VideoInstance) {
+  const options = {
+    length: CONSTRAINTS_FIELDS.VIDEOS.TRUNCATED_DESCRIPTION.max
+  }
+
+  return truncate(this.description, options)
 }
 
-optimizeOriginalVideofile = function (this: VideoInstance) {
+optimizeOriginalVideofile = async function (this: VideoInstance) {
   const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
   const newExtname = '.mp4'
   const inputVideoFile = this.getOriginalFile()
@@ -650,40 +650,32 @@ optimizeOriginalVideofile = function (this: VideoInstance) {
     outputPath: videoOutputPath
   }
 
-  return transcode(transcodeOptions)
-    .then(() => {
-      return unlinkPromise(videoInputPath)
-    })
-    .then(() => {
-      // Important to do this before getVideoFilename() to take in account the new file extension
-      inputVideoFile.set('extname', newExtname)
+  try {
+    // Could be very long!
+    await transcode(transcodeOptions)
 
-      return renamePromise(videoOutputPath, this.getVideoFilePath(inputVideoFile))
-    })
-    .then(() => {
-      return statPromise(this.getVideoFilePath(inputVideoFile))
-    })
-    .then(stats => {
-      return inputVideoFile.set('size', stats.size)
-    })
-    .then(() => {
-      return this.createTorrentAndSetInfoHash(inputVideoFile)
-    })
-    .then(() => {
-      return inputVideoFile.save()
-    })
-    .then(() => {
-      return undefined
-    })
-    .catch(err => {
-      // Auto destruction...
-      this.destroy().catch(err => logger.error('Cannot destruct video after transcoding failure.', err))
+    await unlinkPromise(videoInputPath)
 
-      throw err
-    })
+    // Important to do this before getVideoFilename() to take in account the new file extension
+    inputVideoFile.set('extname', newExtname)
+
+    await renamePromise(videoOutputPath, this.getVideoFilePath(inputVideoFile))
+    const stats = await statPromise(this.getVideoFilePath(inputVideoFile))
+
+    inputVideoFile.set('size', stats.size)
+
+    await this.createTorrentAndSetInfoHash(inputVideoFile)
+    await inputVideoFile.save()
+
+  } catch (err) {
+    // Auto destruction...
+    this.destroy().catch(err => logger.error('Cannot destruct video after transcoding failure.', err))
+
+    throw err
+  }
 }
 
-transcodeOriginalVideofile = function (this: VideoInstance, resolution: VideoResolution) {
+transcodeOriginalVideofile = async function (this: VideoInstance, resolution: VideoResolution) {
   const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
   const extname = '.mp4'
 
@@ -703,25 +695,18 @@ transcodeOriginalVideofile = function (this: VideoInstance, resolution: VideoRes
     outputPath: videoOutputPath,
     resolution
   }
-  return transcode(transcodeOptions)
-    .then(() => {
-      return statPromise(videoOutputPath)
-    })
-    .then(stats => {
-      newVideoFile.set('size', stats.size)
 
-      return undefined
-    })
-    .then(() => {
-      return this.createTorrentAndSetInfoHash(newVideoFile)
-    })
-    .then(() => {
-      return newVideoFile.save()
-    })
-    .then(() => {
-      return this.VideoFiles.push(newVideoFile)
-    })
-    .then(() => undefined)
+  await transcode(transcodeOptions)
+
+  const stats = await statPromise(videoOutputPath)
+
+  newVideoFile.set('size', stats.size)
+
+  await this.createTorrentAndSetInfoHash(newVideoFile)
+
+  await newVideoFile.save()
+
+  this.VideoFiles.push(newVideoFile)
 }
 
 getOriginalFileHeight = function (this: VideoInstance) {
@@ -730,6 +715,36 @@ getOriginalFileHeight = function (this: VideoInstance) {
   return getVideoFileHeight(originalFilePath)
 }
 
+getDescriptionPath = function (this: VideoInstance) {
+  return `/api/${API_VERSION}/videos/${this.uuid}/description`
+}
+
+getCategoryLabel = function (this: VideoInstance) {
+  let categoryLabel = VIDEO_CATEGORIES[this.category]
+
+  // Maybe our server is not up to date and there are new categories since our version
+  if (!categoryLabel) categoryLabel = 'Misc'
+
+  return categoryLabel
+}
+
+getLicenceLabel = function (this: VideoInstance) {
+  let licenceLabel = VIDEO_LICENCES[this.licence]
+
+  // Maybe our server is not up to date and there are new licences since our version
+  if (!licenceLabel) licenceLabel = 'Unknown'
+
+  return licenceLabel
+}
+
+getLanguageLabel = function (this: VideoInstance) {
+  // Language is an optional attribute
+  let languageLabel = VIDEO_LANGUAGES[this.language]
+  if (!languageLabel) languageLabel = 'Unknown'
+
+  return languageLabel
+}
+
 removeThumbnail = function (this: VideoInstance) {
   const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName())
   return unlinkPromise(thumbnailPath)
@@ -770,8 +785,87 @@ list = function () {
   return Video.findAll(query)
 }
 
+listAllAndSharedByAccountForOutbox = function (accountId: number, start: number, count: number) {
+  const queryVideo = 'SELECT "Video"."id" FROM "Videos" AS "Video" ' +
+                'INNER JOIN "VideoChannels" AS "VideoChannel" ON "VideoChannel"."id" = "Video"."channelId" ' +
+                'WHERE "VideoChannel"."accountId" = ' + accountId
+  const queryVideoShare = 'SELECT "Video"."id" FROM "VideoShares" AS "VideoShare" ' +
+                          'INNER JOIN "Videos" AS "Video" ON "Video"."id" = "VideoShare"."videoId" ' +
+                          'INNER JOIN "VideoChannels" AS "VideoChannel" ON "VideoChannel"."id" = "Video"."channelId" ' +
+                          'WHERE "VideoShare"."accountId" = ' + accountId
+  const rawQuery = `(${queryVideo}) UNION (${queryVideoShare}) LIMIT ${count} OFFSET ${start}`
+
+  const query = {
+    distinct: true,
+    offset: start,
+    limit: count,
+    order: [ getSort('createdAt'), [ Video['sequelize'].models.Tag, 'name', 'ASC' ] ],
+    where: {
+      id: {
+        [Sequelize.Op.in]: Sequelize.literal('(' + rawQuery + ')')
+      }
+    },
+    include: [
+      {
+        model: Video['sequelize'].models.VideoShare,
+        required: false
+      },
+      {
+        model: Video['sequelize'].models.VideoChannel,
+        required: true,
+        include: [
+          {
+            model: Video['sequelize'].models.Account,
+            required: true
+          }
+        ]
+      },
+      Video['sequelize'].models.Tag,
+      Video['sequelize'].models.VideoFile
+    ]
+  }
+
+  return Video.findAndCountAll(query).then(({ rows, count }) => {
+    return {
+      data: rows,
+      total: count
+    }
+  })
+}
+
+listUserVideosForApi = function (userId: number, start: number, count: number, sort: string) {
+  const query = {
+    distinct: true,
+    offset: start,
+    limit: count,
+    order: [ getSort(sort), [ Video['sequelize'].models.Tag, 'name', 'ASC' ] ],
+    include: [
+      {
+        model: Video['sequelize'].models.VideoChannel,
+        required: true,
+        include: [
+          {
+            model: Video['sequelize'].models.Account,
+            where: {
+              userId
+            },
+            required: true
+          }
+        ]
+      },
+      Video['sequelize'].models.Tag
+    ]
+  }
+
+  return Video.findAndCountAll(query).then(({ rows, count }) => {
+    return {
+      data: rows,
+      total: count
+    }
+  })
+}
+
 listForApi = function (start: number, count: number, sort: string) {
-  // Exclude blacklisted videos from the list
   const query = {
     distinct: true,
     offset: start,
@@ -780,20 +874,21 @@ listForApi = function (start: number, count: number, sort: string) {
     include: [
       {
         model: Video['sequelize'].models.VideoChannel,
+        required: true,
         include: [
           {
-            model: Video['sequelize'].models.Author,
+            model: Video['sequelize'].models.Account,
+            required: true,
             include: [
               {
-                model: Video['sequelize'].models.Pod,
+                model: Video['sequelize'].models.Server,
                 required: false
               }
             ]
           }
         ]
       },
-      Video['sequelize'].models.Tag,
-      Video['sequelize'].models.VideoFile
+      Video['sequelize'].models.Tag
     ],
     where: createBaseVideosWhere()
   }
@@ -819,10 +914,10 @@ loadByHostAndUUID = function (fromHost: string, uuid: string, t?: Sequelize.Tran
         model: Video['sequelize'].models.VideoChannel,
         include: [
           {
-            model: Video['sequelize'].models.Author,
+            model: Video['sequelize'].models.Account,
             include: [
               {
-                model: Video['sequelize'].models.Pod,
+                model: Video['sequelize'].models.Server,
                 required: true,
                 where: {
                   host: fromHost
@@ -840,7 +935,7 @@ loadByHostAndUUID = function (fromHost: string, uuid: string, t?: Sequelize.Tran
   return Video.findOne(query)
 }
 
-listOwnedAndPopulateAuthorAndTags = function () {
+listOwnedAndPopulateAccountAndTags = function () {
   const query = {
     where: {
       remote: false
@@ -849,7 +944,7 @@ listOwnedAndPopulateAuthorAndTags = function () {
       Video['sequelize'].models.VideoFile,
       {
         model: Video['sequelize'].models.VideoChannel,
-        include: [ Video['sequelize'].models.Author ]
+        include: [ Video['sequelize'].models.Account ]
       },
       Video['sequelize'].models.Tag
     ]
@@ -858,7 +953,7 @@ listOwnedAndPopulateAuthorAndTags = function () {
   return Video.findAll(query)
 }
 
-listOwnedByAuthor = function (author: string) {
+listOwnedByAccount = function (account: string) {
   const query = {
     where: {
       remote: false
@@ -871,9 +966,9 @@ listOwnedByAuthor = function (author: string) {
         model: Video['sequelize'].models.VideoChannel,
         include: [
           {
-            model: Video['sequelize'].models.Author,
+            model: Video['sequelize'].models.Account,
             where: {
-              name: author
+              name: account
             }
           }
         ]
@@ -901,6 +996,41 @@ loadByUUID = function (uuid: string, t?: Sequelize.Transaction) {
   return Video.findOne(query)
 }
 
+loadByUrlAndPopulateAccount = function (url: string, t?: Sequelize.Transaction) {
+  const query: Sequelize.FindOptions<VideoAttributes> = {
+    where: {
+      url
+    },
+    include: [
+      Video['sequelize'].models.VideoFile,
+      {
+        model: Video['sequelize'].models.VideoChannel,
+        include: [ Video['sequelize'].models.Account ]
+      }
+    ]
+  }
+
+  if (t !== undefined) query.transaction = t
+
+  return Video.findOne(query)
+}
+
+loadByUUIDOrURL = function (uuid: string, url: string, t?: Sequelize.Transaction) {
+  const query: Sequelize.FindOptions<VideoAttributes> = {
+    where: {
+      [Sequelize.Op.or]: [
+        { uuid },
+        { url }
+      ]
+    },
+    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: {
@@ -915,13 +1045,13 @@ loadLocalVideoByUUID = function (uuid: string, t?: Sequelize.Transaction) {
   return Video.findOne(query)
 }
 
-loadAndPopulateAuthor = function (id: number) {
+loadAndPopulateAccount = function (id: number) {
   const options = {
     include: [
       Video['sequelize'].models.VideoFile,
       {
         model: Video['sequelize'].models.VideoChannel,
-        include: [ Video['sequelize'].models.Author ]
+        include: [ Video['sequelize'].models.Account ]
       }
     ]
   }
@@ -929,15 +1059,15 @@ loadAndPopulateAuthor = function (id: number) {
   return Video.findById(id, options)
 }
 
-loadAndPopulateAuthorAndPodAndTags = function (id: number) {
+loadAndPopulateAccountAndServerAndTags = function (id: number) {
   const options = {
     include: [
       {
         model: Video['sequelize'].models.VideoChannel,
         include: [
           {
-            model: Video['sequelize'].models.Author,
-            include: [ { model: Video['sequelize'].models.Pod, required: false } ]
+            model: Video['sequelize'].models.Account,
+            include: [ { model: Video['sequelize'].models.Server, required: false } ]
           }
         ]
       },
@@ -949,7 +1079,7 @@ loadAndPopulateAuthorAndPodAndTags = function (id: number) {
   return Video.findById(id, options)
 }
 
-loadByUUIDAndPopulateAuthorAndPodAndTags = function (uuid: string) {
+loadByUUIDAndPopulateAccountAndServerAndTags = function (uuid: string) {
   const options = {
     where: {
       uuid
@@ -959,8 +1089,8 @@ loadByUUIDAndPopulateAuthorAndPodAndTags = function (uuid: string) {
         model: Video['sequelize'].models.VideoChannel,
         include: [
           {
-            model: Video['sequelize'].models.Author,
-            include: [ { model: Video['sequelize'].models.Pod, required: false } ]
+            model: Video['sequelize'].models.Account,
+            include: [ { model: Video['sequelize'].models.Server, required: false } ]
           }
         ]
       },
@@ -972,20 +1102,20 @@ loadByUUIDAndPopulateAuthorAndPodAndTags = function (uuid: string) {
   return Video.findOne(options)
 }
 
-searchAndPopulateAuthorAndPodAndTags = function (value: string, field: string, start: number, count: number, sort: string) {
-  const podInclude: Sequelize.IncludeOptions = {
-    model: Video['sequelize'].models.Pod,
+searchAndPopulateAccountAndServerAndTags = function (value: string, field: string, start: number, count: number, sort: string) {
+  const serverInclude: Sequelize.IncludeOptions = {
+    model: Video['sequelize'].models.Server,
     required: false
   }
 
-  const authorInclude: Sequelize.IncludeOptions = {
-    model: Video['sequelize'].models.Author,
-    include: [ podInclude ]
+  const accountInclude: Sequelize.IncludeOptions = {
+    model: Video['sequelize'].models.Account,
+    include: [ serverInclude ]
   }
 
   const videoChannelInclude: Sequelize.IncludeOptions = {
     model: Video['sequelize'].models.VideoChannel,
-    include: [ authorInclude ],
+    include: [ accountInclude ],
     required: true
   }
 
@@ -993,10 +1123,6 @@ searchAndPopulateAuthorAndPodAndTags = function (value: string, field: string, s
     model: Video['sequelize'].models.Tag
   }
 
-  const videoFileInclude: Sequelize.IncludeOptions = {
-    model: Video['sequelize'].models.VideoFile
-  }
-
   const query: Sequelize.FindOptions<VideoAttributes> = {
     distinct: true,
     where: createBaseVideosWhere(),
@@ -1005,14 +1131,9 @@ searchAndPopulateAuthorAndPodAndTags = function (value: string, field: string, s
     order: [ getSort(sort), [ Video['sequelize'].models.Tag, 'name', 'ASC' ] ]
   }
 
-  // Make an exact search with the magnet
-  if (field === 'magnetUri') {
-    videoFileInclude.where = {
-      infoHash: magnetUtil.decode(value).infoHash
-    }
-  } else if (field === 'tags') {
+  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"
@@ -1020,27 +1141,27 @@ searchAndPopulateAuthorAndPodAndTags = function (value: string, field: string, s
        )`
     )
   } else if (field === 'host') {
-    // FIXME: Include our pod? (not stored in the database)
-    podInclude.where = {
+    // FIXME: Include our server? (not stored in the database)
+    serverInclude.where = {
       host: {
-        $iLike: '%' + value + '%'
+        [Sequelize.Op.iLike]: '%' + value + '%'
       }
     }
-    podInclude.required = true
-  } else if (field === 'author') {
-    authorInclude.where = {
+    serverInclude.required = true
+  } else if (field === 'account') {
+    accountInclude.where = {
       name: {
-        $iLike: '%' + value + '%'
+        [Sequelize.Op.iLike]: '%' + value + '%'
       }
     }
   } else {
     query.where[field] = {
-      $iLike: '%' + value + '%'
+      [Sequelize.Op.iLike]: '%' + value + '%'
     }
   }
 
   query.include = [
-    videoChannelInclude, tagInclude, videoFileInclude
+    videoChannelInclude, tagInclude
   ]
 
   return Video.findAndCountAll(query).then(({ rows, count }) => {
@@ -1056,10 +1177,11 @@ 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")'
       )
-    }
+    },
+    privacy: VideoPrivacy.PUBLIC
   }
 }
 
@@ -1071,13 +1193,17 @@ function getBaseUrls (video: VideoInstance) {
     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
+    baseUrlHttp = REMOTE_SCHEME.HTTP + '://' + video.VideoChannel.Account.Server.host
+    baseUrlWs = REMOTE_SCHEME.WS + '://' + video.VideoChannel.Account.Server.host
   }
 
   return { baseUrlHttp, baseUrlWs }
 }
 
+function getThumbnailUrl (video: VideoInstance, baseUrlHttp: string) {
+  return baseUrlHttp + STATIC_PATHS.THUMBNAILS + video.getThumbnailName()
+}
+
 function getTorrentUrl (video: VideoInstance, videoFile: VideoFileInstance, baseUrlHttp: string) {
   return baseUrlHttp + STATIC_PATHS.TORRENTS + video.getTorrentFileName(videoFile)
 }