Make sure a report doesn't get deleted upon the deletion of its video
[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 { getServerActor } from '../../helpers/utils'
47 import {
48   ACTIVITY_PUB,
49   API_VERSION,
50   CONSTRAINTS_FIELDS,
51   LAZY_STATIC_PATHS,
52   REMOTE_SCHEME,
53   STATIC_DOWNLOAD_PATHS,
54   STATIC_PATHS,
55   VIDEO_CATEGORIES,
56   VIDEO_LANGUAGES,
57   VIDEO_LICENCES,
58   VIDEO_PRIVACIES,
59   VIDEO_STATES,
60   WEBSERVER
61 } from '../../initializers/constants'
62 import { sendDeleteVideo } from '../../lib/activitypub/send'
63 import { AccountModel } from '../account/account'
64 import { AccountVideoRateModel } from '../account/account-video-rate'
65 import { ActorModel } from '../activitypub/actor'
66 import { AvatarModel } from '../avatar/avatar'
67 import { ServerModel } from '../server/server'
68 import { buildTrigramSearchIndex, buildWhereIdOrUUID, getVideoSort, isOutdated, throwIfNotValid } from '../utils'
69 import { TagModel } from './tag'
70 import { VideoAbuseModel } from './video-abuse'
71 import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel } from './video-channel'
72 import { VideoCommentModel } from './video-comment'
73 import { VideoFileModel } from './video-file'
74 import { VideoShareModel } from './video-share'
75 import { VideoTagModel } from './video-tag'
76 import { ScheduleVideoUpdateModel } from './schedule-video-update'
77 import { VideoCaptionModel } from './video-caption'
78 import { VideoBlacklistModel } from './video-blacklist'
79 import { remove } from 'fs-extra'
80 import { VideoViewModel } from './video-views'
81 import { VideoRedundancyModel } from '../redundancy/video-redundancy'
82 import {
83   videoFilesModelToFormattedJSON,
84   VideoFormattingJSONOptions,
85   videoModelToActivityPubObject,
86   videoModelToFormattedDetailsJSON,
87   videoModelToFormattedJSON
88 } from './video-format-utils'
89 import { UserVideoHistoryModel } from '../account/user-video-history'
90 import { VideoImportModel } from './video-import'
91 import { VideoStreamingPlaylistModel } from './video-streaming-playlist'
92 import { VideoPlaylistElementModel } from './video-playlist-element'
93 import { CONFIG } from '../../initializers/config'
94 import { ThumbnailModel } from './thumbnail'
95 import { ThumbnailType } from '../../../shared/models/videos/thumbnail.type'
96 import { VideoStreamingPlaylistType } from '../../../shared/models/videos/video-streaming-playlist.type'
97 import {
98   MChannel,
99   MChannelAccountDefault,
100   MChannelId,
101   MStreamingPlaylist,
102   MStreamingPlaylistFilesVideo,
103   MUserAccountId,
104   MUserId,
105   MVideoAccountLight,
106   MVideoAccountLightBlacklistAllFiles,
107   MVideoAP,
108   MVideoDetails,
109   MVideoFileVideo,
110   MVideoFormattable,
111   MVideoFormattableDetails,
112   MVideoForUser,
113   MVideoFullLight,
114   MVideoIdThumbnail,
115   MVideoImmutable,
116   MVideoThumbnail,
117   MVideoThumbnailBlacklist,
118   MVideoWithAllFiles,
119   MVideoWithFile,
120   MVideoWithRights
121 } from '../../typings/models'
122 import { MVideoFile, MVideoFileStreamingPlaylistVideo } from '../../typings/models/video/video-file'
123 import { MThumbnail } from '../../typings/models/video/thumbnail'
124 import { VideoFile } from '@shared/models/videos/video-file.model'
125 import { getHLSDirectory, getTorrentFileName, getTorrentFilePath, getVideoFilename, getVideoFilePath } from '@server/lib/video-paths'
126 import { ModelCache } from '@server/models/model-cache'
127 import { buildListQuery, BuildVideosQueryOptions, wrapForAPIResults } from './video-query-builder'
128 import { buildNSFWFilter } from '@server/helpers/express-utils'
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.toFormattedJSON()
814
815     for (const abuse of instance.VideoAbuses) {
816       tasks.push((_ => {
817         abuse.deletedVideo = details
818         return abuse.save({ transaction: options.transaction })
819       })())
820     }
821
822     Promise.all(tasks)
823            .catch(err => {
824              logger.error('Some errors when saving details of video %s in its abuses before destroy hook.', instance.uuid, { err })
825            })
826
827     return undefined
828   }
829
830   static listLocal (): Bluebird<MVideoWithAllFiles[]> {
831     const query = {
832       where: {
833         remote: false
834       }
835     }
836
837     return VideoModel.scope([
838       ScopeNames.WITH_WEBTORRENT_FILES,
839       ScopeNames.WITH_STREAMING_PLAYLISTS,
840       ScopeNames.WITH_THUMBNAILS
841     ]).findAll(query)
842   }
843
844   static listAllAndSharedByActorForOutbox (actorId: number, start: number, count: number) {
845     function getRawQuery (select: string) {
846       const queryVideo = 'SELECT ' + select + ' FROM "video" AS "Video" ' +
847         'INNER JOIN "videoChannel" AS "VideoChannel" ON "VideoChannel"."id" = "Video"."channelId" ' +
848         'INNER JOIN "account" AS "Account" ON "Account"."id" = "VideoChannel"."accountId" ' +
849         'WHERE "Account"."actorId" = ' + actorId
850       const queryVideoShare = 'SELECT ' + select + ' FROM "videoShare" AS "VideoShare" ' +
851         'INNER JOIN "video" AS "Video" ON "Video"."id" = "VideoShare"."videoId" ' +
852         'WHERE "VideoShare"."actorId" = ' + actorId
853
854       return `(${queryVideo}) UNION (${queryVideoShare})`
855     }
856
857     const rawQuery = getRawQuery('"Video"."id"')
858     const rawCountQuery = getRawQuery('COUNT("Video"."id") as "total"')
859
860     const query = {
861       distinct: true,
862       offset: start,
863       limit: count,
864       order: getVideoSort('createdAt', [ 'Tags', 'name', 'ASC' ] as any), // FIXME: sequelize typings
865       where: {
866         id: {
867           [Op.in]: Sequelize.literal('(' + rawQuery + ')')
868         },
869         [Op.or]: [
870           { privacy: VideoPrivacy.PUBLIC },
871           { privacy: VideoPrivacy.UNLISTED }
872         ]
873       },
874       include: [
875         {
876           attributes: [ 'language', 'fileUrl' ],
877           model: VideoCaptionModel.unscoped(),
878           required: false
879         },
880         {
881           attributes: [ 'id', 'url' ],
882           model: VideoShareModel.unscoped(),
883           required: false,
884           // We only want videos shared by this actor
885           where: {
886             [Op.and]: [
887               {
888                 id: {
889                   [Op.not]: null
890                 }
891               },
892               {
893                 actorId
894               }
895             ]
896           },
897           include: [
898             {
899               attributes: [ 'id', 'url' ],
900               model: ActorModel.unscoped()
901             }
902           ]
903         },
904         {
905           model: VideoChannelModel.unscoped(),
906           required: true,
907           include: [
908             {
909               attributes: [ 'name' ],
910               model: AccountModel.unscoped(),
911               required: true,
912               include: [
913                 {
914                   attributes: [ 'id', 'url', 'followersUrl' ],
915                   model: ActorModel.unscoped(),
916                   required: true
917                 }
918               ]
919             },
920             {
921               attributes: [ 'id', 'url', 'followersUrl' ],
922               model: ActorModel.unscoped(),
923               required: true
924             }
925           ]
926         },
927         VideoFileModel,
928         TagModel
929       ]
930     }
931
932     return Bluebird.all([
933       VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findAll(query),
934       VideoModel.sequelize.query<{ total: string }>(rawCountQuery, { type: QueryTypes.SELECT })
935     ]).then(([ rows, totals ]) => {
936       // totals: totalVideos + totalVideoShares
937       let totalVideos = 0
938       let totalVideoShares = 0
939       if (totals[0]) totalVideos = parseInt(totals[0].total, 10)
940       if (totals[1]) totalVideoShares = parseInt(totals[1].total, 10)
941
942       const total = totalVideos + totalVideoShares
943       return {
944         data: rows,
945         total: total
946       }
947     })
948   }
949
950   static listUserVideosForApi (
951     accountId: number,
952     start: number,
953     count: number,
954     sort: string,
955     search?: string
956   ) {
957     function buildBaseQuery (): FindOptions {
958       let baseQuery = {
959         offset: start,
960         limit: count,
961         order: getVideoSort(sort),
962         include: [
963           {
964             model: VideoChannelModel,
965             required: true,
966             include: [
967               {
968                 model: AccountModel,
969                 where: {
970                   id: accountId
971                 },
972                 required: true
973               }
974             ]
975           }
976         ]
977       }
978
979       if (search) {
980         baseQuery = Object.assign(baseQuery, {
981           where: {
982             name: {
983               [Op.iLike]: '%' + search + '%'
984             }
985           }
986         })
987       }
988
989       return baseQuery
990     }
991
992     const countQuery = buildBaseQuery()
993     const findQuery = buildBaseQuery()
994
995     const findScopes: (string | ScopeOptions)[] = [
996       ScopeNames.WITH_SCHEDULED_UPDATE,
997       ScopeNames.WITH_BLACKLISTED,
998       ScopeNames.WITH_THUMBNAILS
999     ]
1000
1001     return Promise.all([
1002       VideoModel.count(countQuery),
1003       VideoModel.scope(findScopes).findAll<MVideoForUser>(findQuery)
1004     ]).then(([ count, rows ]) => {
1005       return {
1006         data: rows,
1007         total: count
1008       }
1009     })
1010   }
1011
1012   static async listForApi (options: {
1013     start: number
1014     count: number
1015     sort: string
1016     nsfw: boolean
1017     includeLocalVideos: boolean
1018     withFiles: boolean
1019     categoryOneOf?: number[]
1020     licenceOneOf?: number[]
1021     languageOneOf?: string[]
1022     tagsOneOf?: string[]
1023     tagsAllOf?: string[]
1024     filter?: VideoFilter
1025     accountId?: number
1026     videoChannelId?: number
1027     followerActorId?: number
1028     videoPlaylistId?: number
1029     trendingDays?: number
1030     user?: MUserAccountId
1031     historyOfUser?: MUserId
1032     countVideos?: boolean
1033   }) {
1034     if (options.filter && options.filter === 'all-local' && !options.user.hasRight(UserRight.SEE_ALL_VIDEOS)) {
1035       throw new Error('Try to filter all-local but no user has not the see all videos right')
1036     }
1037
1038     const trendingDays = options.sort.endsWith('trending')
1039       ? CONFIG.TRENDING.VIDEOS.INTERVAL_DAYS
1040       : undefined
1041
1042     const serverActor = await getServerActor()
1043
1044     // followerActorId === null has a meaning, so just check undefined
1045     const followerActorId = options.followerActorId !== undefined
1046       ? options.followerActorId
1047       : serverActor.id
1048
1049     const queryOptions = {
1050       start: options.start,
1051       count: options.count,
1052       sort: options.sort,
1053       followerActorId,
1054       serverAccountId: serverActor.Account.id,
1055       nsfw: options.nsfw,
1056       categoryOneOf: options.categoryOneOf,
1057       licenceOneOf: options.licenceOneOf,
1058       languageOneOf: options.languageOneOf,
1059       tagsOneOf: options.tagsOneOf,
1060       tagsAllOf: options.tagsAllOf,
1061       filter: options.filter,
1062       withFiles: options.withFiles,
1063       accountId: options.accountId,
1064       videoChannelId: options.videoChannelId,
1065       videoPlaylistId: options.videoPlaylistId,
1066       includeLocalVideos: options.includeLocalVideos,
1067       user: options.user,
1068       historyOfUser: options.historyOfUser,
1069       trendingDays
1070     }
1071
1072     return VideoModel.getAvailableForApi(queryOptions, options.countVideos)
1073   }
1074
1075   static async searchAndPopulateAccountAndServer (options: {
1076     includeLocalVideos: boolean
1077     search?: string
1078     start?: number
1079     count?: number
1080     sort?: string
1081     startDate?: string // ISO 8601
1082     endDate?: string // ISO 8601
1083     originallyPublishedStartDate?: string
1084     originallyPublishedEndDate?: string
1085     nsfw?: boolean
1086     categoryOneOf?: number[]
1087     licenceOneOf?: number[]
1088     languageOneOf?: string[]
1089     tagsOneOf?: string[]
1090     tagsAllOf?: string[]
1091     durationMin?: number // seconds
1092     durationMax?: number // seconds
1093     user?: MUserAccountId
1094     filter?: VideoFilter
1095   }) {
1096     const serverActor = await getServerActor()
1097     const queryOptions = {
1098       followerActorId: serverActor.id,
1099       serverAccountId: serverActor.Account.id,
1100       includeLocalVideos: options.includeLocalVideos,
1101       nsfw: options.nsfw,
1102       categoryOneOf: options.categoryOneOf,
1103       licenceOneOf: options.licenceOneOf,
1104       languageOneOf: options.languageOneOf,
1105       tagsOneOf: options.tagsOneOf,
1106       tagsAllOf: options.tagsAllOf,
1107       user: options.user,
1108       filter: options.filter,
1109       start: options.start,
1110       count: options.count,
1111       sort: options.sort,
1112       startDate: options.startDate,
1113       endDate: options.endDate,
1114       originallyPublishedStartDate: options.originallyPublishedStartDate,
1115       originallyPublishedEndDate: options.originallyPublishedEndDate,
1116
1117       durationMin: options.durationMin,
1118       durationMax: options.durationMax,
1119
1120       search: options.search
1121     }
1122
1123     return VideoModel.getAvailableForApi(queryOptions)
1124   }
1125
1126   static load (id: number | string, t?: Transaction): Bluebird<MVideoThumbnail> {
1127     const where = buildWhereIdOrUUID(id)
1128     const options = {
1129       where,
1130       transaction: t
1131     }
1132
1133     return VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findOne(options)
1134   }
1135
1136   static loadWithBlacklist (id: number | string, t?: Transaction): Bluebird<MVideoThumbnailBlacklist> {
1137     const where = buildWhereIdOrUUID(id)
1138     const options = {
1139       where,
1140       transaction: t
1141     }
1142
1143     return VideoModel.scope([
1144       ScopeNames.WITH_THUMBNAILS,
1145       ScopeNames.WITH_BLACKLISTED
1146     ]).findOne(options)
1147   }
1148
1149   static loadImmutableAttributes (id: number | string, t?: Transaction): Bluebird<MVideoImmutable> {
1150     const fun = () => {
1151       const query = {
1152         where: buildWhereIdOrUUID(id),
1153         transaction: t
1154       }
1155
1156       return VideoModel.scope(ScopeNames.WITH_IMMUTABLE_ATTRIBUTES).findOne(query)
1157     }
1158
1159     return ModelCache.Instance.doCache({
1160       cacheType: 'load-video-immutable-id',
1161       key: '' + id,
1162       deleteKey: 'video',
1163       fun
1164     })
1165   }
1166
1167   static loadWithRights (id: number | string, t?: Transaction): Bluebird<MVideoWithRights> {
1168     const where = buildWhereIdOrUUID(id)
1169     const options = {
1170       where,
1171       transaction: t
1172     }
1173
1174     return VideoModel.scope([
1175       ScopeNames.WITH_BLACKLISTED,
1176       ScopeNames.WITH_USER_ID,
1177       ScopeNames.WITH_THUMBNAILS
1178     ]).findOne(options)
1179   }
1180
1181   static loadOnlyId (id: number | string, t?: Transaction): Bluebird<MVideoIdThumbnail> {
1182     const where = buildWhereIdOrUUID(id)
1183
1184     const options = {
1185       attributes: [ 'id' ],
1186       where,
1187       transaction: t
1188     }
1189
1190     return VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findOne(options)
1191   }
1192
1193   static loadWithFiles (id: number | string, t?: Transaction, logging?: boolean): Bluebird<MVideoWithAllFiles> {
1194     const where = buildWhereIdOrUUID(id)
1195
1196     const query = {
1197       where,
1198       transaction: t,
1199       logging
1200     }
1201
1202     return VideoModel.scope([
1203       ScopeNames.WITH_WEBTORRENT_FILES,
1204       ScopeNames.WITH_STREAMING_PLAYLISTS,
1205       ScopeNames.WITH_THUMBNAILS
1206     ]).findOne(query)
1207   }
1208
1209   static loadByUUID (uuid: string): Bluebird<MVideoThumbnail> {
1210     const options = {
1211       where: {
1212         uuid
1213       }
1214     }
1215
1216     return VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findOne(options)
1217   }
1218
1219   static loadByUrl (url: string, transaction?: Transaction): Bluebird<MVideoThumbnail> {
1220     const query: FindOptions = {
1221       where: {
1222         url
1223       },
1224       transaction
1225     }
1226
1227     return VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findOne(query)
1228   }
1229
1230   static loadByUrlImmutableAttributes (url: string, transaction?: Transaction): Bluebird<MVideoImmutable> {
1231     const fun = () => {
1232       const query: FindOptions = {
1233         where: {
1234           url
1235         },
1236         transaction
1237       }
1238
1239       return VideoModel.scope(ScopeNames.WITH_IMMUTABLE_ATTRIBUTES).findOne(query)
1240     }
1241
1242     return ModelCache.Instance.doCache({
1243       cacheType: 'load-video-immutable-url',
1244       key: url,
1245       deleteKey: 'video',
1246       fun
1247     })
1248   }
1249
1250   static loadByUrlAndPopulateAccount (url: string, transaction?: Transaction): Bluebird<MVideoAccountLightBlacklistAllFiles> {
1251     const query: FindOptions = {
1252       where: {
1253         url
1254       },
1255       transaction
1256     }
1257
1258     return VideoModel.scope([
1259       ScopeNames.WITH_ACCOUNT_DETAILS,
1260       ScopeNames.WITH_WEBTORRENT_FILES,
1261       ScopeNames.WITH_STREAMING_PLAYLISTS,
1262       ScopeNames.WITH_THUMBNAILS,
1263       ScopeNames.WITH_BLACKLISTED
1264     ]).findOne(query)
1265   }
1266
1267   static loadAndPopulateAccountAndServerAndTags (id: number | string, t?: Transaction, userId?: number): Bluebird<MVideoFullLight> {
1268     const where = buildWhereIdOrUUID(id)
1269
1270     const options = {
1271       order: [ [ 'Tags', 'name', 'ASC' ] ] as any,
1272       where,
1273       transaction: t
1274     }
1275
1276     const scopes: (string | ScopeOptions)[] = [
1277       ScopeNames.WITH_TAGS,
1278       ScopeNames.WITH_BLACKLISTED,
1279       ScopeNames.WITH_ACCOUNT_DETAILS,
1280       ScopeNames.WITH_SCHEDULED_UPDATE,
1281       ScopeNames.WITH_WEBTORRENT_FILES,
1282       ScopeNames.WITH_STREAMING_PLAYLISTS,
1283       ScopeNames.WITH_THUMBNAILS
1284     ]
1285
1286     if (userId) {
1287       scopes.push({ method: [ ScopeNames.WITH_USER_HISTORY, userId ] })
1288     }
1289
1290     return VideoModel
1291       .scope(scopes)
1292       .findOne(options)
1293   }
1294
1295   static loadForGetAPI (parameters: {
1296     id: number | string
1297     t?: Transaction
1298     userId?: number
1299   }): Bluebird<MVideoDetails> {
1300     const { id, t, userId } = parameters
1301     const where = buildWhereIdOrUUID(id)
1302
1303     const options = {
1304       order: [ [ 'Tags', 'name', 'ASC' ] ] as any, // FIXME: sequelize typings
1305       where,
1306       transaction: t
1307     }
1308
1309     const scopes: (string | ScopeOptions)[] = [
1310       ScopeNames.WITH_TAGS,
1311       ScopeNames.WITH_BLACKLISTED,
1312       ScopeNames.WITH_ACCOUNT_DETAILS,
1313       ScopeNames.WITH_SCHEDULED_UPDATE,
1314       ScopeNames.WITH_THUMBNAILS,
1315       { method: [ ScopeNames.WITH_WEBTORRENT_FILES, true ] },
1316       { method: [ ScopeNames.WITH_STREAMING_PLAYLISTS, true ] }
1317     ]
1318
1319     if (userId) {
1320       scopes.push({ method: [ ScopeNames.WITH_USER_HISTORY, userId ] })
1321     }
1322
1323     return VideoModel
1324       .scope(scopes)
1325       .findOne(options)
1326   }
1327
1328   static async getStats () {
1329     const totalLocalVideos = await VideoModel.count({
1330       where: {
1331         remote: false
1332       }
1333     })
1334
1335     let totalLocalVideoViews = await VideoModel.sum('views', {
1336       where: {
1337         remote: false
1338       }
1339     })
1340
1341     // Sequelize could return null...
1342     if (!totalLocalVideoViews) totalLocalVideoViews = 0
1343
1344     const { total: totalVideos } = await VideoModel.listForApi({
1345       start: 0,
1346       count: 0,
1347       sort: '-publishedAt',
1348       nsfw: buildNSFWFilter(),
1349       includeLocalVideos: true,
1350       withFiles: false
1351     })
1352
1353     return {
1354       totalLocalVideos,
1355       totalLocalVideoViews,
1356       totalVideos
1357     }
1358   }
1359
1360   static incrementViews (id: number, views: number) {
1361     return VideoModel.increment('views', {
1362       by: views,
1363       where: {
1364         id
1365       }
1366     })
1367   }
1368
1369   static checkVideoHasInstanceFollow (videoId: number, followerActorId: number) {
1370     // Instances only share videos
1371     const query = 'SELECT 1 FROM "videoShare" ' +
1372       'INNER JOIN "actorFollow" ON "actorFollow"."targetActorId" = "videoShare"."actorId" ' +
1373       'WHERE "actorFollow"."actorId" = $followerActorId AND "videoShare"."videoId" = $videoId ' +
1374       'LIMIT 1'
1375
1376     const options = {
1377       type: QueryTypes.SELECT as QueryTypes.SELECT,
1378       bind: { followerActorId, videoId },
1379       raw: true
1380     }
1381
1382     return VideoModel.sequelize.query(query, options)
1383                      .then(results => results.length === 1)
1384   }
1385
1386   static bulkUpdateSupportField (videoChannel: MChannel, t: Transaction) {
1387     const options = {
1388       where: {
1389         channelId: videoChannel.id
1390       },
1391       transaction: t
1392     }
1393
1394     return VideoModel.update({ support: videoChannel.support }, options)
1395   }
1396
1397   static getAllIdsFromChannel (videoChannel: MChannelId): Bluebird<number[]> {
1398     const query = {
1399       attributes: [ 'id' ],
1400       where: {
1401         channelId: videoChannel.id
1402       }
1403     }
1404
1405     return VideoModel.findAll(query)
1406                      .then(videos => videos.map(v => v.id))
1407   }
1408
1409   // threshold corresponds to how many video the field should have to be returned
1410   static async getRandomFieldSamples (field: 'category' | 'channelId', threshold: number, count: number) {
1411     const serverActor = await getServerActor()
1412     const followerActorId = serverActor.id
1413
1414     const queryOptions: BuildVideosQueryOptions = {
1415       attributes: [ `"${field}"` ],
1416       group: `GROUP BY "${field}"`,
1417       having: `HAVING COUNT("${field}") >= ${threshold}`,
1418       start: 0,
1419       sort: 'random',
1420       count,
1421       serverAccountId: serverActor.Account.id,
1422       followerActorId,
1423       includeLocalVideos: true
1424     }
1425
1426     const { query, replacements } = buildListQuery(VideoModel, queryOptions)
1427
1428     return this.sequelize.query<any>(query, { replacements, type: QueryTypes.SELECT })
1429         .then(rows => rows.map(r => r[field]))
1430   }
1431
1432   static buildTrendingQuery (trendingDays: number) {
1433     return {
1434       attributes: [],
1435       subQuery: false,
1436       model: VideoViewModel,
1437       required: false,
1438       where: {
1439         startDate: {
1440           [Op.gte]: new Date(new Date().getTime() - (24 * 3600 * 1000) * trendingDays)
1441         }
1442       }
1443     }
1444   }
1445
1446   private static async getAvailableForApi (
1447     options: BuildVideosQueryOptions,
1448     countVideos = true
1449   ) {
1450     function getCount () {
1451       if (countVideos !== true) return Promise.resolve(undefined)
1452
1453       const countOptions = Object.assign({}, options, { isCount: true })
1454       const { query: queryCount, replacements: replacementsCount } = buildListQuery(VideoModel, countOptions)
1455
1456       return VideoModel.sequelize.query<any>(queryCount, { replacements: replacementsCount, type: QueryTypes.SELECT })
1457           .then(rows => rows.length !== 0 ? rows[0].total : 0)
1458     }
1459
1460     function getModels () {
1461       if (options.count === 0) return Promise.resolve([])
1462
1463       const { query, replacements, order } = buildListQuery(VideoModel, options)
1464       const queryModels = wrapForAPIResults(query, replacements, options, order)
1465
1466       return VideoModel.sequelize.query<any>(queryModels, { replacements, type: QueryTypes.SELECT, nest: true })
1467           .then(rows => VideoModel.buildAPIResult(rows))
1468     }
1469
1470     const [ count, rows ] = await Promise.all([ getCount(), getModels() ])
1471
1472     return {
1473       data: rows,
1474       total: count
1475     }
1476   }
1477
1478   private static buildAPIResult (rows: any[]) {
1479     const memo: { [ id: number ]: VideoModel } = {}
1480
1481     const thumbnailsDone = new Set<number>()
1482     const historyDone = new Set<number>()
1483     const videoFilesDone = new Set<number>()
1484
1485     const videos: VideoModel[] = []
1486
1487     const avatarKeys = [ 'id', 'filename', 'fileUrl', 'onDisk', 'createdAt', 'updatedAt' ]
1488     const actorKeys = [ 'id', 'preferredUsername', 'url', 'serverId', 'avatarId' ]
1489     const serverKeys = [ 'id', 'host' ]
1490     const videoFileKeys = [ 'id', 'createdAt', 'updatedAt', 'resolution', 'size', 'extname', 'infoHash', 'fps', 'videoId' ]
1491     const videoKeys = [
1492       'id',
1493       'uuid',
1494       'name',
1495       'category',
1496       'licence',
1497       'language',
1498       'privacy',
1499       'nsfw',
1500       'description',
1501       'support',
1502       'duration',
1503       'views',
1504       'likes',
1505       'dislikes',
1506       'remote',
1507       'url',
1508       'commentsEnabled',
1509       'downloadEnabled',
1510       'waitTranscoding',
1511       'state',
1512       'publishedAt',
1513       'originallyPublishedAt',
1514       'channelId',
1515       'createdAt',
1516       'updatedAt'
1517     ]
1518
1519     function buildActor (rowActor: any) {
1520       const avatarModel = rowActor.Avatar.id !== null
1521         ? new AvatarModel(pick(rowActor.Avatar, avatarKeys))
1522         : null
1523
1524       const serverModel = rowActor.Server.id !== null
1525         ? new ServerModel(pick(rowActor.Server, serverKeys))
1526         : null
1527
1528       const actorModel = new ActorModel(pick(rowActor, actorKeys))
1529       actorModel.Avatar = avatarModel
1530       actorModel.Server = serverModel
1531
1532       return actorModel
1533     }
1534
1535     for (const row of rows) {
1536       if (!memo[row.id]) {
1537         // Build Channel
1538         const channel = row.VideoChannel
1539         const channelModel = new VideoChannelModel(pick(channel, [ 'id', 'name', 'description', 'actorId' ]))
1540         channelModel.Actor = buildActor(channel.Actor)
1541
1542         const account = row.VideoChannel.Account
1543         const accountModel = new AccountModel(pick(account, [ 'id', 'name' ]))
1544         accountModel.Actor = buildActor(account.Actor)
1545
1546         channelModel.Account = accountModel
1547
1548         const videoModel = new VideoModel(pick(row, videoKeys))
1549         videoModel.VideoChannel = channelModel
1550
1551         videoModel.UserVideoHistories = []
1552         videoModel.Thumbnails = []
1553         videoModel.VideoFiles = []
1554
1555         memo[row.id] = videoModel
1556         // Don't take object value to have a sorted array
1557         videos.push(videoModel)
1558       }
1559
1560       const videoModel = memo[row.id]
1561
1562       if (row.userVideoHistory?.id && !historyDone.has(row.userVideoHistory.id)) {
1563         const historyModel = new UserVideoHistoryModel(pick(row.userVideoHistory, [ 'id', 'currentTime' ]))
1564         videoModel.UserVideoHistories.push(historyModel)
1565
1566         historyDone.add(row.userVideoHistory.id)
1567       }
1568
1569       if (row.Thumbnails?.id && !thumbnailsDone.has(row.Thumbnails.id)) {
1570         const thumbnailModel = new ThumbnailModel(pick(row.Thumbnails, [ 'id', 'type', 'filename' ]))
1571         videoModel.Thumbnails.push(thumbnailModel)
1572
1573         thumbnailsDone.add(row.Thumbnails.id)
1574       }
1575
1576       if (row.VideoFiles?.id && !videoFilesDone.has(row.VideoFiles.id)) {
1577         const videoFileModel = new VideoFileModel(pick(row.VideoFiles, videoFileKeys))
1578         videoModel.VideoFiles.push(videoFileModel)
1579
1580         videoFilesDone.add(row.VideoFiles.id)
1581       }
1582     }
1583
1584     return videos
1585   }
1586
1587   private static isPrivacyForFederation (privacy: VideoPrivacy) {
1588     const castedPrivacy = parseInt(privacy + '', 10)
1589
1590     return castedPrivacy === VideoPrivacy.PUBLIC || castedPrivacy === VideoPrivacy.UNLISTED
1591   }
1592
1593   static getCategoryLabel (id: number) {
1594     return VIDEO_CATEGORIES[id] || 'Misc'
1595   }
1596
1597   static getLicenceLabel (id: number) {
1598     return VIDEO_LICENCES[id] || 'Unknown'
1599   }
1600
1601   static getLanguageLabel (id: string) {
1602     return VIDEO_LANGUAGES[id] || 'Unknown'
1603   }
1604
1605   static getPrivacyLabel (id: number) {
1606     return VIDEO_PRIVACIES[id] || 'Unknown'
1607   }
1608
1609   static getStateLabel (id: number) {
1610     return VIDEO_STATES[id] || 'Unknown'
1611   }
1612
1613   isBlacklisted () {
1614     return !!this.VideoBlacklist
1615   }
1616
1617   isBlocked () {
1618     return (this.VideoChannel.Account.Actor.Server && this.VideoChannel.Account.Actor.Server.isBlocked()) ||
1619       this.VideoChannel.Account.isBlocked()
1620   }
1621
1622   getQualityFileBy<T extends MVideoWithFile> (this: T, fun: (files: MVideoFile[], it: (file: MVideoFile) => number) => MVideoFile) {
1623     if (Array.isArray(this.VideoFiles) && this.VideoFiles.length !== 0) {
1624       const file = fun(this.VideoFiles, file => file.resolution)
1625
1626       return Object.assign(file, { Video: this })
1627     }
1628
1629     // No webtorrent files, try with streaming playlist files
1630     if (Array.isArray(this.VideoStreamingPlaylists) && this.VideoStreamingPlaylists.length !== 0) {
1631       const streamingPlaylistWithVideo = Object.assign(this.VideoStreamingPlaylists[0], { Video: this })
1632
1633       const file = fun(streamingPlaylistWithVideo.VideoFiles, file => file.resolution)
1634       return Object.assign(file, { VideoStreamingPlaylist: streamingPlaylistWithVideo })
1635     }
1636
1637     return undefined
1638   }
1639
1640   getMaxQualityFile<T extends MVideoWithFile> (this: T): MVideoFileVideo | MVideoFileStreamingPlaylistVideo {
1641     return this.getQualityFileBy(maxBy)
1642   }
1643
1644   getMinQualityFile<T extends MVideoWithFile> (this: T): MVideoFileVideo | MVideoFileStreamingPlaylistVideo {
1645     return this.getQualityFileBy(minBy)
1646   }
1647
1648   getWebTorrentFile<T extends MVideoWithFile> (this: T, resolution: number): MVideoFileVideo {
1649     if (Array.isArray(this.VideoFiles) === false) return undefined
1650
1651     const file = this.VideoFiles.find(f => f.resolution === resolution)
1652     if (!file) return undefined
1653
1654     return Object.assign(file, { Video: this })
1655   }
1656
1657   async addAndSaveThumbnail (thumbnail: MThumbnail, transaction: Transaction) {
1658     thumbnail.videoId = this.id
1659
1660     const savedThumbnail = await thumbnail.save({ transaction })
1661
1662     if (Array.isArray(this.Thumbnails) === false) this.Thumbnails = []
1663
1664     // Already have this thumbnail, skip
1665     if (this.Thumbnails.find(t => t.id === savedThumbnail.id)) return
1666
1667     this.Thumbnails.push(savedThumbnail)
1668   }
1669
1670   generateThumbnailName () {
1671     return this.uuid + '.jpg'
1672   }
1673
1674   getMiniature () {
1675     if (Array.isArray(this.Thumbnails) === false) return undefined
1676
1677     return this.Thumbnails.find(t => t.type === ThumbnailType.MINIATURE)
1678   }
1679
1680   generatePreviewName () {
1681     return this.uuid + '.jpg'
1682   }
1683
1684   hasPreview () {
1685     return !!this.getPreview()
1686   }
1687
1688   getPreview () {
1689     if (Array.isArray(this.Thumbnails) === false) return undefined
1690
1691     return this.Thumbnails.find(t => t.type === ThumbnailType.PREVIEW)
1692   }
1693
1694   isOwned () {
1695     return this.remote === false
1696   }
1697
1698   getWatchStaticPath () {
1699     return '/videos/watch/' + this.uuid
1700   }
1701
1702   getEmbedStaticPath () {
1703     return '/videos/embed/' + this.uuid
1704   }
1705
1706   getMiniatureStaticPath () {
1707     const thumbnail = this.getMiniature()
1708     if (!thumbnail) return null
1709
1710     return join(STATIC_PATHS.THUMBNAILS, thumbnail.filename)
1711   }
1712
1713   getPreviewStaticPath () {
1714     const preview = this.getPreview()
1715     if (!preview) return null
1716
1717     // We use a local cache, so specify our cache endpoint instead of potential remote URL
1718     return join(LAZY_STATIC_PATHS.PREVIEWS, preview.filename)
1719   }
1720
1721   toFormattedJSON (this: MVideoFormattable, options?: VideoFormattingJSONOptions): Video {
1722     return videoModelToFormattedJSON(this, options)
1723   }
1724
1725   toFormattedDetailsJSON (this: MVideoFormattableDetails): VideoDetails {
1726     return videoModelToFormattedDetailsJSON(this)
1727   }
1728
1729   getFormattedVideoFilesJSON (): VideoFile[] {
1730     const { baseUrlHttp, baseUrlWs } = this.getBaseUrls()
1731     return videoFilesModelToFormattedJSON(this, baseUrlHttp, baseUrlWs, this.VideoFiles)
1732   }
1733
1734   toActivityPubObject (this: MVideoAP): VideoTorrentObject {
1735     return videoModelToActivityPubObject(this)
1736   }
1737
1738   getTruncatedDescription () {
1739     if (!this.description) return null
1740
1741     const maxLength = CONSTRAINTS_FIELDS.VIDEOS.TRUNCATED_DESCRIPTION.max
1742     return peertubeTruncate(this.description, { length: maxLength })
1743   }
1744
1745   getMaxQualityResolution () {
1746     const file = this.getMaxQualityFile()
1747     const videoOrPlaylist = file.getVideoOrStreamingPlaylist()
1748     const originalFilePath = getVideoFilePath(videoOrPlaylist, file)
1749
1750     return getVideoFileResolution(originalFilePath)
1751   }
1752
1753   getDescriptionAPIPath () {
1754     return `/api/${API_VERSION}/videos/${this.uuid}/description`
1755   }
1756
1757   getHLSPlaylist (): MStreamingPlaylistFilesVideo {
1758     if (!this.VideoStreamingPlaylists) return undefined
1759
1760     const playlist = this.VideoStreamingPlaylists.find(p => p.type === VideoStreamingPlaylistType.HLS)
1761     playlist.Video = this
1762
1763     return playlist
1764   }
1765
1766   setHLSPlaylist (playlist: MStreamingPlaylist) {
1767     const toAdd = [ playlist ] as [ VideoStreamingPlaylistModel ]
1768
1769     if (Array.isArray(this.VideoStreamingPlaylists) === false || this.VideoStreamingPlaylists.length === 0) {
1770       this.VideoStreamingPlaylists = toAdd
1771       return
1772     }
1773
1774     this.VideoStreamingPlaylists = this.VideoStreamingPlaylists
1775                                        .filter(s => s.type !== VideoStreamingPlaylistType.HLS)
1776                                        .concat(toAdd)
1777   }
1778
1779   removeFile (videoFile: MVideoFile, isRedundancy = false) {
1780     const filePath = getVideoFilePath(this, videoFile, isRedundancy)
1781     return remove(filePath)
1782       .catch(err => logger.warn('Cannot delete file %s.', filePath, { err }))
1783   }
1784
1785   removeTorrent (videoFile: MVideoFile) {
1786     const torrentPath = getTorrentFilePath(this, videoFile)
1787     return remove(torrentPath)
1788       .catch(err => logger.warn('Cannot delete torrent %s.', torrentPath, { err }))
1789   }
1790
1791   async removeStreamingPlaylistFiles (streamingPlaylist: MStreamingPlaylist, isRedundancy = false) {
1792     const directoryPath = getHLSDirectory(this, isRedundancy)
1793
1794     await remove(directoryPath)
1795
1796     if (isRedundancy !== true) {
1797       const streamingPlaylistWithFiles = streamingPlaylist as MStreamingPlaylistFilesVideo
1798       streamingPlaylistWithFiles.Video = this
1799
1800       if (!Array.isArray(streamingPlaylistWithFiles.VideoFiles)) {
1801         streamingPlaylistWithFiles.VideoFiles = await streamingPlaylistWithFiles.$get('VideoFiles')
1802       }
1803
1804       // Remove physical files and torrents
1805       await Promise.all(
1806         streamingPlaylistWithFiles.VideoFiles.map(file => streamingPlaylistWithFiles.removeTorrent(file))
1807       )
1808     }
1809   }
1810
1811   isOutdated () {
1812     if (this.isOwned()) return false
1813
1814     return isOutdated(this, ACTIVITY_PUB.VIDEO_REFRESH_INTERVAL)
1815   }
1816
1817   hasPrivacyForFederation () {
1818     return VideoModel.isPrivacyForFederation(this.privacy)
1819   }
1820
1821   isNewVideo (newPrivacy: VideoPrivacy) {
1822     return this.hasPrivacyForFederation() === false && VideoModel.isPrivacyForFederation(newPrivacy) === true
1823   }
1824
1825   setAsRefreshed () {
1826     this.changed('updatedAt', true)
1827
1828     return this.save()
1829   }
1830
1831   requiresAuth () {
1832     return this.privacy === VideoPrivacy.PRIVATE || this.privacy === VideoPrivacy.INTERNAL || !!this.VideoBlacklist
1833   }
1834
1835   setPrivacy (newPrivacy: VideoPrivacy) {
1836     if (this.privacy === VideoPrivacy.PRIVATE && newPrivacy !== VideoPrivacy.PRIVATE) {
1837       this.publishedAt = new Date()
1838     }
1839
1840     this.privacy = newPrivacy
1841   }
1842
1843   isConfidential () {
1844     return this.privacy === VideoPrivacy.PRIVATE ||
1845       this.privacy === VideoPrivacy.UNLISTED ||
1846       this.privacy === VideoPrivacy.INTERNAL
1847   }
1848
1849   async publishIfNeededAndSave (t: Transaction) {
1850     if (this.state !== VideoState.PUBLISHED) {
1851       this.state = VideoState.PUBLISHED
1852       this.publishedAt = new Date()
1853       await this.save({ transaction: t })
1854
1855       return true
1856     }
1857
1858     return false
1859   }
1860
1861   getBaseUrls () {
1862     if (this.isOwned()) {
1863       return {
1864         baseUrlHttp: WEBSERVER.URL,
1865         baseUrlWs: WEBSERVER.WS + '://' + WEBSERVER.HOSTNAME + ':' + WEBSERVER.PORT
1866       }
1867     }
1868
1869     return {
1870       baseUrlHttp: REMOTE_SCHEME.HTTP + '://' + this.VideoChannel.Account.Actor.Server.host,
1871       baseUrlWs: REMOTE_SCHEME.WS + '://' + this.VideoChannel.Account.Actor.Server.host
1872     }
1873   }
1874
1875   getTrackerUrls (baseUrlHttp: string, baseUrlWs: string) {
1876     return [ baseUrlWs + '/tracker/socket', baseUrlHttp + '/tracker/announce' ]
1877   }
1878
1879   getTorrentUrl (videoFile: MVideoFile, baseUrlHttp: string) {
1880     return baseUrlHttp + STATIC_PATHS.TORRENTS + getTorrentFileName(this, videoFile)
1881   }
1882
1883   getTorrentDownloadUrl (videoFile: MVideoFile, baseUrlHttp: string) {
1884     return baseUrlHttp + STATIC_DOWNLOAD_PATHS.TORRENTS + getTorrentFileName(this, videoFile)
1885   }
1886
1887   getVideoFileUrl (videoFile: MVideoFile, baseUrlHttp: string) {
1888     return baseUrlHttp + STATIC_PATHS.WEBSEED + getVideoFilename(this, videoFile)
1889   }
1890
1891   getVideoFileMetadataUrl (videoFile: MVideoFile, baseUrlHttp: string) {
1892     const path = '/api/v1/videos/'
1893
1894     return this.isOwned()
1895       ? baseUrlHttp + path + this.uuid + '/metadata/' + videoFile.id
1896       : videoFile.metadataUrl
1897   }
1898
1899   getVideoRedundancyUrl (videoFile: MVideoFile, baseUrlHttp: string) {
1900     return baseUrlHttp + STATIC_PATHS.REDUNDANCY + getVideoFilename(this, videoFile)
1901   }
1902
1903   getVideoFileDownloadUrl (videoFile: MVideoFile, baseUrlHttp: string) {
1904     return baseUrlHttp + STATIC_DOWNLOAD_PATHS.VIDEOS + getVideoFilename(this, videoFile)
1905   }
1906
1907   getBandwidthBits (videoFile: MVideoFile) {
1908     return Math.ceil((videoFile.size * 8) / this.duration)
1909   }
1910 }