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