]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blobdiff - server/models/video/video.ts
Merge branch 'release/v1.0.0' into develop
[github/Chocobozzz/PeerTube.git] / server / models / video / video.ts
index b5d33334731d16728f13fbceae80b45304c24414..6c183933b165452829000d5a65c6dd6dfa5678af 100644 (file)
-import * as safeBuffer from 'safe-buffer'
-const Buffer = safeBuffer.Buffer
+import * as Bluebird from 'bluebird'
+import { maxBy } from 'lodash'
 import * as magnetUtil from 'magnet-uri'
-import { map, maxBy, truncate } from 'lodash'
 import * as parseTorrent from 'parse-torrent'
 import { join } from 'path'
 import * as Sequelize from 'sequelize'
-
-import { TagInstance } from './tag-interface'
 import {
-  logger,
-  isVideoNameValid,
+  AllowNull,
+  BeforeDestroy,
+  BelongsTo,
+  BelongsToMany,
+  Column,
+  CreatedAt,
+  DataType,
+  Default,
+  ForeignKey,
+  HasMany,
+  HasOne,
+  IFindOptions,
+  IIncludeOptions,
+  Is,
+  IsInt,
+  IsUUID,
+  Min,
+  Model,
+  Scopes,
+  Table,
+  UpdatedAt
+} from 'sequelize-typescript'
+import { UserRight, VideoPrivacy, 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 { createTorrentPromise, peertubeTruncate } from '../../helpers/core-utils'
+import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
+import { isArray, isBooleanValid } from '../../helpers/custom-validators/misc'
+import {
   isVideoCategoryValid,
-  isVideoLicenceValid,
-  isVideoLanguageValid,
-  isVideoNSFWValid,
   isVideoDescriptionValid,
   isVideoDurationValid,
+  isVideoLanguageValid,
+  isVideoLicenceValid,
+  isVideoNameValid,
   isVideoPrivacyValid,
-  readFileBufferPromise,
-  unlinkPromise,
-  renamePromise,
-  writeFilePromise,
-  createTorrentPromise,
-  statPromise,
-  generateImageFromVideoFile,
-  transcode,
-  getVideoFileHeight,
-  getActivityPubUrl
-} from '../../helpers'
+  isVideoStateValid,
+  isVideoSupportValid
+} from '../../helpers/custom-validators/videos'
+import { generateImageFromVideoFile, getVideoFileResolution } from '../../helpers/ffmpeg-utils'
+import { logger } from '../../helpers/logger'
+import { getServerActor } from '../../helpers/utils'
 import {
+  ACTIVITY_PUB,
+  API_VERSION,
   CONFIG,
+  CONSTRAINTS_FIELDS,
+  PREVIEWS_SIZE,
   REMOTE_SCHEME,
+  STATIC_DOWNLOAD_PATHS,
   STATIC_PATHS,
+  THUMBNAILS_SIZE,
   VIDEO_CATEGORIES,
-  VIDEO_LICENCES,
   VIDEO_LANGUAGES,
-  THUMBNAILS_SIZE,
-  PREVIEWS_SIZE,
-  CONSTRAINTS_FIELDS,
-  API_VERSION,
-  VIDEO_PRIVACIES
+  VIDEO_LICENCES,
+  VIDEO_PRIVACIES,
+  VIDEO_STATES
 } from '../../initializers'
