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