]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blobdiff - server/models/video/video.ts
Use sequelize scopes
[github/Chocobozzz/PeerTube.git] / server / models / video / video.ts
index 629051ae46c24af0b3747a897e6014b848f10667..1f940a50d1cba97584c225084f84dbb4e9d62e7a 100644 (file)
-import * as safeBuffer from 'safe-buffer'
-const Buffer = safeBuffer.Buffer
-import * as ffmpeg from 'fluent-ffmpeg'
+import * as Bluebird from 'bluebird'
+import { map, maxBy, truncate } from 'lodash'
 import * as magnetUtil from 'magnet-uri'
-import { map, values } from 'lodash'
 import * as parseTorrent from 'parse-torrent'
 import { join } from 'path'
 import * as Sequelize from 'sequelize'
-import * as Promise from 'bluebird'
-
-import { database as db } from '../../initializers/database'
-import { TagInstance } from './tag-interface'
 import {
+  AfterDestroy,
+  AllowNull,
+  BelongsTo,
+  BelongsToMany,
+  Column,
+  CreatedAt,
+  DataType,
+  Default,
+  ForeignKey,
+  HasMany,
+  IFindOptions,
+  Is,
+  IsInt,
+  IsUUID,
+  Min,
+  Model,
+  Scopes,
+  Table,
+  UpdatedAt
+} from 'sequelize-typescript'
+import { IIncludeOptions } from 'sequelize-typescript/lib/interfaces/IIncludeOptions'
+import { VideoPrivacy, VideoResolution } from '../../../shared'
+import { VideoTorrentObject } from '../../../shared/models/activitypub/objects'
+import { Video, VideoDetails } from '../../../shared/models/videos'
+import {
+  activityPubCollection,
+  createTorrentPromise,
+  generateImageFromVideoFile,
+  getVideoFileHeight,
   logger,
-  isVideoNameValid,
+  renamePromise,
+  statPromise,
+  transcode,
+  unlinkPromise,
+  writeFilePromise
+} from '../../helpers'
+import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub'
+import {
   isVideoCategoryValid,
-  isVideoLicenceValid,
-  isVideoLanguageValid,
-  isVideoNSFWValid,
   isVideoDescriptionValid,
-  isVideoInfoHashValid,
   isVideoDurationValid,
-  readFileBufferPromise,
-  unlinkPromise,
-  renamePromise,
-  writeFilePromise,
-  createTorrentPromise
-} from '../../helpers'
+  isVideoLanguageValid,
+  isVideoLicenceValid,
+  isVideoNameValid,
+  isVideoNSFWValid,
+  isVideoPrivacyValid
+} 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 { getAnnounceActivityPubUrl } from '../../lib/activitypub'
+import { sendDeleteVideo } from '../../lib/index'
+import { AccountModel } from '../account/account'
+import { AccountVideoRateModel } from '../account/account-video-rate'
+import { ServerModel } from '../server/server'
+import { getSort, throwIfNotValid } from '../utils'
+import { TagModel } from './tag'
+import { VideoAbuseModel } from './video-abuse'
+import { VideoChannelModel } from './video-channel'
+import { VideoFileModel } from './video-file'
+import { VideoShareModel } from './video-share'
+import { VideoTagModel } from './video-tag'
+
+enum ScopeNames {
+  NOT_IN_BLACKLIST = 'NOT_IN_BLACKLIST',
+  PUBLIC = 'PUBLIC',
+  WITH_ACCOUNT = 'WITH_ACCOUNT',
+  WITH_TAGS = 'WITH_TAGS',
+  WITH_FILES = 'WITH_FILES',
+  WITH_SHARES = 'WITH_SHARES',
+  WITH_RATES = 'WITH_RATES'
+}
 
