]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blobdiff - server/models/video/video.ts
Refractor activity pub lib/helpers
[github/Chocobozzz/PeerTube.git] / server / models / video / video.ts
index e66ebee2ddf2b1605bd9617d6754d1deb96daf6e..e2069eb0c6d8110280433e4ba978616f627649a2 100644 (file)
-import * as safeBuffer from 'safe-buffer'
-const Buffer = safeBuffer.Buffer
-import * as createTorrent from 'create-torrent'
-import * as ffmpeg from 'fluent-ffmpeg'
-import * as fs from 'fs'
+import { map, maxBy, truncate } from 'lodash'
 import * as magnetUtil from 'magnet-uri'
-import { map, values } from 'lodash'
-import { parallel, series } from 'async'
 import * as parseTorrent from 'parse-torrent'
 import { join } from 'path'
+import * as safeBuffer from 'safe-buffer'
 import * as Sequelize from 'sequelize'
-
-import { database as db } from '../../initializers/database'
-import { VideoTagInstance } from './video-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,
+  isVideoDescriptionValid,
+  isVideoDurationValid,
   isVideoLanguageValid,
+  isVideoLicenceValid,
+  isVideoNameValid,
   isVideoNSFWValid,
-  isVideoDescriptionValid,
-  isVideoInfoHashValid,
-  isVideoDurationValid
+  isVideoPrivacyValid,
+  logger,
+  renamePromise,
+  statPromise,
+  transcode,
+  unlinkPromise,
+  writeFilePromise
 } from '../../helpers'
