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