-import { addMethodsToModel, getSort } from '../utils'
-import {
-  VideoInstance,
-  VideoAttributes,
-
-  VideoMethods
-} from './video-interface'
-
-let Video: Sequelize.Model<VideoInstance, VideoAttributes>
-let generateMagnetUri: VideoMethods.GenerateMagnetUri
-let getVideoFilename: VideoMethods.GetVideoFilename
-let getThumbnailName: VideoMethods.GetThumbnailName
-let getPreviewName: VideoMethods.GetPreviewName
-let getTorrentName: VideoMethods.GetTorrentName
-let isOwned: VideoMethods.IsOwned
-let toFormatedJSON: VideoMethods.ToFormatedJSON
-let toAddRemoteJSON: VideoMethods.ToAddRemoteJSON
-let toUpdateRemoteJSON: VideoMethods.ToUpdateRemoteJSON
-let transcodeVideofile: VideoMethods.TranscodeVideofile
-
-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 load: VideoMethods.Load
-let loadAndPopulateAuthor: VideoMethods.LoadAndPopulateAuthor
-let loadAndPopulateAuthorAndPodAndTags: VideoMethods.LoadAndPopulateAuthorAndPodAndTags
-let searchAndPopulateAuthorAndPodAndTags: VideoMethods.SearchAndPopulateAuthorAndPodAndTags
-
-export default function (sequelize: Sequelize.Sequelize, DataTypes: Sequelize.DataTypes) {
-  Video = sequelize.define<VideoInstance, VideoAttributes>('Video',
-    {
+@Scopes({
+  [ScopeNames.NOT_IN_BLACKLIST]: {
+    where: {
       id: {
-        type: DataTypes.UUID,
-        defaultValue: DataTypes.UUIDV4,
-        primaryKey: true,
-        validate: {
-          isUUID: 4
-        }
-      },
-      name: {
-        type: DataTypes.STRING,
-        allowNull: false,
-        validate: {
-          nameValid: function (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) {
-            const res = isVideoCategoryValid(value)
-            if (res === false) throw new Error('Video category is not valid.')
-          }
-        }
-      },
-      licence: {
-        type: DataTypes.INTEGER,
-        allowNull: false,
-        defaultValue: null,
-        validate: {
-          licenceValid: function (value) {
-            const res = isVideoLicenceValid(value)
-            if (res === false) throw new Error('Video licence is not valid.')
-          }
-        }
-      },
-      language: {
-        type: DataTypes.INTEGER,
-        allowNull: true,
-        validate: {
-          languageValid: function (value) {
-            const res = isVideoLanguageValid(value)
-            if (res === false) throw new Error('Video language is not valid.')
-          }
-        }
-      },
-      nsfw: {
-        type: DataTypes.BOOLEAN,
-        allowNull: false,
-        validate: {
-          nsfwValid: function (value) {
-            const res = isVideoNSFWValid(value)
-            if (res === false) throw new Error('Video nsfw attribute is not valid.')
-          }
-        }
-      },
-      description: {
-        type: DataTypes.STRING,
-        allowNull: false,
-        validate: {
-          descriptionValid: function (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) {
-            const res = isVideoDurationValid(value)
-            if (res === false) throw new Error('Video duration is not valid.')
+        [Sequelize.Op.notIn]: Sequelize.literal(
+          '(SELECT "videoBlacklist"."videoId" FROM "videoBlacklist")'
+        )
+      }
+    }
+  },
+  [ScopeNames.PUBLIC]: {
+    where: {
+      privacy: VideoPrivacy.PUBLIC
+    }
+  },
+  [ScopeNames.WITH_ACCOUNT]: {
+    include: [
+      {
+        model: () => VideoChannelModel,
+        required: true,
+        include: [
+          {
+            model: () => AccountModel,
+            required: true,
+            include: [
+              {
+                model: () => ServerModel,
+                required: false
+              }
+            ]
           }
-        }
-      },
-      views: {
-        type: DataTypes.INTEGER,
-        allowNull: false,
-        defaultValue: 0,
-        validate: {
-          min: 0,
-          isInt: true
-        }
-      },
-      likes: {
-        type: DataTypes.INTEGER,
-        allowNull: false,
-        defaultValue: 0,
-        validate: {
-          min: 0,
-          isInt: true
-        }
-      },
-      dislikes: {
-        type: DataTypes.INTEGER,
-        allowNull: false,
-        defaultValue: 0,
-        validate: {
-          min: 0,
-          isInt: true
-        }
+        ]
+      }
+    ]
+  },
+  [ScopeNames.WITH_TAGS]: {
+    include: [ () => TagModel ]
+  },
+  [ScopeNames.WITH_FILES]: {
+    include: [
+      {
+        model: () => VideoFileModel,
+        required: true
+      }
+    ]
+  },
+  [ScopeNames.WITH_SHARES]: {
+    include: [
+      {
+        model: () => VideoShareModel,
+        include: [ () => AccountModel ]
       }
+    ]
+  },
+  [ScopeNames.WITH_RATES]: {
+    include: [
+      {
+        model: () => AccountVideoRateModel,
+        include: [ () => AccountModel ]
+      }
+    ]
+  }
+})
+@Table({
+  tableName: 'video',
+  indexes: [
+    {
+      fields: [ 'name' ]
     },
     {
-      indexes: [
-        {
-          fields: [ 'authorId' ]
-        },
-        {
-          fields: [ 'remoteId' ]
-        },
-        {
-          fields: [ 'name' ]
-        },
-        {
-          fields: [ 'createdAt' ]
-        },
+      fields: [ 'createdAt' ]
+    },
+    {
+      fields: [ 'duration' ]
+    },
+    {
+      fields: [ 'views' ]
+    },
+    {
+      fields: [ 'likes' ]
+    },
+    {
+      fields: [ 'uuid' ]
+    },
+    {
+      fields: [ 'channelId' ]
+    }
+  ]
+})
+export class VideoModel extends Model<VideoModel> {
+
+  @AllowNull(false)
+  @Default(DataType.UUIDV4)
+  @IsUUID(4)
+  @Column(DataType.UUID)
+  uuid: string
+
+  @AllowNull(false)
+  @Is('VideoName', value => throwIfNotValid(value, isVideoNameValid, 'name'))
+  @Column
+  name: string
+
+  @AllowNull(true)
+  @Default(null)
+  @Is('VideoCategory', value => throwIfNotValid(value, isVideoCategoryValid, 'category'))
+  @Column
+  category: number
+
+  @AllowNull(true)
+  @Default(null)
+  @Is('VideoLicence', value => throwIfNotValid(value, isVideoLicenceValid, 'licence'))
+  @Column
+  licence: number
+
+  @AllowNull(true)
+  @Default(null)
+  @Is('VideoLanguage', value => throwIfNotValid(value, isVideoLanguageValid, 'language'))
+  @Column
+  language: number
+
+  @AllowNull(false)
+  @Is('VideoPrivacy', value => throwIfNotValid(value, isVideoPrivacyValid, 'privacy'))
+  @Column
+  privacy: number
+
+  @AllowNull(false)
+  @Is('VideoNSFW', value => throwIfNotValid(value, isVideoNSFWValid, 'NSFW boolean'))
+  @Column
+  nsfw: boolean
+
+  @AllowNull(true)
+  @Default(null)
+  @Is('VideoDescription', value => throwIfNotValid(value, isVideoDescriptionValid, 'description'))
+  @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.DESCRIPTION.max))
+  description: string
+
+  @AllowNull(false)
+  @Is('VideoDuration', value => throwIfNotValid(value, isVideoDurationValid, 'duration'))
+  @Column
+  duration: number
+
+  @AllowNull(false)
+  @Default(0)
+  @IsInt
+  @Min(0)
+  @Column
+  views: number
+
+  @AllowNull(false)
+  @Default(0)
+  @IsInt
+  @Min(0)
+  @Column
+  likes: number
+
+  @AllowNull(false)
+  @Default(0)
+  @IsInt
+  @Min(0)
+  @Column
+  dislikes: number
+
+  @AllowNull(false)
+  @Column
+  remote: boolean
+
+  @AllowNull(false)
+  @Is('VideoUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url'))
+  @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.URL.max))
+  url: string
+
+  @CreatedAt
+  createdAt: Date
+
+  @UpdatedAt
+  updatedAt: Date
+
+  @ForeignKey(() => VideoChannelModel)
+  @Column
+  channelId: number
+
+  @BelongsTo(() => VideoChannelModel, {
+    foreignKey: {
+      allowNull: false
+    },
+    onDelete: 'cascade'
+  })
+  VideoChannel: VideoChannelModel
+
+  @BelongsToMany(() => TagModel, {
+    foreignKey: 'videoId',
+    through: () => VideoTagModel,
+    onDelete: 'CASCADE'
+  })
+  Tags: TagModel[]
+
+  @HasMany(() => VideoAbuseModel, {
+    foreignKey: {
+      name: 'videoId',
+      allowNull: false
+    },
+    onDelete: 'cascade'
+  })
+  VideoAbuses: VideoAbuseModel[]
+
+  @HasMany(() => VideoFileModel, {
+    foreignKey: {
+      name: 'videoId',
+      allowNull: false
+    },
+    onDelete: 'cascade'
+  })
+  VideoFiles: VideoFileModel[]
+
+  @HasMany(() => VideoShareModel, {
+    foreignKey: {
+      name: 'videoId',
+      allowNull: false
+    },
+    onDelete: 'cascade'
+  })
+  VideoShares: VideoShareModel[]
+
+  @HasMany(() => AccountVideoRateModel, {
+    foreignKey: {
+      name: 'videoId',
+      allowNull: false
+    },
+    onDelete: 'cascade'
+  })
+  AccountVideoRates: AccountVideoRateModel[]
+
+  @AfterDestroy
+  static removeFilesAndSendDelete (instance: VideoModel) {
+    const tasks = []
+
+    tasks.push(
+      instance.removeThumbnail()
+    )
+
+    if (instance.isOwned()) {
+      tasks.push(
+        instance.removePreview(),
+        sendDeleteVideo(instance, undefined)
+      )
+
+      // Remove physical files and torrents
+      instance.VideoFiles.forEach(file => {
+        tasks.push(instance.removeFile(file))
+        tasks.push(instance.removeTorrent(file))
+      })
+    }
+
+    return Promise.all(tasks)
+      .catch(err => {
+        logger.error('Some errors when removing files of video %s in after destroy hook.', instance.uuid, err)
+      })
+  }
+
+  static list () {
+    return VideoModel.scope(ScopeNames.WITH_FILES).findAll()
+  }
+
+  static listAllAndSharedByAccountForOutbox (accountId: number, start: number, count: number) {
+    function getRawQuery (select: string) {
+      const queryVideo = 'SELECT ' + select + ' FROM "video" AS "Video" ' +
+        'INNER JOIN "videoChannel" AS "VideoChannel" ON "VideoChannel"."id" = "Video"."channelId" ' +
+        'WHERE "VideoChannel"."accountId" = ' + accountId
+      const queryVideoShare = 'SELECT ' + select + ' FROM "videoShare" AS "VideoShare" ' +
+        'INNER JOIN "video" AS "Video" ON "Video"."id" = "VideoShare"."videoId" ' +
+        'WHERE "VideoShare"."accountId" = ' + accountId
+
+      return `(${queryVideo}) UNION (${queryVideoShare})`
+    }
+
+    const rawQuery = getRawQuery('"Video"."id"')
+    const rawCountQuery = getRawQuery('COUNT("Video"."id") as "total"')
+
+    const query = {
+      distinct: true,
+      offset: start,
+      limit: count,
+      order: [ getSort('createdAt'), [ 'Tags', 'name', 'ASC' ] ],
+      where: {
+        id: {
+          [Sequelize.Op.in]: Sequelize.literal('(' + rawQuery + ')')
+        }
+      },
+      include: [
         {
-          fields: [ 'duration' ]
+          model: VideoShareModel,
+          required: false,
+          where: {
+            [Sequelize.Op.and]: [
+              {
+                id: {
+                  [Sequelize.Op.not]: null
+                }
+              },
+              {
+                accountId
+              }
+            ]
+          },
+          include: [ AccountModel ]
         },
         {
-          fields: [ 'infoHash' ]
+          model: VideoChannelModel,
+          required: true,
+          include: [
+            {
+              model: AccountModel,
+              required: true
+            }
+          ]
         },
         {
-          fields: [ 'views' ]
+          model: AccountVideoRateModel,
+          include: [ AccountModel ]
         },
+        VideoFileModel,
+        TagModel
+      ]
+    }
+
+    return Bluebird.all([
+      // FIXME: typing issue
+      VideoModel.findAll(query as any),
+      VideoModel.sequelize.query(rawCountQuery, { type: Sequelize.QueryTypes.SELECT })
+    ]).then(([ rows, totals ]) => {
+      // totals: totalVideos + totalVideoShares
+      let totalVideos = 0
+      let totalVideoShares = 0
+      if (totals[0]) totalVideos = parseInt(totals[0].total, 10)
+      if (totals[1]) totalVideoShares = parseInt(totals[1].total, 10)
+
+      const total = totalVideos + totalVideoShares
+      return {
+        data: rows,
+        total: total
+      }
+    })
+  }
+
+  static listUserVideosForApi (userId: number, start: number, count: number, sort: string) {
+    const query = {
+      offset: start,
+      limit: count,
+      order: [ getSort(sort) ],
+      include: [
         {
-          fields: [ 'likes' ]
+          model: VideoChannelModel,
+          required: true,
+          include: [
+            {
+              model: AccountModel,
+              where: {
+                userId
+              },
+              required: true
+            }
+          ]
         }
-      ],
-      hooks: {
-        beforeValidate,
-        beforeCreate,
-        afterDestroy
+      ]
+    }
+
+    return VideoModel.findAndCountAll(query).then(({ rows, count }) => {
+      return {
+        data: rows,
+        total: count
       }
+    })
+  }
+
+  static listForApi (start: number, count: number, sort: string) {
+    const query = {
+      offset: start,
+      limit: count,
+      order: [ getSort(sort) ]
     }
-  )
-
-  const classMethods = [
-    associate,
-
-    generateThumbnailFromData,
-    getDurationFromFile,
-    list,
-    listForApi,
-    listOwnedAndPopulateAuthorAndTags,
-    listOwnedByAuthor,
-    load,
-    loadByHostAndRemoteId,
-    loadAndPopulateAuthor,
-    loadAndPopulateAuthorAndPodAndTags,
-    searchAndPopulateAuthorAndPodAndTags,
-    removeFromBlacklist
-  ]
-  const instanceMethods = [
-    generateMagnetUri,
-    getVideoFilename,
-    getThumbnailName,
-    getPreviewName,
-    getTorrentName,
-    isOwned,
-    toFormatedJSON,
-    toAddRemoteJSON,
-    toUpdateRemoteJSON,
-    transcodeVideofile
-  ]
-  addMethodsToModel(Video, classMethods, instanceMethods)
 
-  return Video
-}
+    return VideoModel.scope([ ScopeNames.NOT_IN_BLACKLIST, ScopeNames.PUBLIC, ScopeNames.WITH_ACCOUNT ])
+      .findAndCountAll(query)
+      .then(({ rows, count }) => {
+        return {
+          data: rows,
+          total: count
+        }
+      })
+  }
 
