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