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