]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blobdiff - server/models/video/video.ts
Change how we handle resolution
[github/Chocobozzz/PeerTube.git] / server / models / video / video.ts
index 629051ae46c24af0b3747a897e6014b848f10667..2ba6cf25f0cc4daa8b137f0c6e8045a506633f64 100644 (file)
@@ -1,14 +1,13 @@
 import * as safeBuffer from 'safe-buffer'
 const Buffer = safeBuffer.Buffer
-import * as ffmpeg from 'fluent-ffmpeg'
 import * as magnetUtil from 'magnet-uri'
-import { map, values } from 'lodash'
+import { map } from 'lodash'
 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 { database as db } from '../../initializers/database'
 import { TagInstance } from './tag-interface'
 import {
   logger,
@@ -18,16 +17,18 @@ import {
   isVideoLanguageValid,
   isVideoNSFWValid,
   isVideoDescriptionValid,
-  isVideoInfoHashValid,
   isVideoDurationValid,
   readFileBufferPromise,
   unlinkPromise,
   renamePromise,
   writeFilePromise,
-  createTorrentPromise
+  createTorrentPromise,
+  statPromise,
+  generateImageFromVideoFile,
+  transcode,
+  getVideoFileHeight
 } from '../../helpers'
 import {
-  CONSTRAINTS_FIELDS,
   CONFIG,
   REMOTE_SCHEME,
   STATIC_PATHS,
@@ -36,7 +37,9 @@ import {
   VIDEO_LANGUAGES,
   THUMBNAILS_SIZE
 } from '../../initializers'
-import { JobScheduler, removeVideoToFriends } from '../../lib'
+import { removeVideoToFriends } from '../../lib'
+import { VideoResolution } from '../../../shared'
+import { VideoFileInstance, VideoFileModel } from './video-file-interface'
 
 import { addMethodsToModel, getSort } from '../utils'
 import {
@@ -47,36 +50,48 @@ import {
 } from './video-interface'
 
 let Video: Sequelize.Model<VideoInstance, VideoAttributes>
+let getOriginalFile: VideoMethods.GetOriginalFile
 let generateMagnetUri: VideoMethods.GenerateMagnetUri
 let getVideoFilename: VideoMethods.GetVideoFilename
 let getThumbnailName: VideoMethods.GetThumbnailName
 let getPreviewName: VideoMethods.GetPreviewName
-let getTorrentName: VideoMethods.GetTorrentName
+let getTorrentFileName: VideoMethods.GetTorrentFileName
 let isOwned: VideoMethods.IsOwned
-let toFormatedJSON: VideoMethods.ToFormatedJSON
+let toFormattedJSON: VideoMethods.ToFormattedJSON
 let toAddRemoteJSON: VideoMethods.ToAddRemoteJSON
 let toUpdateRemoteJSON: VideoMethods.ToUpdateRemoteJSON
-let transcodeVideofile: VideoMethods.TranscodeVideofile
+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 generateThumbnailFromData: VideoMethods.GenerateThumbnailFromData
-let getDurationFromFile: VideoMethods.GetDurationFromFile
 let list: VideoMethods.List
 let listForApi: VideoMethods.ListForApi
-let loadByHostAndRemoteId: VideoMethods.LoadByHostAndRemoteId
+let loadByHostAndUUID: VideoMethods.LoadByHostAndUUID
 let listOwnedAndPopulateAuthorAndTags: VideoMethods.ListOwnedAndPopulateAuthorAndTags
 let listOwnedByAuthor: VideoMethods.ListOwnedByAuthor
 let load: VideoMethods.Load
+let loadByUUID: VideoMethods.LoadByUUID
 let loadAndPopulateAuthor: VideoMethods.LoadAndPopulateAuthor
 let loadAndPopulateAuthorAndPodAndTags: VideoMethods.LoadAndPopulateAuthorAndPodAndTags
+let loadByUUIDAndPopulateAuthorAndPodAndTags: VideoMethods.LoadByUUIDAndPopulateAuthorAndPodAndTags
 let searchAndPopulateAuthorAndPodAndTags: VideoMethods.SearchAndPopulateAuthorAndPodAndTags
+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
         }
@@ -85,28 +100,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.')
           }
@@ -117,7 +121,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.')
           }