+import { isVideoUrlValid } from '../../helpers/custom-validators/videos'
 import {
-  CONSTRAINTS_FIELDS,
+  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 { JobScheduler, removeVideoToFriends } from '../../lib'
 
 import { addMethodsToModel, getSort } from '../utils'
-import {
-  VideoClass,
-  VideoInstance,
-  VideoAttributes,
 
-  VideoMethods
-} from './video-interface'
+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 generateMagnetUri: VideoMethods.GenerateMagnetUri
+let getOriginalFile: VideoMethods.GetOriginalFile
 let getVideoFilename: VideoMethods.GetVideoFilename
 let getThumbnailName: VideoMethods.GetThumbnailName
+let getThumbnailPath: VideoMethods.GetThumbnailPath
 let getPreviewName: VideoMethods.GetPreviewName
-let getTorrentName: VideoMethods.GetTorrentName
+let getPreviewPath: VideoMethods.GetPreviewPath
+let getTorrentFileName: VideoMethods.GetTorrentFileName
 let isOwned: VideoMethods.IsOwned
-let toFormatedJSON: VideoMethods.ToFormatedJSON
-let toAddRemoteJSON: VideoMethods.ToAddRemoteJSON
-let toUpdateRemoteJSON: VideoMethods.ToUpdateRemoteJSON
-let transcodeVideofile: VideoMethods.TranscodeVideofile
+let toFormattedJSON: VideoMethods.ToFormattedJSON
+let toFormattedDetailsJSON: VideoMethods.ToFormattedDetailsJSON
+let toActivityPubObject: VideoMethods.ToActivityPubObject
+let optimizeOriginalVideofile: VideoMethods.OptimizeOriginalVideofile
+let transcodeOriginalVideofile: VideoMethods.TranscodeOriginalVideofile
+let createPreview: VideoMethods.CreatePreview
+let createThumbnail: VideoMethods.CreateThumbnail
+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 getDurationFromFile: VideoMethods.GetDurationFromFile
 let list: VideoMethods.List
 let listForApi: VideoMethods.ListForApi
-let loadByHostAndRemoteId: VideoMethods.LoadByHostAndRemoteId
-let listOwnedAndPopulateAuthorAndTags: VideoMethods.ListOwnedAndPopulateAuthorAndTags
-let listOwnedByAuthor: VideoMethods.ListOwnedByAuthor
+let listUserVideosForApi: VideoMethods.ListUserVideosForApi
+let loadByHostAndUUID: VideoMethods.LoadByHostAndUUID
+let listOwnedAndPopulateAccountAndTags: VideoMethods.ListOwnedAndPopulateAccountAndTags
+let listOwnedByAccount: VideoMethods.ListOwnedByAccount
 let load: VideoMethods.Load
-let loadAndPopulateAuthor: VideoMethods.LoadAndPopulateAuthor
-let loadAndPopulateAuthorAndPodAndTags: VideoMethods.LoadAndPopulateAuthorAndPodAndTags
-let searchAndPopulateAuthorAndPodAndTags: VideoMethods.SearchAndPopulateAuthorAndPodAndTags
+let loadByUrlAndPopulateAccount: VideoMethods.LoadByUrlAndPopulateAccount
+let loadByUUID: VideoMethods.LoadByUUID
+let loadByUUIDOrURL: VideoMethods.LoadByUUIDOrURL
+let loadLocalVideoByUUID: VideoMethods.LoadLocalVideoByUUID
+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
+let removeTorrent: VideoMethods.RemoveTorrent
 
 export default function (sequelize: Sequelize.Sequelize, DataTypes: Sequelize.DataTypes) {
   Video = sequelize.define<VideoInstance, VideoAttributes>('Video',
     {
-      id: {
+      uuid: {
         type: DataTypes.UUID,
         defaultValue: DataTypes.UUIDV4,
-        primaryKey: true,
+        allowNull: false,
         validate: {
           isUUID: 4
         }
@@ -83,28 +111,17 @@ export default function (sequelize: Sequelize.Sequelize, DataTypes: Sequelize.Da
         type: DataTypes.STRING,
         allowNull: false,
         validate: {
-          nameValid: function (value) {
+          nameValid: value => {
             const res = isVideoNameValid(value)
             if (res === false) throw new Error('Video name is not valid.')
           }
         }
       },
-      extname: {
-        type: DataTypes.ENUM(values(CONSTRAINTS_FIELDS.VIDEOS.EXTNAME)),
-        allowNull: false
-      },
-      remoteId: {
-        type: DataTypes.UUID,
-        allowNull: true,
-        validate: {
-          isUUID: 4
-        }
-      },
       category: {
         type: DataTypes.INTEGER,
         allowNull: false,
         validate: {
-          categoryValid: function (value) {
+          categoryValid: value => {
             const res = isVideoCategoryValid(value)
             if (res === false) throw new Error('Video category is not valid.')
           }
@@ -115,7 +132,7 @@ export default function (sequelize: Sequelize.Sequelize, DataTypes: Sequelize.Da
         allowNull: false,
         defaultValue: null,
         validate: {
-          licenceValid: function (value) {
+          licenceValid: value => {
             const res = isVideoLicenceValid(value)
             if (res === false) throw new Error('Video licence is not valid.')
           }
@@ -125,47 +142,47 @@ export default function (sequelize: Sequelize.Sequelize, DataTypes: Sequelize.Da
         type: DataTypes.INTEGER,
         allowNull: true,
         validate: {
-          languageValid: function (value) {
+          languageValid: value => {
             const res = isVideoLanguageValid(value)
             if (res === false) throw new Error('Video language is not valid.')
           }
         }
       },
+      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,
         validate: {
-          nsfwValid: function (value) {
+          nsfwValid: value => {
             const res = isVideoNSFWValid(value)
             if (res === false) throw new Error('Video nsfw attribute is not valid.')
           }
         }
       },
       description: {
-        type: DataTypes.STRING,
+        type: DataTypes.STRING(CONSTRAINTS_FIELDS.VIDEOS.DESCRIPTION.max),
         allowNull: false,
         validate: {
-          descriptionValid: function (value) {
+          descriptionValid: value => {
             const res = isVideoDescriptionValid(value)
             if (res === false) throw new Error('Video description is not valid.')
           }
         }
       },
-      infoHash: {
-        type: DataTypes.STRING,
-        allowNull: false,
-        validate: {
-          infoHashValid: function (value) {
-            const res = isVideoInfoHashValid(value)
-            if (res === false) throw new Error('Video info hash is not valid.')
-          }
-        }
-      },
       duration: {
         type: DataTypes.INTEGER,
         allowNull: false,
         validate: {
-          durationValid: function (value) {
+          durationValid: value => {
             const res = isVideoDurationValid(value)
             if (res === false) throw new Error('Video duration is not valid.')
           }
@@ -197,16 +214,25 @@ export default function (sequelize: Sequelize.Sequelize, DataTypes: Sequelize.Da
           min: 0,
           isInt: true
         }
+      },
+      remote: {
+        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.')
+          }
+        }
       }
     },
     {
       indexes: [
-        {
-          fields: [ 'authorId' ]
-        },
-        {
-          fields: [ 'remoteId' ]
-        },
         {
           fields: [ 'name' ]
         },
@@ -216,19 +242,20 @@ export default function (sequelize: Sequelize.Sequelize, DataTypes: Sequelize.Da
         {
           fields: [ 'duration' ]
         },
-        {
-          fields: [ 'infoHash' ]
-        },
         {
           fields: [ 'views' ]
         },
         {
           fields: [ 'likes' ]
+        },
+        {
+          fields: [ 'uuid' ]
+        },
+        {
+          fields: [ 'channelId' ]
         }
       ],
       hooks: {
-        beforeValidate,
-        beforeCreate,
         afterDestroy
       }
     }
@@ -238,137 +265,63 @@ export default function (sequelize: Sequelize.Sequelize, DataTypes: Sequelize.Da
     associate,
 
     generateThumbnailFromData,
-    getDurationFromFile,
     list,
     listForApi,
-    listOwnedAndPopulateAuthorAndTags,
-    listOwnedByAuthor,
+    listUserVideosForApi,
+    listOwnedAndPopulateAccountAndTags,
+    listOwnedByAccount,
     load,
-    loadByHostAndRemoteId,
-    loadAndPopulateAuthor,
-    loadAndPopulateAuthorAndPodAndTags,
-    searchAndPopulateAuthorAndPodAndTags,
-    removeFromBlacklist
+    loadByUrlAndPopulateAccount,
+    loadAndPopulateAccount,
+    loadAndPopulateAccountAndServerAndTags,
+    loadByHostAndUUID,
+    loadByUUIDOrURL,
+    loadByUUID,
+    loadLocalVideoByUUID,
+    loadByUUIDAndPopulateAccountAndServerAndTags,
+    searchAndPopulateAccountAndServerAndTags
   ]
   const instanceMethods = [
-    generateMagnetUri,
-    getVideoFilename,
-    getThumbnailName,
+    createPreview,
+    createThumbnail,
+    createTorrentAndSetInfoHash,
     getPreviewName,
-    getTorrentName,
+    getPreviewPath,
+    getThumbnailName,
+    getThumbnailPath,
+    getTorrentFileName,
+    getVideoFilename,
+    getVideoFilePath,
+    getOriginalFile,
     isOwned,
-    toFormatedJSON,
-    toAddRemoteJSON,
-    toUpdateRemoteJSON,
-    transcodeVideofile,
+    removeFile,
+    removePreview,
+    removeThumbnail,
+    removeTorrent,
+    toActivityPubObject,
+    toFormattedJSON,
+    toFormattedDetailsJSON,
+    optimizeOriginalVideofile,
+    transcodeOriginalVideofile,
+    getOriginalFileHeight,
+    getEmbedPath,
+    getTruncatedDescription,
+    getDescriptionPath,
+    getCategoryLabel,
+    getLicenceLabel,
+    getLanguageLabel
   ]
   addMethodsToModel(Video, classMethods, instanceMethods)
 
   return Video
 }
 
-function beforeValidate (video: VideoInstance) {
-  // Put a fake infoHash if it does not exists yet
-  if (video.isOwned() && !video.infoHash) {
-    // 40 hexa length
-    video.infoHash = '0123456789abcdef0123456789abcdef01234567'
-  }
-}
-
-function beforeCreate (video: VideoInstance, options: { transaction: Sequelize.Transaction }) {
-  return new Promise(function (resolve, reject) {
-    const tasks = []
-
-    if (video.isOwned()) {
-      const videoPath = join(CONFIG.STORAGE.VIDEOS_DIR, video.getVideoFilename())
-
-      tasks.push(
-        function createVideoTorrent (callback) {
-          createTorrentFromVideo(video, videoPath, callback)
-        },
-
-        function createVideoThumbnail (callback) {
-          createThumbnail(video, videoPath, callback)
-        },
-
-        function createVideoPreview (callback) {
-          createPreview(video, videoPath, callback)
-        }
-      )
-
-      if (CONFIG.TRANSCODING.ENABLED === true) {
-        tasks.push(
-          function createVideoTranscoderJob (callback) {
-            const dataInput = {
-              id: video.id
-            }
-
-            JobScheduler.Instance.createJob(options.transaction, 'videoTranscoder', dataInput, callback)
-          }
-        )
-      }
-
-      return parallel(tasks, function (err) {
-        if (err) return reject(err)
-
-        return resolve()
-      })
-    }
-
-    return resolve()
-  })
-}
-
-function afterDestroy (video: VideoInstance) {
-  return new Promise(function (resolve, reject) {
-    const tasks = []
-
-    tasks.push(
-      function (callback) {
-        removeThumbnail(video, callback)
-      }
-    )
-
-    if (video.isOwned()) {
-      tasks.push(
-        function removeVideoFile (callback) {
-          removeFile(video, callback)
-        },
-
-        function removeVideoTorrent (callback) {
-          removeTorrent(video, callback)
-        },
-
-        function removeVideoPreview (callback) {
-          removePreview(video, callback)
-        },
-
-        function notifyFriends (callback) {
-          const params = {
-            remoteId: video.id
-          }
-
-          removeVideoToFriends(params)
-
-          return callback()
-        }
-      )
-    }
-
-    parallel(tasks, function (err) {
-      if (err) return reject(err)
-
-      return resolve()
-    })
-  })
-}
-
 // ------------------------------ METHODS ------------------------------
 
 function associate (models) {
-  Video.belongsTo(models.Author, {
+  Video.belongsTo(models.VideoChannel, {
     foreignKey: {
-      name: 'authorId',
+      name: 'channelId',
       allowNull: false
     },
     onDelete: 'cascade'
@@ -387,109 +340,165 @@ function associate (models) {
     },
     onDelete: 'cascade'
   })
+
+  Video.hasMany(models.VideoFile, {
+    foreignKey: {
+      name: 'videoId',
+      allowNull: false
+    },
+    onDelete: 'cascade'
+  })
 }
 
-generateMagnetUri = function (this: VideoInstance) {
-  let baseUrlHttp
-  let baseUrlWs
+function afterDestroy (video: VideoInstance) {
+  const tasks = []
 
-  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
-  }
+  tasks.push(
+    video.removeThumbnail()
+  )
 
-  const xs = baseUrlHttp + STATIC_PATHS.TORRENTS + this.getTorrentName()
-  const announce = [ baseUrlWs + '/tracker/socket' ]
-  const urlList = [ baseUrlHttp + STATIC_PATHS.WEBSEED + this.getVideoFilename() ]
+  if (video.isOwned()) {
+    tasks.push(
+      video.removePreview(),
+      sendDeleteVideo(video, undefined)
+    )
 
-  const magnetHash = {
-    xs,
-    announce,
-    urlList,
-    infoHash: this.infoHash,
-    name: this.name
+    // Remove physical files and torrents
+    video.VideoFiles.forEach(file => {
+      tasks.push(video.removeFile(file))
+      tasks.push(video.removeTorrent(file))
+    })
   }
 
-  return magnetUtil.encode(magnetHash)
+  return Promise.all(tasks)
+    .catch(err => {
+      logger.error('Some errors when removing files of video %s in after destroy hook.', video.uuid, err)
+    })
 }
 
-getVideoFilename = function (this: VideoInstance) {
-  if (this.isOwned()) return this.id + this.extname
+getOriginalFile = function (this: VideoInstance) {
+  if (Array.isArray(this.VideoFiles) === false) return undefined
 
-  return this.remoteId + this.extname
+  // The original file is the file that have the higher resolution
+  return maxBy(this.VideoFiles, file => file.resolution)
+}
+
+getVideoFilename = function (this: VideoInstance, videoFile: VideoFileInstance) {
+  return this.uuid + '-' + videoFile.resolution + videoFile.extname
 }
 
 getThumbnailName = function (this: VideoInstance) {
   // We always have a copy of the thumbnail
-  return this.id + '.jpg'
+  const extension = '.jpg'
+  return this.uuid + extension
 }
 
 getPreviewName = function (this: VideoInstance) {
   const extension = '.jpg'
-
-  if (this.isOwned()) return this.id + extension
-
-  return this.remoteId + extension
+  return this.uuid + extension
 }
 
-getTorrentName = function (this: VideoInstance) {
+getTorrentFileName = function (this: VideoInstance, videoFile: VideoFileInstance) {
   const extension = '.torrent'
+  return this.uuid + '-' + videoFile.resolution + extension
+}
+
+isOwned = function (this: VideoInstance) {
+  return this.remote === false
+}
 
-  if (this.isOwned()) return this.id + extension
+createPreview = function (this: VideoInstance, videoFile: VideoFileInstance) {
+  const imageSize = PREVIEWS_SIZE.width + 'x' + PREVIEWS_SIZE.height
 
-  return this.remoteId + extension
+  return generateImageFromVideoFile(
+    this.getVideoFilePath(videoFile),
+    CONFIG.STORAGE.PREVIEWS_DIR,
+    this.getPreviewName(),
+    imageSize
+  )
 }
 
-isOwned = function (this: VideoInstance) {
-  return this.remoteId === null
+createThumbnail = function (this: VideoInstance, videoFile: VideoFileInstance) {
+  const imageSize = THUMBNAILS_SIZE.width + 'x' + THUMBNAILS_SIZE.height
+
+  return generateImageFromVideoFile(
+    this.getVideoFilePath(videoFile),
+    CONFIG.STORAGE.THUMBNAILS_DIR,
+    this.getThumbnailName(),
+    imageSize
+  )
 }
 
-toFormatedJSON = function (this: VideoInstance) {
-  let podHost
+getVideoFilePath = function (this: VideoInstance, videoFile: VideoFileInstance) {
+  return join(CONFIG.STORAGE.VIDEOS_DIR, this.getVideoFilename(videoFile))
+}
 
-  if (this.Author.Pod) {
-    podHost = this.Author.Pod.host
-  } else {
-    // It means it's our video
-    podHost = CONFIG.WEBSERVER.HOST
+createTorrentAndSetInfoHash = async function (this: VideoInstance, videoFile: VideoFileInstance) {
+  const options = {
+    announceList: [
+      [ CONFIG.WEBSERVER.WS + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT + '/tracker/socket' ]
+    ],
+    urlList: [
+      CONFIG.WEBSERVER.URL + STATIC_PATHS.WEBSEED + this.getVideoFilename(videoFile)
+    ]
   }
 
-  // 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'
+  const torrent = await createTorrentPromise(this.getVideoFilePath(videoFile), options)
 
-  // 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'
+  const filePath = join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
+  logger.info('Creating torrent %s.', filePath)
 
-  // Language is an optional attribute
-  let languageLabel = VIDEO_LANGUAGES[this.language]
-  if (!languageLabel) languageLabel = 'Unknown'
+  await writeFilePromise(filePath, torrent)
+
+  const parsedTorrent = parseTorrent(torrent)
+  videoFile.infoHash = parsedTorrent.infoHash
+}
+
+getEmbedPath = function (this: VideoInstance) {
+  return '/videos/embed/' + this.uuid
+}
+
+getThumbnailPath = function (this: VideoInstance) {
+  return join(STATIC_PATHS.THUMBNAILS, this.getThumbnailName())
+}
+
+getPreviewPath = function (this: VideoInstance) {
+  return join(STATIC_PATHS.PREVIEWS, this.getPreviewName())
+}
+
+toFormattedJSON = function (this: VideoInstance) {
+  let serverHost
+
+  if (this.VideoChannel.Account.Server) {
+    serverHost = this.VideoChannel.Account.Server.host
+  } else {
+    // It means it's our video
+    serverHost = CONFIG.WEBSERVER.HOST
+  }
 
   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(),
-    magnetUri: this.generateMagnetUri(),
-    author: this.Author.name,
+    account: this.VideoChannel.Account.name,
     duration: this.duration,
     views: this.views,
     likes: this.likes,
     dislikes: this.dislikes,
-    tags: map<VideoTagInstance, string>(this.Tags, 'name'),
-    thumbnailPath: join(STATIC_PATHS.THUMBNAILS, this.getThumbnailName()),
+    tags: map<TagInstance, string>(this.Tags, 'name'),
+    thumbnailPath: this.getThumbnailPath(),
+    previewPath: this.getPreviewPath(),
+    embedPath: this.getEmbedPath(),
     createdAt: this.createdAt,
     updatedAt: this.updatedAt
   }
@@ -497,145 +506,308 @@ toFormatedJSON = function (this: VideoInstance) {
   return json
 }
 
-toAddRemoteJSON = function (this: VideoInstance, callback: VideoMethods.ToAddRemoteJSONCallback) {
-  // Get thumbnail data to send to the other pod
-  const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName())
-  fs.readFile(thumbnailPath, (err, thumbnailData) => {
-    if (err) {
-      logger.error('Cannot read the thumbnail of the video')
-      return callback(err)
-    }
+toFormattedDetailsJSON = function (this: VideoInstance) {
+  const formattedJson = this.toFormattedJSON()
 
-    const remoteVideo = {
-      name: this.name,
-      category: this.category,
-      licence: this.licence,
-      language: this.language,
-      nsfw: this.nsfw,
-      description: this.description,
-      infoHash: this.infoHash,
-      remoteId: this.id,
-      author: this.Author.name,
-      duration: this.duration,
-      thumbnailData: thumbnailData.toString('binary'),
-      tags: map<VideoTagInstance, string>(this.Tags, 'name'),
-      createdAt: this.createdAt,
-      updatedAt: this.updatedAt,
-      extname: this.extname,
-      views: this.views,
-      likes: this.likes,
-      dislikes: this.dislikes
-    }
+  // 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'
 
-    return callback(null, remoteVideo)
-  })
+  const detailsJson = {
+    privacyLabel,
+    privacy: this.privacy,
+    descriptionPath: this.getDescriptionPath(),
+    channel: this.VideoChannel.toFormattedJSON(),
+    files: []
+  }
+
+  // Format and sort video files
+  const { baseUrlHttp, baseUrlWs } = getBaseUrls(this)
+  detailsJson.files = this.VideoFiles
+                   .map(videoFile => {
+                     let resolutionLabel = videoFile.resolution + 'p'
+
+                     const videoFileJson = {
+                       resolution: videoFile.resolution,
+                       resolutionLabel,
+                       magnetUri: generateMagnetUri(this, videoFile, baseUrlHttp, baseUrlWs),
+                       size: videoFile.size,
+                       torrentUrl: getTorrentUrl(this, videoFile, baseUrlHttp),
+                       fileUrl: getVideoFileUrl(this, videoFile, baseUrlHttp)
+                     }
+
+                     return videoFileJson
+                   })
+                   .sort((a, b) => {
+                     if (a.resolution < b.resolution) return 1
+                     if (a.resolution === b.resolution) return 0
+                     return -1
+                   })
+
+  return Object.assign(formattedJson, detailsJson)
 }
 
-toUpdateRemoteJSON = function (this: VideoInstance) {
-  const json = {
+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
+    })
+
+    url.push({
+      type: 'Link',
+      mimeType: 'application/x-bittorrent',
+      url: getTorrentUrl(this, file, baseUrlHttp),
+      width: file.resolution
+    })
+
+    url.push({
+      type: 'Link',
+      mimeType: 'application/x-bittorrent;x-scheme-handler/magnet',
+      url: generateMagnetUri(this, file, baseUrlHttp, baseUrlWs),
+      width: file.resolution
+    })
+  }
+
+  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,
-    infoHash: this.infoHash,
-    remoteId: this.id,
-    author: this.Author.name,
-    duration: this.duration,
-    tags: map<VideoTagInstance, string>(this.Tags, 'name'),
-    createdAt: this.createdAt,
-    updatedAt: this.updatedAt,
-    extname: this.extname,
+    // 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
+    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?
   }
 
-  return json
+  return videoObject
 }
 
-transcodeVideofile = function (this: VideoInstance, finalCallback: VideoMethods.TranscodeVideofileCallback) {
-  const video = this
+getTruncatedDescription = function (this: VideoInstance) {
+  const options = {
+    length: CONSTRAINTS_FIELDS.VIDEOS.TRUNCATED_DESCRIPTION.max
+  }
+
+  return truncate(this.description, options)
+}
 
+optimizeOriginalVideofile = async function (this: VideoInstance) {
   const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
   const newExtname = '.mp4'
-  const videoInputPath = join(videosDirectory, video.getVideoFilename())
-  const videoOutputPath = join(videosDirectory, video.id + '-transcoded' + newExtname)
-
-  ffmpeg(videoInputPath)
-    .output(videoOutputPath)
-    .videoCodec('libx264')
-    .outputOption('-threads ' + CONFIG.TRANSCODING.THREADS)
-    .outputOption('-movflags faststart')
-    .on('error', finalCallback)
-    .on('end', function () {
-      series([
-        function removeOldFile (callback) {
-          fs.unlink(videoInputPath, callback)
-        },
+  const inputVideoFile = this.getOriginalFile()
+  const videoInputPath = join(videosDirectory, this.getVideoFilename(inputVideoFile))
+  const videoOutputPath = join(videosDirectory, this.id + '-transcoded' + newExtname)
 
-        function moveNewFile (callback) {
-          // Important to do this before getVideoFilename() to take in account the new file extension
-          video.set('extname', newExtname)
+  const transcodeOptions = {
+    inputPath: videoInputPath,
+    outputPath: videoOutputPath
+  }
 
-          const newVideoPath = join(videosDirectory, video.getVideoFilename())
-          fs.rename(videoOutputPath, newVideoPath, callback)
-        },
+  try {
+    // Could be very long!
+    await transcode(transcodeOptions)
 
-        function torrent (callback) {
-          const newVideoPath = join(videosDirectory, video.getVideoFilename())
-          createTorrentFromVideo(video, newVideoPath, callback)
-        },
+    await unlinkPromise(videoInputPath)
 
-        function videoExtension (callback) {
-          video.save().asCallback(callback)
-        }
+    // Important to do this before getVideoFilename() to take in account the new file extension
+    inputVideoFile.set('extname', newExtname)
 
-      ], function (err: Error) {
-        if (err) {
-          // Autodesctruction...
-          video.destroy().asCallback(function (err) {
-            if (err) logger.error('Cannot destruct video after transcoding failure.', { error: err })
-          })
+    await renamePromise(videoOutputPath, this.getVideoFilePath(inputVideoFile))
+    const stats = await statPromise(this.getVideoFilePath(inputVideoFile))
 
-          return finalCallback(err)
-        }
+    inputVideoFile.set('size', stats.size)
 
-        return finalCallback(null)
-      })
-    })
-    .run()
+    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 = async function (this: VideoInstance, resolution: VideoResolution) {
+  const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
+  const extname = '.mp4'
+
+  // We are sure it's x264 in mp4 because optimizeOriginalVideofile was already executed
+  const videoInputPath = join(videosDirectory, this.getVideoFilename(this.getOriginalFile()))
+
+  const newVideoFile = (Video['sequelize'].models.VideoFile as VideoFileModel).build({
+    resolution,
+    extname,
+    size: 0,
+    videoId: this.id
+  })
+  const videoOutputPath = join(videosDirectory, this.getVideoFilename(newVideoFile))
+
+  const transcodeOptions = {
+    inputPath: videoInputPath,
+    outputPath: videoOutputPath,
+    resolution
+  }
+
+  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) {
+  const originalFilePath = this.getVideoFilePath(this.getOriginalFile())
+
+  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)
+}
+
+removePreview = function (this: VideoInstance) {
+  // Same name than video thumbnail
+  return unlinkPromise(CONFIG.STORAGE.PREVIEWS_DIR + this.getPreviewName())
+}
+
+removeFile = function (this: VideoInstance, videoFile: VideoFileInstance) {
+  const filePath = join(CONFIG.STORAGE.VIDEOS_DIR, this.getVideoFilename(videoFile))
+  return unlinkPromise(filePath)
+}
+
+removeTorrent = function (this: VideoInstance, videoFile: VideoFileInstance) {
+  const torrentPath = join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
+  return unlinkPromise(torrentPath)
 }
 
 // ------------------------------ STATICS ------------------------------
 
-generateThumbnailFromData = function (video: VideoInstance, thumbnailData: string, callback: VideoMethods.GenerateThumbnailFromDataCallback) {
+generateThumbnailFromData = function (video: VideoInstance, thumbnailData: string) {
   // Creating the thumbnail for a remote video
 
   const thumbnailName = video.getThumbnailName()
   const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, thumbnailName)
-  fs.writeFile(thumbnailPath, Buffer.from(thumbnailData, 'binary'), function (err) {
-    if (err) return callback(err)
-
-    return callback(null, thumbnailName)
+  return writeFilePromise(thumbnailPath, Buffer.from(thumbnailData, 'binary')).then(() => {
+    return thumbnailName
   })
 }
 
-getDurationFromFile = function (videoPath: string, callback: VideoMethods.GetDurationFromFileCallback) {
-  ffmpeg.ffprobe(videoPath, function (err, metadata) {
-    if (err) return callback(err)
+list = function () {
+  const query = {
+    include: [ Video['sequelize'].models.VideoFile ]
+  }
 
-    return callback(null, Math.floor(metadata.format.duration))
-  })
+  return Video.findAll(query)
 }
 
-list = function (callback: VideoMethods.ListCallback) {
-  return Video.findAll().asCallback(callback)
+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, callback: VideoMethods.ListForApiCallback) {
-  // Exclude Blakclisted videos from the list
+listForApi = function (start: number, count: number, sort: string) {
   const query = {
     distinct: true,
     offset: start,
@@ -643,36 +815,102 @@ listForApi = function (start: number, count: number, sort: string, callback: Vid
     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,
+        required: true,
+        include: [
+          {
+            model: Video['sequelize'].models.Account,
+            required: true,
+            include: [
+              {
+                model: Video['sequelize'].models.Server,
+                required: false
+              }
+            ]
+          }
+        ]
       },
-
       Video['sequelize'].models.Tag
     ],
     where: createBaseVideosWhere()
   }
 
-  return Video.findAndCountAll(query).asCallback(function (err, result) {
-    if (err) return callback(err)
-
-    return callback(null, result.rows, result.count)
+  return Video.findAndCountAll(query).then(({ rows, count }) => {
+    return {
+      data: rows,
+      total: count
+    }
   })
 }
 
-loadByHostAndRemoteId = function (fromHost: string, remoteId: string, callback: VideoMethods.LoadByHostAndRemoteIdCallback) {
+loadByHostAndUUID = function (fromHost: string, uuid: string, t?: Sequelize.Transaction) {
+  const query: Sequelize.FindOptions<VideoAttributes> = {
+    where: {
+      uuid
+    },
+    include: [
+      {
+        model: Video['sequelize'].models.VideoFile
+      },
+      {
+        model: Video['sequelize'].models.VideoChannel,
+        include: [
+          {
+            model: Video['sequelize'].models.Account,
+            include: [
+              {
+                model: Video['sequelize'].models.Server,
+                required: true,
+                where: {
+                  host: fromHost
+                }
+              }
+            ]
+          }
+        ]
+      }
+    ]
+  }
+
+  if (t !== undefined) query.transaction = t
+
+  return Video.findOne(query)
+}
+
+listOwnedAndPopulateAccountAndTags = function () {
   const query = {
     where: {
-      remoteId: remoteId
+      remote: false
     },
     include: [
+      Video['sequelize'].models.VideoFile,
       {
-        model: Video['sequelize'].models.Author,
+        model: Video['sequelize'].models.VideoChannel,
+        include: [ Video['sequelize'].models.Account ]
+      },
+      Video['sequelize'].models.Tag
+    ]
+  }
+
+  return Video.findAll(query)
+}
+
+listOwnedByAccount = function (account: string) {
+  const query = {
+    where: {
+      remote: false
+    },
+    include: [
+      {
+        model: Video['sequelize'].models.VideoFile
+      },
+      {
+        model: Video['sequelize'].models.VideoChannel,
         include: [
           {
-            model: Video['sequelize'].models.Pod,
-            required: true,
+            model: Video['sequelize'].models.Account,
             where: {
-              host: fromHost
+              name: account
             }
           }
         ]
@@ -680,90 +918,154 @@ loadByHostAndRemoteId = function (fromHost: string, remoteId: string, callback:
     ]
   }
 
-  return Video.findOne(query).asCallback(callback)
+  return Video.findAll(query)
 }
 
-listOwnedAndPopulateAuthorAndTags = function (callback: VideoMethods.ListOwnedAndPopulateAuthorAndTagsCallback) {
-  // If remoteId is null this is *our* video
-  const query = {
+load = function (id: number) {
+  return Video.findById(id)
+}
+
+loadByUUID = function (uuid: string, t?: Sequelize.Transaction) {
+  const query: Sequelize.FindOptions<VideoAttributes> = {
     where: {
-      remoteId: null
+      uuid
     },
-    include: [ Video['sequelize'].models.Author, Video['sequelize'].models.Tag ]
+    include: [ Video['sequelize'].models.VideoFile ]
   }
 
-  return Video.findAll(query).asCallback(callback)
+  if (t !== undefined) query.transaction = t
+
+  return Video.findOne(query)
 }
 
-listOwnedByAuthor = function (author: string, callback: VideoMethods.ListOwnedByAuthorCallback) {
-  const query = {
+loadByUrlAndPopulateAccount = function (url: string, t?: Sequelize.Transaction) {
+  const query: Sequelize.FindOptions<VideoAttributes> = {
     where: {
-      remoteId: null
+      url
     },
     include: [
+      Video['sequelize'].models.VideoFile,
       {
-        model: Video['sequelize'].models.Author,
-        where: {
-          name: author
-        }
+        model: Video['sequelize'].models.VideoChannel,
+        include: [ Video['sequelize'].models.Account ]
       }
     ]
   }
 
-  return Video.findAll(query).asCallback(callback)
+  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)
 }
 
-load = function (id: string, callback: VideoMethods.LoadCallback) {
-  return Video.findById(id).asCallback(callback)
+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: string, callback: VideoMethods.LoadAndPopulateAuthorCallback) {
+loadAndPopulateAccount = function (id: number) {
   const options = {
-    include: [ Video['sequelize'].models.Author ]
+    include: [
+      Video['sequelize'].models.VideoFile,
+      {
+        model: Video['sequelize'].models.VideoChannel,
+        include: [ Video['sequelize'].models.Account ]
+      }
+    ]
   }
 
-  return Video.findById(id, options).asCallback(callback)
+  return Video.findById(id, options)
 }
 
-loadAndPopulateAuthorAndPodAndTags = function (id: string, callback: VideoMethods.LoadAndPopulateAuthorAndPodAndTagsCallback) {
+loadAndPopulateAccountAndServerAndTags = 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.Account,
+            include: [ { model: Video['sequelize'].models.Server, required: false } ]
+          }
+        ]
       },
-      Video['sequelize'].models.Tag
+      Video['sequelize'].models.Tag,
+      Video['sequelize'].models.VideoFile
     ]
   }
 
-  return Video.findById(id, options).asCallback(callback)
+  return Video.findById(id, options)
 }
 
-searchAndPopulateAuthorAndPodAndTags = function (
-  value: string,
-  field: string,
-  start: number,
-  count: number,
-  sort: string,
-  callback: VideoMethods.SearchAndPopulateAuthorAndPodAndTagsCallback
-) {
-  const podInclude: any = {
-    model: Video['sequelize'].models.Pod,
+loadByUUIDAndPopulateAccountAndServerAndTags = function (uuid: string) {
+  const options = {
+    where: {
+      uuid
+    },
+    include: [
+      {
+        model: Video['sequelize'].models.VideoChannel,
+        include: [
+          {
+            model: Video['sequelize'].models.Account,
+            include: [ { model: Video['sequelize'].models.Server, required: false } ]
+          }
+        ]
+      },
+      Video['sequelize'].models.Tag,
+      Video['sequelize'].models.VideoFile
+    ]
+  }
+
+  return Video.findOne(options)
+}
+
+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: any = {
-    model: Video['sequelize'].models.Author,
-    include: [
-      podInclude
-    ]
+  const accountInclude: Sequelize.IncludeOptions = {
+    model: Video['sequelize'].models.Account,
+    include: [ serverInclude ]
   }
 
-  const tagInclude: any = {
+  const videoChannelInclude: Sequelize.IncludeOptions = {
+    model: Video['sequelize'].models.VideoChannel,
+    include: [ accountInclude ],
+    required: true
+  }
+
+  const tagInclude: Sequelize.IncludeOptions = {
     model: Video['sequelize'].models.Tag
   }
 
-  const query: any = {
+  const query: Sequelize.FindOptions<VideoAttributes> = {
     distinct: true,
     where: createBaseVideosWhere(),
     offset: start,
@@ -771,49 +1073,44 @@ searchAndPopulateAuthorAndPodAndTags = function (
     order: [ getSort(sort), [ Video['sequelize'].models.Tag, 'name', 'ASC' ] ]
   }
 
-  // Make an exact search with the magnet
-  if (field === 'magnetUri') {
-    const infoHash = magnetUtil.decode(value).infoHash
-    query.where.infoHash = infoHash
-  } else if (field === 'tags') {
+  if (field === 'tags') {
     const escapedValue = Video['sequelize'].escape('%' + value + '%')
-    query.where.id.$in = Video['sequelize'].literal(
-      '(SELECT "VideoTags"."videoId" FROM "Tags" INNER JOIN "VideoTags" ON "Tags"."id" = "VideoTags"."tagId" WHERE name LIKE ' + escapedValue + ')'
+    query.where['id'][Sequelize.Op.in] = Video['sequelize'].literal(
+      `(SELECT "VideoTags"."videoId"
+        FROM "Tags"
+        INNER JOIN "VideoTags" ON "Tags"."id" = "VideoTags"."tagId"
+        WHERE name ILIKE ${escapedValue}
+       )`
     )
   } 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: {
-        $like: '%' + value + '%'
+        [Sequelize.Op.iLike]: '%' + value + '%'
       }
     }
-    podInclude.required = true
-  } else if (field === 'author') {
-    authorInclude.where = {
+    serverInclude.required = true
+  } else if (field === 'account') {
+    accountInclude.where = {
       name: {
-        $like: '%' + value + '%'
+        [Sequelize.Op.iLike]: '%' + value + '%'
       }
     }
-
-    // authorInclude.or = true
   } else {
     query.where[field] = {
-      $like: '%' + value + '%'
+      [Sequelize.Op.iLike]: '%' + value + '%'
     }
   }
 
   query.include = [
-    authorInclude, tagInclude
+    videoChannelInclude, tagInclude
   ]
 
-  if (tagInclude.where) {
-    // query.include.push([ Video['sequelize'].models.Tag ])
-  }
-
-  return Video.findAndCountAll(query).asCallback(function (err, result) {
-    if (err) return callback(err)
-
-    return callback(null, result.rows, result.count)
+  return Video.findAndCountAll(query).then(({ rows, count }) => {
+    return {
+      data: rows,
+      total: count
+    }
   })
 }
 
@@ -822,100 +1119,53 @@ searchAndPopulateAuthorAndPodAndTags = function (
 function createBaseVideosWhere () {
   return {
     id: {
-      $notIn: Video['sequelize'].literal(
+      [Sequelize.Op.notIn]: Video['sequelize'].literal(
         '(SELECT "BlacklistedVideos"."videoId" FROM "BlacklistedVideos")'
       )
-    }
+    },
+    privacy: VideoPrivacy.PUBLIC
   }
 }
 
-function removeThumbnail (video: VideoInstance, callback: (err: Error) => void) {
-  const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, video.getThumbnailName())
-  fs.unlink(thumbnailPath, callback)
-}
-
-function removeFile (video: VideoInstance, callback: (err: Error) => void) {
-  const filePath = join(CONFIG.STORAGE.VIDEOS_DIR, video.getVideoFilename())
-  fs.unlink(filePath, callback)
-}
-
-function removeTorrent (video: VideoInstance, callback: (err: Error) => void) {
-  const torrenPath = join(CONFIG.STORAGE.TORRENTS_DIR, video.getTorrentName())
-  fs.unlink(torrenPath, callback)
-}
-
-function removePreview (video: VideoInstance, callback: (err: Error) => void) {
-  // Same name than video thumnail
-  fs.unlink(CONFIG.STORAGE.PREVIEWS_DIR + video.getPreviewName(), callback)
-}
+function getBaseUrls (video: VideoInstance) {
+  let baseUrlHttp
+  let baseUrlWs
 
-function createTorrentFromVideo (video: VideoInstance, videoPath: string, callback: (err: Error) => void) {
-  const options = {
-    announceList: [
-      [ CONFIG.WEBSERVER.WS + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT + '/tracker/socket' ]
-    ],
-    urlList: [
-      CONFIG.WEBSERVER.URL + STATIC_PATHS.WEBSEED + video.getVideoFilename()
-    ]
+  if (video.isOwned()) {
+    baseUrlHttp = CONFIG.WEBSERVER.URL
+    baseUrlWs = CONFIG.WEBSERVER.WS + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT
+  } else {
+    baseUrlHttp = REMOTE_SCHEME.HTTP + '://' + video.VideoChannel.Account.Server.host
+    baseUrlWs = REMOTE_SCHEME.WS + '://' + video.VideoChannel.Account.Server.host
   }
 
-  createTorrent(videoPath, options, function (err, torrent) {
-    if (err) return callback(err)
-
-    const filePath = join(CONFIG.STORAGE.TORRENTS_DIR, video.getTorrentName())
-    fs.writeFile(filePath, torrent, function (err) {
-      if (err) return callback(err)
+  return { baseUrlHttp, baseUrlWs }
+}
 
-      const parsedTorrent = parseTorrent(torrent)
-      video.set('infoHash', parsedTorrent.infoHash)
-      video.validate().asCallback(callback)
-    })
-  })
+function getThumbnailUrl (video: VideoInstance, baseUrlHttp: string) {
+  return baseUrlHttp + STATIC_PATHS.THUMBNAILS + video.getThumbnailName()
 }
 
-function createPreview (video: VideoInstance, videoPath: string, callback: (err: Error) => void) {
-  generateImage(video, videoPath, CONFIG.STORAGE.PREVIEWS_DIR, video.getPreviewName(), null, callback)
+function getTorrentUrl (video: VideoInstance, videoFile: VideoFileInstance, baseUrlHttp: string) {
+  return baseUrlHttp + STATIC_PATHS.TORRENTS + video.getTorrentFileName(videoFile)
 }
 
-function createThumbnail (video: VideoInstance, videoPath: string, callback: (err: Error) => void) {
-  generateImage(video, videoPath, CONFIG.STORAGE.THUMBNAILS_DIR, video.getThumbnailName(), THUMBNAILS_SIZE, callback)
+function getVideoFileUrl (video: VideoInstance, videoFile: VideoFileInstance, baseUrlHttp: string) {
+  return baseUrlHttp + STATIC_PATHS.WEBSEED + video.getVideoFilename(videoFile)
 }
 
-type GenerateImageCallback = (err: Error, imageName: string) => void
-function generateImage (video: VideoInstance, videoPath: string, folder: string, imageName: string, size: string, callback?: GenerateImageCallback) {
-  const options: any = {
-    filename: imageName,
-    count: 1,
-    folder
-  }
+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) ]
 
-  if (size) {
-    options.size = size
+  const magnetHash = {
+    xs,
+    announce,
+    urlList,
+    infoHash: videoFile.infoHash,
+    name: video.name
   }
 
-  ffmpeg(videoPath)
-    .on('error', callback)
-    .on('end', function () {
-      callback(null, imageName)
-    })
-    .thumbnail(options)
-}
-
-function removeFromBlacklist (video: VideoInstance, callback: (err: Error) => void) {
-  // Find the blacklisted video
-  db.BlacklistedVideo.loadByVideoId(video.id, function (err, video) {
-    // If an error occured, stop here
-    if (err) {
-      logger.error('Error when fetching video from blacklist.', { error: err })
-      return callback(err)
-    }
-
-    // If we found the video, remove it from the blacklist
-    if (video) {
-      video.destroy().asCallback(callback)
-    } else {
-      // If haven't found it, simply ignore it and do nothing
-      return callback(null)
-    }
-  })
+  return magnetUtil.encode(magnetHash)
 }