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