]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blobdiff - server/models/video/video.ts
Fix fps federation
[github/Chocobozzz/PeerTube.git] / server / models / video / video.ts
index 4dce8e2fcbe1fc817dbcab285d5f2569da7758af..39fe2100789b94b9e2ea768cadbebd72bffd12b5 100644 (file)
 import * as Bluebird from 'bluebird'
-import { map, maxBy, truncate } from 'lodash'
+import { map, maxBy } from 'lodash'
 import * as magnetUtil from 'magnet-uri'
 import * as parseTorrent from 'parse-torrent'
-import { join } from 'path'
+import { extname, join } from 'path'
 import * as Sequelize from 'sequelize'
-import { VideoPrivacy, VideoResolution } from '../../../shared'
-import { VideoTorrentObject } from '../../../shared/models/activitypub/objects/video-torrent-object'
-import { activityPubCollection } from '../../helpers/activitypub'
-import { createTorrentPromise, renamePromise, statPromise, unlinkPromise, writeFilePromise } from '../../helpers/core-utils'
-import { isVideoCategoryValid, isVideoLanguageValid, isVideoPrivacyValid } from '../../helpers/custom-validators/videos'
-import { generateImageFromVideoFile, getVideoFileHeight, transcode } from '../../helpers/ffmpeg-utils'
 import {
-  isActivityPubUrlValid,
+  AllowNull,
+  BeforeDestroy,
+  BelongsTo,
+  BelongsToMany,
+  Column,
+  CreatedAt,
+  DataType,
+  Default,
+  ForeignKey,
+  HasMany,
+  HasOne,
+  IFindOptions,
+  Is,
+  IsInt,
+  IsUUID,
+  Min,
+  Model,
+  Scopes,
+  Table,
+  UpdatedAt
+} from 'sequelize-typescript'
+import { VideoPrivacy, VideoResolution, VideoState } from '../../../shared'
+import { VideoTorrentObject } from '../../../shared/models/activitypub/objects'
+import { Video, VideoDetails, VideoFile } from '../../../shared/models/videos'
+import { VideoFilter } from '../../../shared/models/videos/video-query.type'
+import {
+  copyFilePromise,
+  createTorrentPromise,
+  peertubeTruncate,
+  renamePromise,
+  statPromise,
+  unlinkPromise,
+  writeFilePromise
+} from '../../helpers/core-utils'
+import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
+import { isBooleanValid } from '../../helpers/custom-validators/misc'
+import {
+  isVideoCategoryValid,
   isVideoDescriptionValid,
   isVideoDurationValid,
+  isVideoLanguageValid,
   isVideoLicenceValid,
   isVideoNameValid,
-  isVideoNSFWValid
-} from '../../helpers/index'
+  isVideoPrivacyValid,
+  isVideoStateValid,
+  isVideoSupportValid
+} from '../../helpers/custom-validators/videos'
+import { generateImageFromVideoFile, getVideoFileFPS, getVideoFileResolution, transcode } from '../../helpers/ffmpeg-utils'
 import { logger } from '../../helpers/logger'
