Merge from upstream
[oweals/peertube.git] / server / models / redundancy / video-redundancy.ts
1 import {
2   AllowNull,
3   BeforeDestroy,
4   BelongsTo,
5   Column,
6   CreatedAt,
7   DataType,
8   ForeignKey,
9   Is,
10   Model,
11   Scopes,
12   Table,
13   UpdatedAt
14 } from 'sequelize-typescript'
15 import { ActorModel } from '../activitypub/actor'
16 import { getVideoSort, throwIfNotValid } from '../utils'
17 import { isActivityPubUrlValid, isUrlValid } from '../../helpers/custom-validators/activitypub/misc'
18 import { CONFIG, CONSTRAINTS_FIELDS, VIDEO_EXT_MIMETYPE } from '../../initializers'
19 import { VideoFileModel } from '../video/video-file'
20 import { getServerActor } from '../../helpers/utils'
21 import { VideoModel } from '../video/video'
22 import { VideoRedundancyStrategy } from '../../../shared/models/redundancy'
23 import { logger } from '../../helpers/logger'
24 import { CacheFileObject, VideoPrivacy } from '../../../shared'
25 import { VideoChannelModel } from '../video/video-channel'
26 import { ServerModel } from '../server/server'
27 import { sample } from 'lodash'
28 import { isTestInstance } from '../../helpers/core-utils'
29 import * as Bluebird from 'bluebird'
30 import * as Sequelize from 'sequelize'
31
32 export enum ScopeNames {
33   WITH_VIDEO = 'WITH_VIDEO'
34 }
35
36 @Scopes({
37   [ ScopeNames.WITH_VIDEO ]: {
38     include: [
39       {
40         model: () => VideoFileModel,
41         required: true,
42         include: [
43           {
44             model: () => VideoModel,
45             required: true
46           }
47         ]
48       }
49     ]
50   }
51 })
52
53 @Table({
54   tableName: 'videoRedundancy',
55   indexes: [
56     {
57       fields: [ 'videoFileId' ]
58     },
59     {
60       fields: [ 'actorId' ]
61     },
62     {
63       fields: [ 'url' ],
64       unique: true
65     }
66   ]
67 })
68 export class VideoRedundancyModel extends Model<VideoRedundancyModel> {
69
70   @CreatedAt
71   createdAt: Date
72
73   @UpdatedAt
74   updatedAt: Date
75
76   @AllowNull(false)
77   @Column
78   expiresOn: Date
79
80   @AllowNull(false)
81   @Is('VideoRedundancyFileUrl', value => throwIfNotValid(value, isUrlValid, 'fileUrl'))
82   @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS_REDUNDANCY.URL.max))
83   fileUrl: string
84
85   @AllowNull(false)
86   @Is('VideoRedundancyUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url'))
87   @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS_REDUNDANCY.URL.max))
88   url: string
89
90   @AllowNull(true)
91   @Column
92   strategy: string // Only used by us
93
94   @ForeignKey(() => VideoFileModel)
95   @Column
96   videoFileId: number
97
98   @BelongsTo(() => VideoFileModel, {
99     foreignKey: {
100       allowNull: false
101     },
102     onDelete: 'cascade'
103   })
104   VideoFile: VideoFileModel
105
106   @ForeignKey(() => ActorModel)
107   @Column
108   actorId: number
109
110   @BelongsTo(() => ActorModel, {
111     foreignKey: {
112       allowNull: false
113     },
114     onDelete: 'cascade'
115   })
116   Actor: ActorModel
117
118   @BeforeDestroy
119   static async removeFile (instance: VideoRedundancyModel) {
120     // Not us
121     if (!instance.strategy) return
122
123     const videoFile = await VideoFileModel.loadWithVideo(instance.videoFileId)
124
125     const logIdentifier = `${videoFile.Video.uuid}-${videoFile.resolution}`
126     logger.info('Removing duplicated video file %s.', logIdentifier)
127
128     videoFile.Video.removeFile(videoFile)
129              .catch(err => logger.error('Cannot delete %s files.', logIdentifier, { err }))
130
131     return undefined
132   }
133
134   static async loadLocalByFileId (videoFileId: number) {
135     const actor = await getServerActor()
136
137     const query = {
138       where: {
139         actorId: actor.id,
140         videoFileId
141       }
142     }
143
144     return VideoRedundancyModel.scope(ScopeNames.WITH_VIDEO).findOne(query)
145   }
146
147   static loadByUrl (url: string, transaction?: Sequelize.Transaction) {
148     const query = {
149       where: {
150         url
151       },
152       transaction
153     }
154
155     return VideoRedundancyModel.findOne(query)
156   }
157
158   static async isLocalByVideoUUIDExists (uuid: string) {
159     const actor = await getServerActor()
160
161     const query = {
162       raw: true,
163       attributes: [ 'id' ],
164       where: {
165         actorId: actor.id
166       },
167       include: [
168         {
169           attributes: [ ],
170           model: VideoFileModel,
171           required: true,
172           include: [
173             {
174               attributes: [ ],
175               model: VideoModel,
176               required: true,
177               where: {
178                 uuid
179               }
180             }
181           ]
182         }
183       ]
184     }
185
186     return VideoRedundancyModel.findOne(query)
187       .then(r => !!r)
188   }
189
190   static async getVideoSample (p: Bluebird<VideoModel[]>) {
191     const rows = await p
192     const ids = rows.map(r => r.id)
193     const id = sample(ids)
194
195     return VideoModel.loadWithFile(id, undefined, !isTestInstance())
196   }
197
198   static async findMostViewToDuplicate (randomizedFactor: number) {
199     // On VideoModel!
200     const query = {
201       attributes: [ 'id', 'views' ],
202       limit: randomizedFactor,
203       order: getVideoSort('-views'),
204       where: {
205         privacy: VideoPrivacy.PUBLIC
206       },
207       include: [
208         await VideoRedundancyModel.buildVideoFileForDuplication(),
209         VideoRedundancyModel.buildServerRedundancyInclude()
210       ]
211     }
212
213     return VideoRedundancyModel.getVideoSample(VideoModel.unscoped().findAll(query))
214   }
215
216   static async findTrendingToDuplicate (randomizedFactor: number) {
217     // On VideoModel!
218     const query = {
219       attributes: [ 'id', 'views' ],
220       subQuery: false,
221       group: 'VideoModel.id',
222       limit: randomizedFactor,
223       order: getVideoSort('-trending'),
224       where: {
225         privacy: VideoPrivacy.PUBLIC
226       },
227       include: [
228         await VideoRedundancyModel.buildVideoFileForDuplication(),
229         VideoRedundancyModel.buildServerRedundancyInclude(),
230
231         VideoModel.buildTrendingQuery(CONFIG.TRENDING.VIDEOS.INTERVAL_DAYS)
232       ]
233     }
234
235     return VideoRedundancyModel.getVideoSample(VideoModel.unscoped().findAll(query))
236   }
237
238   static async findRecentlyAddedToDuplicate (randomizedFactor: number, minViews: number) {
239     // On VideoModel!
240     const query = {
241       attributes: [ 'id', 'publishedAt' ],
242       limit: randomizedFactor,
243       order: getVideoSort('-publishedAt'),
244       where: {
245         privacy: VideoPrivacy.PUBLIC,
246         views: {
247           [ Sequelize.Op.gte ]: minViews
248         }
249       },
250       include: [
251         await VideoRedundancyModel.buildVideoFileForDuplication(),
252         VideoRedundancyModel.buildServerRedundancyInclude()
253       ]
254     }
255
256     return VideoRedundancyModel.getVideoSample(VideoModel.unscoped().findAll(query))
257   }
258
259   static async loadOldestLocalThatAlreadyExpired (strategy: VideoRedundancyStrategy, expiresAfterMs: number) {
260     const expiredDate = new Date()
261     expiredDate.setMilliseconds(expiredDate.getMilliseconds() - expiresAfterMs)
262
263     const actor = await getServerActor()
264
265     const query = {
266       where: {
267         actorId: actor.id,
268         strategy,
269         createdAt: {
270           [ Sequelize.Op.lt ]: expiredDate
271         }
272       }
273     }
274
275     return VideoRedundancyModel.scope([ ScopeNames.WITH_VIDEO ]).findOne(query)
276   }
277
278   static async getTotalDuplicated (strategy: VideoRedundancyStrategy) {
279     const actor = await getServerActor()
280
281     const options = {
282       include: [
283         {
284           attributes: [],
285           model: VideoRedundancyModel,
286           required: true,
287           where: {
288             actorId: actor.id,
289             strategy
290           }
291         }
292       ]
293     }
294
295     return VideoFileModel.sum('size', options as any) // FIXME: typings
296       .then(v => {
297         if (!v || isNaN(v)) return 0
298
299         return v
300       })
301   }
302
303   static async listLocalExpired () {
304     const actor = await getServerActor()
305
306     const query = {
307       where: {
308         actorId: actor.id,
309         expiresOn: {
310           [ Sequelize.Op.lt ]: new Date()
311         }
312       }
313     }
314
315     return VideoRedundancyModel.scope([ ScopeNames.WITH_VIDEO ]).findAll(query)
316   }
317
318   static async listRemoteExpired () {
319     const actor = await getServerActor()
320
321     const query = {
322       where: {
323         actorId: {
324           [Sequelize.Op.ne]: actor.id
325         },
326         expiresOn: {
327           [ Sequelize.Op.lt ]: new Date()
328         }
329       }
330     }
331
332     return VideoRedundancyModel.scope([ ScopeNames.WITH_VIDEO ]).findAll(query)
333   }
334
335   static async listLocalOfServer (serverId: number) {
336     const actor = await getServerActor()
337
338     const query = {
339       where: {
340         actorId: actor.id
341       },
342       include: [
343         {
344           model: VideoFileModel,
345           required: true,
346           include: [
347             {
348               model: VideoModel,
349               required: true,
350               include: [
351                 {
352                   attributes: [],
353                   model: VideoChannelModel.unscoped(),
354                   required: true,
355                   include: [
356                     {
357                       attributes: [],
358                       model: ActorModel.unscoped(),
359                       required: true,
360                       where: {
361                         serverId
362                       }
363                     }
364                   ]
365                 }
366               ]
367             }
368           ]
369         }
370       ]
371     }
372
373     return VideoRedundancyModel.findAll(query)
374   }
375
376   static async getStats (strategy: VideoRedundancyStrategy) {
377     const actor = await getServerActor()
378
379     const query = {
380       raw: true,
381       attributes: [
382         [ Sequelize.fn('COALESCE', Sequelize.fn('SUM', Sequelize.col('VideoFile.size')), '0'), 'totalUsed' ],
383         [ Sequelize.fn('COUNT', Sequelize.fn('DISTINCT', Sequelize.col('videoId'))), 'totalVideos' ],
384         [ Sequelize.fn('COUNT', Sequelize.col('videoFileId')), 'totalVideoFiles' ]
385       ],
386       where: {
387         strategy,
388         actorId: actor.id
389       },
390       include: [
391         {
392           attributes: [],
393           model: VideoFileModel,
394           required: true
395         }
396       ]
397     }
398
399     return VideoRedundancyModel.find(query as any) // FIXME: typings
400       .then((r: any) => ({
401         totalUsed: parseInt(r.totalUsed.toString(), 10),
402         totalVideos: r.totalVideos,
403         totalVideoFiles: r.totalVideoFiles
404       }))
405   }
406
407   toActivityPubObject (): CacheFileObject {
408     return {
409       id: this.url,
410       type: 'CacheFile' as 'CacheFile',
411       object: this.VideoFile.Video.url,
412       expires: this.expiresOn.toISOString(),
413       url: {
414         type: 'Link',
415         mimeType: VIDEO_EXT_MIMETYPE[ this.VideoFile.extname ] as any,
416         mediaType: VIDEO_EXT_MIMETYPE[ this.VideoFile.extname ] as any,
417         href: this.fileUrl,
418         height: this.VideoFile.resolution,
419         size: this.VideoFile.size,
420         fps: this.VideoFile.fps
421       }
422     }
423   }
424
425   // Don't include video files we already duplicated
426   private static async buildVideoFileForDuplication () {
427     const actor = await getServerActor()
428
429     const notIn = Sequelize.literal(
430       '(' +
431         `SELECT "videoFileId" FROM "videoRedundancy" WHERE "actorId" = ${actor.id}` +
432       ')'
433     )
434
435     return {
436       attributes: [],
437       model: VideoFileModel.unscoped(),
438       required: true,
439       where: {
440         id: {
441           [ Sequelize.Op.notIn ]: notIn
442         }
443       }
444     }
445   }
446
447   private static buildServerRedundancyInclude () {
448     return {
449       attributes: [],
450       model: VideoChannelModel.unscoped(),
451       required: true,
452       include: [
453         {
454           attributes: [],
455           model: ActorModel.unscoped(),
456           required: true,
457           include: [
458             {
459               attributes: [],
460               model: ServerModel.unscoped(),
461               required: true,
462               where: {
463                 redundancyAllowed: true
464               }
465             }
466           ]
467         }
468       ]
469     }
470   }
471 }