-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'
+  static load (id: number) {
+    return VideoModel.findById(id)
   }
-}
 
-function beforeCreate (video: VideoInstance, options: { transaction: Sequelize.Transaction }) {
-  if (video.isOwned()) {
-    const videoPath = join(CONFIG.STORAGE.VIDEOS_DIR, video.getVideoFilename())
-    const tasks = []
+  static loadByUrlAndPopulateAccount (url: string, t?: Sequelize.Transaction) {
+    const query: IFindOptions<VideoModel> = {
+      where: {
+        url
+      }
+    }
 
-    tasks.push(
-      createTorrentFromVideo(video, videoPath),
-      createThumbnail(video, videoPath),
-      createPreview(video, videoPath)
-    )
+    if (t !== undefined) query.transaction = t
 
-    if (CONFIG.TRANSCODING.ENABLED === true) {
-      const dataInput = {
-        id: video.id
+    return VideoModel.scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_FILES ]).findOne(query)
+  }
+
+  static loadByUUIDOrURL (uuid: string, url: string, t?: Sequelize.Transaction) {
+    const query: IFindOptions<VideoModel> = {
+      where: {
+        [Sequelize.Op.or]: [
+          { uuid },
+          { url }
+        ]
       }
+    }
 
-      tasks.push(
-        JobScheduler.Instance.createJob(options.transaction, 'videoTranscoder', dataInput)
-      )
+    if (t !== undefined) query.transaction = t
+
+    return VideoModel.scope(ScopeNames.WITH_FILES).findOne(query)
+  }
+
+  static loadAndPopulateAccountAndServerAndTags (id: number) {
+    const options = {
+      order: [ [ 'Tags', 'name', 'ASC' ] ]
     }
 
-    return Promise.all(tasks)
+    return VideoModel
+      .scope([ ScopeNames.WITH_RATES, ScopeNames.WITH_SHARES, ScopeNames.WITH_TAGS, ScopeNames.WITH_FILES, ScopeNames.WITH_ACCOUNT ])
+      .findById(id, options)
   }
 
-  return Promise.resolve()
-}
+  static loadByUUIDAndPopulateAccountAndServerAndTags (uuid: string) {
+    const options = {
+      order: [ [ 'Tags', 'name', 'ASC' ] ],
+      where: {
+        uuid
+      }
+    }
 
-function afterDestroy (video: VideoInstance) {
-  const tasks = []
+    return VideoModel
+      .scope([ ScopeNames.WITH_RATES, ScopeNames.WITH_SHARES, ScopeNames.WITH_TAGS, ScopeNames.WITH_FILES, ScopeNames.WITH_ACCOUNT ])
+      .findOne(options)
+  }
 
-  tasks.push(
-    removeThumbnail(video)
-  )
+  static searchAndPopulateAccountAndServerAndTags (value: string, start: number, count: number, sort: string) {
+    const serverInclude: IIncludeOptions = {
+      model: ServerModel,
+      required: false
+    }
 
-  if (video.isOwned()) {
-    const removeVideoToFriendsParams = {
-      remoteId: video.id
+    const accountInclude: IIncludeOptions = {
+      model: AccountModel,
+      include: [ serverInclude ]
     }
 
-    tasks.push(
-      removeFile(video),
-      removeTorrent(video),
-      removePreview(video),
-      removeVideoToFriends(removeVideoToFriendsParams)
-    )
+    const videoChannelInclude: IIncludeOptions = {
+      model: VideoChannelModel,
+      include: [ accountInclude ],
+      required: true
+    }
+
+    const tagInclude: IIncludeOptions = {
+      model: TagModel
+    }
+
+    const query: IFindOptions<VideoModel> = {
+      distinct: true, // Because we have tags
+      offset: start,
+      limit: count,
+      order: [ getSort(sort) ],
+      where: {}
+    }
+
+    // TODO: search on tags too
+    // const escapedValue = Video['sequelize'].escape('%' + value + '%')
+    // 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}
+    //    )`
+    // )
+
+    // TODO: search on account too
+    // accountInclude.where = {
+    //   name: {
+    //     [Sequelize.Op.iLike]: '%' + value + '%'
+    //   }
+    // }
+    query.where['name'] = {
+      [Sequelize.Op.iLike]: '%' + value + '%'
+    }
+
+    query.include = [
+      videoChannelInclude, tagInclude
+    ]
+
+    return VideoModel.scope([ ScopeNames.NOT_IN_BLACKLIST, ScopeNames.PUBLIC ])
+      .findAndCountAll(query).then(({ rows, count }) => {
+        return {
+          data: rows,
+          total: count
+        }
+      })
   }
 
-  return Promise.all(tasks)
-}
+  getOriginalFile () {
+    if (Array.isArray(this.VideoFiles) === false) return undefined
 
-// ------------------------------ METHODS ------------------------------
+    // The original file is the file that have the higher resolution
+    return maxBy(this.VideoFiles, file => file.resolution)
+  }
 
-function associate (models) {
-  Video.belongsTo(models.Author, {
-    foreignKey: {
-      name: 'authorId',
-      allowNull: false
-    },
-    onDelete: 'cascade'
-  })
+  getVideoFilename (videoFile: VideoFileModel) {
+    return this.uuid + '-' + videoFile.resolution + videoFile.extname
+  }
 
-  Video.belongsToMany(models.Tag, {
-    foreignKey: 'videoId',
-    through: models.VideoTag,
-    onDelete: 'cascade'
-  })
+  getThumbnailName () {
+    // We always have a copy of the thumbnail
+    const extension = '.jpg'
+    return this.uuid + extension
+  }
 
-  Video.hasMany(models.VideoAbuse, {
-    foreignKey: {
-      name: 'videoId',
-      allowNull: false
-    },
-    onDelete: 'cascade'
-  })
-}
+  getPreviewName () {
+    const extension = '.jpg'
+    return this.uuid + extension
+  }
 
-generateMagnetUri = function (this: VideoInstance) {
-  let baseUrlHttp
-  let baseUrlWs
+  getTorrentFileName (videoFile: VideoFileModel) {
+    const extension = '.torrent'
+    return this.uuid + '-' + videoFile.resolution + extension
+  }
 
-  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
+  isOwned () {
+    return this.remote === false
   }
 
-  const xs = baseUrlHttp + STATIC_PATHS.TORRENTS + this.getTorrentName()
-  const announce = [ baseUrlWs + '/tracker/socket' ]
-  const urlList = [ baseUrlHttp + STATIC_PATHS.WEBSEED + this.getVideoFilename() ]
+  createPreview (videoFile: VideoFileModel) {
+    const imageSize = PREVIEWS_SIZE.width + 'x' + PREVIEWS_SIZE.height
 
-  const magnetHash = {
-    xs,
-    announce,
-    urlList,
-    infoHash: this.infoHash,
-    name: this.name
+    return generateImageFromVideoFile(
+      this.getVideoFilePath(videoFile),
+      CONFIG.STORAGE.PREVIEWS_DIR,
+      this.getPreviewName(),
+      imageSize
+    )
   }
 
-  return magnetUtil.encode(magnetHash)
-}
+  createThumbnail (videoFile: VideoFileModel) {
+    const imageSize = THUMBNAILS_SIZE.width + 'x' + THUMBNAILS_SIZE.height
 
-getVideoFilename = function (this: VideoInstance) {
-  if (this.isOwned()) return this.id + this.extname
+    return generateImageFromVideoFile(
+      this.getVideoFilePath(videoFile),
+      CONFIG.STORAGE.THUMBNAILS_DIR,
+      this.getThumbnailName(),
+      imageSize
+    )
+  }
 
-  return this.remoteId + this.extname
-}
+  getVideoFilePath (videoFile: VideoFileModel) {
+    return join(CONFIG.STORAGE.VIDEOS_DIR, this.getVideoFilename(videoFile))
+  }
 
-getThumbnailName = function (this: VideoInstance) {
-  // We always have a copy of the thumbnail
-  return this.id + '.jpg'
-}
+  createTorrentAndSetInfoHash = async function (videoFile: VideoFileModel) {
+    const options = {
+      announceList: [
+        [ CONFIG.WEBSERVER.WS + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT + '/tracker/socket' ]
+      ],
+      urlList: [
+        CONFIG.WEBSERVER.URL + STATIC_PATHS.WEBSEED + this.getVideoFilename(videoFile)
+      ]
+    }
 
-getPreviewName = function (this: VideoInstance) {
-  const extension = '.jpg'
+    const torrent = await createTorrentPromise(this.getVideoFilePath(videoFile), options)
 
-  if (this.isOwned()) return this.id + extension
+    const filePath = join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
+    logger.info('Creating torrent %s.', filePath)
 
-  return this.remoteId + extension
-}
+    await writeFilePromise(filePath, torrent)
 
-getTorrentName = function (this: VideoInstance) {
-  const extension = '.torrent'
+    const parsedTorrent = parseTorrent(torrent)
+    videoFile.infoHash = parsedTorrent.infoHash
+  }
 
-  if (this.isOwned()) return this.id + extension
+  getEmbedPath () {
+    return '/videos/embed/' + this.uuid
+  }
 
-  return this.remoteId + extension
-}
+  getThumbnailPath () {
+    return join(STATIC_PATHS.THUMBNAILS, this.getThumbnailName())
+  }
 
-isOwned = function (this: VideoInstance) {
-  return this.remoteId === null
-}
+  getPreviewPath () {
+    return join(STATIC_PATHS.PREVIEWS, this.getPreviewName())
+  }
 
-toFormatedJSON = function (this: VideoInstance) {
-  let podHost
-
-  if (this.Author.Pod) {
-    podHost = this.Author.Pod.host
-  } else {
-    // It means it's our video
-    podHost = 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,
-    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(),
-    magnetUri: this.generateMagnetUri(),
-    author: this.Author.name,
-    duration: this.duration,
-    views: this.views,
-    likes: this.likes,
-    dislikes: this.dislikes,
-    tags: map<TagInstance, string>(this.Tags, 'name'),
-    thumbnailPath: join(STATIC_PATHS.THUMBNAILS, this.getThumbnailName()),
-    createdAt: this.createdAt,
-    updatedAt: this.updatedAt
-  }
-
-  return json
-}
+  toFormattedJSON () {
+    let serverHost
 
-toAddRemoteJSON = function (this: VideoInstance) {
-  // Get thumbnail data to send to the other pod
-  const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName())
+    if (this.VideoChannel.Account.Server) {
+      serverHost = this.VideoChannel.Account.Server.host
+    } else {
+      // It means it's our video
+      serverHost = CONFIG.WEBSERVER.HOST
+    }
 
-  return readFileBufferPromise(thumbnailPath).then(thumbnailData => {
-    const remoteVideo = {
+    return {
+      id: this.id,
+      uuid: this.uuid,
       name: this.name,
       category: this.category,
+      categoryLabel: this.getCategoryLabel(),
       licence: this.licence,
+      licenceLabel: this.getLicenceLabel(),
       language: this.language,
+      languageLabel: this.getLanguageLabel(),
       nsfw: this.nsfw,
-      description: this.description,
-      infoHash: this.infoHash,
-      remoteId: this.id,
-      author: this.Author.name,
+      description: this.getTruncatedDescription(),
+      serverHost,
+      isLocal: this.isOwned(),
+      accountName: this.VideoChannel.Account.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
-    }
-
-    return remoteVideo
-  })
-}
+      dislikes: this.dislikes,
+      thumbnailPath: this.getThumbnailPath(),
+      previewPath: this.getPreviewPath(),
+      embedPath: this.getEmbedPath(),
+      createdAt: this.createdAt,
+      updatedAt: this.updatedAt
+    } as Video
+  }
 
-toUpdateRemoteJSON = function (this: VideoInstance) {
-  const json = {
-    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
-  }
-
-  return json
-}
+  toFormattedDetailsJSON () {
+    const formattedJson = this.toFormattedJSON()
+
+    // 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 detailsJson = {
+      privacyLabel,
+      privacy: this.privacy,
+      descriptionPath: this.getDescriptionPath(),
+      channel: this.VideoChannel.toFormattedJSON(),
+      account: this.VideoChannel.Account.toFormattedJSON(),
+      tags: map<TagModel, string>(this.Tags, 'name'),
+      files: []
+    }
 
-transcodeVideofile = function (this: VideoInstance) {
-  const video = this
-
-  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)
-          })
+    // Format and sort video files
+    const { baseUrlHttp, baseUrlWs } = this.getBaseUrls()
+    detailsJson.files = this.VideoFiles
+      .map(videoFile => {
+        let resolutionLabel = videoFile.resolution + 'p'
+
+        return {
+          resolution: videoFile.resolution,
+          resolutionLabel,
+          magnetUri: this.generateMagnetUri(videoFile, baseUrlHttp, baseUrlWs),
+          size: videoFile.size,
+          torrentUrl: this.getTorrentUrl(videoFile, baseUrlHttp),
+          fileUrl: this.getVideoFileUrl(videoFile, baseUrlHttp)
+        }
+      })
+      .sort((a, b) => {
+        if (a.resolution < b.resolution) return 1
+        if (a.resolution === b.resolution) return 0
+        return -1
       })
-      .run()
-  })
-}
 