+import { getServerActor } from '../../helpers/utils'
 import {
   API_VERSION,
   CONFIG,
   CONSTRAINTS_FIELDS,
   PREVIEWS_SIZE,
   REMOTE_SCHEME,
+  STATIC_DOWNLOAD_PATHS,
   STATIC_PATHS,
   THUMBNAILS_SIZE,
   VIDEO_CATEGORIES,
+  VIDEO_EXT_MIMETYPE,
   VIDEO_LANGUAGES,
   VIDEO_LICENCES,
-  VIDEO_PRIVACIES
-} from '../../initializers/constants'
-import { getAnnounceActivityPubUrl } from '../../lib/activitypub/url'
-import { sendDeleteVideo } from '../../lib/index'
-import { addMethodsToModel, getSort } from '../utils'
-import { TagInstance } from './tag-interface'
-import { VideoFileInstance, VideoFileModel } from './video-file-interface'
-import { VideoAttributes, VideoInstance, VideoMethods } from './video-interface'
-
-let Video: Sequelize.Model<VideoInstance, VideoAttributes>
-let getOriginalFile: VideoMethods.GetOriginalFile
-let getVideoFilename: VideoMethods.GetVideoFilename
-let getThumbnailName: VideoMethods.GetThumbnailName
-let getThumbnailPath: VideoMethods.GetThumbnailPath
-let getPreviewName: VideoMethods.GetPreviewName
-let getPreviewPath: VideoMethods.GetPreviewPath
-let getTorrentFileName: VideoMethods.GetTorrentFileName
-let isOwned: VideoMethods.IsOwned
-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 list: VideoMethods.List
-let listForApi: VideoMethods.ListForApi
-let listAllAndSharedByAccountForOutbox: VideoMethods.ListAllAndSharedByAccountForOutbox
-let listUserVideosForApi: VideoMethods.ListUserVideosForApi
-let load: VideoMethods.Load
-let loadByUrlAndPopulateAccount: VideoMethods.LoadByUrlAndPopulateAccount
-let loadByUUID: VideoMethods.LoadByUUID
-let loadByUUIDOrURL: VideoMethods.LoadByUUIDOrURL
-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',
-    {
-      uuid: {
-        type: DataTypes.UUID,
-        defaultValue: DataTypes.UUIDV4,
-        allowNull: false,
-        validate: {
-          isUUID: 4
-        }
-      },
-      name: {
-        type: DataTypes.STRING,
-        allowNull: false,
-        validate: {
-          nameValid: value => {
-            const res = isVideoNameValid(value)
-            if (res === false) throw new Error('Video name is not valid.')
-          }
-        }
-      },
-      category: {
-        type: DataTypes.INTEGER,
-        allowNull: false,
-        validate: {
-          categoryValid: 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: value => {
-            const res = isVideoLicenceValid(value)
-            if (res === false) throw new Error('Video licence is not valid.')
-          }
-        }
-      },
-      language: {
-        type: DataTypes.INTEGER,
-        allowNull: true,
-        validate: {
-          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: value => {
-            const res = isVideoNSFWValid(value)
-            if (res === false) throw new Error('Video nsfw attribute is not valid.')
-          }
-        }
-      },
-      description: {
-        type: DataTypes.STRING(CONSTRAINTS_FIELDS.VIDEOS.DESCRIPTION.max),
-        allowNull: false,
-        validate: {
-          descriptionValid: value => {
-            const res = isVideoDescriptionValid(value)
-            if (res === false) throw new Error('Video description is not valid.')
-          }
-        }
-      },
-      duration: {
-        type: DataTypes.INTEGER,
-        allowNull: false,
-        validate: {
-          durationValid: value => {
-            const res = isVideoDurationValid(value)
-            if (res === false) throw new Error('Video duration is not valid.')
-          }
-        }
-      },
-      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
-        }
-      },
-      remote: {
-        type: DataTypes.BOOLEAN,
-        allowNull: false,
-        defaultValue: false
-      },
-      url: {
-        type: DataTypes.STRING(CONSTRAINTS_FIELDS.VIDEOS.URL.max),
-        allowNull: false,
-        validate: {
-          urlValid: value => {
-            const res = isActivityPubUrlValid(value)
-            if (res === false) throw new Error('Video URL is not valid.')
-          }
-        }
-      }
-    },
-    {
-      indexes: [
-        {
-          fields: [ 'name' ]
-        },
-        {
-          fields: [ 'createdAt' ]
-        },
-        {
-          fields: [ 'duration' ]
-        },
+  VIDEO_PRIVACIES,
+  VIDEO_STATES
+} from '../../initializers'
+import {
+  getVideoCommentsActivityPubUrl,
+  getVideoDislikesActivityPubUrl,
+  getVideoLikesActivityPubUrl,
+  getVideoSharesActivityPubUrl
+} from '../../lib/activitypub'
+import { sendDeleteVideo } from '../../lib/activitypub/send'
+import { AccountModel } from '../account/account'
+import { AccountVideoRateModel } from '../account/account-video-rate'
+import { ActorModel } from '../activitypub/actor'
+import { AvatarModel } from '../avatar/avatar'
+import { ServerModel } from '../server/server'
+import { buildTrigramSearchIndex, createSimilarityAttribute, getSort, throwIfNotValid } from '../utils'
+import { TagModel } from './tag'
+import { VideoAbuseModel } from './video-abuse'
+import { VideoChannelModel } from './video-channel'
+import { VideoCommentModel } from './video-comment'
+import { VideoFileModel } from './video-file'
+import { VideoShareModel } from './video-share'
+import { VideoTagModel } from './video-tag'
+import { ScheduleVideoUpdateModel } from './schedule-video-update'
+import { VideoCaptionModel } from './video-caption'
+
+// FIXME: Define indexes here because there is an issue with TS and Sequelize.literal when called directly in the annotation
+const indexes: Sequelize.DefineIndexesOptions[] = [
+  buildTrigramSearchIndex('video_name_trigram', 'name'),
+
+  { fields: [ 'createdAt' ] },
+  { fields: [ 'publishedAt' ] },
+  { fields: [ 'duration' ] },
+  { fields: [ 'category' ] },
+  { fields: [ 'licence' ] },
+  { fields: [ 'nsfw' ] },
+  { fields: [ 'language' ] },
+  { fields: [ 'waitTranscoding' ] },
+  { fields: [ 'state' ] },
+  { fields: [ 'remote' ] },
+  { fields: [ 'views' ] },
+  { fields: [ 'likes' ] },
+  { fields: [ 'channelId' ] },
+  {
+    fields: [ 'uuid' ],
+    unique: true
+  },
+  {
+    fields: [ 'url'],
+    unique: true
+  }
+]
+
+export enum ScopeNames {
+  AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST',
+  WITH_ACCOUNT_DETAILS = 'WITH_ACCOUNT_DETAILS',
+  WITH_TAGS = 'WITH_TAGS',
+  WITH_FILES = 'WITH_FILES',
+  WITH_SCHEDULED_UPDATE = 'WITH_SCHEDULED_UPDATE'
+}
+
+type AvailableForListOptions = {
+  actorId: number,
+  filter?: VideoFilter,
+  categoryOneOf?: number[],
+  nsfw?: boolean,
+  licenceOneOf?: number[],
+  languageOneOf?: string[],
+  tagsOneOf?: string[],
+  tagsAllOf?: string[],
+  withFiles?: boolean,
+  accountId?: number,
+  videoChannelId?: number
+}
+
+@Scopes({
+  [ScopeNames.AVAILABLE_FOR_LIST]: (options: AvailableForListOptions) => {
+    const accountInclude = {
+      attributes: [ 'id', 'name' ],
+      model: AccountModel.unscoped(),
+      required: true,
+      where: {},
+      include: [
         {
-          fields: [ 'views' ]
-        },
+          attributes: [ 'id', 'uuid', 'preferredUsername', 'url', 'serverId', 'avatarId' ],
+          model: ActorModel.unscoped(),
+          required: true,
+          where: VideoModel.buildActorWhereWithFilter(options.filter),
+          include: [
+            {
+              attributes: [ 'host' ],
+              model: ServerModel.unscoped(),
+              required: false
+            },
+            {
+              model: AvatarModel.unscoped(),
+              required: false
+            }
+          ]
+        }
+      ]
+    }
+
+    const videoChannelInclude = {
+      attributes: [ 'name', 'description', 'id' ],
+      model: VideoChannelModel.unscoped(),
+      required: true,
+      where: {},
+      include: [
         {
-          fields: [ 'likes' ]
+          attributes: [ 'uuid', 'preferredUsername', 'url', 'serverId', 'avatarId' ],
+          model: ActorModel.unscoped(),
+          required: true,
+          include: [
+            {
+              attributes: [ 'host' ],
+              model: ServerModel.unscoped(),
+              required: false
+            },
+            {
+              model: AvatarModel.unscoped(),
+              required: false
+            }
+          ]
         },
-        {
-          fields: [ 'uuid' ]
+        accountInclude
+      ]
+    }
+
+    // Force actorId to be a number to avoid SQL injections
+    const actorIdNumber = parseInt(options.actorId.toString(), 10)
+
+    // FIXME: It would be more efficient to use a CTE so we join AFTER the filters, but sequelize does not support it...
+    const query: IFindOptions<VideoModel> = {
+      where: {
+        id: {
+          [Sequelize.Op.notIn]: Sequelize.literal(
+            '(SELECT "videoBlacklist"."videoId" FROM "videoBlacklist")'
+          ),
+          [ Sequelize.Op.in ]: Sequelize.literal(
+            '(' +
+              'SELECT "videoShare"."videoId" AS "id" FROM "videoShare" ' +
+              'INNER JOIN "actorFollow" ON "actorFollow"."targetActorId" = "videoShare"."actorId" ' +
+              'WHERE "actorFollow"."actorId" = ' + actorIdNumber +
+              ' UNION ' +
+              'SELECT "video"."id" AS "id" FROM "video" ' +
+              'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' +
+              'INNER JOIN "account" ON "account"."id" = "videoChannel"."accountId" ' +
+              'INNER JOIN "actor" ON "account"."actorId" = "actor"."id" ' +
+              'WHERE "actor"."serverId" IS NULL ' +
+              ' UNION ALL ' +
+              'SELECT "video"."id" AS "id" FROM "video" ' +
+              'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' +
+              'INNER JOIN "account" ON "account"."id" = "videoChannel"."accountId" ' +
+              'INNER JOIN "actor" ON "account"."actorId" = "actor"."id" ' +
+              'INNER JOIN "actorFollow" ON "actorFollow"."targetActorId" = "actor"."id" ' +
+              'WHERE "actorFollow"."actorId" = ' + actorIdNumber +
+            ')'
+          )
         },
-        {
-          fields: [ 'channelId' ]
-        }
-      ],
-      hooks: {
-        afterDestroy
+        // Always list public videos
+        privacy: VideoPrivacy.PUBLIC,
+        // Always list published videos, or videos that are being transcoded but on which we don't want to wait for transcoding
+        [ Sequelize.Op.or ]: [
+          {
+            state: VideoState.PUBLISHED
+          },
+          {
+            [ Sequelize.Op.and ]: {
+              state: VideoState.TO_TRANSCODE,
+              waitTranscoding: false
+            }
+          }
+        ]
+      },
+      include: [ videoChannelInclude ]
+    }
+
+    if (options.withFiles === true) {
+      query.include.push({
+        model: VideoFileModel.unscoped(),
+        required: true
+      })
+    }
+
+    // FIXME: issues with sequelize count when making a join on n:m relation, so we just make a IN()
+    if (options.tagsAllOf || options.tagsOneOf) {
+      const createTagsIn = (tags: string[]) => {
+        return tags.map(t => VideoModel.sequelize.escape(t))
+                   .join(', ')
+      }
+
+      if (options.tagsOneOf) {
+        query.where['id'][Sequelize.Op.in] = Sequelize.literal(
+          '(' +
+            'SELECT "videoId" FROM "videoTag" ' +
+            'INNER JOIN "tag" ON "tag"."id" = "videoTag"."tagId" ' +
+            'WHERE "tag"."name" IN (' + createTagsIn(options.tagsOneOf) + ')' +
+          ')'
+        )
+      }
+
+      if (options.tagsAllOf) {
+        query.where['id'][Sequelize.Op.in] = Sequelize.literal(
+            '(' +
+              'SELECT "videoId" FROM "videoTag" ' +
+              'INNER JOIN "tag" ON "tag"."id" = "videoTag"."tagId" ' +
+              'WHERE "tag"."name" IN (' + createTagsIn(options.tagsAllOf) + ')' +
+              'GROUP BY "videoTag"."videoId" HAVING COUNT(*) = ' + options.tagsAllOf.length +
+            ')'
+        )
       }
     }
-  )
-
-  const classMethods = [
-    associate,
-
-    list,
-    listAllAndSharedByAccountForOutbox,
-    listForApi,
-    listUserVideosForApi,
-    load,
-    loadByUrlAndPopulateAccount,
-    loadAndPopulateAccountAndServerAndTags,
-    loadByUUIDOrURL,
-    loadByUUID,
-    loadByUUIDAndPopulateAccountAndServerAndTags,
-    searchAndPopulateAccountAndServerAndTags
-  ]
-  const instanceMethods = [
-    createPreview,
-    createThumbnail,
-    createTorrentAndSetInfoHash,
-    getPreviewName,
-    getPreviewPath,
-    getThumbnailName,
-    getThumbnailPath,
-    getTorrentFileName,
-    getVideoFilename,
-    getVideoFilePath,
-    getOriginalFile,
-    isOwned,
-    removeFile,
-    removePreview,
-    removeThumbnail,
-    removeTorrent,
-    toActivityPubObject,
-    toFormattedJSON,
-    toFormattedDetailsJSON,
-    optimizeOriginalVideofile,
-    transcodeOriginalVideofile,
-    getOriginalFileHeight,
-    getEmbedPath,
-    getTruncatedDescription,
-    getDescriptionPath,
-    getCategoryLabel,
-    getLicenceLabel,
-    getLanguageLabel
-  ]
-  addMethodsToModel(Video, classMethods, instanceMethods)
-
-  return Video
-}
 
-// ------------------------------ METHODS ------------------------------
+    if (options.nsfw === true || options.nsfw === false) {
+      query.where['nsfw'] = options.nsfw
+    }
+
+    if (options.categoryOneOf) {
+      query.where['category'] = {
+        [Sequelize.Op.or]: options.categoryOneOf
+      }
+    }
+
+    if (options.licenceOneOf) {
+      query.where['licence'] = {
+        [Sequelize.Op.or]: options.licenceOneOf
+      }
+    }
+
+    if (options.languageOneOf) {
+      query.where['language'] = {
+        [Sequelize.Op.or]: options.languageOneOf
+      }
+    }
+
+    if (options.accountId) {
+      accountInclude.where = {
+        id: options.accountId
+      }
+    }
+
+    if (options.videoChannelId) {
+      videoChannelInclude.where = {
+        id: options.videoChannelId
+      }
+    }
+
+    return query
+  },
+  [ScopeNames.WITH_ACCOUNT_DETAILS]: {
+    include: [
+      {
+        model: () => VideoChannelModel.unscoped(),
+        required: true,
+        include: [
+          {
+            attributes: {
+              exclude: [ 'privateKey', 'publicKey' ]
+            },
+            model: () => ActorModel.unscoped(),
+            required: true,
+            include: [
+              {
+                attributes: [ 'host' ],
+                model: () => ServerModel.unscoped(),
+                required: false
+              },
+              {
+                model: () => AvatarModel.unscoped(),
+                required: false
+              }
+            ]
+          },
+          {
+            model: () => AccountModel.unscoped(),
+            required: true,
+            include: [
+              {
+                model: () => ActorModel.unscoped(),
+                attributes: {
+                  exclude: [ 'privateKey', 'publicKey' ]
+                },
+                required: true,
+                include: [
+                  {
+                    attributes: [ 'host' ],
+                    model: () => ServerModel.unscoped(),
+                    required: false
+                  },
+                  {
+                    model: () => AvatarModel.unscoped(),
+                    required: false
+                  }
+                ]
+              }
+            ]
+          }
+        ]
+      }
+    ]
+  },
+  [ScopeNames.WITH_TAGS]: {
+    include: [ () => TagModel ]
+  },
+  [ScopeNames.WITH_FILES]: {
+    include: [
+      {
+        model: () => VideoFileModel.unscoped(),
+        required: false
+      }
+    ]
+  },
+  [ScopeNames.WITH_SCHEDULED_UPDATE]: {
+    include: [
+      {
+        model: () => ScheduleVideoUpdateModel.unscoped(),
+        required: false
+      }
+    ]
+  }
+})
+@Table({
+  tableName: 'video',
+  indexes
+})
+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(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.LANGUAGE.max))
+  language: string
+
+  @AllowNull(false)
+  @Is('VideoPrivacy', value => throwIfNotValid(value, isVideoPrivacyValid, 'privacy'))
+  @Column
+  privacy: number
+
+  @AllowNull(false)
+  @Is('VideoNSFW', value => throwIfNotValid(value, isBooleanValid, '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(true)
+  @Default(null)
+  @Is('VideoSupport', value => throwIfNotValid(value, isVideoSupportValid, 'support'))
+  @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.SUPPORT.max))
+  support: 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
+
+  @AllowNull(false)
+  @Column
+  commentsEnabled: boolean
+
+  @AllowNull(false)
+  @Column
+  waitTranscoding: boolean
+
+  @AllowNull(false)
+  @Default(null)
+  @Is('VideoState', value => throwIfNotValid(value, isVideoStateValid, 'state'))
+  @Column
+  state: VideoState
+
+  @CreatedAt
+  createdAt: Date
+
+  @UpdatedAt
+  updatedAt: Date
+
+  @AllowNull(false)
+  @Default(Sequelize.NOW)
+  @Column
+  publishedAt: Date
+
+  @ForeignKey(() => VideoChannelModel)
+  @Column
+  channelId: number
+
+  @BelongsTo(() => VideoChannelModel, {
+    foreignKey: {
+      allowNull: true
+    },
+    hooks: true
+  })
+  VideoChannel: VideoChannelModel
+
+  @BelongsToMany(() => TagModel, {
+    foreignKey: 'videoId',
+    through: () => VideoTagModel,
+    onDelete: 'CASCADE'
+  })
+  Tags: TagModel[]
 
