Remove duplicated videos on unfollow/delete redundancy
[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, 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   @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       where: {
164         privacy: VideoPrivacy.PUBLIC
165       },
166       include: [
167         await VideoRedundancyModel.buildVideoFileForDuplication(),
168         VideoRedundancyModel.buildServerRedundancyInclude()
169       ]
170     }
171
172     return VideoRedundancyModel.getVideoSample(VideoModel.unscoped().findAll(query))
173   }
174
175   static async findTrendingToDuplicate (randomizedFactor: number) {
176     // On VideoModel!
177     const query = {
178       attributes: [ 'id', 'views' ],
179       subQuery: false,
180       group: 'VideoModel.id',
181       limit: randomizedFactor,
182       order: getVideoSort('-trending'),
183       where: {
184         privacy: VideoPrivacy.PUBLIC
185       },
186       include: [
187         await VideoRedundancyModel.buildVideoFileForDuplication(),
188         VideoRedundancyModel.buildServerRedundancyInclude(),
189
190         VideoModel.buildTrendingQuery(CONFIG.TRENDING.VIDEOS.INTERVAL_DAYS)
191       ]
192     }
193
194     return VideoRedundancyModel.getVideoSample(VideoModel.unscoped().findAll(query))
195   }
196
197   static async findRecentlyAddedToDuplicate (randomizedFactor: number, minViews: number) {
198     // On VideoModel!
199     const query = {
200       attributes: [ 'id', 'publishedAt' ],
201       limit: randomizedFactor,
202       order: getVideoSort('-publishedAt'),
203       where: {
204         privacy: VideoPrivacy.PUBLIC,
205         views: {
206           [ Sequelize.Op.gte ]: minViews
207         }
208       },
209       include: [
210         await VideoRedundancyModel.buildVideoFileForDuplication(),
211         VideoRedundancyModel.buildServerRedundancyInclude()
212       ]
213     }
214
215     return VideoRedundancyModel.getVideoSample(VideoModel.unscoped().findAll(query))
216   }
217
218   static async loadOldestLocalThatAlreadyExpired (strategy: VideoRedundancyStrategy, expiresAfterMs: number) {
219     const expiredDate = new Date()
220     expiredDate.setMilliseconds(expiredDate.getMilliseconds() - expiresAfterMs)
221
222     const actor = await getServerActor()
223
224     const query = {
225       where: {
226         actorId: actor.id,
227         strategy,
228         createdAt: {
229           [ Sequelize.Op.lt ]: expiredDate
230         }
231       }
232     }
233
234     return VideoRedundancyModel.scope([ ScopeNames.WITH_VIDEO ]).findOne(query)
235   }
236
237   static async getTotalDuplicated (strategy: VideoRedundancyStrategy) {
238     const actor = await getServerActor()
239
240     const options = {
241       include: [
242         {
243           attributes: [],
244           model: VideoRedundancyModel,
245           required: true,
246           where: {
247             actorId: actor.id,
248             strategy
249           }
250         }
251       ]
252     }
253
254     return VideoFileModel.sum('size', options as any) // FIXME: typings
255   }
256
257   static async listLocalExpired () {
258     const actor = await getServerActor()
259
260     const query = {
261       where: {
262         actorId: actor.id,
263         expiresOn: {
264           [ Sequelize.Op.lt ]: new Date()
265         }
266       }
267     }
268
269     return VideoRedundancyModel.scope([ ScopeNames.WITH_VIDEO ]).findAll(query)
270   }
271
272   static async listRemoteExpired () {
273     const actor = await getServerActor()
274
275     const query = {
276       where: {
277         actorId: {
278           [Sequelize.Op.ne]: actor.id
279         },
280         expiresOn: {
281           [ Sequelize.Op.lt ]: new Date()
282         }
283       }
284     }
285
286     return VideoRedundancyModel.scope([ ScopeNames.WITH_VIDEO ]).findAll(query)
287   }
288
289   static async listLocalOfServer (serverId: number) {
290     const actor = await getServerActor()
291
292     const query = {
293       where: {
294         actorId: actor.id
295       },
296       include: [
297         {
298           model: VideoFileModel,
299           required: true,
300           include: [
301             {
302               model: VideoModel,
303               required: true,
304               include: [
305                 {
306                   attributes: [],
307                   model: VideoChannelModel.unscoped(),
308                   required: true,
309                   include: [
310                     {
311                       attributes: [],
312                       model: ActorModel.unscoped(),
313                       required: true,
314                       where: {
315                         serverId
316                       }
317                     }
318                   ]
319                 }
320               ]
321             }
322           ]
323         }
324       ]
325     }
326
327     return VideoRedundancyModel.findAll(query)
328   }
329
330   static async getStats (strategy: VideoRedundancyStrategy) {
331     const actor = await getServerActor()
332
333     const query = {
334       raw: true,
335       attributes: [
336         [ Sequelize.fn('COALESCE', Sequelize.fn('SUM', Sequelize.col('VideoFile.size')), '0'), 'totalUsed' ],
337         [ Sequelize.fn('COUNT', Sequelize.fn('DISTINCT', Sequelize.col('videoId'))), 'totalVideos' ],
338         [ Sequelize.fn('COUNT', Sequelize.col('videoFileId')), 'totalVideoFiles' ]
339       ],
340       where: {
341         strategy,
342         actorId: actor.id
343       },
344       include: [
345         {
346           attributes: [],
347           model: VideoFileModel,
348           required: true
349         }
350       ]
351     }
352
353     return VideoRedundancyModel.find(query as any) // FIXME: typings
354       .then((r: any) => ({
355         totalUsed: parseInt(r.totalUsed.toString(), 10),
356         totalVideos: r.totalVideos,
357         totalVideoFiles: r.totalVideoFiles
358       }))
359   }
360
361   toActivityPubObject (): CacheFileObject {
362     return {
363       id: this.url,
364       type: 'CacheFile' as 'CacheFile',
365       object: this.VideoFile.Video.url,
366       expires: this.expiresOn.toISOString(),
367       url: {
368         type: 'Link',
369         mimeType: VIDEO_EXT_MIMETYPE[ this.VideoFile.extname ] as any,
370         href: this.fileUrl,
371         height: this.VideoFile.resolution,
372         size: this.VideoFile.size,
373         fps: this.VideoFile.fps
374       }
375     }
376   }
377
378   // Don't include video files we already duplicated
379   private static async buildVideoFileForDuplication () {
380     const actor = await getServerActor()
381
382     const notIn = Sequelize.literal(
383       '(' +
384         `SELECT "videoFileId" FROM "videoRedundancy" WHERE "actorId" = ${actor.id}` +
385       ')'
386     )
387
388     return {
389       attributes: [],
390       model: VideoFileModel.unscoped(),
391       required: true,
392       where: {
393         id: {
394           [ Sequelize.Op.notIn ]: notIn
395         }
396       }
397     }
398   }
399
400   private static buildServerRedundancyInclude () {
401     return {
402       attributes: [],
403       model: VideoChannelModel.unscoped(),
404       required: true,
405       include: [
406         {
407           attributes: [],
408           model: ActorModel.unscoped(),
409           required: true,
410           include: [
411             {
412               attributes: [],
413               model: ServerModel.unscoped(),
414               required: true,
415               where: {
416                 redundancyAllowed: true
417               }
418             }
419           ]
420         }
421       ]
422     }
423   }
424 }