@@ -127,7 +131,7 @@ 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.')
           }
@@ -137,7 +141,7 @@ export default function (sequelize: Sequelize.Sequelize, DataTypes: Sequelize.Da
         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.')
           }
@@ -147,27 +151,17 @@ export default function (sequelize: Sequelize.Sequelize, DataTypes: Sequelize.Da
         type: DataTypes.STRING,
         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.')
           }
@@ -199,6 +193,11 @@ export default function (sequelize: Sequelize.Sequelize, DataTypes: Sequelize.Da
           min: 0,
           isInt: true
         }
+      },
+      remote: {
+        type: DataTypes.BOOLEAN,
+        allowNull: false,
+        defaultValue: false
       }
     },
     {
@@ -206,9 +205,6 @@ export default function (sequelize: Sequelize.Sequelize, DataTypes: Sequelize.Da
         {
           fields: [ 'authorId' ]
         },
-        {
-          fields: [ 'remoteId' ]
-        },
         {
           fields: [ 'name' ]
         },
@@ -218,19 +214,17 @@ export default function (sequelize: Sequelize.Sequelize, DataTypes: Sequelize.Da
         {
           fields: [ 'duration' ]
         },
-        {
-          fields: [ 'infoHash' ]
-        },
         {
           fields: [ 'views' ]
         },
         {
           fields: [ 'likes' ]
+        },
+        {
+          fields: [ 'uuid' ]
         }
       ],
       hooks: {
-        beforeValidate,
-        beforeCreate,
         afterDestroy
       }
     }
@@ -240,93 +234,46 @@ export default function (sequelize: Sequelize.Sequelize, DataTypes: Sequelize.Da
     associate,
 
     generateThumbnailFromData,
-    getDurationFromFile,
     list,
     listForApi,
     listOwnedAndPopulateAuthorAndTags,
     listOwnedByAuthor,
     load,
-    loadByHostAndRemoteId,
     loadAndPopulateAuthor,
     loadAndPopulateAuthorAndPodAndTags,
-    searchAndPopulateAuthorAndPodAndTags,
-    removeFromBlacklist
+    loadByHostAndUUID,
+    loadByUUID,
+    loadByUUIDAndPopulateAuthorAndPodAndTags,
+    searchAndPopulateAuthorAndPodAndTags
   ]
   const instanceMethods = [
+    createPreview,
+    createThumbnail,
+    createTorrentAndSetInfoHash,
     generateMagnetUri,
-    getVideoFilename,
-    getThumbnailName,
     getPreviewName,
-    getTorrentName,
+    getThumbnailName,
+    getTorrentFileName,
+    getVideoFilename,
+    getVideoFilePath,
+    getOriginalFile,
     isOwned,
-    toFormatedJSON,
+    removeFile,
+    removePreview,
+    removeThumbnail,
+    removeTorrent,
     toAddRemoteJSON,
+    toFormattedJSON,
     toUpdateRemoteJSON,
-    transcodeVideofile
+    optimizeOriginalVideofile,
+    transcodeOriginalVideofile,
+    getOriginalFileHeight
   ]
   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 }) {
-  if (video.isOwned()) {
-    const videoPath = join(CONFIG.STORAGE.VIDEOS_DIR, video.getVideoFilename())
-    const tasks = []
-
-    tasks.push(
-      createTorrentFromVideo(video, videoPath),
-      createThumbnail(video, videoPath),
-      createPreview(video, videoPath)
-    )
-
-    if (CONFIG.TRANSCODING.ENABLED === true) {
-      const dataInput = {
-        id: video.id
-      }
-
-      tasks.push(
-        JobScheduler.Instance.createJob(options.transaction, 'videoTranscoder', dataInput)
-      )
-    }
-
-    return Promise.all(tasks)
-  }
-
-  return Promise.resolve()
-}
-
-function afterDestroy (video: VideoInstance) {
-  const tasks = []
-
-  tasks.push(
-    removeThumbnail(video)
-  )
-
-  if (video.isOwned()) {
-    const removeVideoToFriendsParams = {
-      remoteId: video.id
-    }
-
-    tasks.push(
-      removeFile(video),
-      removeTorrent(video),
-      removePreview(video),
-      removeVideoToFriends(removeVideoToFriendsParams)
-    )
-  }
-
-  return Promise.all(tasks)
-}
-
 // ------------------------------ METHODS ------------------------------
 
 function associate (models) {
@@ -351,67 +298,147 @@ 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, options: { transaction: Sequelize.Transaction }) {
+  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()) {
+    const removeVideoToFriendsParams = {
+      uuid: video.uuid
+    }
 
-  const magnetHash = {
-    xs,
-    announce,
-    urlList,
-    infoHash: this.infoHash,
-    name: this.name
+    tasks.push(
+      video.removePreview(),
+      removeVideoToFriends(removeVideoToFriendsParams, options.transaction)
+    )
+
+    // Remove physical files and torrents
+    video.VideoFiles.forEach(file => {
+      video.removeFile(file),
+      video.removeTorrent(file)
+    })
   }
 
-  return magnetUtil.encode(magnetHash)
+  return Promise.all(tasks)
 }
 