-function associate (models) {
-  Video.belongsTo(models.VideoChannel, {
+  @HasMany(() => VideoAbuseModel, {
     foreignKey: {
-      name: 'channelId',
+      name: 'videoId',
       allowNull: false
     },
     onDelete: 'cascade'
   })
+  VideoAbuses: VideoAbuseModel[]
 
-  Video.belongsToMany(models.Tag, {
-    foreignKey: 'videoId',
-    through: models.VideoTag,
+  @HasMany(() => VideoFileModel, {
+    foreignKey: {
+      name: 'videoId',
+      allowNull: false
+    },
     onDelete: 'cascade'
   })
+  VideoFiles: VideoFileModel[]
 
-  Video.hasMany(models.VideoAbuse, {
+  @HasMany(() => VideoShareModel, {
     foreignKey: {
       name: 'videoId',
       allowNull: false
     },
     onDelete: 'cascade'
   })
+  VideoShares: VideoShareModel[]
 
-  Video.hasMany(models.VideoFile, {
+  @HasMany(() => AccountVideoRateModel, {
     foreignKey: {
       name: 'videoId',
       allowNull: false
     },
     onDelete: 'cascade'
   })
+  AccountVideoRates: AccountVideoRateModel[]
 
-  Video.hasMany(models.VideoShare, {
+  @HasMany(() => VideoCommentModel, {
     foreignKey: {
       name: 'videoId',
       allowNull: false
     },
-    onDelete: 'cascade'
+    onDelete: 'cascade',
+    hooks: true
   })
+  VideoComments: VideoCommentModel[]
 
-  Video.hasMany(models.AccountVideoRate, {
+  @HasOne(() => ScheduleVideoUpdateModel, {
     foreignKey: {
       name: 'videoId',
       allowNull: false
     },
     onDelete: 'cascade'
   })
-}
-
-function afterDestroy (video: VideoInstance) {
-  const tasks = []
+  ScheduleVideoUpdate: ScheduleVideoUpdateModel
 
-  tasks.push(
-    video.removeThumbnail()
-  )
+  @HasMany(() => VideoCaptionModel, {
+    foreignKey: {
+      name: 'videoId',
+      allowNull: false
+    },
+    onDelete: 'cascade',
+    hooks: true,
+    ['separate' as any]: true
+  })
+  VideoCaptions: VideoCaptionModel[]
+
+  @BeforeDestroy
+  static async sendDelete (instance: VideoModel, options) {
+    if (instance.isOwned()) {
+      if (!instance.VideoChannel) {
+        instance.VideoChannel = await instance.$get('VideoChannel', {
+          include: [
+            {
+              model: AccountModel,
+              include: [ ActorModel ]
+            }
+          ],
+          transaction: options.transaction
+        }) as VideoChannelModel
+      }
 
-  if (video.isOwned()) {
-    tasks.push(
-      video.removePreview(),
-      sendDeleteVideo(video, undefined)
-    )
+      return sendDeleteVideo(instance, options.transaction)
+    }
 
-    // Remove physical files and torrents
-    video.VideoFiles.forEach(file => {
-      tasks.push(video.removeFile(file))
-      tasks.push(video.removeTorrent(file))
-    })
+    return undefined
   }
 
-  return Promise.all(tasks)
-    .catch(err => {
-      logger.error('Some errors when removing files of video %s in after destroy hook.', video.uuid, err)
-    })
-}
+  @BeforeDestroy
+  static async removeFiles (instance: VideoModel) {
+    const tasks: Promise<any>[] = []
 
-getOriginalFile = function (this: VideoInstance) {
-  if (Array.isArray(this.VideoFiles) === false) return undefined
+    logger.info('Removing files of video %s.', instance.url)
 
-  // The original file is the file that have the higher resolution
-  return maxBy(this.VideoFiles, file => file.resolution)
-}
+    tasks.push(instance.removeThumbnail())
 
-getVideoFilename = function (this: VideoInstance, videoFile: VideoFileInstance) {
-  return this.uuid + '-' + videoFile.resolution + videoFile.extname
-}
+    if (instance.isOwned()) {
+      if (!Array.isArray(instance.VideoFiles)) {
+        instance.VideoFiles = await instance.$get('VideoFiles') as VideoFileModel[]
+      }
 
-getThumbnailName = function (this: VideoInstance) {
-  // We always have a copy of the thumbnail
-  const extension = '.jpg'
-  return this.uuid + extension
-}
+      tasks.push(instance.removePreview())
 
-getPreviewName = function (this: VideoInstance) {
-  const extension = '.jpg'
-  return this.uuid + extension
-}
+      // Remove physical files and torrents
+      instance.VideoFiles.forEach(file => {
+        tasks.push(instance.removeFile(file))
+        tasks.push(instance.removeTorrent(file))
+      })
+    }
 
-getTorrentFileName = function (this: VideoInstance, videoFile: VideoFileInstance) {
-  const extension = '.torrent'
-  return this.uuid + '-' + videoFile.resolution + extension
-}
+    // Do not wait video deletion because we could be in a transaction
+    Promise.all(tasks)
+      .catch(err => {
+        logger.error('Some errors when removing files of video %s in before destroy hook.', instance.uuid, { err })
+      })
 
-isOwned = function (this: VideoInstance) {
-  return this.remote === false
-}
+    return undefined
+  }
 
-createPreview = function (this: VideoInstance, videoFile: VideoFileInstance) {
-  const imageSize = PREVIEWS_SIZE.width + 'x' + PREVIEWS_SIZE.height
+  static list () {
+    return VideoModel.scope(ScopeNames.WITH_FILES).findAll()
+  }
 
-  return generateImageFromVideoFile(
-    this.getVideoFilePath(videoFile),
-    CONFIG.STORAGE.PREVIEWS_DIR,
-    this.getPreviewName(),
-    imageSize
-  )
-}
+  static listAllAndSharedByActorForOutbox (actorId: 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" ' +
+        'INNER JOIN "account" AS "Account" ON "Account"."id" = "VideoChannel"."accountId" ' +
+        'WHERE "Account"."actorId" = ' + actorId
+      const queryVideoShare = 'SELECT ' + select + ' FROM "videoShare" AS "VideoShare" ' +
+        'INNER JOIN "video" AS "Video" ON "Video"."id" = "VideoShare"."videoId" ' +
+        'WHERE "VideoShare"."actorId" = ' + actorId
+
+      return `(${queryVideo}) UNION (${queryVideoShare})`
+    }
 
-createThumbnail = function (this: VideoInstance, videoFile: VideoFileInstance) {
-  const imageSize = THUMBNAILS_SIZE.width + 'x' + THUMBNAILS_SIZE.height
+    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 + ')')
+        },
+        [Sequelize.Op.or]: [
+          { privacy: VideoPrivacy.PUBLIC },
+          { privacy: VideoPrivacy.UNLISTED }
+        ]
+      },
+      include: [
+        {
+          attributes: [ 'language' ],
+          model: VideoCaptionModel.unscoped(),
+          required: false
+        },
+        {
+          attributes: [ 'id', 'url' ],
+          model: VideoShareModel.unscoped(),
+          required: false,
+          // We only want videos shared by this actor
+          where: {
+            [Sequelize.Op.and]: [
+              {
+                id: {
+                  [Sequelize.Op.not]: null
+                }
+              },
+              {
+                actorId
+              }
+            ]
+          },
+          include: [
+            {
+              attributes: [ 'id', 'url' ],
+              model: ActorModel.unscoped()
+            }
+          ]
+        },
+        {
+          model: VideoChannelModel.unscoped(),
+          required: true,
+          include: [
+            {
+              attributes: [ 'name' ],
+              model: AccountModel.unscoped(),
+              required: true,
+              include: [
+                {
+                  attributes: [ 'id', 'url', 'followersUrl' ],
+                  model: ActorModel.unscoped(),
+                  required: true
+                }
+              ]
+            },
+            {
+              attributes: [ 'id', 'url', 'followersUrl' ],
+              model: ActorModel.unscoped(),
+              required: true
+            }
+          ]
+        },
+        VideoFileModel,
+        TagModel
+      ]
+    }
 
-  return generateImageFromVideoFile(
-    this.getVideoFilePath(videoFile),
-    CONFIG.STORAGE.THUMBNAILS_DIR,
-    this.getThumbnailName(),
-    imageSize
-  )
-}
+    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
+      }
+    })
+  }
 
-getVideoFilePath = function (this: VideoInstance, videoFile: VideoFileInstance) {
-  return join(CONFIG.STORAGE.VIDEOS_DIR, this.getVideoFilename(videoFile))
-}
+  static listUserVideosForApi (accountId: number, start: number, count: number, sort: string, hideNSFW: boolean, withFiles = false) {
+    const query: IFindOptions<VideoModel> = {
+      offset: start,
+      limit: count,
+      order: getSort(sort),
+      include: [
+        {
+          model: VideoChannelModel,
+          required: true,
+          include: [
+            {
+              model: AccountModel,
+              where: {
+                id: accountId
+              },
+              required: true
+            }
+          ]
+        },
+        {
+          model: ScheduleVideoUpdateModel,
+          required: false
+        }
+      ]
+    }
 
-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)
-    ]
+    if (withFiles === true) {
+      query.include.push({
+        model: VideoFileModel.unscoped(),
+        required: true
+      })
+    }
+
+    if (hideNSFW === true) {
+      query.where = {
+        nsfw: false
+      }
+    }
+
+    return VideoModel.findAndCountAll(query).then(({ rows, count }) => {
+      return {
+        data: rows,
+        total: count
+      }
+    })
   }
 
-  const torrent = await createTorrentPromise(this.getVideoFilePath(videoFile), options)
+  static async listForApi (options: {
+    start: number,
+    count: number,
+    sort: string,
+    nsfw: boolean,
+    withFiles: boolean,
+    categoryOneOf?: number[],
+    licenceOneOf?: number[],
+    languageOneOf?: string[],
+    tagsOneOf?: string[],
+    tagsAllOf?: string[],
+    filter?: VideoFilter,
+    accountId?: number,
+    videoChannelId?: number
+  }) {
+    const query = {
+      offset: options.start,
+      limit: options.count,
+      order: getSort(options.sort)
+    }
 
-  const filePath = join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
-  logger.info('Creating torrent %s.', filePath)
+    const serverActor = await getServerActor()
+    const scopes = {
+      method: [
+        ScopeNames.AVAILABLE_FOR_LIST, {
+          actorId: serverActor.id,
+          nsfw: options.nsfw,
+          categoryOneOf: options.categoryOneOf,
+          licenceOneOf: options.licenceOneOf,
+          languageOneOf: options.languageOneOf,
+          tagsOneOf: options.tagsOneOf,
+          tagsAllOf: options.tagsAllOf,
+          filter: options.filter,
+          withFiles: options.withFiles,
+          accountId: options.accountId,
+          videoChannelId: options.videoChannelId
+        } as AvailableForListOptions
+      ]
+    }
 
-  await writeFilePromise(filePath, torrent)
+    return VideoModel.scope(scopes)
+      .findAndCountAll(query)
+      .then(({ rows, count }) => {
+        return {
+          data: rows,
+          total: count
+        }
+      })
+  }
 
-  const parsedTorrent = parseTorrent(torrent)
-  videoFile.infoHash = parsedTorrent.infoHash
-}
+  static async searchAndPopulateAccountAndServer (options: {
+    search?: string
+    start?: number
+    count?: number
+    sort?: string
+    startDate?: string // ISO 8601
+    endDate?: string // ISO 8601
+    nsfw?: boolean
+    categoryOneOf?: number[]
+    licenceOneOf?: number[]
+    languageOneOf?: string[]
+    tagsOneOf?: string[]
+    tagsAllOf?: string[]
+    durationMin?: number // seconds
+    durationMax?: number // seconds
+  }) {
+    const whereAnd = [ ]
+
+    if (options.startDate || options.endDate) {
+      const publishedAtRange = { }
+
+      if (options.startDate) publishedAtRange[Sequelize.Op.gte] = options.startDate
+      if (options.endDate) publishedAtRange[Sequelize.Op.lte] = options.endDate
+
+      whereAnd.push({ publishedAt: publishedAtRange })
+    }
 
-getEmbedPath = function (this: VideoInstance) {
-  return '/videos/embed/' + this.uuid
-}
+    if (options.durationMin || options.durationMax) {
+      const durationRange = { }
 
-getThumbnailPath = function (this: VideoInstance) {
-  return join(STATIC_PATHS.THUMBNAILS, this.getThumbnailName())
-}
+      if (options.durationMin) durationRange[Sequelize.Op.gte] = options.durationMin
+      if (options.durationMax) durationRange[Sequelize.Op.lte] = options.durationMax
 
-getPreviewPath = function (this: VideoInstance) {
-  return join(STATIC_PATHS.PREVIEWS, this.getPreviewName())
-}
+      whereAnd.push({ duration: durationRange })
+    }
 
-toFormattedJSON = function (this: VideoInstance) {
-  let serverHost
+    const attributesInclude = []
+    const escapedSearch = VideoModel.sequelize.escape(options.search)
+    const escapedLikeSearch = VideoModel.sequelize.escape('%' + options.search + '%')
+    if (options.search) {
+      whereAnd.push(
+        {
+          id: {
+            [ Sequelize.Op.in ]: Sequelize.literal(
+              '(' +
+                'SELECT "video"."id" FROM "video" WHERE ' +
+                'lower(immutable_unaccent("video"."name")) % lower(immutable_unaccent(' + escapedSearch + ')) OR ' +
+                'lower(immutable_unaccent("video"."name")) LIKE lower(immutable_unaccent(' + escapedLikeSearch + '))' +
+                'UNION ALL ' +
+                'SELECT "video"."id" FROM "video" LEFT JOIN "videoTag" ON "videoTag"."videoId" = "video"."id" ' +
+                'INNER JOIN "tag" ON "tag"."id" = "videoTag"."tagId" ' +
+                'WHERE "tag"."name" = ' + escapedSearch +
+              ')'
+            )
+          }
+        }
+      )
 
-  if (this.VideoChannel.Account.Server) {
-    serverHost = this.VideoChannel.Account.Server.host
-  } else {
-    // It means it's our video
-    serverHost = CONFIG.WEBSERVER.HOST
-  }
+      attributesInclude.push(createSimilarityAttribute('VideoModel.name', options.search))
+    }
 
-  const json = {
-    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.getTruncatedDescription(),
-    serverHost,
-    isLocal: this.isOwned(),
-    account: this.VideoChannel.Account.name,
-    duration: this.duration,
-    views: this.views,
-    likes: this.likes,
-    dislikes: this.dislikes,
-    tags: map<TagInstance, string>(this.Tags, 'name'),
-    thumbnailPath: this.getThumbnailPath(),
-    previewPath: this.getPreviewPath(),
-    embedPath: this.getEmbedPath(),
-    createdAt: this.createdAt,
-    updatedAt: this.updatedAt
-  }
+    // Cannot search on similarity if we don't have a search
+    if (!options.search) {
+      attributesInclude.push(
+        Sequelize.literal('0 as similarity')
+      )
+    }
 
-  return json
-}
+    const query: IFindOptions<VideoModel> = {
+      attributes: {
+        include: attributesInclude
+      },
+      offset: options.start,
+      limit: options.count,
+      order: getSort(options.sort),
+      where: {
+        [ Sequelize.Op.and ]: whereAnd
+      }
+    }
+
+    const serverActor = await getServerActor()
+    const scopes = {
+      method: [
+        ScopeNames.AVAILABLE_FOR_LIST, {
+          actorId: serverActor.id,
+          nsfw: options.nsfw,
+          categoryOneOf: options.categoryOneOf,
+          licenceOneOf: options.licenceOneOf,
+          languageOneOf: options.languageOneOf,
+          tagsOneOf: options.tagsOneOf,
+          tagsAllOf: options.tagsAllOf
+        } as AvailableForListOptions
+      ]
+    }
 
-toFormattedDetailsJSON = function (this: VideoInstance) {
-  const formattedJson = this.toFormattedJSON()
+    return VideoModel.scope(scopes)
+      .findAndCountAll(query)
+      .then(({ rows, count }) => {
+        return {
+          data: rows,
+          total: count
+        }
+      })
+  }
 
-  // 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'
+  static load (id: number, t?: Sequelize.Transaction) {
+    const options = t ? { transaction: t } : undefined
 
-  const detailsJson = {
-    privacyLabel,
-    privacy: this.privacy,
-    descriptionPath: this.getDescriptionPath(),
-    channel: this.VideoChannel.toFormattedJSON(),
-    files: []
+    return VideoModel.findById(id, options)
   }
 
-  // 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)
-}
+  static loadByUrlAndPopulateAccount (url: string, t?: Sequelize.Transaction) {
+    const query: IFindOptions<VideoModel> = {
+      where: {
+        url
+      }
+    }
 
-toActivityPubObject = function (this: VideoInstance) {
-  const { baseUrlHttp, baseUrlWs } = getBaseUrls(this)
-  if (!this.Tags) this.Tags = []
+    if (t !== undefined) query.transaction = t
 
-  const tag = this.Tags.map(t => ({
-    type: 'Hashtag' as 'Hashtag',
-    name: t.name
-  }))
+    return VideoModel.scope([ ScopeNames.WITH_ACCOUNT_DETAILS, ScopeNames.WITH_FILES ]).findOne(query)
+  }
 
-  let language
-  if (this.language) {
-    language = {
-      identifier: this.language + '',
-      name: this.getLanguageLabel()
+  static loadByUUIDOrURLAndPopulateAccount (uuid: string, url: string, t?: Sequelize.Transaction) {
+    const query: IFindOptions<VideoModel> = {
+      where: {
+        [Sequelize.Op.or]: [
+          { uuid },
+          { url }
+        ]
+      }
     }
+
+    if (t !== undefined) query.transaction = t
+
+    return VideoModel.scope([ ScopeNames.WITH_ACCOUNT_DETAILS, ScopeNames.WITH_FILES ]).findOne(query)
   }
 
-  let likesObject
-  let dislikesObject
+  static loadAndPopulateAccountAndServerAndTags (id: number) {
+    const options = {
+      order: [ [ 'Tags', 'name', 'ASC' ] ]
+    }
 
-  if (Array.isArray(this.AccountVideoRates)) {
-    const likes: string[] = []
-    const dislikes: string[] = []
+    return VideoModel
+      .scope([ ScopeNames.WITH_TAGS, ScopeNames.WITH_FILES, ScopeNames.WITH_ACCOUNT_DETAILS, ScopeNames.WITH_SCHEDULED_UPDATE ])
+      .findById(id, options)
+  }
 
-    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)
+  static loadByUUID (uuid: string) {
+    const options = {
+      where: {
+        uuid
       }
     }
 
-    likesObject = activityPubCollection(likes)
-    dislikesObject = activityPubCollection(dislikes)
+    return VideoModel
+      .scope([ ScopeNames.WITH_FILES ])
+      .findOne(options)
   }
 
-  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)
+  static loadByUUIDAndPopulateAccountAndServerAndTags (uuid: string, t?: Sequelize.Transaction) {
+    const options = {
+      order: [ [ 'Tags', 'name', 'ASC' ] ],
+      where: {
+        uuid
+      },
+      transaction: t
     }
 
-    sharesObject = activityPubCollection(shares)
+    return VideoModel
+      .scope([ ScopeNames.WITH_TAGS, ScopeNames.WITH_FILES, ScopeNames.WITH_ACCOUNT_DETAILS, ScopeNames.WITH_SCHEDULED_UPDATE ])
+      .findOne(options)
   }
 
-  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
+  static async getStats () {
+    const totalLocalVideos = await VideoModel.count({
+      where: {
+        remote: false
+      }
     })
+    const totalVideos = await VideoModel.count()
 
-    url.push({
-      type: 'Link',
-      mimeType: 'application/x-bittorrent',
-      url: getTorrentUrl(this, file, baseUrlHttp),
-      width: file.resolution
+    let totalLocalVideoViews = await VideoModel.sum('views', {
+      where: {
+        remote: false
+      }
     })
+    // Sequelize could return null...
+    if (!totalLocalVideoViews) totalLocalVideoViews = 0
 
-    url.push({
-      type: 'Link',
-      mimeType: 'application/x-bittorrent;x-scheme-handler/magnet',
-      url: generateMagnetUri(this, file, baseUrlHttp, baseUrlWs),
-      width: file.resolution
-    })
+    return {
+      totalLocalVideos,
+      totalLocalVideoViews,
+      totalVideos
+    }
   }
 
-  // Add video url too
-  url.push({
-    type: 'Link',
-    mimeType: 'text/html',
-    url: CONFIG.WEBSERVER.URL + '/videos/watch/' + this.uuid
-  })
+  private static buildActorWhereWithFilter (filter?: VideoFilter) {
+    if (filter && filter === 'local') {
+      return {
+        serverId: null
+      }
+    }
 
-  const videoObject: VideoTorrentObject = {
-    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: {
-      identifier: this.category + '',
-      name: this.getCategoryLabel()
-    },
-    licence: {
-      identifier: this.licence + '',
-      name: this.getLicenceLabel()
-    },
-    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: getThumbnailUrl(this, baseUrlHttp),
-      mediaType: 'image/jpeg',
-      width: THUMBNAILS_SIZE.width,
-      height: THUMBNAILS_SIZE.height
-    },
-    url,
-    likes: likesObject,
-    dislikes: dislikesObject,
-    shares: sharesObject
+    return {}
   }
 
-  return videoObject
-}
-
-getTruncatedDescription = function (this: VideoInstance) {
-  const options = {
-    length: CONSTRAINTS_FIELDS.VIDEOS.TRUNCATED_DESCRIPTION.max
+  private static getCategoryLabel (id: number) {
+    return VIDEO_CATEGORIES[id] || 'Misc'
   }
 
-  return truncate(this.description, options)
-}
+  private static getLicenceLabel (id: number) {
+    return VIDEO_LICENCES[id] || 'Unknown'
+  }
 
-optimizeOriginalVideofile = async function (this: VideoInstance) {
-  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)
+  private static getLanguageLabel (id: string) {
+    return VIDEO_LANGUAGES[id] || 'Unknown'
+  }
 
-  const transcodeOptions = {
-    inputPath: videoInputPath,
-    outputPath: videoOutputPath
+  private static getPrivacyLabel (id: number) {
+    return VIDEO_PRIVACIES[id] || 'Unknown'
   }
 
-  try {
-    // Could be very long!
-    await transcode(transcodeOptions)
+  private static getStateLabel (id: number) {
+    return VIDEO_STATES[id] || 'Unknown'
+  }
 
-    await unlinkPromise(videoInputPath)
+  getOriginalFile () {
+    if (Array.isArray(this.VideoFiles) === false) return undefined
 
-    // Important to do this before getVideoFilename() to take in account the new file extension
-    inputVideoFile.set('extname', newExtname)
+    // The original file is the file that have the higher resolution
+    return maxBy(this.VideoFiles, file => file.resolution)
+  }
 
-    await renamePromise(videoOutputPath, this.getVideoFilePath(inputVideoFile))
-    const stats = await statPromise(this.getVideoFilePath(inputVideoFile))
+  getVideoFilename (videoFile: VideoFileModel) {
+    return this.uuid + '-' + videoFile.resolution + videoFile.extname
+  }
 
-    inputVideoFile.set('size', stats.size)
+  getThumbnailName () {
+    // We always have a copy of the thumbnail
+    const extension = '.jpg'
+    return this.uuid + extension
+  }
 
-    await this.createTorrentAndSetInfoHash(inputVideoFile)
-    await inputVideoFile.save()
+  getPreviewName () {
+    const extension = '.jpg'
+    return this.uuid + extension
+  }
 
-  } catch (err) {
-    // Auto destruction...
-    this.destroy().catch(err => logger.error('Cannot destruct video after transcoding failure.', err))
+  getTorrentFileName (videoFile: VideoFileModel) {
+    const extension = '.torrent'
+    return this.uuid + '-' + videoFile.resolution + extension
+  }
 
-    throw err
+  isOwned () {
+    return this.remote === false
   }
-}
 
-transcodeOriginalVideofile = async function (this: VideoInstance, resolution: VideoResolution) {
-  const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
-  const extname = '.mp4'
+  createPreview (videoFile: VideoFileModel) {
+    return generateImageFromVideoFile(
+      this.getVideoFilePath(videoFile),
+      CONFIG.STORAGE.PREVIEWS_DIR,
+      this.getPreviewName(),
+      PREVIEWS_SIZE
+    )
+  }
 
-  // We are sure it's x264 in mp4 because optimizeOriginalVideofile was already executed
-  const videoInputPath = join(videosDirectory, this.getVideoFilename(this.getOriginalFile()))
+  createThumbnail (videoFile: VideoFileModel) {
+    return generateImageFromVideoFile(
+      this.getVideoFilePath(videoFile),
+      CONFIG.STORAGE.THUMBNAILS_DIR,
+      this.getThumbnailName(),
+      THUMBNAILS_SIZE
+    )
+  }
 
-  const newVideoFile = (Video['sequelize'].models.VideoFile as VideoFileModel).build({
-    resolution,
-    extname,
-    size: 0,
-    videoId: this.id
-  })
-  const videoOutputPath = join(videosDirectory, this.getVideoFilename(newVideoFile))
+  getTorrentFilePath (videoFile: VideoFileModel) {
+    return join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
+  }
 
-  const transcodeOptions = {
-    inputPath: videoInputPath,
-    outputPath: videoOutputPath,
-    resolution
+  getVideoFilePath (videoFile: VideoFileModel) {
+    return join(CONFIG.STORAGE.VIDEOS_DIR, this.getVideoFilename(videoFile))
   }
 
-  await transcode(transcodeOptions)
+  async createTorrentAndSetInfoHash (videoFile: VideoFileModel) {
+    const options = {
+      // Keep the extname, it's used by the client to stream the file inside a web browser
+      name: `${this.name} ${videoFile.resolution}p${videoFile.extname}`,
+      createdBy: 'PeerTube',
+      announceList: [
+        [ CONFIG.WEBSERVER.WS + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT + '/tracker/socket' ],
+        [ CONFIG.WEBSERVER.URL + '/tracker/announce' ]
+      ],
+      urlList: [
+        CONFIG.WEBSERVER.URL + STATIC_PATHS.WEBSEED + this.getVideoFilename(videoFile)
+      ]
+    }
 
-  const stats = await statPromise(videoOutputPath)
+    const torrent = await createTorrentPromise(this.getVideoFilePath(videoFile), options)
 
-  newVideoFile.set('size', stats.size)
+    const filePath = join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
+    logger.info('Creating torrent %s.', filePath)
 
-  await this.createTorrentAndSetInfoHash(newVideoFile)
+    await writeFilePromise(filePath, torrent)
 
-  await newVideoFile.save()
+    const parsedTorrent = parseTorrent(torrent)
+    videoFile.infoHash = parsedTorrent.infoHash
+  }
 
-  this.VideoFiles.push(newVideoFile)
-}
+  getEmbedStaticPath () {
+    return '/videos/embed/' + this.uuid
+  }
 
-getOriginalFileHeight = function (this: VideoInstance) {
-  const originalFilePath = this.getVideoFilePath(this.getOriginalFile())
+  getThumbnailStaticPath () {
+    return join(STATIC_PATHS.THUMBNAILS, this.getThumbnailName())
+  }
 
-  return getVideoFileHeight(originalFilePath)
-}
+  getPreviewStaticPath () {
+    return join(STATIC_PATHS.PREVIEWS, this.getPreviewName())
+  }
 
-getDescriptionPath = function (this: VideoInstance) {
-  return `/api/${API_VERSION}/videos/${this.uuid}/description`
-}
+  toFormattedJSON (options?: {
+    additionalAttributes: {
+      state?: boolean,
+      waitTranscoding?: boolean,
+      scheduledUpdate?: boolean
+    }
+  }): Video {
+    const formattedAccount = this.VideoChannel.Account.toFormattedJSON()
+    const formattedVideoChannel = this.VideoChannel.toFormattedJSON()
+
+    const videoObject: Video = {
+      id: this.id,
+      uuid: this.uuid,
+      name: this.name,
+      category: {
+        id: this.category,
+        label: VideoModel.getCategoryLabel(this.category)
+      },
+      licence: {
+        id: this.licence,
+        label: VideoModel.getLicenceLabel(this.licence)
+      },
+      language: {
+        id: this.language,
+        label: VideoModel.getLanguageLabel(this.language)
+      },
+      privacy: {
+        id: this.privacy,
+        label: VideoModel.getPrivacyLabel(this.privacy)
+      },
+      nsfw: this.nsfw,
+      description: this.getTruncatedDescription(),
+      isLocal: this.isOwned(),
+      duration: this.duration,
+      views: this.views,
+      likes: this.likes,
+      dislikes: this.dislikes,
+      thumbnailPath: this.getThumbnailStaticPath(),
+      previewPath: this.getPreviewStaticPath(),
+      embedPath: this.getEmbedStaticPath(),
+      createdAt: this.createdAt,
+      updatedAt: this.updatedAt,
+      publishedAt: this.publishedAt,
+      account: {
+        id: formattedAccount.id,
+        uuid: formattedAccount.uuid,
+        name: formattedAccount.name,
+        displayName: formattedAccount.displayName,
+        url: formattedAccount.url,
+        host: formattedAccount.host,
+        avatar: formattedAccount.avatar
+      },
+      channel: {
+        id: formattedVideoChannel.id,
+        uuid: formattedVideoChannel.uuid,
+        name: formattedVideoChannel.name,
+        displayName: formattedVideoChannel.displayName,
+        url: formattedVideoChannel.url,
+        host: formattedVideoChannel.host,
+        avatar: formattedVideoChannel.avatar
+      }
+    }
 
-getCategoryLabel = function (this: VideoInstance) {
-  let categoryLabel = VIDEO_CATEGORIES[this.category]
+    if (options) {
+      if (options.additionalAttributes.state === true) {
+        videoObject.state = {
+          id: this.state,
+          label: VideoModel.getStateLabel(this.state)
+        }
+      }
 
-  // Maybe our server is not up to date and there are new categories since our version
-  if (!categoryLabel) categoryLabel = 'Misc'
+      if (options.additionalAttributes.waitTranscoding === true) {
+        videoObject.waitTranscoding = this.waitTranscoding
+      }
 
-  return categoryLabel
-}
+      if (options.additionalAttributes.scheduledUpdate === true && this.ScheduleVideoUpdate) {
+        videoObject.scheduledUpdate = {
+          updateAt: this.ScheduleVideoUpdate.updateAt,
+          privacy: this.ScheduleVideoUpdate.privacy || undefined
+        }
+      }
+    }
 
-getLicenceLabel = function (this: VideoInstance) {
-  let licenceLabel = VIDEO_LICENCES[this.licence]
+    return videoObject
+  }
 
-  // Maybe our server is not up to date and there are new licences since our version
-  if (!licenceLabel) licenceLabel = 'Unknown'
+  toFormattedDetailsJSON (): VideoDetails {
+    const formattedJson = this.toFormattedJSON({
+      additionalAttributes: {
+        scheduledUpdate: true
+      }
+    })
 
-  return licenceLabel
-}
+    const detailsJson = {
+      support: this.support,
+      descriptionPath: this.getDescriptionPath(),
+      channel: this.VideoChannel.toFormattedJSON(),
+      account: this.VideoChannel.Account.toFormattedJSON(),
+      tags: map(this.Tags, 'name'),
+      commentsEnabled: this.commentsEnabled,
+      waitTranscoding: this.waitTranscoding,
+      state: {
+        id: this.state,
+        label: VideoModel.getStateLabel(this.state)
+      },
+      files: []
+    }
 
-getLanguageLabel = function (this: VideoInstance) {
-  // Language is an optional attribute
-  let languageLabel = VIDEO_LANGUAGES[this.language]
-  if (!languageLabel) languageLabel = 'Unknown'
+    // Format and sort video files
+    detailsJson.files = this.getFormattedVideoFilesJSON()
 
-  return languageLabel
-}
+    return Object.assign(formattedJson, detailsJson)
+  }
 
-removeThumbnail = function (this: VideoInstance) {
-  const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName())
-  return unlinkPromise(thumbnailPath)
-}
+  getFormattedVideoFilesJSON (): VideoFile[] {
+    const { baseUrlHttp, baseUrlWs } = this.getBaseUrls()
 
-removePreview = function (this: VideoInstance) {
-  // Same name than video thumbnail
-  return unlinkPromise(CONFIG.STORAGE.PREVIEWS_DIR + this.getPreviewName())
-}
+    return this.VideoFiles
+        .map(videoFile => {
+          let resolutionLabel = videoFile.resolution + 'p'
 
-removeFile = function (this: VideoInstance, videoFile: VideoFileInstance) {
-  const filePath = join(CONFIG.STORAGE.VIDEOS_DIR, this.getVideoFilename(videoFile))
-  return unlinkPromise(filePath)
-}
+          return {
+            resolution: {
+              id: videoFile.resolution,
+              label: resolutionLabel
+            },
+            magnetUri: this.generateMagnetUri(videoFile, baseUrlHttp, baseUrlWs),
+            size: videoFile.size,
+            fps: videoFile.fps,
+            torrentUrl: this.getTorrentUrl(videoFile, baseUrlHttp),
+            torrentDownloadUrl: this.getTorrentDownloadUrl(videoFile, baseUrlHttp),
+            fileUrl: this.getVideoFileUrl(videoFile, baseUrlHttp),
+            fileDownloadUrl: this.getVideoFileDownloadUrl(videoFile, baseUrlHttp)
+          } as VideoFile
+        })
+        .sort((a, b) => {
+          if (a.resolution.id < b.resolution.id) return 1
+          if (a.resolution.id === b.resolution.id) return 0
+          return -1
+        })
+  }
 
-removeTorrent = function (this: VideoInstance, videoFile: VideoFileInstance) {
-  const torrentPath = join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
-  return unlinkPromise(torrentPath)
-}
+  toActivityPubObject (): VideoTorrentObject {
+    const { baseUrlHttp, baseUrlWs } = this.getBaseUrls()
+    if (!this.Tags) this.Tags = []
 
-// ------------------------------ STATICS ------------------------------
+    const tag = this.Tags.map(t => ({
+      type: 'Hashtag' as 'Hashtag',
+      name: t.name
+    }))
 
-list = function () {
-  const query = {
-    include: [ Video['sequelize'].models.VideoFile ]
-  }
+    let language
+    if (this.language) {
+      language = {
+        identifier: this.language,
+        name: VideoModel.getLanguageLabel(this.language)
+      }
+    }
 
-  return Video.findAll(query)
-}
+    let category
+    if (this.category) {
+      category = {
+        identifier: this.category + '',
+        name: VideoModel.getCategoryLabel(this.category)
+      }
+    }
 
-listAllAndSharedByAccountForOutbox = function (accountId: number, start: number, count: number) {
-  function getRawQuery (select: string) {
-    const queryVideo = 'SELECT ' + select + ' FROM "Videos" AS "Video" ' +
-      'INNER JOIN "VideoChannels" AS "VideoChannel" ON "VideoChannel"."id" = "Video"."channelId" ' +
-      'WHERE "VideoChannel"."accountId" = ' + accountId
-    const queryVideoShare = 'SELECT ' + select + ' FROM "VideoShares" AS "VideoShare" ' +
-      'INNER JOIN "Videos" AS "Video" ON "Video"."id" = "VideoShare"."videoId" ' +
-      'WHERE "VideoShare"."accountId" = ' + accountId
+    let licence
+    if (this.licence) {
+      licence = {
+        identifier: this.licence + '',
+        name: VideoModel.getLicenceLabel(this.licence)
+      }
+    }
 
-    let rawQuery = `(${queryVideo}) UNION (${queryVideoShare})`
+    const url = []
+    for (const file of this.VideoFiles) {
+      url.push({
+        type: 'Link',
+        mimeType: VIDEO_EXT_MIMETYPE[file.extname],
+        href: this.getVideoFileUrl(file, baseUrlHttp),
+        width: file.resolution,
+        size: file.size,
+        fps: file.fps
+      })
+
+      url.push({
+        type: 'Link',
+        mimeType: 'application/x-bittorrent',
+        href: this.getTorrentUrl(file, baseUrlHttp),
+        width: file.resolution
+      })
+
+      url.push({
+        type: 'Link',
+        mimeType: 'application/x-bittorrent;x-scheme-handler/magnet',
+        href: this.generateMagnetUri(file, baseUrlHttp, baseUrlWs),
+        width: file.resolution
+      })
+    }
 
-    return rawQuery
-  }
+    // Add video url too
+    url.push({
+      type: 'Link',
+      mimeType: 'text/html',
+      href: CONFIG.WEBSERVER.URL + '/videos/watch/' + this.uuid
+    })
 
-  const rawQuery = getRawQuery('"Video"."id"')
-  const rawCountQuery = getRawQuery('COUNT("Video"."id") as "total"')
-
-  const query = {
-    distinct: true,
-    offset: start,
-    limit: count,
-    order: [ getSort('createdAt'), [ Video['sequelize'].models.Tag, 'name', 'ASC' ] ],
-    where: {
-      id: {
-        [Sequelize.Op.in]: Sequelize.literal('(' + rawQuery + ')')
-      }
-    },
-    include: [
-      {
-        model: Video['sequelize'].models.VideoShare,
-        required: false,
-        where: {
-          [Sequelize.Op.and]: [
-            {
-              id: {
-                [Sequelize.Op.not]: null
-              }
-            },
-            {
-              accountId
-            }
-          ]
-        },
-        include: [ Video['sequelize'].models.Account ]
-      },
-      {
-        model: Video['sequelize'].models.VideoChannel,
-        required: true,
-        include: [
-          {
-            model: Video['sequelize'].models.Account,
-            required: true
-          }
-        ]
-      },
-      {
-        model: Video['sequelize'].models.AccountVideoRate,
-        include: [ Video['sequelize'].models.Account ]
-      },
-      Video['sequelize'].models.VideoFile,
-      Video['sequelize'].models.Tag
-    ]
-  }
+    const subtitleLanguage = []
+    for (const caption of this.VideoCaptions) {
+      subtitleLanguage.push({
+        identifier: caption.language,
+        name: VideoCaptionModel.getLanguageLabel(caption.language)
+      })
+    }
 
-  return Bluebird.all([
-    Video.findAll(query),
-    Video['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
+      type: 'Video' as 'Video',
+      id: this.url,
+      name: this.name,
+      duration: this.getActivityStreamDuration(),
+      uuid: this.uuid,
+      tag,
+      category,
+      licence,
+      language,
+      views: this.views,
+      sensitive: this.nsfw,
+      waitTranscoding: this.waitTranscoding,
+      state: this.state,
+      commentsEnabled: this.commentsEnabled,
+      published: this.publishedAt.toISOString(),
+      updated: this.updatedAt.toISOString(),
+      mediaType: 'text/markdown',
+      content: this.getTruncatedDescription(),
+      support: this.support,
+      subtitleLanguage,
+      icon: {
+        type: 'Image',
+        url: this.getThumbnailUrl(baseUrlHttp),
+        mediaType: 'image/jpeg',
+        width: THUMBNAILS_SIZE.width,
+        height: THUMBNAILS_SIZE.height
+      },
+      url,
+      likes: getVideoLikesActivityPubUrl(this),
+      dislikes: getVideoDislikesActivityPubUrl(this),
+      shares: getVideoSharesActivityPubUrl(this),
+      comments: getVideoCommentsActivityPubUrl(this),
+      attributedTo: [
+        {
+          type: 'Person',
+          id: this.VideoChannel.Account.Actor.url
+        },
+        {
+          type: 'Group',
+          id: this.VideoChannel.Actor.url
+        }
+      ]
     }
-  })
-}
+  }
 
-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
-    ]
+  getTruncatedDescription () {
+    if (!this.description) return null
+
+    const maxLength = CONSTRAINTS_FIELDS.VIDEOS.TRUNCATED_DESCRIPTION.max
+    return peertubeTruncate(this.description, maxLength)
   }
 
-  return Video.findAndCountAll(query).then(({ rows, count }) => {
-    return {
-      data: rows,
-      total: count
+  async optimizeOriginalVideofile () {
+    const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
+    const newExtname = '.mp4'
+    const inputVideoFile = this.getOriginalFile()
+    const videoInputPath = join(videosDirectory, this.getVideoFilename(inputVideoFile))
+    const videoTranscodedPath = join(videosDirectory, this.id + '-transcoded' + newExtname)
+
+    const transcodeOptions = {
+      inputPath: videoInputPath,
+      outputPath: videoTranscodedPath
     }
-  })
-}
 
-listForApi = function (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,
-            required: true,
-            include: [
-              {
-                model: Video['sequelize'].models.Server,
-                required: false
-              }
-            ]
-          }
-        ]
-      },
-      Video['sequelize'].models.Tag
-    ],
-    where: createBaseVideosWhere()
+    // Could be very long!
+    await transcode(transcodeOptions)
+
+    try {
+      await unlinkPromise(videoInputPath)
+
+      // Important to do this before getVideoFilename() to take in account the new file extension
+      inputVideoFile.set('extname', newExtname)
+
+      const videoOutputPath = this.getVideoFilePath(inputVideoFile)
+      await renamePromise(videoTranscodedPath, videoOutputPath)
+      const stats = await statPromise(videoOutputPath)
+      const fps = await getVideoFileFPS(videoOutputPath)
+
+      inputVideoFile.set('size', stats.size)
+      inputVideoFile.set('fps', fps)
+
+      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
+    }
   }
 
-  return Video.findAndCountAll(query).then(({ rows, count }) => {
-    return {
-      data: rows,
-      total: count
+  async transcodeOriginalVideofile (resolution: VideoResolution, isPortraitMode: boolean) {
+    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 = new VideoFileModel({
+      resolution,
+      extname,
+      size: 0,
+      videoId: this.id
+    })
+    const videoOutputPath = join(videosDirectory, this.getVideoFilename(newVideoFile))
+
+    const transcodeOptions = {
+      inputPath: videoInputPath,
+      outputPath: videoOutputPath,
+      resolution,
+      isPortraitMode
     }
-  })
-}
 
-load = function (id: number) {
-  return Video.findById(id)
-}
+    await transcode(transcodeOptions)
 
-loadByUUID = function (uuid: string, t?: Sequelize.Transaction) {
-  const query: Sequelize.FindOptions<VideoAttributes> = {
-    where: {
-      uuid
-    },
-    include: [ Video['sequelize'].models.VideoFile ]
-  }
+    const stats = await statPromise(videoOutputPath)
+    const fps = await getVideoFileFPS(videoOutputPath)
 
-  if (t !== undefined) query.transaction = t
+    newVideoFile.set('size', stats.size)
+    newVideoFile.set('fps', fps)
 
-  return Video.findOne(query)
-}
+    await this.createTorrentAndSetInfoHash(newVideoFile)
 
-loadByUrlAndPopulateAccount = function (url: string, t?: Sequelize.Transaction) {
-  const query: Sequelize.FindOptions<VideoAttributes> = {
-    where: {
-      url
-    },
-    include: [
-      Video['sequelize'].models.VideoFile,
-      {
-        model: Video['sequelize'].models.VideoChannel,
-        include: [ Video['sequelize'].models.Account ]
-      }
-    ]
+    await newVideoFile.save()
+
+    this.VideoFiles.push(newVideoFile)
   }
 
-  if (t !== undefined) query.transaction = t
+  async importVideoFile (inputFilePath: string) {
+    const { videoFileResolution } = await getVideoFileResolution(inputFilePath)
+    const { size } = await statPromise(inputFilePath)
+    const fps = await getVideoFileFPS(inputFilePath)
+
+    let updatedVideoFile = new VideoFileModel({
+      resolution: videoFileResolution,
+      extname: extname(inputFilePath),
+      size,
+      fps,
+      videoId: this.id
+    })
 
-  return Video.findOne(query)
-}
+    const currentVideoFile = this.VideoFiles.find(videoFile => videoFile.resolution === updatedVideoFile.resolution)
 
-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 (currentVideoFile) {
+      // Remove old file and old torrent
+      await this.removeFile(currentVideoFile)
+      await this.removeTorrent(currentVideoFile)
+      // Remove the old video file from the array
+      this.VideoFiles = this.VideoFiles.filter(f => f !== currentVideoFile)
 
-  if (t !== undefined) query.transaction = t
+      // Update the database
+      currentVideoFile.set('extname', updatedVideoFile.extname)
+      currentVideoFile.set('size', updatedVideoFile.size)
+      currentVideoFile.set('fps', updatedVideoFile.fps)
 
-  return Video.findOne(query)
-}
+      updatedVideoFile = currentVideoFile
+    }
 
-loadAndPopulateAccountAndServerAndTags = function (id: number) {
-  const options = {
-    order: [ [ Video['sequelize'].models.Tag, 'name', 'ASC' ] ],
-    include: [
-      {
-        model: Video['sequelize'].models.VideoChannel,
-        include: [
-          {
-            model: Video['sequelize'].models.Account,
-            include: [ { model: Video['sequelize'].models.Server, required: false } ]
-          }
-        ]
-      },
-      {
-        model: Video['sequelize'].models.AccountVideoRate,
-        include: [ Video['sequelize'].models.Account ]
-      },
-      {
-        model: Video['sequelize'].models.VideoShare,
-        include: [ Video['sequelize'].models.Account ]
-      },
-      Video['sequelize'].models.Tag,
-      Video['sequelize'].models.VideoFile
-    ]
-  }
+    const outputPath = this.getVideoFilePath(updatedVideoFile)
+    await copyFilePromise(inputFilePath, outputPath)
 
-  return Video.findById(id, options)
-}
+    await this.createTorrentAndSetInfoHash(updatedVideoFile)
 
-loadByUUIDAndPopulateAccountAndServerAndTags = function (uuid: string) {
-  const options = {
-    order: [ [ Video['sequelize'].models.Tag, 'name', 'ASC' ] ],
-    where: {
-      uuid
-    },
-    include: [
-      {
-        model: Video['sequelize'].models.VideoChannel,
-        include: [
-          {
-            model: Video['sequelize'].models.Account,
-            include: [ { model: Video['sequelize'].models.Server, required: false } ]
-          }
-        ]
-      },
-      {
-        model: Video['sequelize'].models.AccountVideoRate,
-        include: [ Video['sequelize'].models.Account ]
-      },
-      {
-        model: Video['sequelize'].models.VideoShare,
-        include: [ Video['sequelize'].models.Account ]
-      },
-      Video['sequelize'].models.Tag,
-      Video['sequelize'].models.VideoFile
-    ]
+    await updatedVideoFile.save()
+
+    this.VideoFiles.push(updatedVideoFile)
   }
 
-  return Video.findOne(options)
-}
+  getOriginalFileResolution () {
+    const originalFilePath = this.getVideoFilePath(this.getOriginalFile())
 
-searchAndPopulateAccountAndServerAndTags = function (value: string, start: number, count: number, sort: string) {
-  const serverInclude: Sequelize.IncludeOptions = {
-    model: Video['sequelize'].models.Server,
-    required: false
+    return getVideoFileResolution(originalFilePath)
   }
 
-  const accountInclude: Sequelize.IncludeOptions = {
-    model: Video['sequelize'].models.Account,
-    include: [ serverInclude ]
+  getDescriptionPath () {
+    return `/api/${API_VERSION}/videos/${this.uuid}/description`
   }
 
-  const videoChannelInclude: Sequelize.IncludeOptions = {
-    model: Video['sequelize'].models.VideoChannel,
-    include: [ accountInclude ],
-    required: true
+  removeThumbnail () {
+    const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName())
+    return unlinkPromise(thumbnailPath)
+      .catch(err => logger.warn('Cannot delete thumbnail %s.', thumbnailPath, { err }))
   }
 
-  const tagInclude: Sequelize.IncludeOptions = {
-    model: Video['sequelize'].models.Tag
+  removePreview () {
+    const previewPath = join(CONFIG.STORAGE.PREVIEWS_DIR + this.getPreviewName())
+    return unlinkPromise(previewPath)
+      .catch(err => logger.warn('Cannot delete preview %s.', previewPath, { err }))
   }
 
-  const query: Sequelize.FindOptions<VideoAttributes> = {
-    distinct: true,
-    where: createBaseVideosWhere(),
-    offset: start,
-    limit: count,
-    order: [ getSort(sort), [ Video['sequelize'].models.Tag, 'name', 'ASC' ] ]
+  removeFile (videoFile: VideoFileModel) {
+    const filePath = join(CONFIG.STORAGE.VIDEOS_DIR, this.getVideoFilename(videoFile))
+    return unlinkPromise(filePath)
+      .catch(err => logger.warn('Cannot delete file %s.', filePath, { err }))
   }
 
-  // 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 + '%'
+  removeTorrent (videoFile: VideoFileModel) {
+    const torrentPath = join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
+    return unlinkPromise(torrentPath)
+      .catch(err => logger.warn('Cannot delete torrent %s.', torrentPath, { err }))
   }
 
-  query.include = [
-    videoChannelInclude, tagInclude
-  ]
+  getActivityStreamDuration () {
+    // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-duration
+    return 'PT' + this.duration + 'S'
+  }
 
-  return Video.findAndCountAll(query).then(({ rows, count }) => {
-    return {
-      data: rows,
-      total: count
-    }
-  })
-}
+  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.Actor.Server.host
+      baseUrlWs = REMOTE_SCHEME.WS + '://' + this.VideoChannel.Account.Actor.Server.host
+    }
 
-function createBaseVideosWhere () {
-  return {
-    id: {
-      [Sequelize.Op.notIn]: Video['sequelize'].literal(
-        '(SELECT "BlacklistedVideos"."videoId" FROM "BlacklistedVideos")'
-      )
-    },
-    privacy: VideoPrivacy.PUBLIC
+    return { baseUrlHttp, baseUrlWs }
   }
-}
 
-function getBaseUrls (video: VideoInstance) {
-  let baseUrlHttp
-  let baseUrlWs
+  private getThumbnailUrl (baseUrlHttp: string) {
+    return baseUrlHttp + STATIC_PATHS.THUMBNAILS + this.getThumbnailName()
+  }
 
-  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
+  private getTorrentUrl (videoFile: VideoFileModel, baseUrlHttp: string) {
+    return baseUrlHttp + STATIC_PATHS.TORRENTS + this.getTorrentFileName(videoFile)
   }
 
-  return { baseUrlHttp, baseUrlWs }
-}
+  private getTorrentDownloadUrl (videoFile: VideoFileModel, baseUrlHttp: string) {
+    return baseUrlHttp + STATIC_DOWNLOAD_PATHS.TORRENTS + this.getTorrentFileName(videoFile)
+  }
 
-function getThumbnailUrl (video: VideoInstance, baseUrlHttp: string) {
-  return baseUrlHttp + STATIC_PATHS.THUMBNAILS + video.getThumbnailName()
-}
+  private getVideoFileUrl (videoFile: VideoFileModel, baseUrlHttp: string) {
+    return baseUrlHttp + STATIC_PATHS.WEBSEED + this.getVideoFilename(videoFile)
+  }
 
-function getTorrentUrl (video: VideoInstance, videoFile: VideoFileInstance, baseUrlHttp: string) {
-  return baseUrlHttp + STATIC_PATHS.TORRENTS + video.getTorrentFileName(videoFile)
-}
+  private getVideoFileDownloadUrl (videoFile: VideoFileModel, baseUrlHttp: string) {
+    return baseUrlHttp + STATIC_DOWNLOAD_PATHS.VIDEOS + this.getVideoFilename(videoFile)
+  }
 
-function getVideoFileUrl (video: VideoInstance, videoFile: VideoFileInstance, baseUrlHttp: string) {
-  return baseUrlHttp + STATIC_PATHS.WEBSEED + video.getVideoFilename(videoFile)
-}
+  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
+    }
 
-function generateMagnetUri (video: VideoInstance, videoFile: VideoFileInstance, baseUrlHttp: string, baseUrlWs: string) {
-  const xs = getTorrentUrl(video, videoFile, baseUrlHttp)
-  const announce = [ baseUrlWs + '/tracker/socket', baseUrlHttp + '/tracker/announce' ]
-  const urlList = [ getVideoFileUrl(video, videoFile, baseUrlHttp) ]
-
-  const magnetHash = {
-    xs,
-    announce,
-    urlList,
-    infoHash: videoFile.infoHash,
-    name: video.name
+    return magnetUtil.encode(magnetHash)
   }
-
-  return magnetUtil.encode(magnetHash)
 }