]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blame - server/models/video/video-file.ts
Refactor model utils
[github/Chocobozzz/PeerTube.git] / server / models / video / video-file.ts
CommitLineData
90a8bd30 1import { remove } from 'fs-extra'
41fb13c3 2import memoizee from 'memoizee'
90a8bd30 3import { join } from 'path'
9d8ef212 4import { FindOptions, Op, Transaction, WhereOptions } from 'sequelize'
c48e82b5
C
5import {
6 AllowNull,
7 BelongsTo,
8 Column,
9 CreatedAt,
10 DataType,
11 Default,
90a8bd30 12 DefaultScope,
c48e82b5
C
13 ForeignKey,
14 HasMany,
15 Is,
16 Model,
8319d6ae 17 Scopes,
90a8bd30
C
18 Table,
19 UpdatedAt
c48e82b5 20} from 'sequelize-typescript'
90a8bd30 21import validator from 'validator'
90a8bd30
C
22import { logger } from '@server/helpers/logger'
23import { extractVideo } from '@server/helpers/video'
8c4bbd94 24import { CONFIG } from '@server/initializers/config'
7e98a7df 25import { buildRemoteVideoBaseUrl } from '@server/lib/activitypub/url'
9ab330b9
C
26import {
27 getHLSPrivateFileUrl,
28 getHLSPublicFileUrl,
29 getWebTorrentPrivateFileUrl,
30 getWebTorrentPublicFileUrl
31} from '@server/lib/object-storage'
0305db28 32import { getFSTorrentFilePath } from '@server/lib/paths'
3545e72c 33import { isVideoInPrivateDirectory } from '@server/lib/video-privacy'
ad5db104 34import { isStreamingPlaylist, MStreamingPlaylistVideo, MVideo, MVideoWithHost } from '@server/types/models'
482b2623 35import { VideoResolution, VideoStorage } from '@shared/models'
6b5f72be 36import { AttributesOnly } from '@shared/typescript-utils'
3a6f351b 37import {
14e2014a 38 isVideoFileExtnameValid,
3a6f351b
C
39 isVideoFileInfoHashValid,
40 isVideoFileResolutionValid,
41 isVideoFileSizeValid,
42 isVideoFPSResolutionValid
43} from '../../helpers/custom-validators/videos'
90a8bd30
C
44import {
45 LAZY_STATIC_PATHS,
46 MEMOIZE_LENGTH,
47 MEMOIZE_TTL,
90a8bd30
C
48 STATIC_DOWNLOAD_PATHS,
49 STATIC_PATHS,
50 WEBSERVER
51} from '../../initializers/constants'
52import { MVideoFile, MVideoFileStreamingPlaylistVideo, MVideoFileVideo } from '../../types/models/video/video-file'
53import { VideoRedundancyModel } from '../redundancy/video-redundancy'
8c4bbd94 54import { doesExist, parseAggregateResult, throwIfNotValid } from '../shared'
3fd3ab2d 55import { VideoModel } from './video'
ae9bbed4 56import { VideoStreamingPlaylistModel } from './video-streaming-playlist'
93e1258c 57
8319d6ae
RK
58export enum ScopeNames {
59 WITH_VIDEO = 'WITH_VIDEO',
90a8bd30
C
60 WITH_METADATA = 'WITH_METADATA',
61 WITH_VIDEO_OR_PLAYLIST = 'WITH_VIDEO_OR_PLAYLIST'
8319d6ae
RK
62}
63
8319d6ae
RK
64@DefaultScope(() => ({
65 attributes: {
7b81edc8 66 exclude: [ 'metadata' ]
8319d6ae
RK
67 }
68}))
69@Scopes(() => ({
70 [ScopeNames.WITH_VIDEO]: {
71 include: [
72 {
73 model: VideoModel.unscoped(),
74 required: true
75 }
76 ]
77 },
9d8ef212 78 [ScopeNames.WITH_VIDEO_OR_PLAYLIST]: (options: { whereVideo?: WhereOptions } = {}) => {
90a8bd30
C
79 return {
80 include: [
81 {
82 model: VideoModel.unscoped(),
83 required: false,
84 where: options.whereVideo
85 },
86 {
87 model: VideoStreamingPlaylistModel.unscoped(),
88 required: false,
89 include: [
90 {
91 model: VideoModel.unscoped(),
92 required: true,
93 where: options.whereVideo
94 }
95 ]
96 }
97 ]
98 }
99 },
8319d6ae
RK
100 [ScopeNames.WITH_METADATA]: {
101 attributes: {
7b81edc8 102 include: [ 'metadata' ]
8319d6ae
RK
103 }
104 }
105}))
3fd3ab2d
C
106@Table({
107 tableName: 'videoFile',
108 indexes: [
93e1258c 109 {
d7a25329
C
110 fields: [ 'videoId' ],
111 where: {
112 videoId: {
113 [Op.ne]: null
114 }
115 }
116 },
117 {
118 fields: [ 'videoStreamingPlaylistId' ],
119 where: {
120 videoStreamingPlaylistId: {
121 [Op.ne]: null
122 }
123 }
93e1258c 124 },
d7a25329 125
93e1258c 126 {
3fd3ab2d 127 fields: [ 'infoHash' ]
8cd72bd3 128 },
d7a25329 129
90a8bd30
C
130 {
131 fields: [ 'torrentFilename' ],
132 unique: true
133 },
134
135 {
136 fields: [ 'filename' ],
137 unique: true
138 },
139
8cd72bd3
C
140 {
141 fields: [ 'videoId', 'resolution', 'fps' ],
d7a25329
C
142 unique: true,
143 where: {
144 videoId: {
145 [Op.ne]: null
146 }
147 }
148 },
149 {
150 fields: [ 'videoStreamingPlaylistId', 'resolution', 'fps' ],
151 unique: true,
152 where: {
153 videoStreamingPlaylistId: {
154 [Op.ne]: null
155 }
156 }
93e1258c 157 }
93e1258c 158 ]
3fd3ab2d 159})
16c016e8 160export class VideoFileModel extends Model<Partial<AttributesOnly<VideoFileModel>>> {
3fd3ab2d
C
161 @CreatedAt
162 createdAt: Date
163
164 @UpdatedAt
165 updatedAt: Date
166
167 @AllowNull(false)
168 @Is('VideoFileResolution', value => throwIfNotValid(value, isVideoFileResolutionValid, 'resolution'))
169 @Column
170 resolution: number
171
172 @AllowNull(false)
173 @Is('VideoFileSize', value => throwIfNotValid(value, isVideoFileSizeValid, 'size'))
174 @Column(DataType.BIGINT)
175 size: number
176
177 @AllowNull(false)
14e2014a
C
178 @Is('VideoFileExtname', value => throwIfNotValid(value, isVideoFileExtnameValid, 'extname'))
179 @Column
3fd3ab2d
C
180 extname: string
181
c6c0fa6c
C
182 @AllowNull(true)
183 @Is('VideoFileInfohash', value => throwIfNotValid(value, isVideoFileInfoHashValid, 'info hash', true))
3fd3ab2d
C
184 @Column
185 infoHash: string
186
2e7cf5ae
C
187 @AllowNull(false)
188 @Default(-1)
3a6f351b
C
189 @Is('VideoFileFPS', value => throwIfNotValid(value, isVideoFPSResolutionValid, 'fps'))
190 @Column
191 fps: number
192
8319d6ae
RK
193 @AllowNull(true)
194 @Column(DataType.JSONB)
195 metadata: any
196
197 @AllowNull(true)
198 @Column
199 metadataUrl: string
200
02b286f8 201 // Could be null for remote files
90a8bd30
C
202 @AllowNull(true)
203 @Column
204 fileUrl: string
205
206 // Could be null for live files
207 @AllowNull(true)
208 @Column
209 filename: string
210
02b286f8 211 // Could be null for remote files
90a8bd30
C
212 @AllowNull(true)
213 @Column
214 torrentUrl: string
215
216 // Could be null for live files
217 @AllowNull(true)
218 @Column
219 torrentFilename: string
220
3fd3ab2d
C
221 @ForeignKey(() => VideoModel)
222 @Column
223 videoId: number
224
0305db28
JB
225 @AllowNull(false)
226 @Default(VideoStorage.FILE_SYSTEM)
227 @Column
228 storage: VideoStorage
229
3fd3ab2d 230 @BelongsTo(() => VideoModel, {
93e1258c 231 foreignKey: {
d7a25329 232 allowNull: true
93e1258c
C
233 },
234 onDelete: 'CASCADE'
235 })
3fd3ab2d 236 Video: VideoModel
cc43831a 237
d7a25329
C
238 @ForeignKey(() => VideoStreamingPlaylistModel)
239 @Column
240 videoStreamingPlaylistId: number
241
242 @BelongsTo(() => VideoStreamingPlaylistModel, {
243 foreignKey: {
244 allowNull: true
245 },
246 onDelete: 'CASCADE'
247 })
248 VideoStreamingPlaylist: VideoStreamingPlaylistModel
249
c48e82b5
C
250 @HasMany(() => VideoRedundancyModel, {
251 foreignKey: {
09209296 252 allowNull: true
c48e82b5
C
253 },
254 onDelete: 'CASCADE',
255 hooks: true
256 })
257 RedundancyVideos: VideoRedundancyModel[]
258
35f28e94
C
259 static doesInfohashExistCached = memoizee(VideoFileModel.doesInfohashExist, {
260 promise: true,
261 max: MEMOIZE_LENGTH.INFO_HASH_EXISTS,
262 maxAge: MEMOIZE_TTL.INFO_HASH_EXISTS
263 })
264
09209296 265 static doesInfohashExist (infoHash: string) {
cc43831a 266 const query = 'SELECT 1 FROM "videoFile" WHERE "infoHash" = $infoHash LIMIT 1'
cc43831a 267
8c4bbd94 268 return doesExist(this.sequelize, query, { infoHash })
cc43831a 269 }
e5565833 270
8319d6ae
RK
271 static async doesVideoExistForVideoFile (id: number, videoIdOrUUID: number | string) {
272 const videoFile = await VideoFileModel.loadWithVideoOrPlaylist(id, videoIdOrUUID)
7b81edc8
C
273
274 return !!videoFile
8319d6ae
RK
275 }
276
764b1a14
C
277 static async doesOwnedTorrentFileExist (filename: string) {
278 const query = 'SELECT 1 FROM "videoFile" ' +
279 'LEFT JOIN "video" "webtorrent" ON "webtorrent"."id" = "videoFile"."videoId" AND "webtorrent"."remote" IS FALSE ' +
280 'LEFT JOIN "videoStreamingPlaylist" ON "videoStreamingPlaylist"."id" = "videoFile"."videoStreamingPlaylistId" ' +
281 'LEFT JOIN "video" "hlsVideo" ON "hlsVideo"."id" = "videoStreamingPlaylist"."videoId" AND "hlsVideo"."remote" IS FALSE ' +
282 'WHERE "torrentFilename" = $filename AND ("hlsVideo"."id" IS NOT NULL OR "webtorrent"."id" IS NOT NULL) LIMIT 1'
283
8c4bbd94 284 return doesExist(this.sequelize, query, { filename })
764b1a14
C
285 }
286
287 static async doesOwnedWebTorrentVideoFileExist (filename: string) {
288 const query = 'SELECT 1 FROM "videoFile" INNER JOIN "video" ON "video"."id" = "videoFile"."videoId" AND "video"."remote" IS FALSE ' +
0305db28 289 `WHERE "filename" = $filename AND "storage" = ${VideoStorage.FILE_SYSTEM} LIMIT 1`
764b1a14 290
8c4bbd94 291 return doesExist(this.sequelize, query, { filename })
764b1a14
C
292 }
293
294 static loadByFilename (filename: string) {
295 const query = {
296 where: {
297 filename
298 }
299 }
300
301 return VideoFileModel.findOne(query)
302 }
303
3545e72c
C
304 static loadWithVideoByFilename (filename: string): Promise<MVideoFileVideo | MVideoFileStreamingPlaylistVideo> {
305 const query = {
306 where: {
307 filename
308 }
309 }
310
311 return VideoFileModel.scope(ScopeNames.WITH_VIDEO_OR_PLAYLIST).findOne(query)
312 }
313
90a8bd30
C
314 static loadWithVideoOrPlaylistByTorrentFilename (filename: string) {
315 const query = {
316 where: {
317 torrentFilename: filename
318 }
319 }
320
321 return VideoFileModel.scope(ScopeNames.WITH_VIDEO_OR_PLAYLIST).findOne(query)
322 }
323
3545e72c
C
324 static load (id: number): Promise<MVideoFile> {
325 return VideoFileModel.findByPk(id)
326 }
327
8319d6ae
RK
328 static loadWithMetadata (id: number) {
329 return VideoFileModel.scope(ScopeNames.WITH_METADATA).findByPk(id)
330 }
331
25378bc8 332 static loadWithVideo (id: number) {
8319d6ae
RK
333 return VideoFileModel.scope(ScopeNames.WITH_VIDEO).findByPk(id)
334 }
25378bc8 335
8319d6ae 336 static loadWithVideoOrPlaylist (id: number, videoIdOrUUID: number | string) {
7b81edc8
C
337 const whereVideo = validator.isUUID(videoIdOrUUID + '')
338 ? { uuid: videoIdOrUUID }
339 : { id: videoIdOrUUID }
340
341 const options = {
342 where: {
343 id
90a8bd30 344 }
7b81edc8
C
345 }
346
90a8bd30
C
347 return VideoFileModel.scope({ method: [ ScopeNames.WITH_VIDEO_OR_PLAYLIST, whereVideo ] })
348 .findOne(options)
7b81edc8
C
349 .then(file => {
350 // We used `required: false` so check we have at least a video or a streaming playlist
351 if (!file.Video && !file.VideoStreamingPlaylist) return null
352
353 return file
354 })
25378bc8
C
355 }
356
3acc5084 357 static listByStreamingPlaylist (streamingPlaylistId: number, transaction: Transaction) {
ae9bbed4
C
358 const query = {
359 include: [
360 {
361 model: VideoModel.unscoped(),
362 required: true,
363 include: [
364 {
365 model: VideoStreamingPlaylistModel.unscoped(),
366 required: true,
367 where: {
368 id: streamingPlaylistId
369 }
370 }
371 ]
372 }
373 ],
374 transaction
375 }
376
377 return VideoFileModel.findAll(query)
378 }
379
3acc5084 380 static getStats () {
0b84383d 381 const webtorrentFilesQuery: FindOptions = {
44b9c0ba
C
382 include: [
383 {
384 attributes: [],
0b84383d 385 required: true,
44b9c0ba
C
386 model: VideoModel.unscoped(),
387 where: {
388 remote: false
389 }
390 }
391 ]
44b9c0ba 392 }
3acc5084 393
0b84383d
C
394 const hlsFilesQuery: FindOptions = {
395 include: [
396 {
397 attributes: [],
398 required: true,
399 model: VideoStreamingPlaylistModel.unscoped(),
400 include: [
401 {
402 attributes: [],
403 model: VideoModel.unscoped(),
404 required: true,
405 where: {
406 remote: false
407 }
408 }
409 ]
410 }
411 ]
412 }
413
414 return Promise.all([
415 VideoFileModel.aggregate('size', 'SUM', webtorrentFilesQuery),
416 VideoFileModel.aggregate('size', 'SUM', hlsFilesQuery)
417 ]).then(([ webtorrentResult, hlsResult ]) => ({
418 totalLocalVideoFilesSize: parseAggregateResult(webtorrentResult) + parseAggregateResult(hlsResult)
419 }))
44b9c0ba
C
420 }
421
d7a25329
C
422 // Redefine upsert because sequelize does not use an appropriate where clause in the update query with 2 unique indexes
423 static async customUpsert (
424 videoFile: MVideoFile,
425 mode: 'streaming-playlist' | 'video',
426 transaction: Transaction
427 ) {
7b6b445d 428 const baseFind = {
d7a25329 429 fps: videoFile.fps,
7b6b445d
C
430 resolution: videoFile.resolution,
431 transaction
d7a25329
C
432 }
433
7b6b445d
C
434 const element = mode === 'streaming-playlist'
435 ? await VideoFileModel.loadHLSFile({ ...baseFind, playlistId: videoFile.videoStreamingPlaylistId })
436 : await VideoFileModel.loadWebTorrentFile({ ...baseFind, videoId: videoFile.videoId })
d7a25329 437
d7a25329
C
438 if (!element) return videoFile.save({ transaction })
439
440 for (const k of Object.keys(videoFile.toJSON())) {
60b880ac 441 element.set(k, videoFile[k])
d7a25329
C
442 }
443
444 return element.save({ transaction })
445 }
446
7b6b445d
C
447 static async loadWebTorrentFile (options: {
448 videoId: number
449 fps: number
450 resolution: number
451 transaction?: Transaction
452 }) {
453 const where = {
454 fps: options.fps,
455 resolution: options.resolution,
456 videoId: options.videoId
457 }
458
459 return VideoFileModel.findOne({ where, transaction: options.transaction })
460 }
461
462 static async loadHLSFile (options: {
463 playlistId: number
464 fps: number
465 resolution: number
466 transaction?: Transaction
467 }) {
468 const where = {
469 fps: options.fps,
470 resolution: options.resolution,
471 videoStreamingPlaylistId: options.playlistId
472 }
473
474 return VideoFileModel.findOne({ where, transaction: options.transaction })
475 }
476
97969c4e
C
477 static removeHLSFilesOfVideoId (videoStreamingPlaylistId: number) {
478 const options = {
479 where: { videoStreamingPlaylistId }
480 }
481
482 return VideoFileModel.destroy(options)
483 }
484
c4d12552
C
485 hasTorrent () {
486 return this.infoHash && this.torrentFilename
487 }
488
d7a25329 489 getVideoOrStreamingPlaylist (this: MVideoFileVideo | MVideoFileStreamingPlaylistVideo): MVideo | MStreamingPlaylistVideo {
3545e72c 490 if (this.videoId || (this as MVideoFileVideo).Video) return (this as MVideoFileVideo).Video
d7a25329
C
491
492 return (this as MVideoFileStreamingPlaylistVideo).VideoStreamingPlaylist
493 }
494
90a8bd30
C
495 getVideo (this: MVideoFileVideo | MVideoFileStreamingPlaylistVideo): MVideo {
496 return extractVideo(this.getVideoOrStreamingPlaylist())
497 }
498
536598cf 499 isAudio () {
482b2623 500 return this.resolution === VideoResolution.H_NOVIDEO
536598cf
C
501 }
502
bd54ad19
C
503 isLive () {
504 return this.size === -1
505 }
506
053aed43 507 isHLS () {
9e2b2e76 508 return !!this.videoStreamingPlaylistId
053aed43
C
509 }
510
9ab330b9
C
511 // ---------------------------------------------------------------------------
512
513 getObjectStorageUrl (video: MVideo) {
5a122ddd 514 if (video.hasPrivateStaticPath() && CONFIG.OBJECT_STORAGE.PROXY.PROXIFY_PRIVATE_FILES === true) {
9ab330b9
C
515 return this.getPrivateObjectStorageUrl(video)
516 }
517
518 return this.getPublicObjectStorageUrl()
519 }
520
521 private getPrivateObjectStorageUrl (video: MVideo) {
522 if (this.isHLS()) {
523 return getHLSPrivateFileUrl(video, this.filename)
524 }
525
526 return getWebTorrentPrivateFileUrl(this.filename)
527 }
528
529 private getPublicObjectStorageUrl () {
0305db28
JB
530 if (this.isHLS()) {
531 return getHLSPublicFileUrl(this.fileUrl)
532 }
533
534 return getWebTorrentPublicFileUrl(this.fileUrl)
535 }
536
9ab330b9
C
537 // ---------------------------------------------------------------------------
538
8efc27bf 539 getFileUrl (video: MVideo) {
9ab330b9
C
540 if (video.isOwned()) {
541 if (this.storage === VideoStorage.OBJECT_STORAGE) {
542 return this.getObjectStorageUrl(video)
543 }
90a8bd30 544
9ab330b9
C
545 return WEBSERVER.URL + this.getFileStaticPath(video)
546 }
90a8bd30 547
d9a2a031 548 return this.fileUrl
90a8bd30
C
549 }
550
9ab330b9
C
551 // ---------------------------------------------------------------------------
552
90a8bd30 553 getFileStaticPath (video: MVideo) {
9ab330b9 554 if (this.isHLS()) return this.getHLSFileStaticPath(video)
3545e72c 555
9ab330b9
C
556 return this.getWebTorrentFileStaticPath(video)
557 }
3545e72c 558
9ab330b9 559 private getWebTorrentFileStaticPath (video: MVideo) {
3545e72c
C
560 if (isVideoInPrivateDirectory(video.privacy)) {
561 return join(STATIC_PATHS.PRIVATE_WEBSEED, this.filename)
562 }
90a8bd30
C
563
564 return join(STATIC_PATHS.WEBSEED, this.filename)
565 }
566
9ab330b9
C
567 private getHLSFileStaticPath (video: MVideo) {
568 if (isVideoInPrivateDirectory(video.privacy)) {
569 return join(STATIC_PATHS.STREAMING_PLAYLISTS.PRIVATE_HLS, video.uuid, this.filename)
570 }
571
572 return join(STATIC_PATHS.STREAMING_PLAYLISTS.HLS, video.uuid, this.filename)
573 }
574
575 // ---------------------------------------------------------------------------
576
90a8bd30 577 getFileDownloadUrl (video: MVideoWithHost) {
764b1a14
C
578 const path = this.isHLS()
579 ? join(STATIC_DOWNLOAD_PATHS.HLS_VIDEOS, `${video.uuid}-${this.resolution}-fragmented${this.extname}`)
580 : join(STATIC_DOWNLOAD_PATHS.VIDEOS, `${video.uuid}-${this.resolution}${this.extname}`)
90a8bd30
C
581
582 if (video.isOwned()) return WEBSERVER.URL + path
583
584 // FIXME: don't guess remote URL
585 return buildRemoteVideoBaseUrl(video, path)
586 }
587
8efc27bf 588 getRemoteTorrentUrl (video: MVideo) {
90a8bd30
C
589 if (video.isOwned()) throw new Error(`Video ${video.url} is not a remote video`)
590
d9a2a031 591 return this.torrentUrl
90a8bd30
C
592 }
593
594 // We proxify torrent requests so use a local URL
595 getTorrentUrl () {
d61893f7
C
596 if (!this.torrentFilename) return null
597
90a8bd30
C
598 return WEBSERVER.URL + this.getTorrentStaticPath()
599 }
600
601 getTorrentStaticPath () {
d61893f7
C
602 if (!this.torrentFilename) return null
603
90a8bd30
C
604 return join(LAZY_STATIC_PATHS.TORRENTS, this.torrentFilename)
605 }
606
607 getTorrentDownloadUrl () {
d61893f7
C
608 if (!this.torrentFilename) return null
609
90a8bd30
C
610 return WEBSERVER.URL + join(STATIC_DOWNLOAD_PATHS.TORRENTS, this.torrentFilename)
611 }
612
613 removeTorrent () {
d61893f7
C
614 if (!this.torrentFilename) return null
615
0305db28 616 const torrentPath = getFSTorrentFilePath(this)
90a8bd30
C
617 return remove(torrentPath)
618 .catch(err => logger.warn('Cannot delete torrent %s.', torrentPath, { err }))
619 }
620
453e83ea 621 hasSameUniqueKeysThan (other: MVideoFile) {
e5565833
C
622 return this.fps === other.fps &&
623 this.resolution === other.resolution &&
d7a25329
C
624 (
625 (this.videoId !== null && this.videoId === other.videoId) ||
626 (this.videoStreamingPlaylistId !== null && this.videoStreamingPlaylistId === other.videoStreamingPlaylistId)
627 )
e5565833 628 }
ad5db104
C
629
630 withVideoOrPlaylist (videoOrPlaylist: MVideo | MStreamingPlaylistVideo) {
631 if (isStreamingPlaylist(videoOrPlaylist)) return Object.assign(this, { VideoStreamingPlaylist: videoOrPlaylist })
632
633 return Object.assign(this, { Video: videoOrPlaylist })
634 }
93e1258c 635}