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