ab7b49f1e6c9b95d1fd22f3059a11b7822bbbb63
[oweals/peertube.git] / server / models / video / video.ts
1 import * as Bluebird from 'bluebird'
2 import { maxBy } from 'lodash'
3 import * as magnetUtil from 'magnet-uri'
4 import * as parseTorrent from 'parse-torrent'
5 import { join } from 'path'
6 import {
7   CountOptions,
8   FindOptions,
9   IncludeOptions,
10   ModelIndexesOptions,
11   Op,
12   QueryTypes,
13   ScopeOptions,
14   Sequelize,
15   Transaction,
16   WhereOptions
17 } from 'sequelize'
18 import {
19   AllowNull,
20   BeforeDestroy,
21   BelongsTo,
22   BelongsToMany,
23   Column,
24   CreatedAt,
25   DataType,
26   Default,
27   ForeignKey,
28   HasMany,
29   HasOne,
30   Is,
31   IsInt,
32   IsUUID,
33   Min,
34   Model,
35   Scopes,
36   Table,
37   UpdatedAt
38 } from 'sequelize-typescript'
39 import { UserRight, VideoPrivacy, VideoState } from '../../../shared'
40 import { VideoTorrentObject } from '../../../shared/models/activitypub/objects'
41 import { Video, VideoDetails, VideoFile } from '../../../shared/models/videos'
42 import { VideoFilter } from '../../../shared/models/videos/video-query.type'
43 import { peertubeTruncate } from '../../helpers/core-utils'
44 import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
45 import { isArray, isBooleanValid } from '../../helpers/custom-validators/misc'
46 import {
47   isVideoCategoryValid,
48   isVideoDescriptionValid,
49   isVideoDurationValid,
50   isVideoLanguageValid,
51   isVideoLicenceValid,
52   isVideoNameValid,
53   isVideoPrivacyValid,
54   isVideoStateValid,
55   isVideoSupportValid
56 } from '../../helpers/custom-validators/videos'
57 import { getVideoFileResolution } from '../../helpers/ffmpeg-utils'
58 import { logger } from '../../helpers/logger'
59 import { getServerActor } from '../../helpers/utils'
60 import {
61   ACTIVITY_PUB,
62   API_VERSION,
63   CONSTRAINTS_FIELDS,
64   HLS_REDUNDANCY_DIRECTORY,
65   HLS_STREAMING_PLAYLIST_DIRECTORY,
66   LAZY_STATIC_PATHS,
67   REMOTE_SCHEME,
68   STATIC_DOWNLOAD_PATHS,
69   STATIC_PATHS,
70   VIDEO_CATEGORIES,
71   VIDEO_LANGUAGES,
72   VIDEO_LICENCES,
73   VIDEO_PRIVACIES,
74   VIDEO_STATES,
75   WEBSERVER
76 } from '../../initializers/constants'
77 import { sendDeleteVideo } from '../../lib/activitypub/send'
78 import { AccountModel } from '../account/account'
79 import { AccountVideoRateModel } from '../account/account-video-rate'
80 import { ActorModel } from '../activitypub/actor'
81 import { AvatarModel } from '../avatar/avatar'
82 import { ServerModel } from '../server/server'
83 import {
84   buildBlockedAccountSQL,
85   buildTrigramSearchIndex,
86   buildWhereIdOrUUID,
87   createSafeIn,
88   createSimilarityAttribute,
89   getVideoSort,
90   isOutdated,
91   throwIfNotValid
92 } from '../utils'
93 import { TagModel } from './tag'
94 import { VideoAbuseModel } from './video-abuse'
95 import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel } from './video-channel'
96 import { VideoCommentModel } from './video-comment'
97 import { VideoFileModel } from './video-file'
98 import { VideoShareModel } from './video-share'
99 import { VideoTagModel } from './video-tag'
100 import { ScheduleVideoUpdateModel } from './schedule-video-update'
101 import { VideoCaptionModel } from './video-caption'
102 import { VideoBlacklistModel } from './video-blacklist'
103 import { remove, writeFile } from 'fs-extra'
104 import { VideoViewModel } from './video-views'
105 import { VideoRedundancyModel } from '../redundancy/video-redundancy'
106 import {
107   videoFilesModelToFormattedJSON,
108   VideoFormattingJSONOptions,
109   videoModelToActivityPubObject,
110   videoModelToFormattedDetailsJSON,
111   videoModelToFormattedJSON
112 } from './video-format-utils'
113 import { UserVideoHistoryModel } from '../account/user-video-history'
114 import { VideoImportModel } from './video-import'
115 import { VideoStreamingPlaylistModel } from './video-streaming-playlist'
116 import { VideoPlaylistElementModel } from './video-playlist-element'
117 import { CONFIG } from '../../initializers/config'
118 import { ThumbnailModel } from './thumbnail'
119 import { ThumbnailType } from '../../../shared/models/videos/thumbnail.type'
120 import { createTorrentPromise } from '../../helpers/webtorrent'
121 import { VideoStreamingPlaylistType } from '../../../shared/models/videos/video-streaming-playlist.type'
122 import {
123   MChannel,
124   MChannelAccountDefault,
125   MChannelId,
126   MUserAccountId,
127   MUserId,
128   MVideoAccountLight,
129   MVideoAccountLightBlacklistAllFiles,
130   MVideoAP,
131   MVideoDetails,
132   MVideoFormattable,
133   MVideoFormattableDetails,
134   MVideoForUser,
135   MVideoFullLight,
136   MVideoIdThumbnail,
137   MVideoThumbnail,
138   MVideoWithAllFiles,
139   MVideoWithFile,
140   MVideoWithRights
141 } from '../../typings/models'
142 import { MVideoFile, MVideoFileRedundanciesOpt } from '../../typings/models/video/video-file'
143 import { MThumbnail } from '../../typings/models/video/thumbnail'
144
145 // FIXME: Define indexes here because there is an issue with TS and Sequelize.literal when called directly in the annotation
146 const indexes: (ModelIndexesOptions & { where?: WhereOptions })[] = [
147   buildTrigramSearchIndex('video_name_trigram', 'name'),
148
149   { fields: [ 'createdAt' ] },
150   { fields: [ 'publishedAt' ] },
151   { fields: [ 'duration' ] },
152   { fields: [ 'views' ] },
153   { fields: [ 'channelId' ] },
154   {
155     fields: [ 'originallyPublishedAt' ],
156     where: {
157       originallyPublishedAt: {
158         [Op.ne]: null
159       }
160     }
161   },
162   {
163     fields: [ 'category' ], // We don't care videos with an unknown category
164     where: {
165       category: {
166         [Op.ne]: null
167       }
168     }
169   },
170   {
171     fields: [ 'licence' ], // We don't care videos with an unknown licence
172     where: {
173       licence: {
174         [Op.ne]: null
175       }
176     }
177   },
178   {
179     fields: [ 'language' ], // We don't care videos with an unknown language
180     where: {
181       language: {
182         [Op.ne]: null
183       }
184     }
185   },
186   {
187     fields: [ 'nsfw' ], // Most of the videos are not NSFW
188     where: {
189       nsfw: true
190     }
191   },
192   {
193     fields: [ 'remote' ], // Only index local videos
194     where: {
195       remote: false
196     }
197   },
198   {
199     fields: [ 'uuid' ],
200     unique: true
201   },
202   {
203     fields: [ 'url' ],
204     unique: true
205   }
206 ]
207
208 export enum ScopeNames {
209   AVAILABLE_FOR_LIST_IDS = 'AVAILABLE_FOR_LIST_IDS',
210   FOR_API = 'FOR_API',
211   WITH_ACCOUNT_DETAILS = 'WITH_ACCOUNT_DETAILS',
212   WITH_TAGS = 'WITH_TAGS',
213   WITH_FILES = 'WITH_FILES',
214   WITH_SCHEDULED_UPDATE = 'WITH_SCHEDULED_UPDATE',
215   WITH_BLACKLISTED = 'WITH_BLACKLISTED',
216   WITH_BLOCKLIST = 'WITH_BLOCKLIST',
217   WITH_USER_HISTORY = 'WITH_USER_HISTORY',
218   WITH_STREAMING_PLAYLISTS = 'WITH_STREAMING_PLAYLISTS',
219   WITH_USER_ID = 'WITH_USER_ID',
220   WITH_THUMBNAILS = 'WITH_THUMBNAILS'
221 }
222
223 export type ForAPIOptions = {
224   ids?: number[]
225
226   videoPlaylistId?: number
227
228   withFiles?: boolean
229
230   withAccountBlockerIds?: number[]
231 }
232
233 export type AvailableForListIDsOptions = {
234   serverAccountId: number
235   followerActorId: number
236   includeLocalVideos: boolean
237
238   attributesType?: 'none' | 'id' | 'all'
239
240   filter?: VideoFilter
241   categoryOneOf?: number[]
242   nsfw?: boolean
243   licenceOneOf?: number[]
244   languageOneOf?: string[]
245   tagsOneOf?: string[]
246   tagsAllOf?: string[]
247
248   withFiles?: boolean
249
250   accountId?: number
251   videoChannelId?: number
252
253   videoPlaylistId?: number
254
255   trendingDays?: number
256   user?: MUserAccountId
257   historyOfUser?: MUserId
258
259   baseWhere?: WhereOptions[]
260 }
261
262 @Scopes(() => ({
263   [ ScopeNames.FOR_API ]: (options: ForAPIOptions) => {
264     const query: FindOptions = {
265       include: [
266         {
267           model: VideoChannelModel.scope({
268             method: [
269               VideoChannelScopeNames.SUMMARY, {
270                 withAccount: true,
271                 withAccountBlockerIds: options.withAccountBlockerIds
272               } as SummaryOptions
273             ]
274           }),
275           required: true
276         },
277         {
278           attributes: [ 'type', 'filename' ],
279           model: ThumbnailModel,
280           required: false
281         }
282       ]
283     }
284
285     if (options.ids) {
286       query.where = {
287         id: {
288           [ Op.in ]: options.ids // FIXME: sequelize ANY seems broken
289         }
290       }
291     }
292
293     if (options.withFiles === true) {
294       query.include.push({
295         model: VideoFileModel.unscoped(),
296         required: true
297       })
298     }
299
300     if (options.videoPlaylistId) {
301       query.include.push({
302         model: VideoPlaylistElementModel.unscoped(),
303         required: true,
304         where: {
305           videoPlaylistId: options.videoPlaylistId
306         }
307       })
308     }
309
310     return query
311   },
312   [ ScopeNames.AVAILABLE_FOR_LIST_IDS ]: (options: AvailableForListIDsOptions) => {
313     const whereAnd = options.baseWhere ? options.baseWhere : []
314
315     const query: FindOptions = {
316       raw: true,
317       include: []
318     }
319
320     const attributesType = options.attributesType || 'id'
321
322     if (attributesType === 'id') query.attributes = [ 'id' ]
323     else if (attributesType === 'none') query.attributes = [ ]
324
325     whereAnd.push({
326       id: {
327         [ Op.notIn ]: Sequelize.literal(
328           '(SELECT "videoBlacklist"."videoId" FROM "videoBlacklist")'
329         )
330       }
331     })
332
333     if (options.serverAccountId) {
334       whereAnd.push({
335         channelId: {
336           [ Op.notIn ]: Sequelize.literal(
337             '(' +
338               'SELECT id FROM "videoChannel" WHERE "accountId" IN (' +
339                 buildBlockedAccountSQL(options.serverAccountId, options.user ? options.user.Account.id : undefined) +
340               ')' +
341             ')'
342           )
343         }
344       })
345     }
346
347     // Only list public/published videos
348     if (!options.filter || options.filter !== 'all-local') {
349       const privacyWhere = {
350         // Always list public videos
351         privacy: VideoPrivacy.PUBLIC,
352         // Always list published videos, or videos that are being transcoded but on which we don't want to wait for transcoding
353         [ Op.or ]: [
354           {
355             state: VideoState.PUBLISHED
356           },
357           {
358             [ Op.and ]: {
359               state: VideoState.TO_TRANSCODE,
360               waitTranscoding: false
361             }
362           }
363         ]
364       }
365
366       whereAnd.push(privacyWhere)
367     }
368
369     if (options.videoPlaylistId) {
370       query.include.push({
371         attributes: [],
372         model: VideoPlaylistElementModel.unscoped(),
373         required: true,
374         where: {
375           videoPlaylistId: options.videoPlaylistId
376         }
377       })
378
379       query.subQuery = false
380     }
381
382     if (options.filter || options.accountId || options.videoChannelId) {
383       const videoChannelInclude: IncludeOptions = {
384         attributes: [],
385         model: VideoChannelModel.unscoped(),
386         required: true
387       }
388
389       if (options.videoChannelId) {
390         videoChannelInclude.where = {
391           id: options.videoChannelId
392         }
393       }
394
395       if (options.filter || options.accountId) {
396         const accountInclude: IncludeOptions = {
397           attributes: [],
398           model: AccountModel.unscoped(),
399           required: true
400         }
401
402         if (options.filter) {
403           accountInclude.include = [
404             {
405               attributes: [],
406               model: ActorModel.unscoped(),
407               required: true,
408               where: VideoModel.buildActorWhereWithFilter(options.filter)
409             }
410           ]
411         }
412
413         if (options.accountId) {
414           accountInclude.where = { id: options.accountId }
415         }
416
417         videoChannelInclude.include = [ accountInclude ]
418       }
419
420       query.include.push(videoChannelInclude)
421     }
422
423     if (options.followerActorId) {
424       let localVideosReq = ''
425       if (options.includeLocalVideos === true) {
426         localVideosReq = ' UNION ALL ' +
427           'SELECT "video"."id" AS "id" FROM "video" ' +
428           'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' +
429           'INNER JOIN "account" ON "account"."id" = "videoChannel"."accountId" ' +
430           'INNER JOIN "actor" ON "account"."actorId" = "actor"."id" ' +
431           'WHERE "actor"."serverId" IS NULL'
432       }
433
434       // Force actorId to be a number to avoid SQL injections
435       const actorIdNumber = parseInt(options.followerActorId.toString(), 10)
436       whereAnd.push({
437         id: {
438           [ Op.in ]: Sequelize.literal(
439             '(' +
440             'SELECT "videoShare"."videoId" AS "id" FROM "videoShare" ' +
441             'INNER JOIN "actorFollow" ON "actorFollow"."targetActorId" = "videoShare"."actorId" ' +
442             'WHERE "actorFollow"."actorId" = ' + actorIdNumber +
443             ' UNION ALL ' +
444             'SELECT "video"."id" AS "id" FROM "video" ' +
445             'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' +
446             'INNER JOIN "account" ON "account"."id" = "videoChannel"."accountId" ' +
447             'INNER JOIN "actor" ON "account"."actorId" = "actor"."id" ' +
448             'INNER JOIN "actorFollow" ON "actorFollow"."targetActorId" = "actor"."id" ' +
449             'WHERE "actorFollow"."actorId" = ' + actorIdNumber +
450             localVideosReq +
451             ')'
452           )
453         }
454       })
455     }
456
457     if (options.withFiles === true) {
458       whereAnd.push({
459         id: {
460           [ Op.in ]: Sequelize.literal(
461             '(SELECT "videoId" FROM "videoFile")'
462           )
463         }
464       })
465     }
466
467     // FIXME: issues with sequelize count when making a join on n:m relation, so we just make a IN()
468     if (options.tagsAllOf || options.tagsOneOf) {
469       if (options.tagsOneOf) {
470         whereAnd.push({
471           id: {
472             [ Op.in ]: Sequelize.literal(
473               '(' +
474               'SELECT "videoId" FROM "videoTag" ' +
475               'INNER JOIN "tag" ON "tag"."id" = "videoTag"."tagId" ' +
476               'WHERE "tag"."name" IN (' + createSafeIn(VideoModel, options.tagsOneOf) + ')' +
477               ')'
478             )
479           }
480         })
481       }
482
483       if (options.tagsAllOf) {
484         whereAnd.push({
485           id: {
486             [ Op.in ]: Sequelize.literal(
487               '(' +
488               'SELECT "videoId" FROM "videoTag" ' +
489               'INNER JOIN "tag" ON "tag"."id" = "videoTag"."tagId" ' +
490               'WHERE "tag"."name" IN (' + createSafeIn(VideoModel, options.tagsAllOf) + ')' +
491               'GROUP BY "videoTag"."videoId" HAVING COUNT(*) = ' + options.tagsAllOf.length +
492               ')'
493             )
494           }
495         })
496       }
497     }
498
499     if (options.nsfw === true || options.nsfw === false) {
500       whereAnd.push({ nsfw: options.nsfw })
501     }
502
503     if (options.categoryOneOf) {
504       whereAnd.push({
505         category: {
506           [ Op.or ]: options.categoryOneOf
507         }
508       })
509     }
510
511     if (options.licenceOneOf) {
512       whereAnd.push({
513         licence: {
514           [ Op.or ]: options.licenceOneOf
515         }
516       })
517     }
518
519     if (options.languageOneOf) {
520       let videoLanguages = options.languageOneOf
521       if (options.languageOneOf.find(l => l === '_unknown')) {
522         videoLanguages = videoLanguages.concat([ null ])
523       }
524
525       whereAnd.push({
526         [Op.or]: [
527           {
528             language: {
529               [ Op.or ]: videoLanguages
530             }
531           },
532           {
533             id: {
534               [ Op.in ]: Sequelize.literal(
535                 '(' +
536                 'SELECT "videoId" FROM "videoCaption" ' +
537                 'WHERE "language" IN (' + createSafeIn(VideoModel, options.languageOneOf) + ') ' +
538                 ')'
539               )
540             }
541           }
542         ]
543       })
544     }
545
546     if (options.trendingDays) {
547       query.include.push(VideoModel.buildTrendingQuery(options.trendingDays))
548
549       query.subQuery = false
550     }
551
552     if (options.historyOfUser) {
553       query.include.push({
554         model: UserVideoHistoryModel,
555         required: true,
556         where: {
557           userId: options.historyOfUser.id
558         }
559       })
560
561       // Even if the relation is n:m, we know that a user only have 0..1 video history
562       // So we won't have multiple rows for the same video
563       // Without this, we would not be able to sort on "updatedAt" column of UserVideoHistoryModel
564       query.subQuery = false
565     }
566
567     query.where = {
568       [ Op.and ]: whereAnd
569     }
570
571     return query
572   },
573   [ScopeNames.WITH_BLOCKLIST]: {
574
575   },
576   [ ScopeNames.WITH_THUMBNAILS ]: {
577     include: [
578       {
579         model: ThumbnailModel,
580         required: false
581       }
582     ]
583   },
584   [ ScopeNames.WITH_USER_ID ]: {
585     include: [
586       {
587         attributes: [ 'accountId' ],
588         model: VideoChannelModel.unscoped(),
589         required: true,
590         include: [
591           {
592             attributes: [ 'userId' ],
593             model: AccountModel.unscoped(),
594             required: true
595           }
596         ]
597       }
598     ]
599   },
600   [ ScopeNames.WITH_ACCOUNT_DETAILS ]: {
601     include: [
602       {
603         model: VideoChannelModel.unscoped(),
604         required: true,
605         include: [
606           {
607             attributes: {
608               exclude: [ 'privateKey', 'publicKey' ]
609             },
610             model: ActorModel.unscoped(),
611             required: true,
612             include: [
613               {
614                 attributes: [ 'host' ],
615                 model: ServerModel.unscoped(),
616                 required: false
617               },
618               {
619                 model: AvatarModel.unscoped(),
620                 required: false
621               }
622             ]
623           },
624           {
625             model: AccountModel.unscoped(),
626             required: true,
627             include: [
628               {
629                 model: ActorModel.unscoped(),
630                 attributes: {
631                   exclude: [ 'privateKey', 'publicKey' ]
632                 },
633                 required: true,
634                 include: [
635                   {
636                     attributes: [ 'host' ],
637                     model: ServerModel.unscoped(),
638                     required: false
639                   },
640                   {
641                     model: AvatarModel.unscoped(),
642                     required: false
643                   }
644                 ]
645               }
646             ]
647           }
648         ]
649       }
650     ]
651   },
652   [ ScopeNames.WITH_TAGS ]: {
653     include: [ TagModel ]
654   },
655   [ ScopeNames.WITH_BLACKLISTED ]: {
656     include: [
657       {
658         attributes: [ 'id', 'reason', 'unfederated' ],
659         model: VideoBlacklistModel,
660         required: false
661       }
662     ]
663   },
664   [ ScopeNames.WITH_FILES ]: (withRedundancies = false) => {
665     let subInclude: any[] = []
666
667     if (withRedundancies === true) {
668       subInclude = [
669         {
670           attributes: [ 'fileUrl' ],
671           model: VideoRedundancyModel.unscoped(),
672           required: false
673         }
674       ]
675     }
676
677     return {
678       include: [
679         {
680           model: VideoFileModel.unscoped(),
681           separate: true, // We may have multiple files, having multiple redundancies so let's separate this join
682           required: false,
683           include: subInclude
684         }
685       ]
686     }
687   },
688   [ ScopeNames.WITH_STREAMING_PLAYLISTS ]: (withRedundancies = false) => {
689     let subInclude: any[] = []
690
691     if (withRedundancies === true) {
692       subInclude = [
693         {
694           attributes: [ 'fileUrl' ],
695           model: VideoRedundancyModel.unscoped(),
696           required: false
697         }
698       ]
699     }
700
701     return {
702       include: [
703         {
704           model: VideoStreamingPlaylistModel.unscoped(),
705           separate: true, // We may have multiple streaming playlists, having multiple redundancies so let's separate this join
706           required: false,
707           include: subInclude
708         }
709       ]
710     }
711   },
712   [ ScopeNames.WITH_SCHEDULED_UPDATE ]: {
713     include: [
714       {
715         model: ScheduleVideoUpdateModel.unscoped(),
716         required: false
717       }
718     ]
719   },
720   [ ScopeNames.WITH_USER_HISTORY ]: (userId: number) => {
721     return {
722       include: [
723         {
724           attributes: [ 'currentTime' ],
725           model: UserVideoHistoryModel.unscoped(),
726           required: false,
727           where: {
728             userId
729           }
730         }
731       ]
732     }
733   }
734 }))
735 @Table({
736   tableName: 'video',
737   indexes
738 })
739 export class VideoModel extends Model<VideoModel> {
740
741   @AllowNull(false)
742   @Default(DataType.UUIDV4)
743   @IsUUID(4)
744   @Column(DataType.UUID)
745   uuid: string
746
747   @AllowNull(false)
748   @Is('VideoName', value => throwIfNotValid(value, isVideoNameValid, 'name'))
749   @Column
750   name: string
751
752   @AllowNull(true)
753   @Default(null)
754   @Is('VideoCategory', value => throwIfNotValid(value, isVideoCategoryValid, 'category', true))
755   @Column
756   category: number
757
758   @AllowNull(true)
759   @Default(null)
760   @Is('VideoLicence', value => throwIfNotValid(value, isVideoLicenceValid, 'licence', true))
761   @Column
762   licence: number
763
764   @AllowNull(true)
765   @Default(null)
766   @Is('VideoLanguage', value => throwIfNotValid(value, isVideoLanguageValid, 'language', true))
767   @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.LANGUAGE.max))
768   language: string
769
770   @AllowNull(false)
771   @Is('VideoPrivacy', value => throwIfNotValid(value, isVideoPrivacyValid, 'privacy'))
772   @Column
773   privacy: number
774
775   @AllowNull(false)
776   @Is('VideoNSFW', value => throwIfNotValid(value, isBooleanValid, 'NSFW boolean'))
777   @Column
778   nsfw: boolean
779
780   @AllowNull(true)
781   @Default(null)
782   @Is('VideoDescription', value => throwIfNotValid(value, isVideoDescriptionValid, 'description', true))
783   @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.DESCRIPTION.max))
784   description: string
785
786   @AllowNull(true)
787   @Default(null)
788   @Is('VideoSupport', value => throwIfNotValid(value, isVideoSupportValid, 'support', true))
789   @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.SUPPORT.max))
790   support: string
791
792   @AllowNull(false)
793   @Is('VideoDuration', value => throwIfNotValid(value, isVideoDurationValid, 'duration'))
794   @Column
795   duration: number
796
797   @AllowNull(false)
798   @Default(0)
799   @IsInt
800   @Min(0)
801   @Column
802   views: number
803
804   @AllowNull(false)
805   @Default(0)
806   @IsInt
807   @Min(0)
808   @Column
809   likes: number
810
811   @AllowNull(false)
812   @Default(0)
813   @IsInt
814   @Min(0)
815   @Column
816   dislikes: number
817
818   @AllowNull(false)
819   @Column
820   remote: boolean
821
822   @AllowNull(false)
823   @Is('VideoUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url'))
824   @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.URL.max))
825   url: string
826
827   @AllowNull(false)
828   @Column
829   commentsEnabled: boolean
830
831   @AllowNull(false)
832   @Column
833   downloadEnabled: boolean
834
835   @AllowNull(false)
836   @Column
837   waitTranscoding: boolean
838
839   @AllowNull(false)
840   @Default(null)
841   @Is('VideoState', value => throwIfNotValid(value, isVideoStateValid, 'state'))
842   @Column
843   state: VideoState
844
845   @CreatedAt
846   createdAt: Date
847
848   @UpdatedAt
849   updatedAt: Date
850
851   @AllowNull(false)
852   @Default(DataType.NOW)
853   @Column
854   publishedAt: Date
855
856   @AllowNull(true)
857   @Default(null)
858   @Column
859   originallyPublishedAt: Date
860
861   @ForeignKey(() => VideoChannelModel)
862   @Column
863   channelId: number
864
865   @BelongsTo(() => VideoChannelModel, {
866     foreignKey: {
867       allowNull: true
868     },
869     hooks: true
870   })
871   VideoChannel: VideoChannelModel
872
873   @BelongsToMany(() => TagModel, {
874     foreignKey: 'videoId',
875     through: () => VideoTagModel,
876     onDelete: 'CASCADE'
877   })
878   Tags: TagModel[]
879
880   @HasMany(() => ThumbnailModel, {
881     foreignKey: {
882       name: 'videoId',
883       allowNull: true
884     },
885     hooks: true,
886     onDelete: 'cascade'
887   })
888   Thumbnails: ThumbnailModel[]
889
890   @HasMany(() => VideoPlaylistElementModel, {
891     foreignKey: {
892       name: 'videoId',
893       allowNull: true
894     },
895     onDelete: 'set null'
896   })
897   VideoPlaylistElements: VideoPlaylistElementModel[]
898
899   @HasMany(() => VideoAbuseModel, {
900     foreignKey: {
901       name: 'videoId',
902       allowNull: false
903     },
904     onDelete: 'cascade'
905   })
906   VideoAbuses: VideoAbuseModel[]
907
908   @HasMany(() => VideoFileModel, {
909     foreignKey: {
910       name: 'videoId',
911       allowNull: false
912     },
913     hooks: true,
914     onDelete: 'cascade'
915   })
916   VideoFiles: VideoFileModel[]
917
918   @HasMany(() => VideoStreamingPlaylistModel, {
919     foreignKey: {
920       name: 'videoId',
921       allowNull: false
922     },
923     hooks: true,
924     onDelete: 'cascade'
925   })
926   VideoStreamingPlaylists: VideoStreamingPlaylistModel[]
927
928   @HasMany(() => VideoShareModel, {
929     foreignKey: {
930       name: 'videoId',
931       allowNull: false
932     },
933     onDelete: 'cascade'
934   })
935   VideoShares: VideoShareModel[]
936
937   @HasMany(() => AccountVideoRateModel, {
938     foreignKey: {
939       name: 'videoId',
940       allowNull: false
941     },
942     onDelete: 'cascade'
943   })
944   AccountVideoRates: AccountVideoRateModel[]
945
946   @HasMany(() => VideoCommentModel, {
947     foreignKey: {
948       name: 'videoId',
949       allowNull: false
950     },
951     onDelete: 'cascade',
952     hooks: true
953   })
954   VideoComments: VideoCommentModel[]
955
956   @HasMany(() => VideoViewModel, {
957     foreignKey: {
958       name: 'videoId',
959       allowNull: false
960     },
961     onDelete: 'cascade'
962   })
963   VideoViews: VideoViewModel[]
964
965   @HasMany(() => UserVideoHistoryModel, {
966     foreignKey: {
967       name: 'videoId',
968       allowNull: false
969     },
970     onDelete: 'cascade'
971   })
972   UserVideoHistories: UserVideoHistoryModel[]
973
974   @HasOne(() => ScheduleVideoUpdateModel, {
975     foreignKey: {
976       name: 'videoId',
977       allowNull: false
978     },
979     onDelete: 'cascade'
980   })
981   ScheduleVideoUpdate: ScheduleVideoUpdateModel
982
983   @HasOne(() => VideoBlacklistModel, {
984     foreignKey: {
985       name: 'videoId',
986       allowNull: false
987     },
988     onDelete: 'cascade'
989   })
990   VideoBlacklist: VideoBlacklistModel
991
992   @HasOne(() => VideoImportModel, {
993     foreignKey: {
994       name: 'videoId',
995       allowNull: true
996     },
997     onDelete: 'set null'
998   })
999   VideoImport: VideoImportModel
1000
1001   @HasMany(() => VideoCaptionModel, {
1002     foreignKey: {
1003       name: 'videoId',
1004       allowNull: false
1005     },
1006     onDelete: 'cascade',
1007     hooks: true,
1008     [ 'separate' as any ]: true
1009   })
1010   VideoCaptions: VideoCaptionModel[]
1011
1012   @BeforeDestroy
1013   static async sendDelete (instance: MVideoAccountLight, options) {
1014     if (instance.isOwned()) {
1015       if (!instance.VideoChannel) {
1016         instance.VideoChannel = await instance.$get('VideoChannel', {
1017           include: [
1018             ActorModel,
1019             AccountModel
1020           ],
1021           transaction: options.transaction
1022         }) as MChannelAccountDefault
1023       }
1024
1025       return sendDeleteVideo(instance, options.transaction)
1026     }
1027
1028     return undefined
1029   }
1030
1031   @BeforeDestroy
1032   static async removeFiles (instance: VideoModel) {
1033     const tasks: Promise<any>[] = []
1034
1035     logger.info('Removing files of video %s.', instance.url)
1036
1037     if (instance.isOwned()) {
1038       if (!Array.isArray(instance.VideoFiles)) {
1039         instance.VideoFiles = await instance.$get('VideoFiles') as VideoFileModel[]
1040       }
1041
1042       // Remove physical files and torrents
1043       instance.VideoFiles.forEach(file => {
1044         tasks.push(instance.removeFile(file))
1045         tasks.push(instance.removeTorrent(file))
1046       })
1047
1048       // Remove playlists file
1049       tasks.push(instance.removeStreamingPlaylist())
1050     }
1051
1052     // Do not wait video deletion because we could be in a transaction
1053     Promise.all(tasks)
1054            .catch(err => {
1055              logger.error('Some errors when removing files of video %s in before destroy hook.', instance.uuid, { err })
1056            })
1057
1058     return undefined
1059   }
1060
1061   static listLocal (): Bluebird<MVideoWithAllFiles[]> {
1062     const query = {
1063       where: {
1064         remote: false
1065       }
1066     }
1067
1068     return VideoModel.scope([
1069       ScopeNames.WITH_FILES,
1070       ScopeNames.WITH_STREAMING_PLAYLISTS,
1071       ScopeNames.WITH_THUMBNAILS
1072     ]).findAll(query)
1073   }
1074
1075   static listAllAndSharedByActorForOutbox (actorId: number, start: number, count: number) {
1076     function getRawQuery (select: string) {
1077       const queryVideo = 'SELECT ' + select + ' FROM "video" AS "Video" ' +
1078         'INNER JOIN "videoChannel" AS "VideoChannel" ON "VideoChannel"."id" = "Video"."channelId" ' +
1079         'INNER JOIN "account" AS "Account" ON "Account"."id" = "VideoChannel"."accountId" ' +
1080         'WHERE "Account"."actorId" = ' + actorId
1081       const queryVideoShare = 'SELECT ' + select + ' FROM "videoShare" AS "VideoShare" ' +
1082         'INNER JOIN "video" AS "Video" ON "Video"."id" = "VideoShare"."videoId" ' +
1083         'WHERE "VideoShare"."actorId" = ' + actorId
1084
1085       return `(${queryVideo}) UNION (${queryVideoShare})`
1086     }
1087
1088     const rawQuery = getRawQuery('"Video"."id"')
1089     const rawCountQuery = getRawQuery('COUNT("Video"."id") as "total"')
1090
1091     const query = {
1092       distinct: true,
1093       offset: start,
1094       limit: count,
1095       order: getVideoSort('createdAt', [ 'Tags', 'name', 'ASC' ] as any), // FIXME: sequelize typings
1096       where: {
1097         id: {
1098           [ Op.in ]: Sequelize.literal('(' + rawQuery + ')')
1099         },
1100         [ Op.or ]: [
1101           { privacy: VideoPrivacy.PUBLIC },
1102           { privacy: VideoPrivacy.UNLISTED }
1103         ]
1104       },
1105       include: [
1106         {
1107           attributes: [ 'language' ],
1108           model: VideoCaptionModel.unscoped(),
1109           required: false
1110         },
1111         {
1112           attributes: [ 'id', 'url' ],
1113           model: VideoShareModel.unscoped(),
1114           required: false,
1115           // We only want videos shared by this actor
1116           where: {
1117             [ Op.and ]: [
1118               {
1119                 id: {
1120                   [ Op.not ]: null
1121                 }
1122               },
1123               {
1124                 actorId
1125               }
1126             ]
1127           },
1128           include: [
1129             {
1130               attributes: [ 'id', 'url' ],
1131               model: ActorModel.unscoped()
1132             }
1133           ]
1134         },
1135         {
1136           model: VideoChannelModel.unscoped(),
1137           required: true,
1138           include: [
1139             {
1140               attributes: [ 'name' ],
1141               model: AccountModel.unscoped(),
1142               required: true,
1143               include: [
1144                 {
1145                   attributes: [ 'id', 'url', 'followersUrl' ],
1146                   model: ActorModel.unscoped(),
1147                   required: true
1148                 }
1149               ]
1150             },
1151             {
1152               attributes: [ 'id', 'url', 'followersUrl' ],
1153               model: ActorModel.unscoped(),
1154               required: true
1155             }
1156           ]
1157         },
1158         VideoFileModel,
1159         TagModel
1160       ]
1161     }
1162
1163     return Bluebird.all([
1164       VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findAll(query),
1165       VideoModel.sequelize.query<{ total: string }>(rawCountQuery, { type: QueryTypes.SELECT })
1166     ]).then(([ rows, totals ]) => {
1167       // totals: totalVideos + totalVideoShares
1168       let totalVideos = 0
1169       let totalVideoShares = 0
1170       if (totals[ 0 ]) totalVideos = parseInt(totals[ 0 ].total, 10)
1171       if (totals[ 1 ]) totalVideoShares = parseInt(totals[ 1 ].total, 10)
1172
1173       const total = totalVideos + totalVideoShares
1174       return {
1175         data: rows,
1176         total: total
1177       }
1178     })
1179   }
1180
1181   static listUserVideosForApi (accountId: number, start: number, count: number, sort: string) {
1182     function buildBaseQuery (): FindOptions {
1183       return {
1184         offset: start,
1185         limit: count,
1186         order: getVideoSort(sort),
1187         include: [
1188           {
1189             model: VideoChannelModel,
1190             required: true,
1191             include: [
1192               {
1193                 model: AccountModel,
1194                 where: {
1195                   id: accountId
1196                 },
1197                 required: true
1198               }
1199             ]
1200           }
1201         ]
1202       }
1203     }
1204
1205     const countQuery = buildBaseQuery()
1206     const findQuery = buildBaseQuery()
1207
1208     const findScopes = [
1209       ScopeNames.WITH_SCHEDULED_UPDATE,
1210       ScopeNames.WITH_BLACKLISTED,
1211       ScopeNames.WITH_THUMBNAILS
1212     ]
1213
1214     return Promise.all([
1215       VideoModel.count(countQuery),
1216       VideoModel.scope(findScopes).findAll<MVideoForUser>(findQuery)
1217     ]).then(([ count, rows ]) => {
1218       return {
1219         data: rows,
1220         total: count
1221       }
1222     })
1223   }
1224
1225   static async listForApi (options: {
1226     start: number,
1227     count: number,
1228     sort: string,
1229     nsfw: boolean,
1230     includeLocalVideos: boolean,
1231     withFiles: boolean,
1232     categoryOneOf?: number[],
1233     licenceOneOf?: number[],
1234     languageOneOf?: string[],
1235     tagsOneOf?: string[],
1236     tagsAllOf?: string[],
1237     filter?: VideoFilter,
1238     accountId?: number,
1239     videoChannelId?: number,
1240     followerActorId?: number
1241     videoPlaylistId?: number,
1242     trendingDays?: number,
1243     user?: MUserAccountId,
1244     historyOfUser?: MUserId
1245   }, countVideos = true) {
1246     if (options.filter && options.filter === 'all-local' && !options.user.hasRight(UserRight.SEE_ALL_VIDEOS)) {
1247       throw new Error('Try to filter all-local but no user has not the see all videos right')
1248     }
1249
1250     const query: FindOptions & { where?: null } = {
1251       offset: options.start,
1252       limit: options.count,
1253       order: getVideoSort(options.sort)
1254     }
1255
1256     let trendingDays: number
1257     if (options.sort.endsWith('trending')) {
1258       trendingDays = CONFIG.TRENDING.VIDEOS.INTERVAL_DAYS
1259
1260       query.group = 'VideoModel.id'
1261     }
1262
1263     const serverActor = await getServerActor()
1264
1265     // followerActorId === null has a meaning, so just check undefined
1266     const followerActorId = options.followerActorId !== undefined ? options.followerActorId : serverActor.id
1267
1268     const queryOptions = {
1269       followerActorId,
1270       serverAccountId: serverActor.Account.id,
1271       nsfw: options.nsfw,
1272       categoryOneOf: options.categoryOneOf,
1273       licenceOneOf: options.licenceOneOf,
1274       languageOneOf: options.languageOneOf,
1275       tagsOneOf: options.tagsOneOf,
1276       tagsAllOf: options.tagsAllOf,
1277       filter: options.filter,
1278       withFiles: options.withFiles,
1279       accountId: options.accountId,
1280       videoChannelId: options.videoChannelId,
1281       videoPlaylistId: options.videoPlaylistId,
1282       includeLocalVideos: options.includeLocalVideos,
1283       user: options.user,
1284       historyOfUser: options.historyOfUser,
1285       trendingDays
1286     }
1287
1288     return VideoModel.getAvailableForApi(query, queryOptions, countVideos)
1289   }
1290
1291   static async searchAndPopulateAccountAndServer (options: {
1292     includeLocalVideos: boolean
1293     search?: string
1294     start?: number
1295     count?: number
1296     sort?: string
1297     startDate?: string // ISO 8601
1298     endDate?: string // ISO 8601
1299     originallyPublishedStartDate?: string
1300     originallyPublishedEndDate?: string
1301     nsfw?: boolean
1302     categoryOneOf?: number[]
1303     licenceOneOf?: number[]
1304     languageOneOf?: string[]
1305     tagsOneOf?: string[]
1306     tagsAllOf?: string[]
1307     durationMin?: number // seconds
1308     durationMax?: number // seconds
1309     user?: MUserAccountId,
1310     filter?: VideoFilter
1311   }) {
1312     const whereAnd = []
1313
1314     if (options.startDate || options.endDate) {
1315       const publishedAtRange = {}
1316
1317       if (options.startDate) publishedAtRange[ Op.gte ] = options.startDate
1318       if (options.endDate) publishedAtRange[ Op.lte ] = options.endDate
1319
1320       whereAnd.push({ publishedAt: publishedAtRange })
1321     }
1322
1323     if (options.originallyPublishedStartDate || options.originallyPublishedEndDate) {
1324       const originallyPublishedAtRange = {}
1325
1326       if (options.originallyPublishedStartDate) originallyPublishedAtRange[ Op.gte ] = options.originallyPublishedStartDate
1327       if (options.originallyPublishedEndDate) originallyPublishedAtRange[ Op.lte ] = options.originallyPublishedEndDate
1328
1329       whereAnd.push({ originallyPublishedAt: originallyPublishedAtRange })
1330     }
1331
1332     if (options.durationMin || options.durationMax) {
1333       const durationRange = {}
1334
1335       if (options.durationMin) durationRange[ Op.gte ] = options.durationMin
1336       if (options.durationMax) durationRange[ Op.lte ] = options.durationMax
1337
1338       whereAnd.push({ duration: durationRange })
1339     }
1340
1341     const attributesInclude = []
1342     const escapedSearch = VideoModel.sequelize.escape(options.search)
1343     const escapedLikeSearch = VideoModel.sequelize.escape('%' + options.search + '%')
1344     if (options.search) {
1345       whereAnd.push(
1346         {
1347           id: {
1348             [ Op.in ]: Sequelize.literal(
1349               '(' +
1350               'SELECT "video"."id" FROM "video" ' +
1351               'WHERE ' +
1352               'lower(immutable_unaccent("video"."name")) % lower(immutable_unaccent(' + escapedSearch + ')) OR ' +
1353               'lower(immutable_unaccent("video"."name")) LIKE lower(immutable_unaccent(' + escapedLikeSearch + '))' +
1354               'UNION ALL ' +
1355               'SELECT "video"."id" FROM "video" LEFT JOIN "videoTag" ON "videoTag"."videoId" = "video"."id" ' +
1356               'INNER JOIN "tag" ON "tag"."id" = "videoTag"."tagId" ' +
1357               'WHERE "tag"."name" = ' + escapedSearch +
1358               ')'
1359             )
1360           }
1361         }
1362       )
1363
1364       attributesInclude.push(createSimilarityAttribute('VideoModel.name', options.search))
1365     }
1366
1367     // Cannot search on similarity if we don't have a search
1368     if (!options.search) {
1369       attributesInclude.push(
1370         Sequelize.literal('0 as similarity')
1371       )
1372     }
1373
1374     const query = {
1375       attributes: {
1376         include: attributesInclude
1377       },
1378       offset: options.start,
1379       limit: options.count,
1380       order: getVideoSort(options.sort)
1381     }
1382
1383     const serverActor = await getServerActor()
1384     const queryOptions = {
1385       followerActorId: serverActor.id,
1386       serverAccountId: serverActor.Account.id,
1387       includeLocalVideos: options.includeLocalVideos,
1388       nsfw: options.nsfw,
1389       categoryOneOf: options.categoryOneOf,
1390       licenceOneOf: options.licenceOneOf,
1391       languageOneOf: options.languageOneOf,
1392       tagsOneOf: options.tagsOneOf,
1393       tagsAllOf: options.tagsAllOf,
1394       user: options.user,
1395       filter: options.filter,
1396       baseWhere: whereAnd
1397     }
1398
1399     return VideoModel.getAvailableForApi(query, queryOptions)
1400   }
1401
1402   static load (id: number | string, t?: Transaction): Bluebird<MVideoThumbnail> {
1403     const where = buildWhereIdOrUUID(id)
1404     const options = {
1405       where,
1406       transaction: t
1407     }
1408
1409     return VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findOne(options)
1410   }
1411
1412   static loadWithRights (id: number | string, t?: Transaction): Bluebird<MVideoWithRights> {
1413     const where = buildWhereIdOrUUID(id)
1414     const options = {
1415       where,
1416       transaction: t
1417     }
1418
1419     return VideoModel.scope([
1420       ScopeNames.WITH_BLACKLISTED,
1421       ScopeNames.WITH_USER_ID,
1422       ScopeNames.WITH_THUMBNAILS
1423     ]).findOne(options)
1424   }
1425
1426   static loadOnlyId (id: number | string, t?: Transaction): Bluebird<MVideoIdThumbnail> {
1427     const where = buildWhereIdOrUUID(id)
1428
1429     const options = {
1430       attributes: [ 'id' ],
1431       where,
1432       transaction: t
1433     }
1434
1435     return VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findOne(options)
1436   }
1437
1438   static loadWithFiles (id: number | string, t?: Transaction, logging?: boolean): Bluebird<MVideoWithAllFiles> {
1439     const where = buildWhereIdOrUUID(id)
1440
1441     const query = {
1442       where,
1443       transaction: t,
1444       logging
1445     }
1446
1447     return VideoModel.scope([
1448       ScopeNames.WITH_FILES,
1449       ScopeNames.WITH_STREAMING_PLAYLISTS,
1450       ScopeNames.WITH_THUMBNAILS
1451     ]).findOne(query)
1452   }
1453
1454   static loadByUUID (uuid: string): Bluebird<MVideoThumbnail> {
1455     const options = {
1456       where: {
1457         uuid
1458       }
1459     }
1460
1461     return VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findOne(options)
1462   }
1463
1464   static loadByUrl (url: string, transaction?: Transaction): Bluebird<MVideoThumbnail> {
1465     const query: FindOptions = {
1466       where: {
1467         url
1468       },
1469       transaction
1470     }
1471
1472     return VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findOne(query)
1473   }
1474
1475   static loadByUrlAndPopulateAccount (url: string, transaction?: Transaction): Bluebird<MVideoAccountLightBlacklistAllFiles> {
1476     const query: FindOptions = {
1477       where: {
1478         url
1479       },
1480       transaction
1481     }
1482
1483     return VideoModel.scope([
1484       ScopeNames.WITH_ACCOUNT_DETAILS,
1485       ScopeNames.WITH_FILES,
1486       ScopeNames.WITH_STREAMING_PLAYLISTS,
1487       ScopeNames.WITH_THUMBNAILS,
1488       ScopeNames.WITH_BLACKLISTED
1489     ]).findOne(query)
1490   }
1491
1492   static loadAndPopulateAccountAndServerAndTags (id: number | string, t?: Transaction, userId?: number): Bluebird<MVideoFullLight> {
1493     const where = buildWhereIdOrUUID(id)
1494
1495     const options = {
1496       order: [ [ 'Tags', 'name', 'ASC' ] ] as any,
1497       where,
1498       transaction: t
1499     }
1500
1501     const scopes: (string | ScopeOptions)[] = [
1502       ScopeNames.WITH_TAGS,
1503       ScopeNames.WITH_BLACKLISTED,
1504       ScopeNames.WITH_ACCOUNT_DETAILS,
1505       ScopeNames.WITH_SCHEDULED_UPDATE,
1506       ScopeNames.WITH_FILES,
1507       ScopeNames.WITH_STREAMING_PLAYLISTS,
1508       ScopeNames.WITH_THUMBNAILS
1509     ]
1510
1511     if (userId) {
1512       scopes.push({ method: [ ScopeNames.WITH_USER_HISTORY, userId ] })
1513     }
1514
1515     return VideoModel
1516       .scope(scopes)
1517       .findOne(options)
1518   }
1519
1520   static loadForGetAPI (parameters: {
1521     id: number | string,
1522     t?: Transaction,
1523     userId?: number
1524   }): Bluebird<MVideoDetails> {
1525     const { id, t, userId } = parameters
1526     const where = buildWhereIdOrUUID(id)
1527
1528     const options = {
1529       order: [ [ 'Tags', 'name', 'ASC' ] ] as any, // FIXME: sequelize typings
1530       where,
1531       transaction: t
1532     }
1533
1534     const scopes: (string | ScopeOptions)[] = [
1535       ScopeNames.WITH_TAGS,
1536       ScopeNames.WITH_BLACKLISTED,
1537       ScopeNames.WITH_ACCOUNT_DETAILS,
1538       ScopeNames.WITH_SCHEDULED_UPDATE,
1539       ScopeNames.WITH_THUMBNAILS,
1540       { method: [ ScopeNames.WITH_FILES, true ] },
1541       { method: [ ScopeNames.WITH_STREAMING_PLAYLISTS, true ] }
1542     ]
1543
1544     if (userId) {
1545       scopes.push({ method: [ ScopeNames.WITH_USER_HISTORY, userId ] })
1546     }
1547
1548     return VideoModel
1549       .scope(scopes)
1550       .findOne(options)
1551   }
1552
1553   static async getStats () {
1554     const totalLocalVideos = await VideoModel.count({
1555       where: {
1556         remote: false
1557       }
1558     })
1559     const totalVideos = await VideoModel.count()
1560
1561     let totalLocalVideoViews = await VideoModel.sum('views', {
1562       where: {
1563         remote: false
1564       }
1565     })
1566     // Sequelize could return null...
1567     if (!totalLocalVideoViews) totalLocalVideoViews = 0
1568
1569     return {
1570       totalLocalVideos,
1571       totalLocalVideoViews,
1572       totalVideos
1573     }
1574   }
1575
1576   static incrementViews (id: number, views: number) {
1577     return VideoModel.increment('views', {
1578       by: views,
1579       where: {
1580         id
1581       }
1582     })
1583   }
1584
1585   static checkVideoHasInstanceFollow (videoId: number, followerActorId: number) {
1586     // Instances only share videos
1587     const query = 'SELECT 1 FROM "videoShare" ' +
1588     'INNER JOIN "actorFollow" ON "actorFollow"."targetActorId" = "videoShare"."actorId" ' +
1589     'WHERE "actorFollow"."actorId" = $followerActorId AND "videoShare"."videoId" = $videoId ' +
1590     'LIMIT 1'
1591
1592     const options = {
1593       type: QueryTypes.SELECT,
1594       bind: { followerActorId, videoId },
1595       raw: true
1596     }
1597
1598     return VideoModel.sequelize.query(query, options)
1599                      .then(results => results.length === 1)
1600   }
1601
1602   static bulkUpdateSupportField (videoChannel: MChannel, t: Transaction) {
1603     const options = {
1604       where: {
1605         channelId: videoChannel.id
1606       },
1607       transaction: t
1608     }
1609
1610     return VideoModel.update({ support: videoChannel.support }, options)
1611   }
1612
1613   static getAllIdsFromChannel (videoChannel: MChannelId): Bluebird<number[]> {
1614     const query = {
1615       attributes: [ 'id' ],
1616       where: {
1617         channelId: videoChannel.id
1618       }
1619     }
1620
1621     return VideoModel.findAll(query)
1622       .then(videos => videos.map(v => v.id))
1623   }
1624
1625   // threshold corresponds to how many video the field should have to be returned
1626   static async getRandomFieldSamples (field: 'category' | 'channelId', threshold: number, count: number) {
1627     const serverActor = await getServerActor()
1628     const followerActorId = serverActor.id
1629
1630     const scopeOptions: AvailableForListIDsOptions = {
1631       serverAccountId: serverActor.Account.id,
1632       followerActorId,
1633       includeLocalVideos: true,
1634       attributesType: 'none' // Don't break aggregation
1635     }
1636
1637     const query: FindOptions = {
1638       attributes: [ field ],
1639       limit: count,
1640       group: field,
1641       having: Sequelize.where(
1642         Sequelize.fn('COUNT', Sequelize.col(field)), { [ Op.gte ]: threshold }
1643       ),
1644       order: [ (this.sequelize as any).random() ]
1645     }
1646
1647     return VideoModel.scope({ method: [ ScopeNames.AVAILABLE_FOR_LIST_IDS, scopeOptions ] })
1648                      .findAll(query)
1649                      .then(rows => rows.map(r => r[ field ]))
1650   }
1651
1652   static buildTrendingQuery (trendingDays: number) {
1653     return {
1654       attributes: [],
1655       subQuery: false,
1656       model: VideoViewModel,
1657       required: false,
1658       where: {
1659         startDate: {
1660           [ Op.gte ]: new Date(new Date().getTime() - (24 * 3600 * 1000) * trendingDays)
1661         }
1662       }
1663     }
1664   }
1665
1666   private static buildActorWhereWithFilter (filter?: VideoFilter) {
1667     if (filter && (filter === 'local' || filter === 'all-local')) {
1668       return {
1669         serverId: null
1670       }
1671     }
1672
1673     return {}
1674   }
1675
1676   private static async getAvailableForApi (
1677     query: FindOptions & { where?: null }, // Forbid where field in query
1678     options: AvailableForListIDsOptions,
1679     countVideos = true
1680   ) {
1681     const idsScope: ScopeOptions = {
1682       method: [
1683         ScopeNames.AVAILABLE_FOR_LIST_IDS, options
1684       ]
1685     }
1686
1687     // Remove trending sort on count, because it uses a group by
1688     const countOptions = Object.assign({}, options, { trendingDays: undefined })
1689     const countQuery: CountOptions = Object.assign({}, query, { attributes: undefined, group: undefined })
1690     const countScope: ScopeOptions = {
1691       method: [
1692         ScopeNames.AVAILABLE_FOR_LIST_IDS, countOptions
1693       ]
1694     }
1695
1696     const [ count, ids ] = await Promise.all([
1697       countVideos
1698         ? VideoModel.scope(countScope).count(countQuery)
1699         : Promise.resolve<number>(undefined),
1700
1701       VideoModel.scope(idsScope)
1702                 .findAll(query)
1703                 .then(rows => rows.map(r => r.id))
1704     ])
1705
1706     if (ids.length === 0) return { data: [], total: count }
1707
1708     const secondQuery: FindOptions = {
1709       offset: 0,
1710       limit: query.limit,
1711       attributes: query.attributes,
1712       order: [ // Keep original order
1713         Sequelize.literal(
1714           ids.map(id => `"VideoModel".id = ${id} DESC`).join(', ')
1715         )
1716       ]
1717     }
1718
1719     const apiScope: (string | ScopeOptions)[] = []
1720
1721     if (options.user) {
1722       apiScope.push({ method: [ ScopeNames.WITH_USER_HISTORY, options.user.id ] })
1723     }
1724
1725     apiScope.push({
1726       method: [
1727         ScopeNames.FOR_API, {
1728           ids,
1729           withFiles: options.withFiles,
1730           videoPlaylistId: options.videoPlaylistId
1731         } as ForAPIOptions
1732       ]
1733     })
1734
1735     const rows = await VideoModel.scope(apiScope).findAll(secondQuery)
1736
1737     return {
1738       data: rows,
1739       total: count
1740     }
1741   }
1742
1743   static getCategoryLabel (id: number) {
1744     return VIDEO_CATEGORIES[ id ] || 'Misc'
1745   }
1746
1747   static getLicenceLabel (id: number) {
1748     return VIDEO_LICENCES[ id ] || 'Unknown'
1749   }
1750
1751   static getLanguageLabel (id: string) {
1752     return VIDEO_LANGUAGES[ id ] || 'Unknown'
1753   }
1754
1755   static getPrivacyLabel (id: number) {
1756     return VIDEO_PRIVACIES[ id ] || 'Unknown'
1757   }
1758
1759   static getStateLabel (id: number) {
1760     return VIDEO_STATES[ id ] || 'Unknown'
1761   }
1762
1763   isBlacklisted () {
1764     return !!this.VideoBlacklist
1765   }
1766
1767   isBlocked () {
1768     return (this.VideoChannel.Account.Actor.Server && this.VideoChannel.Account.Actor.Server.isBlocked()) ||
1769       this.VideoChannel.Account.isBlocked()
1770   }
1771
1772   getOriginalFile <T extends MVideoWithFile> (this: T) {
1773     if (Array.isArray(this.VideoFiles) === false) return undefined
1774
1775     // The original file is the file that have the higher resolution
1776     return maxBy(this.VideoFiles, file => file.resolution)
1777   }
1778
1779   getFile <T extends MVideoWithFile> (this: T, resolution: number) {
1780     if (Array.isArray(this.VideoFiles) === false) return undefined
1781
1782     return this.VideoFiles.find(f => f.resolution === resolution)
1783   }
1784
1785   async addAndSaveThumbnail (thumbnail: MThumbnail, transaction: Transaction) {
1786     thumbnail.videoId = this.id
1787
1788     const savedThumbnail = await thumbnail.save({ transaction })
1789
1790     if (Array.isArray(this.Thumbnails) === false) this.Thumbnails = []
1791
1792     // Already have this thumbnail, skip
1793     if (this.Thumbnails.find(t => t.id === savedThumbnail.id)) return
1794
1795     this.Thumbnails.push(savedThumbnail)
1796   }
1797
1798   getVideoFilename (videoFile: MVideoFile) {
1799     return this.uuid + '-' + videoFile.resolution + videoFile.extname
1800   }
1801
1802   generateThumbnailName () {
1803     return this.uuid + '.jpg'
1804   }
1805
1806   getMiniature () {
1807     if (Array.isArray(this.Thumbnails) === false) return undefined
1808
1809     return this.Thumbnails.find(t => t.type === ThumbnailType.MINIATURE)
1810   }
1811
1812   generatePreviewName () {
1813     return this.uuid + '.jpg'
1814   }
1815
1816   getPreview () {
1817     if (Array.isArray(this.Thumbnails) === false) return undefined
1818
1819     return this.Thumbnails.find(t => t.type === ThumbnailType.PREVIEW)
1820   }
1821
1822   getTorrentFileName (videoFile: MVideoFile) {
1823     const extension = '.torrent'
1824     return this.uuid + '-' + videoFile.resolution + extension
1825   }
1826
1827   isOwned () {
1828     return this.remote === false
1829   }
1830
1831   getTorrentFilePath (videoFile: MVideoFile) {
1832     return join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
1833   }
1834
1835   getVideoFilePath (videoFile: MVideoFile) {
1836     return join(CONFIG.STORAGE.VIDEOS_DIR, this.getVideoFilename(videoFile))
1837   }
1838
1839   async createTorrentAndSetInfoHash (videoFile: MVideoFile) {
1840     const options = {
1841       // Keep the extname, it's used by the client to stream the file inside a web browser
1842       name: `${this.name} ${videoFile.resolution}p${videoFile.extname}`,
1843       createdBy: 'PeerTube',
1844       announceList: [
1845         [ WEBSERVER.WS + '://' + WEBSERVER.HOSTNAME + ':' + WEBSERVER.PORT + '/tracker/socket' ],
1846         [ WEBSERVER.URL + '/tracker/announce' ]
1847       ],
1848       urlList: [ WEBSERVER.URL + STATIC_PATHS.WEBSEED + this.getVideoFilename(videoFile) ]
1849     }
1850
1851     const torrent = await createTorrentPromise(this.getVideoFilePath(videoFile), options)
1852
1853     const filePath = join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
1854     logger.info('Creating torrent %s.', filePath)
1855
1856     await writeFile(filePath, torrent)
1857
1858     const parsedTorrent = parseTorrent(torrent)
1859     videoFile.infoHash = parsedTorrent.infoHash
1860   }
1861
1862   getWatchStaticPath () {
1863     return '/videos/watch/' + this.uuid
1864   }
1865
1866   getEmbedStaticPath () {
1867     return '/videos/embed/' + this.uuid
1868   }
1869
1870   getMiniatureStaticPath () {
1871     const thumbnail = this.getMiniature()
1872     if (!thumbnail) return null
1873
1874     return join(STATIC_PATHS.THUMBNAILS, thumbnail.filename)
1875   }
1876
1877   getPreviewStaticPath () {
1878     const preview = this.getPreview()
1879     if (!preview) return null
1880
1881     // We use a local cache, so specify our cache endpoint instead of potential remote URL
1882     return join(LAZY_STATIC_PATHS.PREVIEWS, preview.filename)
1883   }
1884
1885   toFormattedJSON (this: MVideoFormattable, options?: VideoFormattingJSONOptions): Video {
1886     return videoModelToFormattedJSON(this, options)
1887   }
1888
1889   toFormattedDetailsJSON (this: MVideoFormattableDetails): VideoDetails {
1890     return videoModelToFormattedDetailsJSON(this)
1891   }
1892
1893   getFormattedVideoFilesJSON (): VideoFile[] {
1894     return videoFilesModelToFormattedJSON(this, this.VideoFiles)
1895   }
1896
1897   toActivityPubObject (this: MVideoAP): VideoTorrentObject {
1898     return videoModelToActivityPubObject(this)
1899   }
1900
1901   getTruncatedDescription () {
1902     if (!this.description) return null
1903
1904     const maxLength = CONSTRAINTS_FIELDS.VIDEOS.TRUNCATED_DESCRIPTION.max
1905     return peertubeTruncate(this.description, maxLength)
1906   }
1907
1908   getOriginalFileResolution () {
1909     const originalFilePath = this.getVideoFilePath(this.getOriginalFile())
1910
1911     return getVideoFileResolution(originalFilePath)
1912   }
1913
1914   getDescriptionAPIPath () {
1915     return `/api/${API_VERSION}/videos/${this.uuid}/description`
1916   }
1917
1918   getHLSPlaylist () {
1919     if (!this.VideoStreamingPlaylists) return undefined
1920
1921     return this.VideoStreamingPlaylists.find(p => p.type === VideoStreamingPlaylistType.HLS)
1922   }
1923
1924   removeFile (videoFile: MVideoFile, isRedundancy = false) {
1925     const baseDir = isRedundancy ? CONFIG.STORAGE.REDUNDANCY_DIR : CONFIG.STORAGE.VIDEOS_DIR
1926
1927     const filePath = join(baseDir, this.getVideoFilename(videoFile))
1928     return remove(filePath)
1929       .catch(err => logger.warn('Cannot delete file %s.', filePath, { err }))
1930   }
1931
1932   removeTorrent (videoFile: MVideoFile) {
1933     const torrentPath = join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
1934     return remove(torrentPath)
1935       .catch(err => logger.warn('Cannot delete torrent %s.', torrentPath, { err }))
1936   }
1937
1938   removeStreamingPlaylist (isRedundancy = false) {
1939     const baseDir = isRedundancy ? HLS_REDUNDANCY_DIRECTORY : HLS_STREAMING_PLAYLIST_DIRECTORY
1940
1941     const filePath = join(baseDir, this.uuid)
1942     return remove(filePath)
1943       .catch(err => logger.warn('Cannot delete playlist directory %s.', filePath, { err }))
1944   }
1945
1946   isOutdated () {
1947     if (this.isOwned()) return false
1948
1949     return isOutdated(this, ACTIVITY_PUB.VIDEO_REFRESH_INTERVAL)
1950   }
1951
1952   setAsRefreshed () {
1953     this.changed('updatedAt', true)
1954
1955     return this.save()
1956   }
1957
1958   getBaseUrls () {
1959     let baseUrlHttp
1960     let baseUrlWs
1961
1962     if (this.isOwned()) {
1963       baseUrlHttp = WEBSERVER.URL
1964       baseUrlWs = WEBSERVER.WS + '://' + WEBSERVER.HOSTNAME + ':' + WEBSERVER.PORT
1965     } else {
1966       baseUrlHttp = REMOTE_SCHEME.HTTP + '://' + this.VideoChannel.Account.Actor.Server.host
1967       baseUrlWs = REMOTE_SCHEME.WS + '://' + this.VideoChannel.Account.Actor.Server.host
1968     }
1969
1970     return { baseUrlHttp, baseUrlWs }
1971   }
1972
1973   generateMagnetUri (videoFile: MVideoFileRedundanciesOpt, baseUrlHttp: string, baseUrlWs: string) {
1974     const xs = this.getTorrentUrl(videoFile, baseUrlHttp)
1975     const announce = this.getTrackerUrls(baseUrlHttp, baseUrlWs)
1976     let urlList = [ this.getVideoFileUrl(videoFile, baseUrlHttp) ]
1977
1978     const redundancies = videoFile.RedundancyVideos
1979     if (isArray(redundancies)) urlList = urlList.concat(redundancies.map(r => r.fileUrl))
1980
1981     const magnetHash = {
1982       xs,
1983       announce,
1984       urlList,
1985       infoHash: videoFile.infoHash,
1986       name: this.name
1987     }
1988
1989     return magnetUtil.encode(magnetHash)
1990   }
1991
1992   getTrackerUrls (baseUrlHttp: string, baseUrlWs: string) {
1993     return [ baseUrlWs + '/tracker/socket', baseUrlHttp + '/tracker/announce' ]
1994   }
1995
1996   getTorrentUrl (videoFile: MVideoFile, baseUrlHttp: string) {
1997     return baseUrlHttp + STATIC_PATHS.TORRENTS + this.getTorrentFileName(videoFile)
1998   }
1999
2000   getTorrentDownloadUrl (videoFile: MVideoFile, baseUrlHttp: string) {
2001     return baseUrlHttp + STATIC_DOWNLOAD_PATHS.TORRENTS + this.getTorrentFileName(videoFile)
2002   }
2003
2004   getVideoFileUrl (videoFile: MVideoFile, baseUrlHttp: string) {
2005     return baseUrlHttp + STATIC_PATHS.WEBSEED + this.getVideoFilename(videoFile)
2006   }
2007
2008   getVideoRedundancyUrl (videoFile: MVideoFile, baseUrlHttp: string) {
2009     return baseUrlHttp + STATIC_PATHS.REDUNDANCY + this.getVideoFilename(videoFile)
2010   }
2011
2012   getVideoFileDownloadUrl (videoFile: MVideoFile, baseUrlHttp: string) {
2013     return baseUrlHttp + STATIC_DOWNLOAD_PATHS.VIDEOS + this.getVideoFilename(videoFile)
2014   }
2015
2016   getBandwidthBits (videoFile: MVideoFile) {
2017     return Math.ceil((videoFile.size * 8) / this.duration)
2018   }
2019 }