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