]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blobdiff - server/models/video/video.ts
Create comment on replied mastodon statutes
[github/Chocobozzz/PeerTube.git] / server / models / video / video.ts
index e5fd925490d48c4608ff01ed5fe44ea7b30dec58..b6a2ce6b5f915a11b06134e841ffbafb7e59da3e 100644 (file)
@@ -4,20 +4,36 @@ import * as magnetUtil from 'magnet-uri'
 import * as parseTorrent from 'parse-torrent'
 import { join } from 'path'
 import * as Sequelize from 'sequelize'
+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/video-torrent-object'
+import { VideoTorrentObject } from '../../../shared/models/activitypub/objects'
+import { Video, VideoDetails } from '../../../shared/models/videos'
 import {
+  activityPubCollection,
   createTorrentPromise,
   generateImageFromVideoFile,
   getVideoFileHeight,
-  isVideoCategoryValid,
-  isVideoDescriptionValid,
-  isVideoDurationValid,
-  isVideoLanguageValid,
-  isVideoLicenceValid,
-  isVideoNameValid,
-  isVideoNSFWValid,
-  isVideoPrivacyValid,
   logger,
   renamePromise,
   statPromise,
@@ -25,8 +41,17 @@ import {
   unlinkPromise,
   writeFilePromise
 } from '../../helpers'
-import { activityPubCollection } from '../../helpers/activitypub'
-import { isVideoUrlValid } from '../../helpers/custom-validators/videos'
+import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub'
+import {
+  isVideoCategoryValid,
+  isVideoDescriptionValid,
+  isVideoDurationValid,
+  isVideoLanguageValid,
+  isVideoLicenceValid,
+  isVideoNameValid,
+  isVideoNSFWValid,
+  isVideoPrivacyValid
+} from '../../helpers/custom-validators/videos'
 import {
   API_VERSION,
   CONFIG,
@@ -40,1136 +65,1047 @@ import {
   VIDEO_LICENCES,
   VIDEO_PRIVACIES
 } from '../../initializers'
-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
+import { getAnnounceActivityPubUrl } 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 { 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 {
+  AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST',
+  WITH_ACCOUNT = 'WITH_ACCOUNT',
+  WITH_TAGS = 'WITH_TAGS',
+  WITH_FILES = 'WITH_FILES',
+  WITH_SHARES = 'WITH_SHARES',
+  WITH_RATES = 'WITH_RATES'
+}
+
+@Scopes({
+  [ScopeNames.AVAILABLE_FOR_LIST]: {
+    where: {
+      id: {
+        [Sequelize.Op.notIn]: Sequelize.literal(
+          '(SELECT "videoBlacklist"."videoId" FROM "videoBlacklist")'
+        )
       },
-      url: {
-        type: DataTypes.STRING(CONSTRAINTS_FIELDS.VIDEOS.URL.max),
-        allowNull: false,
-        validate: {
-          urlValid: value => {
-            const res = isVideoUrlValid(value)
-            if (res === false) throw new Error('Video URL is not valid.')
+      privacy: VideoPrivacy.PUBLIC
+    }
+  },
+  [ScopeNames.WITH_ACCOUNT]: {
+    include: [
+      {
+        model: () => VideoChannelModel,
+        required: true,
+        include: [
+          {
+            model: () => AccountModel,
+            required: true,
+            include: [
+              {
+                model: () => ActorModel,
+                required: true,
+                include: [
+                  {
+                    model: () => ServerModel,
+                    required: false
+                  }
+                ]
+              }
+            ]
           }
-        }
+        ]
+      }
+    ]
+  },
+  [ScopeNames.WITH_TAGS]: {
+    include: [ () => TagModel ]
+  },
+  [ScopeNames.WITH_FILES]: {
+    include: [
+      {
+        model: () => VideoFileModel,
+        required: true
+      }
+    ]
+  },
+  [ScopeNames.WITH_SHARES]: {
+    include: [
+      {
+        model: () => VideoShareModel,
+        include: [ () => ActorModel ]
+      }
+    ]
+  },
+  [ScopeNames.WITH_RATES]: {
+    include: [
+      {
+        model: () => AccountVideoRateModel,
+        include: [ () => AccountModel ]
       }
+    ]
+  }
+})
+@Table({
+  tableName: 'video',
+  indexes: [
+    {
+      fields: [ 'name' ]
     },
     {
-      indexes: [
-        {
-          fields: [ 'name' ]
-        },
-        {
-          fields: [ 'createdAt' ]
-        },
-        {
-          fields: [ 'duration' ]
-        },
-        {
-          fields: [ 'views' ]
-        },
-        {
-          fields: [ 'likes' ]
-        },
-        {
-          fields: [ 'uuid' ]
-        },
-        {
-          fields: [ 'channelId' ]
-        }
-      ],
-      hooks: {
-        afterDestroy
-      }
+      fields: [ 'createdAt' ]
+    },
+    {
+      fields: [ 'duration' ]
+    },
+    {
+      fields: [ 'views' ]
+    },
+    {
+      fields: [ 'likes' ]
+    },
+    {
+      fields: [ 'uuid' ]
+    },
+    {
+      fields: [ 'channelId' ]
     }
-  )
-
-  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 ------------------------------
-
-function associate (models) {
-  Video.belongsTo(models.VideoChannel, {
+})
+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: {
-      name: 'channelId',
-      allowNull: false
+      allowNull: true
     },
     onDelete: 'cascade'
   })
+  VideoChannel: VideoChannelModel
 
-  Video.belongsToMany(models.Tag, {
+  @BelongsToMany(() => TagModel, {
     foreignKey: 'videoId',
-    through: models.VideoTag,
-    onDelete: 'cascade'
+    through: () => VideoTagModel,
+    onDelete: 'CASCADE'
   })
+  Tags: TagModel[]
 
-  Video.hasMany(models.VideoAbuse, {
+  @HasMany(() => VideoAbuseModel, {
     foreignKey: {
       name: 'videoId',
       allowNull: false
     },
     onDelete: 'cascade'
   })
+  VideoAbuses: VideoAbuseModel[]
 
-  Video.hasMany(models.VideoFile, {
+  @HasMany(() => VideoFileModel, {
     foreignKey: {
       name: 'videoId',
       allowNull: false
     },
     onDelete: 'cascade'
   })
+  VideoFiles: VideoFileModel[]
 
-  Video.hasMany(models.VideoShare, {
+  @HasMany(() => VideoShareModel, {
     foreignKey: {
       name: 'videoId',
       allowNull: false
     },
     onDelete: 'cascade'
   })
+  VideoShares: VideoShareModel[]
 
-  Video.hasMany(models.AccountVideoRate, {
+  @HasMany(() => AccountVideoRateModel, {
     foreignKey: {
       name: 'videoId',
       allowNull: false
     },
     onDelete: 'cascade'
   })
-}
-
-function afterDestroy (video: VideoInstance) {
-  const tasks = []
+  AccountVideoRates: AccountVideoRateModel[]
 
-  tasks.push(
-    video.removeThumbnail()
-  )
+  @AfterDestroy
+  static removeFilesAndSendDelete (instance: VideoModel) {
+    const tasks = []
 
-  if (video.isOwned()) {
     tasks.push(
-      video.removePreview(),
-      sendDeleteVideo(video, undefined)
+      instance.removeThumbnail()
     )
 
-    // Remove physical files and torrents
-    video.VideoFiles.forEach(file => {
-      tasks.push(video.removeFile(file))
-      tasks.push(video.removeTorrent(file))
-    })
-  }
-
-  return Promise.all(tasks)
-    .catch(err => {
-      logger.error('Some errors when removing files of video %s in after destroy hook.', video.uuid, err)
-    })
-}
-
-getOriginalFile = function (this: VideoInstance) {
-  if (Array.isArray(this.VideoFiles) === false) return undefined
-
-  // The original file is the file that have the higher resolution
-  return maxBy(this.VideoFiles, file => file.resolution)
-}
+    if (instance.isOwned()) {
+      tasks.push(
+        instance.removePreview(),
+        sendDeleteVideo(instance, undefined)
+      )
 
-getVideoFilename = function (this: VideoInstance, videoFile: VideoFileInstance) {
-  return this.uuid + '-' + videoFile.resolution + videoFile.extname
-}
+      // Remove physical files and torrents
+      instance.VideoFiles.forEach(file => {
+        tasks.push(instance.removeFile(file))
+        tasks.push(instance.removeTorrent(file))
+      })
+    }
 
-getThumbnailName = function (this: VideoInstance) {
-  // We always have a copy of the thumbnail
-  const extension = '.jpg'
-  return this.uuid + extension
-}
+    return Promise.all(tasks)
+      .catch(err => {
+        logger.error('Some errors when removing files of video %s in after destroy hook.', instance.uuid, err)
+      })
+  }
 
-getPreviewName = function (this: VideoInstance) {
-  const extension = '.jpg'
-  return this.uuid + extension
-}
+  static list () {
+    return VideoModel.scope(ScopeNames.WITH_FILES).findAll()
+  }
 
-getTorrentFileName = function (this: VideoInstance, videoFile: VideoFileInstance) {
-  const extension = '.torrent'
-  return this.uuid + '-' + videoFile.resolution + extension
-}
+  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})`
+    }
 
-isOwned = function (this: VideoInstance) {
-  return this.remote === false
-}
+    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: [
+        {
+          model: VideoShareModel,
+          required: false,
+          where: {
+            [Sequelize.Op.and]: [
+              {
+                id: {
+                  [Sequelize.Op.not]: null
+                }
+              },
+              {
+                actorId
+              }
+            ]
+          },
+          include: [
+            {
+              model: ActorModel,
+              required: true
+            }
+          ]
+        },
+        {
+          model: VideoChannelModel,
+          required: true,
+          include: [
+            {
+              model: AccountModel,
+              required: true
+            }
+          ]
+        },
+        {
+          model: AccountVideoRateModel,
+          include: [ AccountModel ]
+        },
+        VideoFileModel,
+        TagModel
+      ]
+    }
 
-createPreview = function (this: VideoInstance, videoFile: VideoFileInstance) {
-  const imageSize = PREVIEWS_SIZE.width + 'x' + PREVIEWS_SIZE.height
+    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
+      }
+    })
+  }
 
-  return generateImageFromVideoFile(
-    this.getVideoFilePath(videoFile),
-    CONFIG.STORAGE.PREVIEWS_DIR,
-    this.getPreviewName(),
-    imageSize
-  )
-}
+  static listUserVideosForApi (userId: number, start: number, count: number, sort: string) {
+    const query = {
+      offset: start,
+      limit: count,
+      order: [ getSort(sort) ],
+      include: [
+        {
+          model: VideoChannelModel,
+          required: true,
+          include: [
+            {
+              model: AccountModel,
+              where: {
+                userId
+              },
+              required: true
+            }
+          ]
+        }
+      ]
+    }
 
-createThumbnail = function (this: VideoInstance, videoFile: VideoFileInstance) {
-  const imageSize = THUMBNAILS_SIZE.width + 'x' + THUMBNAILS_SIZE.height
+    return VideoModel.findAndCountAll(query).then(({ rows, count }) => {
+      return {
+        data: rows,
+        total: count
+      }
+    })
+  }
 
-  return generateImageFromVideoFile(
-    this.getVideoFilePath(videoFile),
-    CONFIG.STORAGE.THUMBNAILS_DIR,
-    this.getThumbnailName(),
-    imageSize
-  )
-}
+  static listForApi (start: number, count: number, sort: string) {
+    const query = {
+      offset: start,
+      limit: count,
+      order: [ getSort(sort) ]
+    }
 
-getVideoFilePath = function (this: VideoInstance, videoFile: VideoFileInstance) {
-  return join(CONFIG.STORAGE.VIDEOS_DIR, this.getVideoFilename(videoFile))
-}
+    return VideoModel.scope([ ScopeNames.AVAILABLE_FOR_LIST, ScopeNames.WITH_ACCOUNT ])
+      .findAndCountAll(query)
+      .then(({ rows, count }) => {
+        return {
+          data: rows,
+          total: count
+        }
+      })
+  }
 
-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)
-    ]
+  static load (id: number) {
+    return VideoModel.findById(id)
   }
 
-  const torrent = await createTorrentPromise(this.getVideoFilePath(videoFile), options)
+  static loadByUrl (url: string, t?: Sequelize.Transaction) {
+    const query: IFindOptions<VideoModel> = {
+      where: {
+        url
+      }
+    }
 
-  const filePath = join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
-  logger.info('Creating torrent %s.', filePath)
+    if (t !== undefined) query.transaction = t
 
-  await writeFilePromise(filePath, torrent)
+    return VideoModel.findOne(query)
+  }
 
-  const parsedTorrent = parseTorrent(torrent)
-  videoFile.infoHash = parsedTorrent.infoHash
-}
+  static loadByUrlAndPopulateAccount (url: string, t?: Sequelize.Transaction) {
+    const query: IFindOptions<VideoModel> = {
+      where: {
+        url
+      }
+    }
 
-getEmbedPath = function (this: VideoInstance) {
-  return '/videos/embed/' + this.uuid
-}
+    if (t !== undefined) query.transaction = t
 
-getThumbnailPath = function (this: VideoInstance) {
-  return join(STATIC_PATHS.THUMBNAILS, this.getThumbnailName())
-}
+    return VideoModel.scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_FILES ]).findOne(query)
+  }
 
-getPreviewPath = function (this: VideoInstance) {
-  return join(STATIC_PATHS.PREVIEWS, this.getPreviewName())
-}
+  static loadByUUIDOrURL (uuid: string, url: string, t?: Sequelize.Transaction) {
+    const query: IFindOptions<VideoModel> = {
+      where: {
+        [Sequelize.Op.or]: [
+          { uuid },
+          { url }
+        ]
+      }
+    }
 
-toFormattedJSON = function (this: VideoInstance) {
-  let serverHost
+    if (t !== undefined) query.transaction = t
 
-  if (this.VideoChannel.Account.Server) {
-    serverHost = this.VideoChannel.Account.Server.host
-  } else {
-    // It means it's our video
-    serverHost = CONFIG.WEBSERVER.HOST
+    return VideoModel.scope(ScopeNames.WITH_FILES).findOne(query)
   }
 
-  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
+  static loadAndPopulateAccountAndServerAndTags (id: number) {
+    const options = {
+      order: [ [ 'Tags', 'name', 'ASC' ] ]
+    }
+
+    return VideoModel
+      .scope([ ScopeNames.WITH_RATES, ScopeNames.WITH_SHARES, ScopeNames.WITH_TAGS, ScopeNames.WITH_FILES, ScopeNames.WITH_ACCOUNT ])
+      .findById(id, options)
   }
 
-  return json
-}
+  static loadByUUID (uuid: string) {
+    const options = {
+      where: {
+        uuid
+      }
+    }
 
-toFormattedDetailsJSON = function (this: VideoInstance) {
-  const formattedJson = this.toFormattedJSON()
+    return VideoModel
+      .scope([ ScopeNames.WITH_FILES ])
+      .findOne(options)
+  }
 
-  // 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 loadByUUIDAndPopulateAccountAndServerAndTags (uuid: string) {
+    const options = {
+      order: [ [ 'Tags', 'name', 'ASC' ] ],
+      where: {
+        uuid
+      }
+    }
 
-  const detailsJson = {
-    privacyLabel,
-    privacy: this.privacy,
-    descriptionPath: this.getDescriptionPath(),
-    channel: this.VideoChannel.toFormattedJSON(),
-    files: []
+    return VideoModel
+      .scope([ ScopeNames.WITH_RATES, ScopeNames.WITH_SHARES, ScopeNames.WITH_TAGS, ScopeNames.WITH_FILES, ScopeNames.WITH_ACCOUNT ])
+      .findOne(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)
-}
-
-toActivityPubObject = function (this: VideoInstance) {
-  const { baseUrlHttp, baseUrlWs } = getBaseUrls(this)
-  if (!this.Tags) this.Tags = []
+  static searchAndPopulateAccountAndServerAndTags (value: string, start: number, count: number, sort: string) {
+    const serverInclude: IIncludeOptions = {
+      model: ServerModel,
+      required: false
+    }
 
-  const tag = this.Tags.map(t => ({
-    type: 'Hashtag' as 'Hashtag',
-    name: t.name
-  }))
+    const accountInclude: IIncludeOptions = {
+      model: AccountModel,
+      include: [
+        {
+          model: ActorModel,
+          required: true,
+          include: [ serverInclude ]
+        }
+      ]
+    }
 
-  let language
-  if (this.language) {
-    language = {
-      identifier: this.language + '',
-      name: this.getLanguageLabel()
+    const videoChannelInclude: IIncludeOptions = {
+      model: VideoChannelModel,
+      include: [ accountInclude ],
+      required: true
     }
-  }
 
-  let likesObject
-  let dislikesObject
+    const tagInclude: IIncludeOptions = {
+      model: TagModel
+    }
 
-  if (Array.isArray(this.AccountVideoRates)) {
-    const likes: string[] = []
-    const dislikes: string[] = []
+    const query: IFindOptions<VideoModel> = {
+      distinct: true, // Because we have tags
+      offset: start,
+      limit: count,
+      order: [ getSort(sort) ],
+      where: {}
+    }
 
-    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)
-      }
+    // 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 + '%'
     }
 
-    likesObject = activityPubCollection(likes)
-    dislikesObject = activityPubCollection(dislikes)
+    query.include = [
+      videoChannelInclude, tagInclude
+    ]
+
+    return VideoModel.scope([ ScopeNames.AVAILABLE_FOR_LIST ])
+      .findAndCountAll(query).then(({ rows, count }) => {
+        return {
+          data: rows,
+          total: count
+        }
+      })
   }
 
-  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
-    })
+  getOriginalFile () {
+    if (Array.isArray(this.VideoFiles) === false) return undefined
 
-    url.push({
-      type: 'Link',
-      mimeType: 'application/x-bittorrent',
-      url: getTorrentUrl(this, file, baseUrlHttp),
-      width: file.resolution
-    })
+    // The original file is the file that have the higher resolution
+    return maxBy(this.VideoFiles, file => file.resolution)
+  }
 
-    url.push({
-      type: 'Link',
-      mimeType: 'application/x-bittorrent;x-scheme-handler/magnet',
-      url: generateMagnetUri(this, file, baseUrlHttp, baseUrlWs),
-      width: file.resolution
-    })
+  getVideoFilename (videoFile: VideoFileModel) {
+    return this.uuid + '-' + videoFile.resolution + videoFile.extname
   }
 
-  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
+  getThumbnailName () {
+    // We always have a copy of the thumbnail
+    const extension = '.jpg'
+    return this.uuid + extension
   }
 
-  return videoObject
-}
+  getPreviewName () {
+    const extension = '.jpg'
+    return this.uuid + extension
+  }
 
-getTruncatedDescription = function (this: VideoInstance) {
-  const options = {
-    length: CONSTRAINTS_FIELDS.VIDEOS.TRUNCATED_DESCRIPTION.max
+  getTorrentFileName (videoFile: VideoFileModel) {
+    const extension = '.torrent'
+    return this.uuid + '-' + videoFile.resolution + extension
   }
 
-  return truncate(this.description, options)
-}
+  isOwned () {
+    return this.remote === false
+  }
 
-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)
+  createPreview (videoFile: VideoFileModel) {
+    const imageSize = PREVIEWS_SIZE.width + 'x' + PREVIEWS_SIZE.height
 
-  const transcodeOptions = {
-    inputPath: videoInputPath,
-    outputPath: videoOutputPath
+    return generateImageFromVideoFile(
+      this.getVideoFilePath(videoFile),
+      CONFIG.STORAGE.PREVIEWS_DIR,
+      this.getPreviewName(),
+      imageSize
+    )
   }
 
-  try {
-    // Could be very long!
-    await transcode(transcodeOptions)
+  createThumbnail (videoFile: VideoFileModel) {
+    const imageSize = THUMBNAILS_SIZE.width + 'x' + THUMBNAILS_SIZE.height
 
-    await unlinkPromise(videoInputPath)
+    return generateImageFromVideoFile(
+      this.getVideoFilePath(videoFile),
+      CONFIG.STORAGE.THUMBNAILS_DIR,
+      this.getThumbnailName(),
+      imageSize
+    )
+  }
 
-    // Important to do this before getVideoFilename() to take in account the new file extension
-    inputVideoFile.set('extname', newExtname)
+  getVideoFilePath (videoFile: VideoFileModel) {
+    return join(CONFIG.STORAGE.VIDEOS_DIR, this.getVideoFilename(videoFile))
+  }
 
-    await renamePromise(videoOutputPath, this.getVideoFilePath(inputVideoFile))
-    const stats = await statPromise(this.getVideoFilePath(inputVideoFile))
+  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)
+      ]
+    }
 
-    inputVideoFile.set('size', stats.size)
+    const torrent = await createTorrentPromise(this.getVideoFilePath(videoFile), options)
 
-    await this.createTorrentAndSetInfoHash(inputVideoFile)
-    await inputVideoFile.save()
+    const filePath = join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
+    logger.info('Creating torrent %s.', filePath)
 
-  } catch (err) {
-    // Auto destruction...
-    this.destroy().catch(err => logger.error('Cannot destruct video after transcoding failure.', err))
+    await writeFilePromise(filePath, torrent)
 
-    throw err
+    const parsedTorrent = parseTorrent(torrent)
+    videoFile.infoHash = parsedTorrent.infoHash
   }
-}
-
-transcodeOriginalVideofile = async function (this: VideoInstance, resolution: VideoResolution) {
-  const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
-  const extname = '.mp4'
 
-  // We are sure it's x264 in mp4 because optimizeOriginalVideofile was already executed
-  const videoInputPath = join(videosDirectory, this.getVideoFilename(this.getOriginalFile()))
+  getEmbedPath () {
+    return '/videos/embed/' + this.uuid
+  }
 
-  const newVideoFile = (Video['sequelize'].models.VideoFile as VideoFileModel).build({
-    resolution,
-    extname,
-    size: 0,
-    videoId: this.id
-  })
-  const videoOutputPath = join(videosDirectory, this.getVideoFilename(newVideoFile))
+  getThumbnailPath () {
+    return join(STATIC_PATHS.THUMBNAILS, this.getThumbnailName())
+  }
 
-  const transcodeOptions = {
-    inputPath: videoInputPath,
-    outputPath: videoOutputPath,
-    resolution
+  getPreviewPath () {
+    return join(STATIC_PATHS.PREVIEWS, this.getPreviewName())
   }
 
-  await transcode(transcodeOptions)
+  toFormattedJSON () {
+    let serverHost
 
-  const stats = await statPromise(videoOutputPath)
+    if (this.VideoChannel.Account.Actor.Server) {
+      serverHost = this.VideoChannel.Account.Actor.Server.host
+    } else {
+      // It means it's our video
+      serverHost = CONFIG.WEBSERVER.HOST
+    }
 
-  newVideoFile.set('size', stats.size)
+    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.getTruncatedDescription(),
+      serverHost,
+      isLocal: this.isOwned(),
+      accountName: this.VideoChannel.Account.name,
+      duration: this.duration,
+      views: this.views,
+      likes: this.likes,
+      dislikes: this.dislikes,
+      thumbnailPath: this.getThumbnailPath(),
+      previewPath: this.getPreviewPath(),
+      embedPath: this.getEmbedPath(),
+      createdAt: this.createdAt,
+      updatedAt: this.updatedAt
+    } as Video
+  }
 
-  await this.createTorrentAndSetInfoHash(newVideoFile)
+  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: []
+    }
 
-  await newVideoFile.save()
+    // 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
+      })
+
+    return Object.assign(formattedJson, detailsJson) as VideoDetails
+  }
 
-  this.VideoFiles.push(newVideoFile)
-}
+  toActivityPubObject (): VideoTorrentObject {
+    const { baseUrlHttp, baseUrlWs } = this.getBaseUrls()
+    if (!this.Tags) this.Tags = []
 
-getOriginalFileHeight = function (this: VideoInstance) {
-  const originalFilePath = this.getVideoFilePath(this.getOriginalFile())
+    const tag = this.Tags.map(t => ({
+      type: 'Hashtag' as 'Hashtag',
+      name: t.name
+    }))
 
-  return getVideoFileHeight(originalFilePath)
-}
+    let language
+    if (this.language) {
+      language = {
+        identifier: this.language + '',
+        name: this.getLanguageLabel()
+      }
+    }
 
-getDescriptionPath = function (this: VideoInstance) {
-  return `/api/${API_VERSION}/videos/${this.uuid}/description`
-}
+    let category
+    if (this.category) {
+      category = {
+        identifier: this.category + '',
+        name: this.getCategoryLabel()
+      }
+    }
 
-getCategoryLabel = function (this: VideoInstance) {
-  let categoryLabel = VIDEO_CATEGORIES[this.category]
+    let licence
+    if (this.licence) {
+      licence = {
+        identifier: this.licence + '',
+        name: this.getLicenceLabel()
+      }
+    }
 
-  // Maybe our server is not up to date and there are new categories since our version
-  if (!categoryLabel) categoryLabel = 'Misc'
+    let likesObject
+    let dislikesObject
 
-  return categoryLabel
-}
+    if (Array.isArray(this.AccountVideoRates)) {
+      const likes: string[] = []
+      const dislikes: string[] = []
 
-getLicenceLabel = function (this: VideoInstance) {
-  let licenceLabel = VIDEO_LICENCES[this.licence]
+      for (const rate of this.AccountVideoRates) {
+        if (rate.type === 'like') {
+          likes.push(rate.Account.Actor.url)
+        } else if (rate.type === 'dislike') {
+          dislikes.push(rate.Account.Actor.url)
+        }
+      }
 
-  // Maybe our server is not up to date and there are new licences since our version
-  if (!licenceLabel) licenceLabel = 'Unknown'
+      likesObject = activityPubCollection(likes)
+      dislikesObject = activityPubCollection(dislikes)
+    }
 
-  return licenceLabel
-}
+    let sharesObject
+    if (Array.isArray(this.VideoShares)) {
+      const shares: string[] = []
 
-getLanguageLabel = function (this: VideoInstance) {
-  // Language is an optional attribute
-  let languageLabel = VIDEO_LANGUAGES[this.language]
-  if (!languageLabel) languageLabel = 'Unknown'
+      for (const videoShare of this.VideoShares) {
+        const shareUrl = getAnnounceActivityPubUrl(this.url, videoShare.Actor)
+        shares.push(shareUrl)
+      }
 
-  return languageLabel
-}
+      sharesObject = activityPubCollection(shares)
+    }
 
-removeThumbnail = function (this: VideoInstance) {
-  const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName())
-  return unlinkPromise(thumbnailPath)
-}
+    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
+      })
+
+      url.push({
+        type: 'Link',
+        mimeType: 'application/x-bittorrent',
+        url: this.getTorrentUrl(file, baseUrlHttp),
+        width: file.resolution
+      })
+
+      url.push({
+        type: 'Link',
+        mimeType: 'application/x-bittorrent;x-scheme-handler/magnet',
+        url: this.generateMagnetUri(file, baseUrlHttp, baseUrlWs),
+        width: file.resolution
+      })
+    }
 
-removePreview = function (this: VideoInstance) {
-  // Same name than video thumbnail
-  return unlinkPromise(CONFIG.STORAGE.PREVIEWS_DIR + this.getPreviewName())
-}
+    // Add video url too
+    url.push({
+      type: 'Link',
+      mimeType: 'text/html',
+      url: CONFIG.WEBSERVER.URL + '/videos/watch/' + this.uuid
+    })
 
-removeFile = function (this: VideoInstance, videoFile: VideoFileInstance) {
-  const filePath = join(CONFIG.STORAGE.VIDEOS_DIR, this.getVideoFilename(videoFile))
-  return unlinkPromise(filePath)
-}
+    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,
+      attributedTo: [
+        {
+          type: 'Group',
+          id: this.VideoChannel.Actor.url
+        }
+      ]
+    }
+  }
 
-removeTorrent = function (this: VideoInstance, videoFile: VideoFileInstance) {
-  const torrentPath = join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
-  return unlinkPromise(torrentPath)
-}
+  getTruncatedDescription () {
+    if (!this.description) return null
 
-// ------------------------------ STATICS ------------------------------
+    const options = {
+      length: CONSTRAINTS_FIELDS.VIDEOS.TRUNCATED_DESCRIPTION.max
+    }
 
-list = function () {
-  const query = {
-    include: [ Video['sequelize'].models.VideoFile ]
+    return truncate(this.description, options)
   }
 
-  return Video.findAll(query)
-}
+  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)
 
-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
+    const transcodeOptions = {
+      inputPath: videoInputPath,
+      outputPath: videoOutputPath
+    }
 
-    let rawQuery = `(${queryVideo}) UNION (${queryVideoShare})`
+    try {
+      // Could be very long!
+      await transcode(transcodeOptions)
 
-    return rawQuery
-  }
+      await unlinkPromise(videoInputPath)
 
-  const rawQuery = getRawQuery('"Video"."id"')
-  const rawCountQuery = getRawQuery('COUNT("Video"."id") as "total"')
+      // Important to do this before getVideoFilename() to take in account the new file extension
+      inputVideoFile.set('extname', newExtname)
 
-  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
-            }
-          ]
-        }
-      },
-      {
-        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
-    ]
-  }
+      await renamePromise(videoOutputPath, this.getVideoFilePath(inputVideoFile))
+      const stats = await statPromise(this.getVideoFilePath(inputVideoFile))
 
-  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
-    }
-  })
-}
+      inputVideoFile.set('size', stats.size)
 
-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
-    ]
-  }
+      await this.createTorrentAndSetInfoHash(inputVideoFile)
+      await inputVideoFile.save()
 
-  return Video.findAndCountAll(query).then(({ rows, count }) => {
-    return {
-      data: rows,
-      total: count
-    }
-  })
-}
+    } catch (err) {
+      // Auto destruction...
+      this.destroy().catch(err => logger.error('Cannot destruct video after transcoding failure.', err))
 
-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()
+      throw err
+    }
   }
 
-  return Video.findAndCountAll(query).then(({ rows, count }) => {
-    return {
-      data: rows,
-      total: count
-    }
-  })
-}
+  transcodeOriginalVideofile = async function (resolution: VideoResolution) {
+    const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
+    const extname = '.mp4'
 
-load = function (id: number) {
-  return Video.findById(id)
-}
+    // We are sure it's x264 in mp4 because optimizeOriginalVideofile was already executed
+    const videoInputPath = join(videosDirectory, this.getVideoFilename(this.getOriginalFile()))
 
-loadByUUID = function (uuid: string, t?: Sequelize.Transaction) {
-  const query: Sequelize.FindOptions<VideoAttributes> = {
-    where: {
-      uuid
-    },
-    include: [ Video['sequelize'].models.VideoFile ]
-  }
+    const newVideoFile = new VideoFileModel({
+      resolution,
+      extname,
+      size: 0,
+      videoId: this.id
+    })
+    const videoOutputPath = join(videosDirectory, this.getVideoFilename(newVideoFile))
 
-  if (t !== undefined) query.transaction = t
+    const transcodeOptions = {
+      inputPath: videoInputPath,
+      outputPath: videoOutputPath,
+      resolution
+    }
 
-  return Video.findOne(query)
-}
+    await transcode(transcodeOptions)
 
-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 ]
-      }
-    ]
-  }
+    const stats = await statPromise(videoOutputPath)
 
-  if (t !== undefined) query.transaction = t
+    newVideoFile.set('size', stats.size)
 
-  return Video.findOne(query)
-}
+    await this.createTorrentAndSetInfoHash(newVideoFile)
 
-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 ]
+    await newVideoFile.save()
+
+    this.VideoFiles.push(newVideoFile)
   }
 
-  if (t !== undefined) query.transaction = t
+  getOriginalFileHeight () {
+    const originalFilePath = this.getVideoFilePath(this.getOriginalFile())
 
-  return Video.findOne(query)
-}
+    return getVideoFileHeight(originalFilePath)
+  }
 
-loadAndPopulateAccountAndServerAndTags = function (id: number) {
-  const options = {
-    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 ]
-      },
-      Video['sequelize'].models.Tag,
-      Video['sequelize'].models.VideoFile
-    ]
+  getDescriptionPath () {
+    return `/api/${API_VERSION}/videos/${this.uuid}/description`
   }
 
-  return Video.findById(id, options)
-}
+  getCategoryLabel () {
+    let categoryLabel = VIDEO_CATEGORIES[this.category]
+    if (!categoryLabel) categoryLabel = 'Misc'
 
-loadByUUIDAndPopulateAccountAndServerAndTags = function (uuid: string) {
-  const options = {
-    where: {
-      uuid
-    },
-    include: [
-      {
-        model: Video['sequelize'].models.VideoChannel,
-        include: [
-          {
-            model: Video['sequelize'].models.Account,
-            include: [ { model: Video['sequelize'].models.Server, required: false } ]
-          }
-        ]
-      },
-      {
-        model: Video['sequelize'].models.AccountVideoRate,
-        include: [ Video['sequelize'].models.Account ]
-      },
-      Video['sequelize'].models.Tag,
-      Video['sequelize'].models.VideoFile
-    ]
+    return categoryLabel
   }
 
-  return Video.findOne(options)
-}
+  getLicenceLabel () {
+    let licenceLabel = VIDEO_LICENCES[this.licence]
+    if (!licenceLabel) licenceLabel = 'Unknown'
 
-searchAndPopulateAccountAndServerAndTags = function (value: string, field: string, start: number, count: number, sort: string) {
-  const serverInclude: Sequelize.IncludeOptions = {
-    model: Video['sequelize'].models.Server,
-    required: false
+    return licenceLabel
   }
 
-  const accountInclude: Sequelize.IncludeOptions = {
-    model: Video['sequelize'].models.Account,
-    include: [ serverInclude ]
+  getLanguageLabel () {
+    let languageLabel = VIDEO_LANGUAGES[this.language]
+    if (!languageLabel) languageLabel = 'Unknown'
+
+    return languageLabel
   }
 
-  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)
   }
 
-  const tagInclude: Sequelize.IncludeOptions = {
-    model: Video['sequelize'].models.Tag
+  removePreview () {
+    // Same name than video thumbnail
+    return unlinkPromise(CONFIG.STORAGE.PREVIEWS_DIR + this.getPreviewName())
   }
 
-  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)
   }
 
-  if (field === 'tags') {
-    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}
-       )`
-    )
-  } else if (field === 'host') {
-    // FIXME: Include our server? (not stored in the database)
-    serverInclude.where = {
-      host: {
-        [Sequelize.Op.iLike]: '%' + value + '%'
-      }
-    }
-    serverInclude.required = true
-  } else if (field === 'account') {
-    accountInclude.where = {
-      name: {
-        [Sequelize.Op.iLike]: '%' + value + '%'
-      }
-    }
-  } else {
-    query.where[field] = {
-      [Sequelize.Op.iLike]: '%' + value + '%'
-    }
+  removeTorrent (videoFile: VideoFileModel) {
+    const torrentPath = join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
+    return unlinkPromise(torrentPath)
   }
 
-  query.include = [
-    videoChannelInclude, tagInclude
-  ]
+  private getBaseUrls () {
+    let baseUrlHttp
+    let baseUrlWs
 
-  return Video.findAndCountAll(query).then(({ rows, count }) => {
-    return {
-      data: rows,
-      total: count
+    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
 
-  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 getThumbnailUrl (baseUrlHttp: string) {
+    return baseUrlHttp + STATIC_PATHS.THUMBNAILS + this.getThumbnailName()
   }
 
-  return { baseUrlHttp, baseUrlWs }
-}
-
-function getThumbnailUrl (video: VideoInstance, baseUrlHttp: string) {
-  return baseUrlHttp + STATIC_PATHS.THUMBNAILS + video.getThumbnailName()
-}
+  private getTorrentUrl (videoFile: VideoFileModel, baseUrlHttp: string) {
+    return baseUrlHttp + STATIC_PATHS.TORRENTS + this.getTorrentFileName(videoFile)
+  }
 
-function getTorrentUrl (video: VideoInstance, videoFile: VideoFileInstance, baseUrlHttp: string) {
-  return baseUrlHttp + STATIC_PATHS.TORRENTS + video.getTorrentFileName(videoFile)
-}
+  private getVideoFileUrl (videoFile: VideoFileModel, baseUrlHttp: string) {
+    return baseUrlHttp + STATIC_PATHS.WEBSEED + 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)
 }