-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'
+  return this.uuid + extension
+}
 
-  if (this.isOwned()) return this.id + extension
+getTorrentFileName = function (this: VideoInstance, videoFile: VideoFileInstance) {
+  const extension = '.torrent'
+  return this.uuid + '-' + videoFile.resolution + extension
+}
 
-  return this.remoteId + extension
+isOwned = function (this: VideoInstance) {
+  return this.remote === false
 }
 
-getTorrentName = function (this: VideoInstance) {
-  const extension = '.torrent'
+createPreview = function (this: VideoInstance, videoFile: VideoFileInstance) {
+  return generateImageFromVideoFile(
+    this.getVideoFilePath(videoFile),
+    CONFIG.STORAGE.PREVIEWS_DIR,
+    this.getPreviewName()
+  )
+}
 
-  if (this.isOwned()) return this.id + extension
+createThumbnail = function (this: VideoInstance, videoFile: VideoFileInstance) {
+  return generateImageFromVideoFile(
+    this.getVideoFilePath(videoFile),
+    CONFIG.STORAGE.THUMBNAILS_DIR,
+    this.getThumbnailName(),
+    THUMBNAILS_SIZE
+  )
+}
 
-  return this.remoteId + extension
+getVideoFilePath = function (this: VideoInstance, videoFile: VideoFileInstance) {
+  return join(CONFIG.STORAGE.VIDEOS_DIR, this.getVideoFilename(videoFile))
 }
 
-isOwned = function (this: VideoInstance) {
-  return this.remoteId === null
+createTorrentAndSetInfoHash = 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)
+    ]
+  }
+
+  return createTorrentPromise(this.getVideoFilePath(videoFile), options)
+    .then(torrent => {
+      const filePath = join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
+      logger.info('Creating torrent %s.', filePath)
+
+      return writeFilePromise(filePath, torrent).then(() => torrent)
+    })
+    .then(torrent => {
+      const parsedTorrent = parseTorrent(torrent)
+
+      videoFile.infoHash = parsedTorrent.infoHash
+    })
+}
+
+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)
 }
 