-// ------------------------------ STATICS ------------------------------
+    return Object.assign(formattedJson, detailsJson) as VideoDetails
+  }
 
-generateThumbnailFromData = function (video: VideoInstance, thumbnailData: string) {
-  // Creating the thumbnail for a remote video
+  toActivityPubObject (): VideoTorrentObject {
+    const { baseUrlHttp, baseUrlWs } = this.getBaseUrls()
+    if (!this.Tags) this.Tags = []
 
-  const thumbnailName = video.getThumbnailName()
-  const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, thumbnailName)
-  return writeFilePromise(thumbnailPath, Buffer.from(thumbnailData, 'binary')).then(() => {
-    return thumbnailName
-  })
-}
+    const tag = this.Tags.map(t => ({
+      type: 'Hashtag' as 'Hashtag',
+      name: t.name
+    }))
 
-getDurationFromFile = function (videoPath: string) {
-  return new Promise<number>((res, rej) => {
-    ffmpeg.ffprobe(videoPath, function (err, metadata) {
-      if (err) return rej(err)
+    let language
+    if (this.language) {
+      language = {
+        identifier: this.language + '',
+        name: this.getLanguageLabel()
+      }
+    }
 
-      return res(Math.floor(metadata.format.duration))
-    })
-  })
-}
+    let category
+    if (this.category) {
+      category = {
+        identifier: this.category + '',
+        name: this.getCategoryLabel()
+      }
+    }
 
