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