-toFormatedJSON = function (this: VideoInstance) {
+toFormattedJSON = function (this: VideoInstance) {
   let podHost
 
   if (this.Author.Pod) {
@@ -435,6 +462,7 @@ toFormatedJSON = function (this: VideoInstance) {
 
   const json = {
     id: this.id,
+    uuid: this.uuid,
     name: this.name,
     category: this.category,
     categoryLabel,
@@ -446,7 +474,6 @@ toFormatedJSON = function (this: VideoInstance) {
     description: this.description,
     podHost,
     isLocal: this.isOwned(),
-    magnetUri: this.generateMagnetUri(),
     author: this.Author.name,
     duration: this.duration,
     views: this.views,
@@ -454,10 +481,32 @@ toFormatedJSON = function (this: VideoInstance) {
     dislikes: this.dislikes,
     tags: map<TagInstance, string>(this.Tags, 'name'),
     thumbnailPath: join(STATIC_PATHS.THUMBNAILS, this.getThumbnailName()),
+    previewPath: join(STATIC_PATHS.PREVIEWS, this.getPreviewName()),
     createdAt: this.createdAt,
-    updatedAt: this.updatedAt
+    updatedAt: this.updatedAt,
+    files: []
   }
 
+  // Format and sort video files
+  json.files = this.VideoFiles
+                   .map(videoFile => {
+                     let resolutionLabel = videoFile.resolution + 'p'
+
+                     const videoFileJson = {
+                       resolution: videoFile.resolution,
+                       resolutionLabel,
+                       magnetUri: this.generateMagnetUri(videoFile),
+                       size: videoFile.size
+                     }
+
+                     return videoFileJson
+                   })
+                   .sort((a, b) => {
+                     if (a.resolution < b.resolution) return 1
+                     if (a.resolution === b.resolution) return 0
+                     return -1
+                   })
+
   return json
 }
 
@@ -467,100 +516,180 @@ toAddRemoteJSON = function (this: VideoInstance) {
 
   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,
-      infoHash: this.infoHash,
-      remoteId: this.id,
       author: this.Author.name,
       duration: this.duration,
       thumbnailData: thumbnailData.toString('binary'),
       tags: map<TagInstance, string>(this.Tags, 'name'),
       createdAt: this.createdAt,
       updatedAt: this.updatedAt,
-      extname: this.extname,
       views: this.views,
       likes: this.likes,
-      dislikes: this.dislikes
+      dislikes: this.dislikes,
+      files: []
     }
 
+    this.VideoFiles.forEach(videoFile => {
+      remoteVideo.files.push({
+        infoHash: videoFile.infoHash,
+        resolution: videoFile.resolution,
+        extname: videoFile.extname,
+        size: videoFile.size
+      })
+    })
+
     return remoteVideo
   })
 }
 
 toUpdateRemoteJSON = function (this: VideoInstance) {
   const json = {
+    uuid: this.uuid,
     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<TagInstance, string>(this.Tags, 'name'),
     createdAt: this.createdAt,
     updatedAt: this.updatedAt,
-    extname: this.extname,
     views: this.views,
     likes: this.likes,
-    dislikes: this.dislikes
+    dislikes: this.dislikes,
+    files: []
   }
 
+  this.VideoFiles.forEach(videoFile => {
+    json.files.push({
+      infoHash: videoFile.infoHash,
+      resolution: videoFile.resolution,
+      extname: videoFile.extname,
+      size: videoFile.size
+    })
+  })
+
   return json
 }
 
-transcodeVideofile = function (this: VideoInstance) {
-  const video = this
-
+optimizeOriginalVideofile = 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)
-
-  return new Promise<void>((res, rej) => {
-    ffmpeg(videoInputPath)
-      .output(videoOutputPath)
-      .videoCodec('libx264')
-      .outputOption('-threads ' + CONFIG.TRANSCODING.THREADS)
-      .outputOption('-movflags faststart')
-      .on('error', rej)
-      .on('end', () => {
-
-        return unlinkPromise(videoInputPath)
-          .then(() => {
-            // Important to do this before getVideoFilename() to take in account the new file extension
-            video.set('extname', newExtname)
-
-            const newVideoPath = join(videosDirectory, video.getVideoFilename())
-            return renamePromise(videoOutputPath, newVideoPath)
-          })
-          .then(() => {
-            const newVideoPath = join(videosDirectory, video.getVideoFilename())
-            return createTorrentFromVideo(video, newVideoPath)
-          })
-          .then(() => {
-            return video.save()
-          })
-          .then(() => {
-            return res()
-          })
-          .catch(err => {
-            // Autodesctruction...
-            video.destroy().asCallback(function (err) {
-              if (err) logger.error('Cannot destruct video after transcoding failure.', { error: err })
-            })
-
-            return rej(err)
-          })
-      })
-      .run()
+  const inputVideoFile = this.getOriginalFile()
+  const videoInputPath = join(videosDirectory, this.getVideoFilename(inputVideoFile))
+  const videoOutputPath = join(videosDirectory, this.id + '-transcoded' + newExtname)
+
+  const transcodeOptions = {
+    inputPath: videoInputPath,
+    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)
+
+      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))
+
+      throw err
+    })
+}
+
+transcodeOriginalVideofile = 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
+  }
+  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)
+}
+
+getOriginalFileHeight = function (this: VideoInstance) {
+  const originalFilePath = this.getVideoFilePath(this.getOriginalFile())
+
+  return getVideoFileHeight(originalFilePath)
+}
+
+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 ------------------------------
@@ -575,22 +704,16 @@ generateThumbnailFromData = function (video: VideoInstance, thumbnailData: strin
   })
 }
 
