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