-import { removeVideoToFriends } from '../../lib'
-import { VideoResolution, VideoPrivacy } from '../../../shared'
-import { VideoFileInstance, VideoFileModel } from './video-file-interface'
-
-import { addMethodsToModel, getSort } from '../utils'
+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 { buildBlockedAccountSQL, buildTrigramSearchIndex, createSimilarityAttribute, getVideoSort, 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'
+import { VideoBlacklistModel } from './video-blacklist'
+import { remove, writeFile } from 'fs-extra'
+import { VideoViewModel } from './video-views'
+import { VideoRedundancyModel } from '../redundancy/video-redundancy'
 import {
-  VideoInstance,
-  VideoAttributes,
-
-  VideoMethods
-} from './video-interface'
-import { VideoTorrentObject } from '../../../shared/models/activitypub/objects/video-torrent-object'
-
-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 generateThumbnailFromData: VideoMethods.GenerateThumbnailFromData
-let list: VideoMethods.List
-let listForApi: VideoMethods.ListForApi
-let listUserVideosForApi: VideoMethods.ListUserVideosForApi
-let loadByHostAndUUID: VideoMethods.LoadByHostAndUUID
-let listOwnedAndPopulateAccountAndTags: VideoMethods.ListOwnedAndPopulateAccountAndTags
-let listOwnedByAccount: VideoMethods.ListOwnedByAccount
-let load: VideoMethods.Load
-let loadByUUID: VideoMethods.LoadByUUID
-let loadByUUIDOrURL: VideoMethods.LoadByUUIDOrURL
-let loadLocalVideoByUUID: VideoMethods.LoadLocalVideoByUUID
-let loadAndPopulateAccount: VideoMethods.LoadAndPopulateAccount
-let loadAndPopulateAccountAndPodAndTags: VideoMethods.LoadAndPopulateAccountAndPodAndTags
-let loadByUUIDAndPopulateAccountAndPodAndTags: VideoMethods.LoadByUUIDAndPopulateAccountAndPodAndTags
-let searchAndPopulateAccountAndPodAndTags: VideoMethods.SearchAndPopulateAccountAndPodAndTags
-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
+  videoFilesModelToFormattedJSON,
+  VideoFormattingJSONOptions,
+  videoModelToActivityPubObject,
+  videoModelToFormattedDetailsJSON,
+  videoModelToFormattedJSON
+} from './video-format-utils'
+import * as validator from 'validator'
+import { UserVideoHistoryModel } from '../account/user-video-history'
+import { UserModel } from '../account/user'
+
+// 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_IDS = 'AVAILABLE_FOR_LIST_IDS',
+  FOR_API = 'FOR_API',
+  WITH_ACCOUNT_DETAILS = 'WITH_ACCOUNT_DETAILS',
+  WITH_TAGS = 'WITH_TAGS',
+  WITH_FILES = 'WITH_FILES',
+  WITH_SCHEDULED_UPDATE = 'WITH_SCHEDULED_UPDATE',
+  WITH_BLACKLISTED = 'WITH_BLACKLISTED',
+  WITH_USER_HISTORY = 'WITH_USER_HISTORY'
+}
+
+type ForAPIOptions = {
+  ids: number[]
+  withFiles?: boolean
+}
+
+type AvailableForListIDsOptions = {
+  serverAccountId: number
+  actorId: number
+  includeLocalVideos: boolean
+  filter?: VideoFilter
+  categoryOneOf?: number[]
+  nsfw?: boolean
+  licenceOneOf?: number[]
+  languageOneOf?: string[]
+  tagsOneOf?: string[]
+  tagsAllOf?: string[]
+  withFiles?: boolean
+  accountId?: number
+  videoChannelId?: number
+  trendingDays?: number
+  user?: UserModel
+}
+
+@Scopes({
+  [ ScopeNames.FOR_API ]: (options: ForAPIOptions) => {
+    const accountInclude = {
+      attributes: [ 'id', 'name' ],
+      model: AccountModel.unscoped(),
+      required: true,
+      include: [
+        {
+          attributes: [ 'id', 'uuid', 'preferredUsername', 'url', 'serverId', 'avatarId' ],
+          model: ActorModel.unscoped(),
+          required: true,
+          include: [
+            {
+              attributes: [ 'host' ],
+              model: ServerModel.unscoped(),
+              required: false
+            },
+            {
+              model: AvatarModel.unscoped(),
+              required: false
+            }
+          ]
         }
-      },
-      name: {
-        type: DataTypes.STRING,
-        allowNull: false,
-        validate: {
-          nameValid: value => {
-            const res = isVideoNameValid(value)
-            if (res === false) throw new Error('Video name is not valid.')
-          }
+      ]
+    }
+
+    const videoChannelInclude = {
+      attributes: [ 'name', 'description', 'id' ],
+      model: VideoChannelModel.unscoped(),
+      required: true,
+      include: [
+        {
+          attributes: [ 'uuid', 'preferredUsername', 'url', 'serverId', 'avatarId' ],
+          model: ActorModel.unscoped(),
+          required: true,
+          include: [
+            {
+              attributes: [ 'host' ],
+              model: ServerModel.unscoped(),
+              required: false
+            },
+            {
+              model: AvatarModel.unscoped(),
+              required: false
+            }
+          ]
+        },
+        accountInclude
+      ]
+    }
+
+    const query: IFindOptions<VideoModel> = {
+      where: {
+        id: {
+          [ Sequelize.Op.any ]: options.ids
         }
       },
-      category: {
-        type: DataTypes.INTEGER,
-        allowNull: false,
-        validate: {
-          categoryValid: value => {
-            const res = isVideoCategoryValid(value)
-            if (res === false) throw new Error('Video category is not valid.')
-          }
+      include: [ videoChannelInclude ]
+    }
+
+    if (options.withFiles === true) {
+      query.include.push({
+        model: VideoFileModel.unscoped(),
+        required: true
+      })
+    }
+
+    return query
+  },
+  [ ScopeNames.AVAILABLE_FOR_LIST_IDS ]: (options: AvailableForListIDsOptions) => {
+    const query: IFindOptions<VideoModel> = {
+      raw: true,
+      attributes: [ 'id' ],
+      where: {
+        id: {
+          [ Sequelize.Op.and ]: [
+            {
+              [ Sequelize.Op.notIn ]: Sequelize.literal(
+                '(SELECT "videoBlacklist"."videoId" FROM "videoBlacklist")'
+              )
+            }
+          ]
+        },
+        channelId: {
+          [ Sequelize.Op.notIn ]: Sequelize.literal(
+            '(' +
+              'SELECT id FROM "videoChannel" WHERE "accountId" IN (' +
+                buildBlockedAccountSQL(options.serverAccountId, options.user ? options.user.Account.id : undefined) +
+              ')' +
+            ')'
+          )
         }
       },
-      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.')
+      include: []
+    }
+
+    // Only list public/published videos
+    if (!options.filter || options.filter !== 'all-local') {
+      const privacyWhere = {
+        // 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
+            }
           }
+        ]
+      }
+
+      Object.assign(query.where, privacyWhere)
+    }
+
+    if (options.filter || options.accountId || options.videoChannelId) {
+      const videoChannelInclude: IIncludeOptions = {
+        attributes: [],
+        model: VideoChannelModel.unscoped(),
+        required: true
+      }
+
+      if (options.videoChannelId) {
+        videoChannelInclude.where = {
+          id: options.videoChannelId
         }
-      },
-      language: {
-        type: DataTypes.INTEGER,
-        allowNull: true,
-        validate: {
-          languageValid: value => {
-            const res = isVideoLanguageValid(value)
-            if (res === false) throw new Error('Video language is not valid.')
-          }
+      }
+
+      if (options.filter || options.accountId) {
+        const accountInclude: IIncludeOptions = {
+          attributes: [],
+          model: AccountModel.unscoped(),
+          required: true
         }
-      },
-      privacy: {
-        type: DataTypes.INTEGER,
-        allowNull: false,
-        validate: {
-          privacyValid: value => {
-            const res = isVideoPrivacyValid(value)
-            if (res === false) throw new Error('Video privacy is not valid.')
-          }
+
+        if (options.filter) {
+          accountInclude.include = [
+            {
+              attributes: [],
+              model: ActorModel.unscoped(),
+              required: true,
+              where: VideoModel.buildActorWhereWithFilter(options.filter)
+            }
+          ]
         }
-      },
-      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.')
-          }
+
+        if (options.accountId) {
+          accountInclude.where = { id: options.accountId }
         }
-      },
-      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.')
+
+        videoChannelInclude.include = [ accountInclude ]
+      }
+
+      query.include.push(videoChannelInclude)
+    }
+
+    if (options.actorId) {
+      let localVideosReq = ''
+      if (options.includeLocalVideos === true) {
+        localVideosReq = ' 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" ' +
+          'WHERE "actor"."serverId" IS NULL'
+      }
+
+      // Force actorId to be a number to avoid SQL injections
+      const actorIdNumber = parseInt(options.actorId.toString(), 10)
+      query.where[ 'id' ][ Sequelize.Op.and ].push({
+        [ 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 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 +
+          localVideosReq +
+          ')'
+        )
+      })
+    }
+
+    if (options.withFiles === true) {
+      query.where[ 'id' ][ Sequelize.Op.and ].push({
+        [ Sequelize.Op.in ]: Sequelize.literal(
+          '(SELECT "videoId" FROM "videoFile")'
+        )
+      })
+    }
+
+    // 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.and ].push({
+          [ 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.and ].push({
+          [ 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 +
+            ')'
+          )
+        })
+      }
+    }
+
+    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.trendingDays) {
+      query.include.push(VideoModel.buildTrendingQuery(options.trendingDays))
+
+      query.subQuery = false
+    }
+
+    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
+                  }
+                ]
+              }
+            ]
           }