-getDurationFromFile = function (videoPath: string) {
-  return new Promise<number>((res, rej) => {
-    ffmpeg.ffprobe(videoPath, function (err, metadata) {
-      if (err) return rej(err)
-
-      return res(Math.floor(metadata.format.duration))
-    })
-  })
-}
-
 list = function () {
-  return Video.findAll()
+  const query = {
+    include: [ Video['sequelize'].models.VideoFile ]
+  }
+
+  return Video.findAll(query)
 }
 
 listForApi = function (start: number, count: number, sort: string) {
-  // Exclude Blakclisted videos from the list
+  // Exclude blacklisted videos from the list
   const query = {
     distinct: true,
     offset: start,
@@ -601,8 +724,8 @@ listForApi = function (start: number, count: number, sort: string) {
         model: Video['sequelize'].models.Author,
         include: [ { model: Video['sequelize'].models.Pod, required: false } ]
       },
-
-      Video['sequelize'].models.Tag
+      Video['sequelize'].models.Tag,
+      Video['sequelize'].models.VideoFile
     ],
     where: createBaseVideosWhere()
   }
@@ -615,12 +738,15 @@ listForApi = function (start: number, count: number, sort: string) {
   })
 }
 
-loadByHostAndRemoteId = function (fromHost: string, remoteId: string) {
+loadByHostAndUUID = function (fromHost: string, uuid: string) {
   const query = {
     where: {
-      remoteId: remoteId
+      uuid
     },
     include: [
+      {
+        model: Video['sequelize'].models.VideoFile
+      },
       {
         model: Video['sequelize'].models.Author,
         include: [
@@ -640,12 +766,15 @@ loadByHostAndRemoteId = function (fromHost: string, remoteId: string) {
 }
 
 listOwnedAndPopulateAuthorAndTags = function () {
-  // If remoteId is null this is *our* video
   const query = {
     where: {
-      remoteId: null
+      remote: false
     },
-    include: [ Video['sequelize'].models.Author, Video['sequelize'].models.Tag ]
+    include: [
+      Video['sequelize'].models.VideoFile,
+      Video['sequelize'].models.Author,
+      Video['sequelize'].models.Tag
+    ]
   }
 
   return Video.findAll(query)
@@ -654,9 +783,12 @@ listOwnedAndPopulateAuthorAndTags = function () {
 listOwnedByAuthor = function (author: string) {
   const query = {
     where: {
-      remoteId: null
+      remote: false
     },
     include: [
+      {
+        model: Video['sequelize'].models.VideoFile
+      },
       {
         model: Video['sequelize'].models.Author,
         where: {
@@ -669,50 +801,83 @@ listOwnedByAuthor = function (author: string) {
   return Video.findAll(query)
 }
 
-load = function (id: string) {
+load = function (id: number) {
   return Video.findById(id)
 }
 
-loadAndPopulateAuthor = function (id: string) {
+loadByUUID = function (uuid: string) {
+  const query = {
+    where: {
+      uuid
+    },
+    include: [ Video['sequelize'].models.VideoFile ]
+  }
+  return Video.findOne(query)
+}
+
+loadAndPopulateAuthor = function (id: number) {
   const options = {
-    include: [ Video['sequelize'].models.Author ]
+    include: [ Video['sequelize'].models.VideoFile, Video['sequelize'].models.Author ]
   }
 
   return Video.findById(id, options)
 }
 
-loadAndPopulateAuthorAndPodAndTags = function (id: string) {
+loadAndPopulateAuthorAndPodAndTags = function (id: number) {
   const options = {
     include: [
       {
         model: Video['sequelize'].models.Author,
         include: [ { model: Video['sequelize'].models.Pod, required: false } ]
       },
-      Video['sequelize'].models.Tag
+      Video['sequelize'].models.Tag,
+      Video['sequelize'].models.VideoFile
     ]
   }
 
   return Video.findById(id, options)
 }
 
+loadByUUIDAndPopulateAuthorAndPodAndTags = function (uuid: string) {
+  const options = {
+    where: {
+      uuid
+    },
+    include: [
+      {
+        model: Video['sequelize'].models.Author,
+        include: [ { model: Video['sequelize'].models.Pod, required: false } ]
+      },
+      Video['sequelize'].models.Tag,
+      Video['sequelize'].models.VideoFile
+    ]
+  }
+
+  return Video.findOne(options)
+}
+
 searchAndPopulateAuthorAndPodAndTags = function (value: string, field: string, start: number, count: number, sort: string) {
-  const podInclude: any = {
+  const podInclude: Sequelize.IncludeOptions = {
     model: Video['sequelize'].models.Pod,
     required: false
   }
 
-  const authorInclude: any = {
+  const authorInclude: Sequelize.IncludeOptions = {
     model: Video['sequelize'].models.Author,
     include: [
       podInclude
     ]
   }
 
-  const tagInclude: any = {
+  const tagInclude: Sequelize.IncludeOptions = {
     model: Video['sequelize'].models.Tag
   }
 
-  const query: any = {
+  const videoFileInclude: Sequelize.IncludeOptions = {
+    model: Video['sequelize'].models.VideoFile
+  }
+
+  const query: Sequelize.FindOptions<VideoAttributes> = {
     distinct: true,
     where: createBaseVideosWhere(),
     offset: start,
@@ -722,47 +887,44 @@ searchAndPopulateAuthorAndPodAndTags = function (value: string, field: string, s
 
   // Make an exact search with the magnet
   if (field === 'magnetUri') {
-    const infoHash = magnetUtil.decode(value).infoHash
-    query.where.infoHash = infoHash
+    videoFileInclude.where = {
+      infoHash: magnetUtil.decode(value).infoHash
+    }
   } else if (field === 'tags') {
     const escapedValue = Video['sequelize'].escape('%' + value + '%')
-    query.where.id.$in = Video['sequelize'].literal(
+    query.where['id'].$in = Video['sequelize'].literal(
       `(SELECT "VideoTags"."videoId"
         FROM "Tags"
         INNER JOIN "VideoTags" ON "Tags"."id" = "VideoTags"."tagId"
-        WHERE name LIKE ${escapedValue}
+        WHERE name ILIKE ${escapedValue}
        )`
     )
   } else if (field === 'host') {
     // FIXME: Include our pod? (not stored in the database)
     podInclude.where = {
       host: {
-        $like: '%' + value + '%'
+        $iLike: '%' + value + '%'
       }
     }
     podInclude.required = true
   } else if (field === 'author') {
     authorInclude.where = {
       name: {
-        $like: '%' + value + '%'
+        $iLike: '%' + value + '%'
       }
     }
 
     // authorInclude.or = true
   } else {
     query.where[field] = {
-      $like: '%' + value + '%'
+      $iLike: '%' + value + '%'
     }
   }
 
   query.include = [
-    authorInclude, tagInclude
+    authorInclude, tagInclude, videoFileInclude
   ]
 
-  if (tagInclude.where) {
-    // query.include.push([ Video['sequelize'].models.Tag ])
-  }
-
   return Video.findAndCountAll(query).then(({ rows, count }) => {
     return {
       data: rows,
@@ -782,87 +944,3 @@ function createBaseVideosWhere () {
     }
   }
 }
-
-function removeThumbnail (video: VideoInstance) {
-  const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, video.getThumbnailName())
-  return unlinkPromise(thumbnailPath)
-}
-
-function removeFile (video: VideoInstance) {
-  const filePath = join(CONFIG.STORAGE.VIDEOS_DIR, video.getVideoFilename())
-  return unlinkPromise(filePath)
-}
-
-function removeTorrent (video: VideoInstance) {
-  const torrenPath = join(CONFIG.STORAGE.TORRENTS_DIR, video.getTorrentName())
-  return unlinkPromise(torrenPath)
-}
-
-function removePreview (video: VideoInstance) {
-  // Same name than video thumnail
-  return unlinkPromise(CONFIG.STORAGE.PREVIEWS_DIR + video.getPreviewName())
-}
-
-function createTorrentFromVideo (video: VideoInstance, videoPath: string) {
-  const options = {
-    announceList: [
-      [ CONFIG.WEBSERVER.WS + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT + '/tracker/socket' ]
-    ],
-    urlList: [
-      CONFIG.WEBSERVER.URL + STATIC_PATHS.WEBSEED + video.getVideoFilename()
-    ]
-  }
-
-  return createTorrentPromise(videoPath, options)
-    .then(torrent => {
-      const filePath = join(CONFIG.STORAGE.TORRENTS_DIR, video.getTorrentName())
-      return writeFilePromise(filePath, torrent).then(() => torrent)
-    })
-    .then(torrent => {
-      const parsedTorrent = parseTorrent(torrent)
-      video.set('infoHash', parsedTorrent.infoHash)
-      return video.validate()
-    })
-}
-
-function createPreview (video: VideoInstance, videoPath: string) {
-  return generateImage(video, videoPath, CONFIG.STORAGE.PREVIEWS_DIR, video.getPreviewName(), null)
-}
-
-function createThumbnail (video: VideoInstance, videoPath: string) {
-  return generateImage(video, videoPath, CONFIG.STORAGE.THUMBNAILS_DIR, video.getThumbnailName(), THUMBNAILS_SIZE)
-}
-
-function generateImage (video: VideoInstance, videoPath: string, folder: string, imageName: string, size: string) {
-  const options: any = {
-    filename: imageName,
-    count: 1,
-    folder
-  }
-
-  if (size) {
-    options.size = size
-  }
-
-  return new Promise<string>((res, rej) => {
-    ffmpeg(videoPath)
-      .on('error', rej)
-      .on('end', function () {
-        return res(imageName)
-      })
-      .thumbnail(options)
-  })
-}
-
-function removeFromBlacklist (video: VideoInstance) {
-  // Find the blacklisted video
-  return db.BlacklistedVideo.loadByVideoId(video.id).then(video => {
-    // Not found the video, skip
-    if (!video) {
-      return null
-    }
-
-    // If we found the video, remove it from the blacklist
-    return video.destroy()
-  })
-}