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