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