-list = function () {
-  return Video.findAll()
-}
+    let licence
+    if (this.licence) {
+      licence = {
+        identifier: this.licence + '',
+        name: this.getLicenceLabel()
+      }
+    }
 
-listForApi = function (start: number, count: number, sort: string) {
-  // Exclude Blakclisted videos from the list
-  const query = {
-    distinct: true,
-    offset: start,
-    limit: count,
-    order: [ getSort(sort), [ Video['sequelize'].models.Tag, 'name', 'ASC' ] ],
-    include: [
-      {
-        model: Video['sequelize'].models.Author,
-        include: [ { model: Video['sequelize'].models.Pod, required: false } ]
-      },
+    let likesObject
+    let dislikesObject
 
-      Video['sequelize'].models.Tag
-    ],
-    where: createBaseVideosWhere()
-  }
+    if (Array.isArray(this.AccountVideoRates)) {
+      const likes: string[] = []
+      const dislikes: string[] = []
 
-  return Video.findAndCountAll(query).then(({ rows, count }) => {
-    return {
-      data: rows,
-      total: count
+      for (const rate of this.AccountVideoRates) {
+        if (rate.type === 'like') {
+          likes.push(rate.Account.url)
+        } else if (rate.type === 'dislike') {
+          dislikes.push(rate.Account.url)
+        }
+      }
+
+      likesObject = activityPubCollection(likes)
+      dislikesObject = activityPubCollection(dislikes)
     }
-  })
-}
 
-loadByHostAndRemoteId = function (fromHost: string, remoteId: string) {
-  const query = {
-    where: {
-      remoteId: remoteId
-    },
-    include: [
-      {
-        model: Video['sequelize'].models.Author,
-        include: [
-          {
-            model: Video['sequelize'].models.Pod,
-            required: true,
-            where: {
-              host: fromHost
-            }
-          }
-        ]
+    let sharesObject
+    if (Array.isArray(this.VideoShares)) {
+      const shares: string[] = []
+
+      for (const videoShare of this.VideoShares) {
+        const shareUrl = getAnnounceActivityPubUrl(this.url, videoShare.Account)
+        shares.push(shareUrl)
       }
-    ]
-  }
 
-  return Video.findOne(query)
-}
+      sharesObject = activityPubCollection(shares)
+    }
 
-listOwnedAndPopulateAuthorAndTags = function () {
-  // If remoteId is null this is *our* video
-  const query = {
-    where: {
-      remoteId: null
-    },
-    include: [ Video['sequelize'].models.Author, Video['sequelize'].models.Tag ]
-  }
+    const url = []
+    for (const file of this.VideoFiles) {
+      url.push({
+        type: 'Link',
+        mimeType: 'video/' + file.extname.replace('.', ''),
+        url: this.getVideoFileUrl(file, baseUrlHttp),
+        width: file.resolution,
+        size: file.size
+      })
 
-  return Video.findAll(query)
-}
+      url.push({
+        type: 'Link',
+        mimeType: 'application/x-bittorrent',
+        url: this.getTorrentUrl(file, baseUrlHttp),
+        width: file.resolution
+      })
 
-listOwnedByAuthor = function (author: string) {
-  const query = {
-    where: {
-      remoteId: null
-    },
-    include: [
-      {
-        model: Video['sequelize'].models.Author,
-        where: {
-          name: author
-        }
-      }
-    ]
+      url.push({
+        type: 'Link',
+        mimeType: 'application/x-bittorrent;x-scheme-handler/magnet',
+        url: this.generateMagnetUri(file, baseUrlHttp, baseUrlWs),
+        width: file.resolution
+      })
+    }
+
+    // Add video url too
+    url.push({
+      type: 'Link',
+      mimeType: 'text/html',
+      url: CONFIG.WEBSERVER.URL + '/videos/watch/' + this.uuid
+    })
+
+    return {
+      type: 'Video' as 'Video',
+      id: this.url,
+      name: this.name,
+      // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-duration
+      duration: 'PT' + this.duration + 'S',
+      uuid: this.uuid,
+      tag,
+      category,
+      licence,
+      language,
+      views: this.views,
+      nsfw: this.nsfw,
+      published: this.createdAt.toISOString(),
+      updated: this.updatedAt.toISOString(),
+      mediaType: 'text/markdown',
+      content: this.getTruncatedDescription(),
+      icon: {
+        type: 'Image',
+        url: this.getThumbnailUrl(baseUrlHttp),
+        mediaType: 'image/jpeg',
+        width: THUMBNAILS_SIZE.width,
+        height: THUMBNAILS_SIZE.height
+      },
+      url,
+      likes: likesObject,
+      dislikes: dislikesObject,
+      shares: sharesObject
+    }
   }
 
-  return Video.findAll(query)
-}
+  getTruncatedDescription () {
+    if (!this.description) return null
 
-load = function (id: string) {
-  return Video.findById(id)
-}
+    const options = {
+      length: CONSTRAINTS_FIELDS.VIDEOS.TRUNCATED_DESCRIPTION.max
+    }
 
-loadAndPopulateAuthor = function (id: string) {
-  const options = {
-    include: [ Video['sequelize'].models.Author ]
+    return truncate(this.description, options)
   }
 
-  return Video.findById(id, options)
-}
+  optimizeOriginalVideofile = async function () {
+    const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
+    const newExtname = '.mp4'
+    const inputVideoFile = this.getOriginalFile()
+    const videoInputPath = join(videosDirectory, this.getVideoFilename(inputVideoFile))
+    const videoOutputPath = join(videosDirectory, this.id + '-transcoded' + newExtname)
 
-loadAndPopulateAuthorAndPodAndTags = function (id: string) {
-  const options = {
-    include: [
-      {
-        model: Video['sequelize'].models.Author,
-        include: [ { model: Video['sequelize'].models.Pod, required: false } ]
-      },
-      Video['sequelize'].models.Tag
-    ]
-  }
+    const transcodeOptions = {
+      inputPath: videoInputPath,
+      outputPath: videoOutputPath
+    }
 
-  return Video.findById(id, options)
-}
+    try {
+      // Could be very long!
+      await transcode(transcodeOptions)
 
-searchAndPopulateAuthorAndPodAndTags = function (value: string, field: string, start: number, count: number, sort: string) {
-  const podInclude: any = {
-    model: Video['sequelize'].models.Pod,
-    required: false
-  }
+      await unlinkPromise(videoInputPath)
 
-  const authorInclude: any = {
-    model: Video['sequelize'].models.Author,
-    include: [
-      podInclude
-    ]
-  }
+      // Important to do this before getVideoFilename() to take in account the new file extension
+      inputVideoFile.set('extname', newExtname)
 
-  const tagInclude: any = {
-    model: Video['sequelize'].models.Tag
-  }
+      await renamePromise(videoOutputPath, this.getVideoFilePath(inputVideoFile))
+      const stats = await statPromise(this.getVideoFilePath(inputVideoFile))
 
-  const query: any = {
-    distinct: true,
-    where: createBaseVideosWhere(),
-    offset: start,
-    limit: count,
-    order: [ getSort(sort), [ Video['sequelize'].models.Tag, 'name', 'ASC' ] ]
-  }
+      inputVideoFile.set('size', stats.size)
 
-  // Make an exact search with the magnet
-  if (field === 'magnetUri') {
-    const infoHash = magnetUtil.decode(value).infoHash
-    query.where.infoHash = infoHash
-  } else 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}
-       )`
-    )
-  } else if (field === 'host') {
-    // FIXME: Include our pod? (not stored in the database)
-    podInclude.where = {
-      host: {
-        $like: '%' + value + '%'
-      }
-    }
-    podInclude.required = true
-  } else if (field === 'author') {
-    authorInclude.where = {
-      name: {
-        $like: '%' + value + '%'
-      }
+      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 (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()))
 
-    // authorInclude.or = true
-  } else {
-    query.where[field] = {
-      $like: '%' + value + '%'
+    const newVideoFile = new VideoFileModel({
+      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)
   }
 
-  query.include = [
-    authorInclude, tagInclude
-  ]
+  getOriginalFileHeight () {
+    const originalFilePath = this.getVideoFilePath(this.getOriginalFile())
 
-  if (tagInclude.where) {
-    // query.include.push([ Video['sequelize'].models.Tag ])
+    return getVideoFileHeight(originalFilePath)
   }
 
-  return Video.findAndCountAll(query).then(({ rows, count }) => {
-    return {
-      data: rows,
-      total: count
-    }
-  })
-}
+  getDescriptionPath () {
+    return `/api/${API_VERSION}/videos/${this.uuid}/description`
+  }
 
-// ---------------------------------------------------------------------------
+  getCategoryLabel () {
+    let categoryLabel = VIDEO_CATEGORIES[this.category]
+    if (!categoryLabel) categoryLabel = 'Misc'
 
-function createBaseVideosWhere () {
-  return {
-    id: {
-      $notIn: Video['sequelize'].literal(
-        '(SELECT "BlacklistedVideos"."videoId" FROM "BlacklistedVideos")'
-      )
-    }
+    return categoryLabel
   }
-}
 
-function removeThumbnail (video: VideoInstance) {
-  const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, video.getThumbnailName())
-  return unlinkPromise(thumbnailPath)
-}
+  getLicenceLabel () {
+    let licenceLabel = VIDEO_LICENCES[this.licence]
+    if (!licenceLabel) licenceLabel = 'Unknown'
 
-function removeFile (video: VideoInstance) {
-  const filePath = join(CONFIG.STORAGE.VIDEOS_DIR, video.getVideoFilename())
-  return unlinkPromise(filePath)
-}
+    return licenceLabel
+  }
 
-function removeTorrent (video: VideoInstance) {
-  const torrenPath = join(CONFIG.STORAGE.TORRENTS_DIR, video.getTorrentName())
-  return unlinkPromise(torrenPath)
-}
+  getLanguageLabel () {
+    let languageLabel = VIDEO_LANGUAGES[this.language]
+    if (!languageLabel) languageLabel = 'Unknown'
 
-function removePreview (video: VideoInstance) {
-  // Same name than video thumnail
-  return unlinkPromise(CONFIG.STORAGE.PREVIEWS_DIR + video.getPreviewName())
-}
+    return languageLabel
+  }
 
-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()
-    ]
+  removeThumbnail () {
+    const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName())
+    return unlinkPromise(thumbnailPath)
   }
 
-  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()
-    })
-}
+  removePreview () {
+    // Same name than video thumbnail
+    return unlinkPromise(CONFIG.STORAGE.PREVIEWS_DIR + this.getPreviewName())
+  }
 
-function createPreview (video: VideoInstance, videoPath: string) {
-  return generateImage(video, videoPath, CONFIG.STORAGE.PREVIEWS_DIR, video.getPreviewName(), null)
-}
+  removeFile (videoFile: VideoFileModel) {
+    const filePath = join(CONFIG.STORAGE.VIDEOS_DIR, this.getVideoFilename(videoFile))
+    return unlinkPromise(filePath)
+  }
 
-function createThumbnail (video: VideoInstance, videoPath: string) {
-  return generateImage(video, videoPath, CONFIG.STORAGE.THUMBNAILS_DIR, video.getThumbnailName(), THUMBNAILS_SIZE)
-}
+  removeTorrent (videoFile: VideoFileModel) {
+    const torrentPath = join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
+    return unlinkPromise(torrentPath)
+  }
+
+  private getBaseUrls () {
+    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.VideoChannel.Account.Server.host
+      baseUrlWs = REMOTE_SCHEME.WS + '://' + this.VideoChannel.Account.Server.host
+    }
 
-function generateImage (video: VideoInstance, videoPath: string, folder: string, imageName: string, size: string) {
-  const options: any = {
-    filename: imageName,
-    count: 1,
-    folder
+    return { baseUrlHttp, baseUrlWs }
   }
 
-  if (size) {
-    options.size = size
+  private getThumbnailUrl (baseUrlHttp: string) {
+    return baseUrlHttp + STATIC_PATHS.THUMBNAILS + this.getThumbnailName()
   }
 
-  return new Promise<string>((res, rej) => {
-    ffmpeg(videoPath)
-      .on('error', rej)
-      .on('end', function () {
-        return res(imageName)
-      })
-      .thumbnail(options)
-  })
-}
+  private getTorrentUrl (videoFile: VideoFileModel, baseUrlHttp: string) {
+    return baseUrlHttp + STATIC_PATHS.TORRENTS + this.getTorrentFileName(videoFile)
+  }
+
+  private getVideoFileUrl (videoFile: VideoFileModel, baseUrlHttp: string) {
+    return baseUrlHttp + STATIC_PATHS.WEBSEED + this.getVideoFilename(videoFile)
+  }
 
-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
+  private generateMagnetUri (videoFile: VideoFileModel, baseUrlHttp: string, baseUrlWs: string) {
+    const xs = this.getTorrentUrl(videoFile, baseUrlHttp)
+    const announce = [ baseUrlWs + '/tracker/socket', baseUrlHttp + '/tracker/announce' ]
+    const urlList = [ this.getVideoFileUrl(videoFile, baseUrlHttp) ]
+
+    const magnetHash = {
+      xs,
+      announce,
+      urlList,
+      infoHash: videoFile.infoHash,
+      name: this.name
     }
 
-    // If we found the video, remove it from the blacklist
-    return video.destroy()
-  })
+    return magnetUtil.encode(magnetHash)
+  }
 }