-        }
-      },
-      duration: {
-        type: DataTypes.INTEGER,
-        allowNull: false,
-        validate: {
-          durationValid: value => {
-            const res = isVideoDurationValid(value)
-            if (res === false) throw new Error('Video duration is not valid.')
+        ]
+      }
+    ]
+  },
+  [ ScopeNames.WITH_TAGS ]: {
+    include: [ () => TagModel ]
+  },
+  [ ScopeNames.WITH_BLACKLISTED ]: {
+    include: [
+      {
+        attributes: [ 'id', 'reason' ],
+        model: () => VideoBlacklistModel,
+        required: false
+      }
+    ]
+  },
+  [ ScopeNames.WITH_FILES ]: {
+    include: [
+      {
+        model: () => VideoFileModel.unscoped(),
+        // FIXME: typings
+        [ 'separate' as any ]: true, // We may have multiple files, having multiple redundancies so let's separate this join
+        required: false,
+        include: [
+          {
+            attributes: [ 'fileUrl' ],
+            model: () => VideoRedundancyModel.unscoped(),
+            required: false
           }
-        }
-      },
-      views: {
-        type: DataTypes.INTEGER,
-        allowNull: false,
-        defaultValue: 0,
-        validate: {
-          min: 0,
-          isInt: true
-        }
-      },
-      likes: {
-        type: DataTypes.INTEGER,
-        allowNull: false,
-        defaultValue: 0,
-        validate: {
-          min: 0,
-          isInt: true
-        }
-      },
-      dislikes: {
-        type: DataTypes.INTEGER,
-        allowNull: false,
-        defaultValue: 0,
-        validate: {
-          min: 0,
-          isInt: true
-        }
-      },
-      remote: {
-        type: DataTypes.BOOLEAN,
-        allowNull: false,
-        defaultValue: false
-      },
-      url: {
-        type: DataTypes.STRING,
-        allowNull: false,
-        validate: {
-          isUrl: true
-        }
+        ]
       }
-    },
-    {
-      indexes: [
-        {
-          fields: [ 'name' ]
-        },
-        {
-          fields: [ 'createdAt' ]
-        },
-        {
-          fields: [ 'duration' ]
-        },
-        {
-          fields: [ 'views' ]
-        },
-        {
-          fields: [ 'likes' ]
-        },
-        {
-          fields: [ 'uuid' ]
-        },
-        {
-          fields: [ 'channelId' ]
-        },
+    ]
+  },
+  [ ScopeNames.WITH_SCHEDULED_UPDATE ]: {
+    include: [
+      {
+        model: () => ScheduleVideoUpdateModel.unscoped(),
+        required: false
+      }
+    ]
+  },
+  [ ScopeNames.WITH_USER_HISTORY ]: (userId: number) => {
+    return {
+      include: [
         {
-          fields: [ 'parentId' ]
+          attributes: [ 'currentTime' ],
+          model: UserVideoHistoryModel.unscoped(),
+          required: false,
+          where: {
+            userId
+          }
         }
-      ],
-      hooks: {
-        afterDestroy
-      }
+      ]
     }
-  )
-
-  const classMethods = [
-    associate,
-
-    generateThumbnailFromData,
-    list,
-    listForApi,
-    listUserVideosForApi,
-    listOwnedAndPopulateAccountAndTags,
-    listOwnedByAccount,
-    load,
-    loadAndPopulateAccount,
-    loadAndPopulateAccountAndPodAndTags,
-    loadByHostAndUUID,
-    loadByUUIDOrURL,
-    loadByUUID,
-    loadLocalVideoByUUID,
-    loadByUUIDAndPopulateAccountAndPodAndTags,
-    searchAndPopulateAccountAndPodAndTags
-  ]
-  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
-}
+  }
+})
+@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
 
