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