]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blame_incremental - server/models/redundancy/video-redundancy.ts
Fix URI search config update
[github/Chocobozzz/PeerTube.git] / server / models / redundancy / video-redundancy.ts
... / ...
CommitLineData
1import { sample } from 'lodash'
2import { col, FindOptions, fn, literal, Op, QueryTypes, Transaction, WhereOptions } from 'sequelize'
3import {
4 AllowNull,
5 BeforeDestroy,
6 BelongsTo,
7 Column,
8 CreatedAt,
9 DataType,
10 ForeignKey,
11 Is,
12 Model,
13 Scopes,
14 Table,
15 UpdatedAt
16} from 'sequelize-typescript'
17import { getServerActor } from '@server/models/application/application'
18import { MActor, MVideoForRedundancyAPI, MVideoRedundancy, MVideoRedundancyAP, MVideoRedundancyVideo } from '@server/types/models'
19import { VideoRedundanciesTarget } from '@shared/models/redundancy/video-redundancies-filters.model'
20import {
21 FileRedundancyInformation,
22 StreamingPlaylistRedundancyInformation,
23 VideoRedundancy
24} from '@shared/models/redundancy/video-redundancy.model'
25import { CacheFileObject, VideoPrivacy } from '../../../shared'
26import { VideoRedundancyStrategy, VideoRedundancyStrategyWithManual } from '../../../shared/models/redundancy'
27import { isTestInstance } from '../../helpers/core-utils'
28import { isActivityPubUrlValid, isUrlValid } from '../../helpers/custom-validators/activitypub/misc'
29import { logger } from '../../helpers/logger'
30import { CONFIG } from '../../initializers/config'
31import { CONSTRAINTS_FIELDS, MIMETYPES } from '../../initializers/constants'
32import { ActorModel } from '../activitypub/actor'
33import { ServerModel } from '../server/server'
34import { getSort, getVideoSort, parseAggregateResult, throwIfNotValid } from '../utils'
35import { VideoModel } from '../video/video'
36import { VideoChannelModel } from '../video/video-channel'
37import { VideoFileModel } from '../video/video-file'
38import { VideoStreamingPlaylistModel } from '../video/video-streaming-playlist'
39import { forEachSeries } from 'async'
40
41export enum ScopeNames {
42 WITH_VIDEO = 'WITH_VIDEO'
43}
44
45@Scopes(() => ({
46 [ScopeNames.WITH_VIDEO]: {
47 include: [
48 {
49 model: VideoFileModel,
50 required: false,
51 include: [
52 {
53 model: VideoModel,
54 required: true
55 }
56 ]
57 },
58 {
59 model: VideoStreamingPlaylistModel,
60 required: false,
61 include: [
62 {
63 model: VideoModel,
64 required: true
65 }
66 ]
67 }
68 ]
69 }
70}))
71
72@Table({
73 tableName: 'videoRedundancy',
74 indexes: [
75 {
76 fields: [ 'videoFileId' ]
77 },
78 {
79 fields: [ 'actorId' ]
80 },
81 {
82 fields: [ 'url' ],
83 unique: true
84 }
85 ]
86})
87export class VideoRedundancyModel extends Model {
88
89 @CreatedAt
90 createdAt: Date
91
92 @UpdatedAt
93 updatedAt: Date
94
95 @AllowNull(true)
96 @Column
97 expiresOn: Date
98
99 @AllowNull(false)
100 @Is('VideoRedundancyFileUrl', value => throwIfNotValid(value, isUrlValid, 'fileUrl'))
101 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS_REDUNDANCY.URL.max))
102 fileUrl: string
103
104 @AllowNull(false)
105 @Is('VideoRedundancyUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url'))
106 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS_REDUNDANCY.URL.max))
107 url: string
108
109 @AllowNull(true)
110 @Column
111 strategy: string // Only used by us
112
113 @ForeignKey(() => VideoFileModel)
114 @Column
115 videoFileId: number
116
117 @BelongsTo(() => VideoFileModel, {
118 foreignKey: {
119 allowNull: true
120 },
121 onDelete: 'cascade'
122 })
123 VideoFile: VideoFileModel
124
125 @ForeignKey(() => VideoStreamingPlaylistModel)
126 @Column
127 videoStreamingPlaylistId: number
128
129 @BelongsTo(() => VideoStreamingPlaylistModel, {
130 foreignKey: {
131 allowNull: true
132 },
133 onDelete: 'cascade'
134 })
135 VideoStreamingPlaylist: VideoStreamingPlaylistModel
136
137 @ForeignKey(() => ActorModel)
138 @Column
139 actorId: number
140
141 @BelongsTo(() => ActorModel, {
142 foreignKey: {
143 allowNull: false
144 },
145 onDelete: 'cascade'
146 })
147 Actor: ActorModel
148
149 @BeforeDestroy
150 static async removeFile (instance: VideoRedundancyModel) {
151 if (!instance.isOwned()) return
152
153 if (instance.videoFileId) {
154 const videoFile = await VideoFileModel.loadWithVideo(instance.videoFileId)
155
156 const logIdentifier = `${videoFile.Video.uuid}-${videoFile.resolution}`
157 logger.info('Removing duplicated video file %s.', logIdentifier)
158
159 videoFile.Video.removeFile(videoFile, true)
160 .catch(err => logger.error('Cannot delete %s files.', logIdentifier, { err }))
161 }
162
163 if (instance.videoStreamingPlaylistId) {
164 const videoStreamingPlaylist = await VideoStreamingPlaylistModel.loadWithVideo(instance.videoStreamingPlaylistId)
165
166 const videoUUID = videoStreamingPlaylist.Video.uuid
167 logger.info('Removing duplicated video streaming playlist %s.', videoUUID)
168
169 videoStreamingPlaylist.Video.removeStreamingPlaylistFiles(videoStreamingPlaylist, true)
170 .catch(err => logger.error('Cannot delete video streaming playlist files of %s.', videoUUID, { err }))
171 }
172
173 return undefined
174 }
175
176 static async loadLocalByFileId (videoFileId: number): Promise<MVideoRedundancyVideo> {
177 const actor = await getServerActor()
178
179 const query = {
180 where: {
181 actorId: actor.id,
182 videoFileId
183 }
184 }
185
186 return VideoRedundancyModel.scope(ScopeNames.WITH_VIDEO).findOne(query)
187 }
188
189 static async loadLocalByStreamingPlaylistId (videoStreamingPlaylistId: number): Promise<MVideoRedundancyVideo> {
190 const actor = await getServerActor()
191
192 const query = {
193 where: {
194 actorId: actor.id,
195 videoStreamingPlaylistId
196 }
197 }
198
199 return VideoRedundancyModel.scope(ScopeNames.WITH_VIDEO).findOne(query)
200 }
201
202 static loadByIdWithVideo (id: number, transaction?: Transaction): Promise<MVideoRedundancyVideo> {
203 const query = {
204 where: { id },
205 transaction
206 }
207
208 return VideoRedundancyModel.scope(ScopeNames.WITH_VIDEO).findOne(query)
209 }
210
211 static loadByUrl (url: string, transaction?: Transaction): Promise<MVideoRedundancy> {
212 const query = {
213 where: {
214 url
215 },
216 transaction
217 }
218
219 return VideoRedundancyModel.findOne(query)
220 }
221
222 static async isLocalByVideoUUIDExists (uuid: string) {
223 const actor = await getServerActor()
224
225 const query = {
226 raw: true,
227 attributes: [ 'id' ],
228 where: {
229 actorId: actor.id
230 },
231 include: [
232 {
233 attributes: [],
234 model: VideoFileModel,
235 required: true,
236 include: [
237 {
238 attributes: [],
239 model: VideoModel,
240 required: true,
241 where: {
242 uuid
243 }
244 }
245 ]
246 }
247 ]
248 }
249
250 return VideoRedundancyModel.findOne(query)
251 .then(r => !!r)
252 }
253
254 static async getVideoSample (p: Promise<VideoModel[]>) {
255 const rows = await p
256 if (rows.length === 0) return undefined
257
258 const ids = rows.map(r => r.id)
259 const id = sample(ids)
260
261 return VideoModel.loadWithFiles(id, undefined, !isTestInstance())
262 }
263
264 static async findMostViewToDuplicate (randomizedFactor: number) {
265 const peertubeActor = await getServerActor()
266
267 // On VideoModel!
268 const query = {
269 attributes: [ 'id', 'views' ],
270 limit: randomizedFactor,
271 order: getVideoSort('-views'),
272 where: {
273 privacy: VideoPrivacy.PUBLIC,
274 isLive: false,
275 ...this.buildVideoIdsForDuplication(peertubeActor)
276 },
277 include: [
278 VideoRedundancyModel.buildServerRedundancyInclude()
279 ]
280 }
281
282 return VideoRedundancyModel.getVideoSample(VideoModel.unscoped().findAll(query))
283 }
284
285 static async findTrendingToDuplicate (randomizedFactor: number) {
286 const peertubeActor = await getServerActor()
287
288 // On VideoModel!
289 const query = {
290 attributes: [ 'id', 'views' ],
291 subQuery: false,
292 group: 'VideoModel.id',
293 limit: randomizedFactor,
294 order: getVideoSort('-trending'),
295 where: {
296 privacy: VideoPrivacy.PUBLIC,
297 isLive: false,
298 ...this.buildVideoIdsForDuplication(peertubeActor)
299 },
300 include: [
301 VideoRedundancyModel.buildServerRedundancyInclude(),
302
303 VideoModel.buildTrendingQuery(CONFIG.TRENDING.VIDEOS.INTERVAL_DAYS)
304 ]
305 }
306
307 return VideoRedundancyModel.getVideoSample(VideoModel.unscoped().findAll(query))
308 }
309
310 static async findRecentlyAddedToDuplicate (randomizedFactor: number, minViews: number) {
311 const peertubeActor = await getServerActor()
312
313 // On VideoModel!
314 const query = {
315 attributes: [ 'id', 'publishedAt' ],
316 limit: randomizedFactor,
317 order: getVideoSort('-publishedAt'),
318 where: {
319 privacy: VideoPrivacy.PUBLIC,
320 isLive: false,
321 views: {
322 [Op.gte]: minViews
323 },
324 ...this.buildVideoIdsForDuplication(peertubeActor)
325 },
326 include: [
327 VideoRedundancyModel.buildServerRedundancyInclude()
328 ]
329 }
330
331 return VideoRedundancyModel.getVideoSample(VideoModel.unscoped().findAll(query))
332 }
333
334 static async loadOldestLocalExpired (strategy: VideoRedundancyStrategy, expiresAfterMs: number): Promise<MVideoRedundancyVideo> {
335 const expiredDate = new Date()
336 expiredDate.setMilliseconds(expiredDate.getMilliseconds() - expiresAfterMs)
337
338 const actor = await getServerActor()
339
340 const query = {
341 where: {
342 actorId: actor.id,
343 strategy,
344 createdAt: {
345 [Op.lt]: expiredDate
346 }
347 }
348 }
349
350 return VideoRedundancyModel.scope([ ScopeNames.WITH_VIDEO ]).findOne(query)
351 }
352
353 static async getTotalDuplicated (strategy: VideoRedundancyStrategy) {
354 const actor = await getServerActor()
355 const redundancyInclude = {
356 attributes: [],
357 model: VideoRedundancyModel,
358 required: true,
359 where: {
360 actorId: actor.id,
361 strategy
362 }
363 }
364
365 const queryFiles: FindOptions = {
366 include: [ redundancyInclude ]
367 }
368
369 const queryStreamingPlaylists: FindOptions = {
370 include: [
371 {
372 attributes: [],
373 model: VideoModel.unscoped(),
374 required: true,
375 include: [
376 {
377 required: true,
378 attributes: [],
379 model: VideoStreamingPlaylistModel.unscoped(),
380 include: [
381 redundancyInclude
382 ]
383 }
384 ]
385 }
386 ]
387 }
388
389 return Promise.all([
390 VideoFileModel.aggregate('size', 'SUM', queryFiles),
391 VideoFileModel.aggregate('size', 'SUM', queryStreamingPlaylists)
392 ]).then(([ r1, r2 ]) => {
393 return parseAggregateResult(r1) + parseAggregateResult(r2)
394 })
395 }
396
397 static async listLocalExpired () {
398 const actor = await getServerActor()
399
400 const query = {
401 where: {
402 actorId: actor.id,
403 expiresOn: {
404 [Op.lt]: new Date()
405 }
406 }
407 }
408
409 return VideoRedundancyModel.scope([ ScopeNames.WITH_VIDEO ]).findAll(query)
410 }
411
412 static async listRemoteExpired () {
413 const actor = await getServerActor()
414
415 const query = {
416 where: {
417 actorId: {
418 [Op.ne]: actor.id
419 },
420 expiresOn: {
421 [Op.lt]: new Date(),
422 [Op.ne]: null
423 }
424 }
425 }
426
427 return VideoRedundancyModel.scope([ ScopeNames.WITH_VIDEO ]).findAll(query)
428 }
429
430 static async listLocalOfServer (serverId: number) {
431 const actor = await getServerActor()
432 const buildVideoInclude = () => ({
433 model: VideoModel,
434 required: true,
435 include: [
436 {
437 attributes: [],
438 model: VideoChannelModel.unscoped(),
439 required: true,
440 include: [
441 {
442 attributes: [],
443 model: ActorModel.unscoped(),
444 required: true,
445 where: {
446 serverId
447 }
448 }
449 ]
450 }
451 ]
452 })
453
454 const query = {
455 where: {
456 actorId: actor.id
457 },
458 include: [
459 {
460 model: VideoFileModel,
461 required: false,
462 include: [ buildVideoInclude() ]
463 },
464 {
465 model: VideoStreamingPlaylistModel,
466 required: false,
467 include: [ buildVideoInclude() ]
468 }
469 ]
470 }
471
472 return VideoRedundancyModel.findAll(query)
473 }
474
475 static listForApi (options: {
476 start: number
477 count: number
478 sort: string
479 target: VideoRedundanciesTarget
480 strategy?: string
481 }) {
482 const { start, count, sort, target, strategy } = options
483 const redundancyWhere: WhereOptions = {}
484 const videosWhere: WhereOptions = {}
485 let redundancySqlSuffix = ''
486
487 if (target === 'my-videos') {
488 Object.assign(videosWhere, { remote: false })
489 } else if (target === 'remote-videos') {
490 Object.assign(videosWhere, { remote: true })
491 Object.assign(redundancyWhere, { strategy: { [Op.ne]: null } })
492 redundancySqlSuffix = ' AND "videoRedundancy"."strategy" IS NOT NULL'
493 }
494
495 if (strategy) {
496 Object.assign(redundancyWhere, { strategy: strategy })
497 }
498
499 const videoFilterWhere = {
500 [Op.and]: [
501 {
502 [Op.or]: [
503 {
504 id: {
505 [Op.in]: literal(
506 '(' +
507 'SELECT "videoId" FROM "videoFile" ' +
508 'INNER JOIN "videoRedundancy" ON "videoRedundancy"."videoFileId" = "videoFile".id' +
509 redundancySqlSuffix +
510 ')'
511 )
512 }
513 },
514 {
515 id: {
516 [Op.in]: literal(
517 '(' +
518 'select "videoId" FROM "videoStreamingPlaylist" ' +
519 'INNER JOIN "videoRedundancy" ON "videoRedundancy"."videoStreamingPlaylistId" = "videoStreamingPlaylist".id' +
520 redundancySqlSuffix +
521 ')'
522 )
523 }
524 }
525 ]
526 },
527
528 videosWhere
529 ]
530 }
531
532 // /!\ On video model /!\
533 const findOptions = {
534 offset: start,
535 limit: count,
536 order: getSort(sort),
537 include: [
538 {
539 required: false,
540 model: VideoFileModel,
541 include: [
542 {
543 model: VideoRedundancyModel.unscoped(),
544 required: false,
545 where: redundancyWhere
546 }
547 ]
548 },
549 {
550 required: false,
551 model: VideoStreamingPlaylistModel.unscoped(),
552 include: [
553 {
554 model: VideoRedundancyModel.unscoped(),
555 required: false,
556 where: redundancyWhere
557 },
558 {
559 model: VideoFileModel,
560 required: false
561 }
562 ]
563 }
564 ],
565 where: videoFilterWhere
566 }
567
568 // /!\ On video model /!\
569 const countOptions = {
570 where: videoFilterWhere
571 }
572
573 return Promise.all([
574 VideoModel.findAll(findOptions),
575
576 VideoModel.count(countOptions)
577 ]).then(([ data, total ]) => ({ total, data }))
578 }
579
580 static async getStats (strategy: VideoRedundancyStrategyWithManual) {
581 const actor = await getServerActor()
582
583 const sql = `WITH "tmp" AS ` +
584 `(` +
585 `SELECT "videoFile"."size" AS "videoFileSize", "videoStreamingFile"."size" AS "videoStreamingFileSize", ` +
586 `"videoFile"."videoId" AS "videoFileVideoId", "videoStreamingPlaylist"."videoId" AS "videoStreamingVideoId"` +
587 `FROM "videoRedundancy" AS "videoRedundancy" ` +
588 `LEFT JOIN "videoFile" AS "videoFile" ON "videoRedundancy"."videoFileId" = "videoFile"."id" ` +
589 `LEFT JOIN "videoStreamingPlaylist" ON "videoRedundancy"."videoStreamingPlaylistId" = "videoStreamingPlaylist"."id" ` +
590 `LEFT JOIN "videoFile" AS "videoStreamingFile" ` +
591 `ON "videoStreamingPlaylist"."id" = "videoStreamingFile"."videoStreamingPlaylistId" ` +
592 `WHERE "videoRedundancy"."strategy" = :strategy AND "videoRedundancy"."actorId" = :actorId` +
593 `), ` +
594 `"videoIds" AS (` +
595 `SELECT "videoFileVideoId" AS "videoId" FROM "tmp" ` +
596 `UNION SELECT "videoStreamingVideoId" AS "videoId" FROM "tmp" ` +
597 `) ` +
598 `SELECT ` +
599 `COALESCE(SUM("videoFileSize"), '0') + COALESCE(SUM("videoStreamingFileSize"), '0') AS "totalUsed", ` +
600 `(SELECT COUNT("videoIds"."videoId") FROM "videoIds") AS "totalVideos", ` +
601 `COUNT(*) AS "totalVideoFiles" ` +
602 `FROM "tmp"`
603
604 return VideoRedundancyModel.sequelize.query<any>(sql, {
605 replacements: { strategy, actorId: actor.id },
606 type: QueryTypes.SELECT
607 }).then(([ row ]) => ({
608 totalUsed: parseAggregateResult(row.totalUsed),
609 totalVideos: row.totalVideos,
610 totalVideoFiles: row.totalVideoFiles
611 }))
612 }
613
614 static toFormattedJSONStatic (video: MVideoForRedundancyAPI): VideoRedundancy {
615 const filesRedundancies: FileRedundancyInformation[] = []
616 const streamingPlaylistsRedundancies: StreamingPlaylistRedundancyInformation[] = []
617
618 for (const file of video.VideoFiles) {
619 for (const redundancy of file.RedundancyVideos) {
620 filesRedundancies.push({
621 id: redundancy.id,
622 fileUrl: redundancy.fileUrl,
623 strategy: redundancy.strategy,
624 createdAt: redundancy.createdAt,
625 updatedAt: redundancy.updatedAt,
626 expiresOn: redundancy.expiresOn,
627 size: file.size
628 })
629 }
630 }
631
632 for (const playlist of video.VideoStreamingPlaylists) {
633 const size = playlist.VideoFiles.reduce((a, b) => a + b.size, 0)
634
635 for (const redundancy of playlist.RedundancyVideos) {
636 streamingPlaylistsRedundancies.push({
637 id: redundancy.id,
638 fileUrl: redundancy.fileUrl,
639 strategy: redundancy.strategy,
640 createdAt: redundancy.createdAt,
641 updatedAt: redundancy.updatedAt,
642 expiresOn: redundancy.expiresOn,
643 size
644 })
645 }
646 }
647
648 return {
649 id: video.id,
650 name: video.name,
651 url: video.url,
652 uuid: video.uuid,
653
654 redundancies: {
655 files: filesRedundancies,
656 streamingPlaylists: streamingPlaylistsRedundancies
657 }
658 }
659 }
660
661 getVideo () {
662 if (this.VideoFile) return this.VideoFile.Video
663
664 if (this.VideoStreamingPlaylist.Video) return this.VideoStreamingPlaylist.Video
665
666 return undefined
667 }
668
669 isOwned () {
670 return !!this.strategy
671 }
672
673 toActivityPubObject (this: MVideoRedundancyAP): CacheFileObject {
674 if (this.VideoStreamingPlaylist) {
675 return {
676 id: this.url,
677 type: 'CacheFile' as 'CacheFile',
678 object: this.VideoStreamingPlaylist.Video.url,
679 expires: this.expiresOn ? this.expiresOn.toISOString() : null,
680 url: {
681 type: 'Link',
682 mediaType: 'application/x-mpegURL',
683 href: this.fileUrl
684 }
685 }
686 }
687
688 return {
689 id: this.url,
690 type: 'CacheFile' as 'CacheFile',
691 object: this.VideoFile.Video.url,
692 expires: this.expiresOn ? this.expiresOn.toISOString() : null,
693 url: {
694 type: 'Link',
695 mediaType: MIMETYPES.VIDEO.EXT_MIMETYPE[this.VideoFile.extname] as any,
696 href: this.fileUrl,
697 height: this.VideoFile.resolution,
698 size: this.VideoFile.size,
699 fps: this.VideoFile.fps
700 }
701 }
702 }
703
704 // Don't include video files we already duplicated
705 private static buildVideoIdsForDuplication (peertubeActor: MActor) {
706 const notIn = literal(
707 '(' +
708 `SELECT "videoFile"."videoId" AS "videoId" FROM "videoRedundancy" ` +
709 `INNER JOIN "videoFile" ON "videoFile"."id" = "videoRedundancy"."videoFileId" ` +
710 `WHERE "videoRedundancy"."actorId" = ${peertubeActor.id} ` +
711 `UNION ` +
712 `SELECT "videoStreamingPlaylist"."videoId" AS "videoId" FROM "videoRedundancy" ` +
713 `INNER JOIN "videoStreamingPlaylist" ON "videoStreamingPlaylist"."id" = "videoRedundancy"."videoStreamingPlaylistId" ` +
714 `WHERE "videoRedundancy"."actorId" = ${peertubeActor.id} ` +
715 ')'
716 )
717
718 return {
719 id: {
720 [Op.notIn]: notIn
721 }
722 }
723 }
724
725 private static buildServerRedundancyInclude () {
726 return {
727 attributes: [],
728 model: VideoChannelModel.unscoped(),
729 required: true,
730 include: [
731 {
732 attributes: [],
733 model: ActorModel.unscoped(),
734 required: true,
735 include: [
736 {
737 attributes: [],
738 model: ServerModel.unscoped(),
739 required: true,
740 where: {
741 redundancyAllowed: true
742 }
743 }
744 ]
745 }
746 ]
747 }
748 }
749}