-// ------------------------------ METHODS ------------------------------
+  @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.belongsTo(models.VideoChannel, {
+  @HasMany(() => VideoFileModel, {
     foreignKey: {
-      name: 'parentId',
-      allowNull: true
+      name: 'videoId',
+      allowNull: false
     },
+    hooks: true,
     onDelete: 'cascade'
   })
+  VideoFiles: VideoFileModel[]
 
-  Video.belongsToMany(models.Tag, {
-    foreignKey: 'videoId',
-    through: models.VideoTag,
+  @HasMany(() => VideoShareModel, {
+    foreignKey: {
+      name: 'videoId',
+      allowNull: false
+    },
     onDelete: 'cascade'
   })
+  VideoShares: VideoShareModel[]
 
-  Video.hasMany(models.VideoAbuse, {
+  @HasMany(() => AccountVideoRateModel, {
     foreignKey: {
       name: 'videoId',
       allowNull: false
     },
     onDelete: 'cascade'
   })
+  AccountVideoRates: AccountVideoRateModel[]
 
-  Video.hasMany(models.VideoFile, {
+  @HasMany(() => VideoCommentModel, {
+    foreignKey: {
+      name: 'videoId',
+      allowNull: false
+    },
+    onDelete: 'cascade',
+    hooks: true
+  })
+  VideoComments: VideoCommentModel[]
+
+  @HasMany(() => VideoViewModel, {
     foreignKey: {
       name: 'videoId',
       allowNull: false
     },
     onDelete: 'cascade'
   })
-}
+  VideoViews: VideoViewModel[]
 
-function afterDestroy (video: VideoInstance) {
-  const tasks = []
+  @HasMany(() => UserVideoHistoryModel, {
+    foreignKey: {
+      name: 'videoId',
+      allowNull: false
+    },
+    onDelete: 'cascade'
+  })
+  UserVideoHistories: UserVideoHistoryModel[]
 
-  tasks.push(
-    video.removeThumbnail()
-  )
+  @HasOne(() => ScheduleVideoUpdateModel, {
+    foreignKey: {
+      name: 'videoId',
+      allowNull: false
+    },
+    onDelete: 'cascade'
+  })
+  ScheduleVideoUpdate: ScheduleVideoUpdateModel
 
-  if (video.isOwned()) {
-    const removeVideoToFriendsParams = {
-      uuid: video.uuid
-    }
+  @HasOne(() => VideoBlacklistModel, {
+    foreignKey: {
+      name: 'videoId',
+      allowNull: false
+    },
+    onDelete: 'cascade'
+  })
+  VideoBlacklist: VideoBlacklistModel
 
-    tasks.push(
-      video.removePreview(),
-      removeVideoToFriends(removeVideoToFriendsParams)
-    )
+  @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
+      }
 
-    // Remove physical files and torrents
-    video.VideoFiles.forEach(file => {
-      tasks.push(video.removeFile(file))
-      tasks.push(video.removeTorrent(file))
-    })
+      return sendDeleteVideo(instance, options.transaction)
+    }
+
+    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 listLocal () {
+    const query = {
+      where: {
+        remote: false
+      }
+    }
 
-createThumbnail = function (this: VideoInstance, videoFile: VideoFileInstance) {
-  const imageSize = THUMBNAILS_SIZE.width + 'x' + THUMBNAILS_SIZE.height
+    return VideoModel.scope(ScopeNames.WITH_FILES).findAll(query)
+  }
 
-  return generateImageFromVideoFile(
-    this.getVideoFilePath(videoFile),
-    CONFIG.STORAGE.THUMBNAILS_DIR,
-    this.getThumbnailName(),
-    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})`
+    }
 
-getVideoFilePath = function (this: VideoInstance, videoFile: VideoFileInstance) {
-  return join(CONFIG.STORAGE.VIDEOS_DIR, this.getVideoFilename(videoFile))
-}
+    const rawQuery = getRawQuery('"Video"."id"')
+    const rawCountQuery = getRawQuery('COUNT("Video"."id") as "total"')
+
+    const query = {
+      distinct: true,
+      offset: start,
+      limit: count,
+      order: getVideoSort('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
+      ]
+    }
 
-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)
-    ]
+    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
+      }
+    })
   }
 
-  const torrent = await createTorrentPromise(this.getVideoFilePath(videoFile), options)
+  static listUserVideosForApi (accountId: number, start: number, count: number, sort: string, withFiles = false) {
+    const query: IFindOptions<VideoModel> = {
+      offset: start,
+      limit: count,
+      order: getVideoSort(sort),
+      include: [
+        {
+          model: VideoChannelModel,
+          required: true,
+          include: [
+            {
+              model: AccountModel,
+              where: {
+                id: accountId
+              },
+              required: true
+            }
+          ]
+        },
+        {
+          model: ScheduleVideoUpdateModel,
+          required: false
+        },
+        {
+          model: VideoBlacklistModel,
+          required: false
+        }
+      ]
+    }
 
-  const filePath = join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
-  logger.info('Creating torrent %s.', filePath)
+    if (withFiles === true) {
+      query.include.push({
+        model: VideoFileModel.unscoped(),
+        required: true
+      })
+    }
 
-  await writeFilePromise(filePath, torrent)
+    return VideoModel.findAndCountAll(query).then(({ rows, count }) => {
+      return {
+        data: rows,
+        total: count
+      }
+    })
+  }
 
-  const parsedTorrent = parseTorrent(torrent)
-  videoFile.infoHash = parsedTorrent.infoHash
-}
+  static async listForApi (options: {
+    start: number,
+    count: number,
+    sort: string,
+    nsfw: boolean,
+    includeLocalVideos: boolean,
+    withFiles: boolean,
+    categoryOneOf?: number[],
+    licenceOneOf?: number[],
+    languageOneOf?: string[],
+    tagsOneOf?: string[],
+    tagsAllOf?: string[],
+    filter?: VideoFilter,
+    accountId?: number,
+    videoChannelId?: number,
+    actorId?: number
+    trendingDays?: number,
+    user?: UserModel
+  }, countVideos = true) {
+    if (options.filter && options.filter === 'all-local' && !options.user.hasRight(UserRight.SEE_ALL_VIDEOS)) {
+      throw new Error('Try to filter all-local but no user has not the see all videos right')
+    }
 
-getEmbedPath = function (this: VideoInstance) {
-  return '/videos/embed/' + this.uuid
-}
+    const query: IFindOptions<VideoModel> = {
+      offset: options.start,
+      limit: options.count,
+      order: getVideoSort(options.sort)
+    }
 
-getThumbnailPath = function (this: VideoInstance) {
-  return join(STATIC_PATHS.THUMBNAILS, this.getThumbnailName())
-}
+    let trendingDays: number
+    if (options.sort.endsWith('trending')) {
+      trendingDays = CONFIG.TRENDING.VIDEOS.INTERVAL_DAYS
 
-getPreviewPath = function (this: VideoInstance) {
-  return join(STATIC_PATHS.PREVIEWS, this.getPreviewName())
-}
+      query.group = 'VideoModel.id'
+    }
 
-toFormattedJSON = function (this: VideoInstance) {
-  let podHost
+    const serverActor = await getServerActor()
+
+    // actorId === null has a meaning, so just check undefined
+    const actorId = options.actorId !== undefined ? options.actorId : serverActor.id
+
+    const queryOptions = {
+      actorId,
+      serverAccountId: serverActor.Account.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,
+      includeLocalVideos: options.includeLocalVideos,
+      user: options.user,
+      trendingDays
+    }
 
-  if (this.VideoChannel.Account.Pod) {
-    podHost = this.VideoChannel.Account.Pod.host
-  } else {
-    // It means it's our video
-    podHost = CONFIG.WEBSERVER.HOST
+    return VideoModel.getAvailableForApi(query, queryOptions, countVideos)
   }
 
-  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(),
-    podHost,
-    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 async searchAndPopulateAccountAndServer (options: {
+    includeLocalVideos: boolean
+    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
+    user?: UserModel,
+    filter?: VideoFilter
+  }) {
+    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 })
+    }
 
-  return json
-}
+    if (options.durationMin || options.durationMax) {
+      const durationRange = {}
 
-toFormattedDetailsJSON = function (this: VideoInstance) {
-  const formattedJson = this.toFormattedJSON()
+      if (options.durationMin) durationRange[ Sequelize.Op.gte ] = options.durationMin
+      if (options.durationMax) durationRange[ Sequelize.Op.lte ] = options.durationMax
 
-  // Maybe our pod is not up to date and there are new privacy settings since our version
-  let privacyLabel = VIDEO_PRIVACIES[this.privacy]
-  if (!privacyLabel) privacyLabel = 'Unknown'
+      whereAnd.push({ duration: durationRange })
+    }
 
-  const detailsJson = {
-    privacyLabel,
-    privacy: this.privacy,
-    descriptionPath: this.getDescriptionPath(),
-    channel: this.VideoChannel.toFormattedJSON(),
-    files: []
-  }
+    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 +
+              ')'
+            )
+          }
+        }
+      )
 
-  // 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)
-}
+      attributesInclude.push(createSimilarityAttribute('VideoModel.name', options.search))
+    }
 
-toActivityPubObject = function (this: VideoInstance) {
-  const { baseUrlHttp, baseUrlWs } = getBaseUrls(this)
-
-  const tag = this.Tags.map(t => ({
-    type: 'Hashtag',
-    name: t.name
-  }))
-
-  const url = []
-  for (const file of this.VideoFiles) {
-    url.push({
-      type: 'Link',
-      mimeType: 'video/' + file.extname,
-      url: getVideoFileUrl(this, file, baseUrlHttp),
-      width: file.resolution,
-      size: file.size
-    })
+    // Cannot search on similarity if we don't have a search
+    if (!options.search) {
+      attributesInclude.push(
+        Sequelize.literal('0 as similarity')
+      )
+    }
 
-    url.push({
-      type: 'Link',
-      mimeType: 'application/x-bittorrent',
-      url: getTorrentUrl(this, file, baseUrlHttp),
-      width: file.resolution
-    })
+    const query: IFindOptions<VideoModel> = {
+      attributes: {
+        include: attributesInclude
+      },
+      offset: options.start,
+      limit: options.count,
+      order: getVideoSort(options.sort),
+      where: {
+        [ Sequelize.Op.and ]: whereAnd
+      }
+    }
 
-    url.push({
-      type: 'Link',
-      mimeType: 'application/x-bittorrent;x-scheme-handler/magnet',
-      url: generateMagnetUri(this, file, baseUrlHttp, baseUrlWs),
-      width: file.resolution
-    })
-  }
+    const serverActor = await getServerActor()
+    const queryOptions = {
+      actorId: serverActor.id,
+      serverAccountId: serverActor.Account.id,
+      includeLocalVideos: options.includeLocalVideos,
+      nsfw: options.nsfw,
+      categoryOneOf: options.categoryOneOf,
+      licenceOneOf: options.licenceOneOf,
+      languageOneOf: options.languageOneOf,
+      tagsOneOf: options.tagsOneOf,
+      tagsAllOf: options.tagsAllOf,
+      user: options.user,
+      filter: options.filter
+    }
 
-  const videoObject: VideoTorrentObject = {
-    type: 'Video',
-    id: getActivityPubUrl('video', this.uuid),
-    name: this.name,
-    // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-duration
-    duration: 'PT' + this.duration + 'S',
-    uuid: this.uuid,
-    tag,
-    category: {
-      id: this.category,
-      label: this.getCategoryLabel()
-    },
-    licence: {
-      id: this.licence,
-      name: this.getLicenceLabel()
-    },
-    language: {
-      id: this.language,
-      name: this.getLanguageLabel()
-    },
-    views: this.views,
-    nsfw: this.nsfw,
-    published: this.createdAt,
-    updated: this.updatedAt,
-    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
+    return VideoModel.getAvailableForApi(query, queryOptions)
   }
 
-  return videoObject
-}
+  static load (id: number | string, t?: Sequelize.Transaction) {
+    const where = VideoModel.buildWhereIdOrUUID(id)
+    const options = {
+      where,
+      transaction: t
+    }
 
-getTruncatedDescription = function (this: VideoInstance) {
-  const options = {
-    length: CONSTRAINTS_FIELDS.VIDEOS.TRUNCATED_DESCRIPTION.max
+    return VideoModel.findOne(options)
   }
 
-  return truncate(this.description, options)
-}
+  static loadOnlyId (id: number | string, t?: Sequelize.Transaction) {
+    const where = VideoModel.buildWhereIdOrUUID(id)
 
-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)
+    const options = {
+      attributes: [ 'id' ],
+      where,
+      transaction: t
+    }
 
-  const transcodeOptions = {
-    inputPath: videoInputPath,
-    outputPath: videoOutputPath
+    return VideoModel.findOne(options)
   }
 
-  try {
-    // Could be very long!
-    await transcode(transcodeOptions)
-
-    await unlinkPromise(videoInputPath)
+  static loadWithFile (id: number, t?: Sequelize.Transaction, logging?: boolean) {
+    return VideoModel.scope(ScopeNames.WITH_FILES)
+                     .findById(id, { transaction: t, logging })
+  }
 
-    // Important to do this before getVideoFilename() to take in account the new file extension
-    inputVideoFile.set('extname', newExtname)
+  static loadByUUIDWithFile (uuid: string) {
+    const options = {
+      where: {
+        uuid
+      }
+    }
 
-    await renamePromise(videoOutputPath, this.getVideoFilePath(inputVideoFile))
-    const stats = await statPromise(this.getVideoFilePath(inputVideoFile))
+    return VideoModel
+      .scope([ ScopeNames.WITH_FILES ])
+      .findOne(options)
+  }
 
-    inputVideoFile.set('size', stats.size)
+  static loadByUrl (url: string, transaction?: Sequelize.Transaction) {
+    const query: IFindOptions<VideoModel> = {
+      where: {
+        url
+      },
+      transaction
+    }
 
-    await this.createTorrentAndSetInfoHash(inputVideoFile)
-    await inputVideoFile.save()
+    return VideoModel.findOne(query)
+  }
 
-  } catch (err) {
-    // Auto destruction...
-    this.destroy().catch(err => logger.error('Cannot destruct video after transcoding failure.', err))
+  static loadByUrlAndPopulateAccount (url: string, transaction?: Sequelize.Transaction) {
+    const query: IFindOptions<VideoModel> = {
+      where: {
+        url
+      },
+      transaction
+    }
 
-    throw err
+    return VideoModel.scope([ ScopeNames.WITH_ACCOUNT_DETAILS, ScopeNames.WITH_FILES ]).findOne(query)
   }
-}
 
-transcodeOriginalVideofile = async function (this: VideoInstance, resolution: VideoResolution) {
-  const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
-  const extname = '.mp4'
+  static loadAndPopulateAccountAndServerAndTags (id: number | string, t?: Sequelize.Transaction, userId?: number) {
+    const where = VideoModel.buildWhereIdOrUUID(id)
 
-  // We are sure it's x264 in mp4 because optimizeOriginalVideofile was already executed
-  const videoInputPath = join(videosDirectory, this.getVideoFilename(this.getOriginalFile()))
+    const options = {
+      order: [ [ 'Tags', 'name', 'ASC' ] ],
+      where,
+      transaction: t
+    }
 
-  const newVideoFile = (Video['sequelize'].models.VideoFile as VideoFileModel).build({
-    resolution,
-    extname,
-    size: 0,
-    videoId: this.id
-  })
-  const videoOutputPath = join(videosDirectory, this.getVideoFilename(newVideoFile))
+    const scopes = [
+      ScopeNames.WITH_TAGS,
+      ScopeNames.WITH_BLACKLISTED,
+      ScopeNames.WITH_FILES,
+      ScopeNames.WITH_ACCOUNT_DETAILS,
+      ScopeNames.WITH_SCHEDULED_UPDATE
+    ]
 
-  const transcodeOptions = {
-    inputPath: videoInputPath,
-    outputPath: videoOutputPath,
-    resolution
+    if (userId) {
+      scopes.push({ method: [ ScopeNames.WITH_USER_HISTORY, userId ] } as any) // FIXME: typings
+    }
+
+    return VideoModel
+      .scope(scopes)
+      .findOne(options)
   }
 
-  await transcode(transcodeOptions)
+  static async getStats () {
+    const totalLocalVideos = await VideoModel.count({
+      where: {
+        remote: false
+      }
+    })
+    const totalVideos = await VideoModel.count()
 
-  const stats = await statPromise(videoOutputPath)
+    let totalLocalVideoViews = await VideoModel.sum('views', {
+      where: {
+        remote: false
+      }
+    })
+    // Sequelize could return null...
+    if (!totalLocalVideoViews) totalLocalVideoViews = 0
 
-  newVideoFile.set('size', stats.size)
+    return {
+      totalLocalVideos,
+      totalLocalVideoViews,
+      totalVideos
+    }
+  }
 
-  await this.createTorrentAndSetInfoHash(newVideoFile)
+  static incrementViews (id: number, views: number) {
+    return VideoModel.increment('views', {
+      by: views,
+      where: {
+        id
+      }
+    })
+  }
 
-  await newVideoFile.save()
+  // threshold corresponds to how many video the field should have to be returned
+  static async getRandomFieldSamples (field: 'category' | 'channelId', threshold: number, count: number) {
+    const serverActor = await getServerActor()
+    const actorId = serverActor.id
 
-  this.VideoFiles.push(newVideoFile)
-}
+    const scopeOptions: AvailableForListIDsOptions = {
+      serverAccountId: serverActor.Account.id,
+      actorId,
+      includeLocalVideos: true
+    }
 
-getOriginalFileHeight = function (this: VideoInstance) {
-  const originalFilePath = this.getVideoFilePath(this.getOriginalFile())
+    const query: IFindOptions<VideoModel> = {
+      attributes: [ field ],
+      limit: count,
+      group: field,
+      having: Sequelize.where(Sequelize.fn('COUNT', Sequelize.col(field)), {
+        [ Sequelize.Op.gte ]: threshold
+      }) as any, // FIXME: typings
+      order: [ this.sequelize.random() ]
+    }
 
-  return getVideoFileHeight(originalFilePath)
-}
+    return VideoModel.scope({ method: [ ScopeNames.AVAILABLE_FOR_LIST_IDS, scopeOptions ] })
+                     .findAll(query)
+                     .then(rows => rows.map(r => r[ field ]))
+  }
 
-getDescriptionPath = function (this: VideoInstance) {
-  return `/api/${API_VERSION}/videos/${this.uuid}/description`
-}
+  static buildTrendingQuery (trendingDays: number) {
+    return {
+      attributes: [],
+      subQuery: false,
+      model: VideoViewModel,
+      required: false,
+      where: {
+        startDate: {
+          [ Sequelize.Op.gte ]: new Date(new Date().getTime() - (24 * 3600 * 1000) * trendingDays)
+        }
+      }
+    }
+  }
 
-getCategoryLabel = function (this: VideoInstance) {
-  let categoryLabel = VIDEO_CATEGORIES[this.category]
+  private static buildActorWhereWithFilter (filter?: VideoFilter) {
+    if (filter && (filter === 'local' || filter === 'all-local')) {
+      return {
+        serverId: null
+      }
+    }
 
-  // Maybe our pod is not up to date and there are new categories since our version
-  if (!categoryLabel) categoryLabel = 'Misc'
+    return {}
+  }
 
-  return categoryLabel
-}
+  private static async getAvailableForApi (
+    query: IFindOptions<VideoModel>,
+    options: AvailableForListIDsOptions,
+    countVideos = true
+  ) {
+    const idsScope = {
+      method: [
+        ScopeNames.AVAILABLE_FOR_LIST_IDS, options
+      ]
+    }
 
-getLicenceLabel = function (this: VideoInstance) {
-  let licenceLabel = VIDEO_LICENCES[this.licence]
+    // Remove trending sort on count, because it uses a group by
+    const countOptions = Object.assign({}, options, { trendingDays: undefined })
+    const countQuery = Object.assign({}, query, { attributes: undefined, group: undefined })
+    const countScope = {
+      method: [
+        ScopeNames.AVAILABLE_FOR_LIST_IDS, countOptions
+      ]
+    }
 
-  // Maybe our pod is not up to date and there are new licences since our version
-  if (!licenceLabel) licenceLabel = 'Unknown'
+    const [ count, rowsId ] = await Promise.all([
+      countVideos ? VideoModel.scope(countScope).count(countQuery) : Promise.resolve(undefined),
+      VideoModel.scope(idsScope).findAll(query)
+    ])
+    const ids = rowsId.map(r => r.id)
 
-  return licenceLabel
-}
+    if (ids.length === 0) return { data: [], total: count }
 
-getLanguageLabel = function (this: VideoInstance) {
-  // Language is an optional attribute
-  let languageLabel = VIDEO_LANGUAGES[this.language]
-  if (!languageLabel) languageLabel = 'Unknown'
+    // FIXME: typings
+    const apiScope: any[] = [
+      {
+        method: [ ScopeNames.FOR_API, { ids, withFiles: options.withFiles } as ForAPIOptions ]
+      }
+    ]
 
-  return languageLabel
-}
+    if (options.user) {
+      apiScope.push({ method: [ ScopeNames.WITH_USER_HISTORY, options.user.id ] })
+    }
 
-removeThumbnail = function (this: VideoInstance) {
-  const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName())
-  return unlinkPromise(thumbnailPath)
-}
+    const secondQuery = {
+      offset: 0,
+      limit: query.limit,
+      attributes: query.attributes,
+      order: [ // Keep original order
+        Sequelize.literal(
+          ids.map(id => `"VideoModel".id = ${id} DESC`).join(', ')
+        )
+      ]
+    }
+    const rows = await VideoModel.scope(apiScope).findAll(secondQuery)
 
-removePreview = function (this: VideoInstance) {
-  // Same name than video thumbnail
-  return unlinkPromise(CONFIG.STORAGE.PREVIEWS_DIR + this.getPreviewName())
-}
+    return {
+      data: rows,
+      total: count
+    }
+  }
 
-removeFile = function (this: VideoInstance, videoFile: VideoFileInstance) {
-  const filePath = join(CONFIG.STORAGE.VIDEOS_DIR, this.getVideoFilename(videoFile))
-  return unlinkPromise(filePath)
-}
+  static getCategoryLabel (id: number) {
+    return VIDEO_CATEGORIES[ id ] || 'Misc'
+  }
 
-removeTorrent = function (this: VideoInstance, videoFile: VideoFileInstance) {
-  const torrentPath = join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
-  return unlinkPromise(torrentPath)
-}
+  static getLicenceLabel (id: number) {
+    return VIDEO_LICENCES[ id ] || 'Unknown'
+  }
 
-// ------------------------------ STATICS ------------------------------
+  static getLanguageLabel (id: string) {
+    return VIDEO_LANGUAGES[ id ] || 'Unknown'
+  }
 
-generateThumbnailFromData = function (video: VideoInstance, thumbnailData: string) {
-  // Creating the thumbnail for a remote video
+  static getPrivacyLabel (id: number) {
+    return VIDEO_PRIVACIES[ id ] || 'Unknown'
+  }
 
-  const thumbnailName = video.getThumbnailName()
-  const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, thumbnailName)
-  return writeFilePromise(thumbnailPath, Buffer.from(thumbnailData, 'binary')).then(() => {
-    return thumbnailName
-  })
-}
+  static getStateLabel (id: number) {
+    return VIDEO_STATES[ id ] || 'Unknown'
+  }
 
-list = function () {
-  const query = {
-    include: [ Video['sequelize'].models.VideoFile ]
+  static buildWhereIdOrUUID (id: number | string) {
+    return validator.isInt('' + id) ? { id } : { uuid: id }
   }
 
-  return Video.findAll(query)
-}
+  getOriginalFile () {
+    if (Array.isArray(this.VideoFiles) === false) return undefined
 
-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
-    ]
+    // The original file is the file that have the higher resolution
+    return maxBy(this.VideoFiles, file => file.resolution)
   }
 
-  return Video.findAndCountAll(query).then(({ rows, count }) => {
-    return {
-      data: rows,
-      total: count
-    }
-  })
-}
+  getVideoFilename (videoFile: VideoFileModel) {
+    return this.uuid + '-' + videoFile.resolution + videoFile.extname
+  }
 
-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,
-        include: [
-          {
-            model: Video['sequelize'].models.Account,
-            include: [
-              {
-                model: Video['sequelize'].models.Pod,
-                required: false
-              }
-            ]
-          }
-        ]
-      },
-      Video['sequelize'].models.Tag
-    ],
-    where: createBaseVideosWhere()
+  getThumbnailName () {
+    // We always have a copy of the thumbnail
+    const extension = '.jpg'
+    return this.uuid + extension
   }
 
-  return Video.findAndCountAll(query).then(({ rows, count }) => {
-    return {
-      data: rows,
-      total: count
-    }
-  })
-}
+  getPreviewName () {
+    const extension = '.jpg'
+    return this.uuid + extension
+  }
 
-loadByHostAndUUID = function (fromHost: string, uuid: string, t?: Sequelize.Transaction) {
-  const query: Sequelize.FindOptions<VideoAttributes> = {
-    where: {
-      uuid
-    },
-    include: [
-      {
-        model: Video['sequelize'].models.VideoFile
-      },
-      {
-        model: Video['sequelize'].models.VideoChannel,
-        include: [
-          {
-            model: Video['sequelize'].models.Account,
-            include: [
-              {
-                model: Video['sequelize'].models.Pod,
-                required: true,
-                where: {
-                  host: fromHost
-                }
-              }
-            ]
-          }
-        ]
-      }
-    ]
+  getTorrentFileName (videoFile: VideoFileModel) {
+    const extension = '.torrent'
+    return this.uuid + '-' + videoFile.resolution + extension
   }
 
-  if (t !== undefined) query.transaction = t
+  isOwned () {
+    return this.remote === false
+  }
 
-  return Video.findOne(query)
-}
+  createPreview (videoFile: VideoFileModel) {
+    return generateImageFromVideoFile(
+      this.getVideoFilePath(videoFile),
+      CONFIG.STORAGE.PREVIEWS_DIR,
+      this.getPreviewName(),
+      PREVIEWS_SIZE
+    )
+  }
 
-listOwnedAndPopulateAccountAndTags = function () {
-  const query = {
-    where: {
-      remote: false
-    },
-    include: [
-      Video['sequelize'].models.VideoFile,
-      {
-        model: Video['sequelize'].models.VideoChannel,
-        include: [ Video['sequelize'].models.Account ]
-      },
-      Video['sequelize'].models.Tag
-    ]
+  createThumbnail (videoFile: VideoFileModel) {
+    return generateImageFromVideoFile(
+      this.getVideoFilePath(videoFile),
+      CONFIG.STORAGE.THUMBNAILS_DIR,
+      this.getThumbnailName(),
+      THUMBNAILS_SIZE
+    )
   }
 
-  return Video.findAll(query)
-}
+  getTorrentFilePath (videoFile: VideoFileModel) {
+    return join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
+  }
 
-listOwnedByAccount = function (account: string) {
-  const query = {
-    where: {
-      remote: false
-    },
-    include: [
-      {
-        model: Video['sequelize'].models.VideoFile
-      },
-      {
-        model: Video['sequelize'].models.VideoChannel,
-        include: [
-          {
-            model: Video['sequelize'].models.Account,
-            where: {
-              name: account
-            }
-          }
-        ]
-      }
-    ]
+  getVideoFilePath (videoFile: VideoFileModel) {
+    return join(CONFIG.STORAGE.VIDEOS_DIR, this.getVideoFilename(videoFile))
   }
 
-  return Video.findAll(query)
-}
+  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) ]
+    }
 
-load = function (id: number) {
-  return Video.findById(id)
-}
+    const torrent = await createTorrentPromise(this.getVideoFilePath(videoFile), options)
 
-loadByUUID = function (uuid: string, t?: Sequelize.Transaction) {
-  const query: Sequelize.FindOptions<VideoAttributes> = {
-    where: {
-      uuid
-    },
-    include: [ Video['sequelize'].models.VideoFile ]
-  }
+    const filePath = join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
+    logger.info('Creating torrent %s.', filePath)
 
-  if (t !== undefined) query.transaction = t
+    await writeFile(filePath, torrent)
 
-  return Video.findOne(query)
-}
+    const parsedTorrent = parseTorrent(torrent)
+    videoFile.infoHash = parsedTorrent.infoHash
+  }
 
-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 ]
+  getEmbedStaticPath () {
+    return '/videos/embed/' + this.uuid
   }
 
-  if (t !== undefined) query.transaction = t
+  getThumbnailStaticPath () {
+    return join(STATIC_PATHS.THUMBNAILS, this.getThumbnailName())
+  }
 
-  return Video.findOne(query)
-}
+  getPreviewStaticPath () {
+    return join(STATIC_PATHS.PREVIEWS, this.getPreviewName())
+  }
 
-loadLocalVideoByUUID = function (uuid: string, t?: Sequelize.Transaction) {
-  const query: Sequelize.FindOptions<VideoAttributes> = {
-    where: {
-      uuid,
-      remote: false
-    },
-    include: [ Video['sequelize'].models.VideoFile ]
+  toFormattedJSON (options?: VideoFormattingJSONOptions): Video {
+    return videoModelToFormattedJSON(this, options)
   }
 
-  if (t !== undefined) query.transaction = t
+  toFormattedDetailsJSON (): VideoDetails {
+    return videoModelToFormattedDetailsJSON(this)
+  }
 
-  return Video.findOne(query)
-}
+  getFormattedVideoFilesJSON (): VideoFile[] {
+    return videoFilesModelToFormattedJSON(this, this.VideoFiles)
+  }
 
-loadAndPopulateAccount = function (id: number) {
-  const options = {
-    include: [
-      Video['sequelize'].models.VideoFile,
-      {
-        model: Video['sequelize'].models.VideoChannel,
-        include: [ Video['sequelize'].models.Account ]
-      }
-    ]
+  toActivityPubObject (): VideoTorrentObject {
+    return videoModelToActivityPubObject(this)
   }
 
-  return Video.findById(id, options)
-}
+  getTruncatedDescription () {
+    if (!this.description) return null
 
-loadAndPopulateAccountAndPodAndTags = function (id: number) {
-  const options = {
-    include: [
-      {
-        model: Video['sequelize'].models.VideoChannel,
-        include: [
-          {
-            model: Video['sequelize'].models.Account,
-            include: [ { model: Video['sequelize'].models.Pod, required: false } ]
-          }
-        ]
-      },
-      Video['sequelize'].models.Tag,
-      Video['sequelize'].models.VideoFile
-    ]
+    const maxLength = CONSTRAINTS_FIELDS.VIDEOS.TRUNCATED_DESCRIPTION.max
+    return peertubeTruncate(this.description, maxLength)
   }
 
-  return Video.findById(id, options)
-}
+  getOriginalFileResolution () {
+    const originalFilePath = this.getVideoFilePath(this.getOriginalFile())
 
-loadByUUIDAndPopulateAccountAndPodAndTags = function (uuid: string) {
-  const options = {
-    where: {
-      uuid
-    },
-    include: [
-      {
-        model: Video['sequelize'].models.VideoChannel,
-        include: [
-          {
-            model: Video['sequelize'].models.Account,
-            include: [ { model: Video['sequelize'].models.Pod, required: false } ]
-          }
-        ]
-      },
-      Video['sequelize'].models.Tag,
-      Video['sequelize'].models.VideoFile
-    ]
+    return getVideoFileResolution(originalFilePath)
   }
 
-  return Video.findOne(options)
-}
-
-searchAndPopulateAccountAndPodAndTags = function (value: string, field: string, start: number, count: number, sort: string) {
-  const podInclude: Sequelize.IncludeOptions = {
-    model: Video['sequelize'].models.Pod,
-    required: false
+  getDescriptionAPIPath () {
+    return `/api/${API_VERSION}/videos/${this.uuid}/description`
   }
 
-  const accountInclude: Sequelize.IncludeOptions = {
-    model: Video['sequelize'].models.Account,
-    include: [ podInclude ]
+  removeThumbnail () {
+    const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName())
+    return remove(thumbnailPath)
+      .catch(err => logger.warn('Cannot delete thumbnail %s.', thumbnailPath, { err }))
   }
 
-  const videoChannelInclude: Sequelize.IncludeOptions = {
-    model: Video['sequelize'].models.VideoChannel,
-    include: [ accountInclude ],
-    required: true
+  removePreview () {
+    const previewPath = join(CONFIG.STORAGE.PREVIEWS_DIR + this.getPreviewName())
+    return remove(previewPath)
+      .catch(err => logger.warn('Cannot delete preview %s.', previewPath, { err }))
   }
 
-  const tagInclude: Sequelize.IncludeOptions = {
-    model: Video['sequelize'].models.Tag
+  removeFile (videoFile: VideoFileModel) {
+    const filePath = join(CONFIG.STORAGE.VIDEOS_DIR, this.getVideoFilename(videoFile))
+    return remove(filePath)
+      .catch(err => logger.warn('Cannot delete file %s.', filePath, { err }))
   }
 
-  const query: Sequelize.FindOptions<VideoAttributes> = {
-    distinct: true,
-    where: createBaseVideosWhere(),
-    offset: start,
-    limit: count,
-    order: [ getSort(sort), [ Video['sequelize'].models.Tag, 'name', 'ASC' ] ]
+  removeTorrent (videoFile: VideoFileModel) {
+    const torrentPath = join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
+    return remove(torrentPath)
+      .catch(err => logger.warn('Cannot delete torrent %s.', torrentPath, { err }))
   }
 
-  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 pod? (not stored in the database)
-    podInclude.where = {
-      host: {
-        [Sequelize.Op.iLike]: '%' + value + '%'
-      }
-    }
-    podInclude.required = true
-  } else if (field === 'account') {
-    accountInclude.where = {
-      name: {
-        [Sequelize.Op.iLike]: '%' + value + '%'
-      }
-    }
-  } else {
-    query.where[field] = {
-      [Sequelize.Op.iLike]: '%' + value + '%'
-    }
+  isOutdated () {
+    if (this.isOwned()) return false
+
+    const now = Date.now()
+    const createdAtTime = this.createdAt.getTime()
+    const updatedAtTime = this.updatedAt.getTime()
+
+    return (now - createdAtTime) > ACTIVITY_PUB.VIDEO_REFRESH_INTERVAL &&
+      (now - updatedAtTime) > ACTIVITY_PUB.VIDEO_REFRESH_INTERVAL
   }
 
-  query.include = [
-    videoChannelInclude, tagInclude
-  ]
+  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
+  generateMagnetUri (videoFile: VideoFileModel, baseUrlHttp: string, baseUrlWs: string) {
+    const xs = this.getTorrentUrl(videoFile, baseUrlHttp)
+    const announce = [ baseUrlWs + '/tracker/socket', baseUrlHttp + '/tracker/announce' ]
+    let urlList = [ this.getVideoFileUrl(videoFile, baseUrlHttp) ]
 
-  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.Pod.host
-    baseUrlWs = REMOTE_SCHEME.WS + '://' + video.VideoChannel.Account.Pod.host
-  }
+    const redundancies = videoFile.RedundancyVideos
+    if (isArray(redundancies)) urlList = urlList.concat(redundancies.map(r => r.fileUrl))
 
-  return { baseUrlHttp, baseUrlWs }
-}
+    const magnetHash = {
+      xs,
+      announce,
+      urlList,
+      infoHash: videoFile.infoHash,
+      name: this.name
+    }
 
-function getThumbnailUrl (video: VideoInstance, baseUrlHttp: string) {
-  return baseUrlHttp + STATIC_PATHS.THUMBNAILS + video.getThumbnailName()
-}
+    return magnetUtil.encode(magnetHash)
+  }
 
-function getTorrentUrl (video: VideoInstance, videoFile: VideoFileInstance, baseUrlHttp: string) {
-  return baseUrlHttp + STATIC_PATHS.TORRENTS + video.getTorrentFileName(videoFile)
-}
+  getThumbnailUrl (baseUrlHttp: string) {
+    return baseUrlHttp + STATIC_PATHS.THUMBNAILS + this.getThumbnailName()
+  }
 
-function getVideoFileUrl (video: VideoInstance, videoFile: VideoFileInstance, baseUrlHttp: string) {
-  return baseUrlHttp + STATIC_PATHS.WEBSEED + video.getVideoFilename(videoFile)
-}
+  getTorrentUrl (videoFile: VideoFileModel, baseUrlHttp: string) {
+    return baseUrlHttp + STATIC_PATHS.TORRENTS + this.getTorrentFileName(videoFile)
+  }
+
+  getTorrentDownloadUrl (videoFile: VideoFileModel, baseUrlHttp: string) {
+    return baseUrlHttp + STATIC_DOWNLOAD_PATHS.TORRENTS + this.getTorrentFileName(videoFile)
+  }
 
-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
+  getVideoFileUrl (videoFile: VideoFileModel, baseUrlHttp: string) {
+    return baseUrlHttp + STATIC_PATHS.WEBSEED + this.getVideoFilename(videoFile)
   }
 
-  return magnetUtil.encode(magnetHash)
+  getVideoFileDownloadUrl (videoFile: VideoFileModel, baseUrlHttp: string) {
+    return baseUrlHttp + STATIC_DOWNLOAD_PATHS.VIDEOS + this.getVideoFilename(videoFile)
+  }
 }