Improve redundancy: add 'min_lifetime' configuration
[oweals/peertube.git] / server / models / redundancy / video-redundancy.ts
1 import {
2   AfterDestroy,
3   AllowNull,
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 } 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   @AfterDestroy
119   static removeFile (instance: VideoRedundancyModel) {
120     // Not us
121     if (!instance.strategy) return
122
123     logger.info('Removing duplicated video file %s-%s.', instance.VideoFile.Video.uuid, instance.VideoFile.resolution)
124
125     return instance.VideoFile.Video.removeFile(instance.VideoFile)
126   }
127
128   static loadByFileId (videoFileId: number) {
129     const query = {
130       where: {
131         videoFileId
132       }
133     }
134
135     return VideoRedundancyModel.scope(ScopeNames.WITH_VIDEO).findOne(query)
136   }
137
138   static loadByUrl (url: string, transaction?: Sequelize.Transaction) {
139     const query = {
140       where: {
141         url
142       },
143       transaction
144     }
145
146     return VideoRedundancyModel.findOne(query)
147   }
148
149   static async getVideoSample (p: Bluebird<VideoModel[]>) {
150     const rows = await p
151     const ids = rows.map(r => r.id)
152     const id = sample(ids)
153
154     return VideoModel.loadWithFile(id, undefined, !isTestInstance())
155   }
156
157   static async findMostViewToDuplicate (randomizedFactor: number) {
158     // On VideoModel!
159     const query = {
160       attributes: [ 'id', 'views' ],
161       limit: randomizedFactor,
162       order: getVideoSort('-views'),
163       include: [
164         await VideoRedundancyModel.buildVideoFileForDuplication(),
165         VideoRedundancyModel.buildServerRedundancyInclude()
166       ]
167     }
168
169     return VideoRedundancyModel.getVideoSample(VideoModel.unscoped().findAll(query))
170   }
171
172   static async findTrendingToDuplicate (randomizedFactor: number) {
173     // On VideoModel!
174     const query = {
175       attributes: [ 'id', 'views' ],
176       subQuery: false,
177       group: 'VideoModel.id',
178       limit: randomizedFactor,
179       order: getVideoSort('-trending'),
180       include: [
181         await VideoRedundancyModel.buildVideoFileForDuplication(),
182         VideoRedundancyModel.buildServerRedundancyInclude(),
183
184         VideoModel.buildTrendingQuery(CONFIG.TRENDING.VIDEOS.INTERVAL_DAYS)
185       ]
186     }
187
188     return VideoRedundancyModel.getVideoSample(VideoModel.unscoped().findAll(query))
189   }
190
191   static async findRecentlyAddedToDuplicate (randomizedFactor: number, minViews: number) {
192     // On VideoModel!
193     const query = {
194       attributes: [ 'id', 'publishedAt' ],
195       limit: randomizedFactor,
196       order: getVideoSort('-publishedAt'),
197       where: {
198         views: {
199           [ Sequelize.Op.gte ]: minViews
200         }
201       },
202       include: [
203         await VideoRedundancyModel.buildVideoFileForDuplication(),
204         VideoRedundancyModel.buildServerRedundancyInclude()
205       ]
206     }
207
208     return VideoRedundancyModel.getVideoSample(VideoModel.unscoped().findAll(query))
209   }
210
211   static async loadOldestLocalThatAlreadyExpired (strategy: VideoRedundancyStrategy, expiresAfterMs: number) {
212     const expiredDate = new Date()
213     expiredDate.setMilliseconds(expiredDate.getMilliseconds() - expiresAfterMs)
214
215     const actor = await getServerActor()
216
217     const query = {
218       where: {
219         actorId: actor.id,
220         strategy,
221         createdAt: {
222           [ Sequelize.Op.lt ]: expiredDate
223         }
224       }
225     }
226
227     return VideoRedundancyModel.scope([ ScopeNames.WITH_VIDEO ]).findOne(query)
228   }
229
230   static async getTotalDuplicated (strategy: VideoRedundancyStrategy) {
231     const actor = await getServerActor()
232
233     const options = {
234       include: [
235         {
236           attributes: [],
237           model: VideoRedundancyModel,
238           required: true,
239           where: {
240             actorId: actor.id,
241             strategy
242           }
243         }
244       ]
245     }
246
247     return VideoFileModel.sum('size', options as any) // FIXME: typings
248   }
249
250   static async listLocalExpired () {
251     const actor = await getServerActor()
252
253     const query = {
254       where: {
255         actorId: actor.id,
256         expiresOn: {
257           [ Sequelize.Op.lt ]: new Date()
258         }
259       }
260     }
261
262     return VideoRedundancyModel.scope([ ScopeNames.WITH_VIDEO ]).findAll(query)
263   }
264
265   static async listRemoteExpired () {
266     const actor = await getServerActor()
267
268     const query = {
269       where: {
270         actorId: {
271           [Sequelize.Op.ne]: actor.id
272         },
273         expiresOn: {
274           [ Sequelize.Op.lt ]: new Date()
275         }
276       }
277     }
278
279     return VideoRedundancyModel.scope([ ScopeNames.WITH_VIDEO ]).findAll(query)
280   }
281
282   static async getStats (strategy: VideoRedundancyStrategy) {
283     const actor = await getServerActor()
284
285     const query = {
286       raw: true,
287       attributes: [
288         [ Sequelize.fn('COALESCE', Sequelize.fn('SUM', Sequelize.col('VideoFile.size')), '0'), 'totalUsed' ],
289         [ Sequelize.fn('COUNT', Sequelize.fn('DISTINCT', 'videoId')), 'totalVideos' ],
290         [ Sequelize.fn('COUNT', 'videoFileId'), 'totalVideoFiles' ]
291       ],
292       where: {
293         strategy,
294         actorId: actor.id
295       },
296       include: [
297         {
298           attributes: [],
299           model: VideoFileModel,
300           required: true
301         }
302       ]
303     }
304
305     return VideoRedundancyModel.find(query as any) // FIXME: typings
306       .then((r: any) => ({
307         totalUsed: parseInt(r.totalUsed.toString(), 10),
308         totalVideos: r.totalVideos,
309         totalVideoFiles: r.totalVideoFiles
310       }))
311   }
312
313   toActivityPubObject (): CacheFileObject {
314     return {
315       id: this.url,
316       type: 'CacheFile' as 'CacheFile',
317       object: this.VideoFile.Video.url,
318       expires: this.expiresOn.toISOString(),
319       url: {
320         type: 'Link',
321         mimeType: VIDEO_EXT_MIMETYPE[ this.VideoFile.extname ] as any,
322         href: this.fileUrl,
323         height: this.VideoFile.resolution,
324         size: this.VideoFile.size,
325         fps: this.VideoFile.fps
326       }
327     }
328   }
329
330   // Don't include video files we already duplicated
331   private static async buildVideoFileForDuplication () {
332     const actor = await getServerActor()
333
334     const notIn = Sequelize.literal(
335       '(' +
336         `SELECT "videoFileId" FROM "videoRedundancy" WHERE "actorId" = ${actor.id}` +
337       ')'
338     )
339
340     return {
341       attributes: [],
342       model: VideoFileModel.unscoped(),
343       required: true,
344       where: {
345         id: {
346           [ Sequelize.Op.notIn ]: notIn
347         }
348       }
349     }
350   }
351
352   private static buildServerRedundancyInclude () {
353     return {
354       attributes: [],
355       model: VideoChannelModel.unscoped(),
356       required: true,
357       include: [
358         {
359           attributes: [],
360           model: ActorModel.unscoped(),
361           required: true,
362           include: [
363             {
364               attributes: [],
365               model: ServerModel.unscoped(),
366               required: true,
367               where: {
368                 redundancyAllowed: true
369               }
370             }
371           ]
372         }
373       ]
374     }
375   }
376 }