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