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