]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blame - server/models/video/video-file.ts
Avoid error when file has no torrent file
[github/Chocobozzz/PeerTube.git] / server / models / video / video-file.ts
CommitLineData
90a8bd30
C
1import { remove } from 'fs-extra'
2import * as memoizee from 'memoizee'
3import { join } from 'path'
4import { FindOptions, Op, QueryTypes, Transaction } 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
C
21import { Where } from 'sequelize/types/lib/utils'
22import validator from 'validator'
23import { buildRemoteVideoBaseUrl } from '@server/helpers/activitypub'
24import { logger } from '@server/helpers/logger'
25import { extractVideo } from '@server/helpers/video'
26import { getTorrentFilePath } from '@server/lib/video-paths'
27import { MStreamingPlaylistVideo, MVideo, MVideoWithHost } from '@server/types/models'
3a6f351b 28import {
14e2014a 29 isVideoFileExtnameValid,
3a6f351b
C
30 isVideoFileInfoHashValid,
31 isVideoFileResolutionValid,
32 isVideoFileSizeValid,
33 isVideoFPSResolutionValid
34} from '../../helpers/custom-validators/videos'
90a8bd30
C
35import {
36 LAZY_STATIC_PATHS,
37 MEMOIZE_LENGTH,
38 MEMOIZE_TTL,
39 MIMETYPES,
40 STATIC_DOWNLOAD_PATHS,
41 STATIC_PATHS,
42 WEBSERVER
43} from '../../initializers/constants'
44import { MVideoFile, MVideoFileStreamingPlaylistVideo, MVideoFileVideo } from '../../types/models/video/video-file'
45import { VideoRedundancyModel } from '../redundancy/video-redundancy'
3acc5084 46import { parseAggregateResult, throwIfNotValid } from '../utils'
3fd3ab2d 47import { VideoModel } from './video'
ae9bbed4 48import { VideoStreamingPlaylistModel } from './video-streaming-playlist'
93e1258c 49
8319d6ae
RK
50export enum ScopeNames {
51 WITH_VIDEO = 'WITH_VIDEO',
90a8bd30
C
52 WITH_METADATA = 'WITH_METADATA',
53 WITH_VIDEO_OR_PLAYLIST = 'WITH_VIDEO_OR_PLAYLIST'
8319d6ae
RK
54}
55
8319d6ae
RK
56@DefaultScope(() => ({
57 attributes: {
7b81edc8 58 exclude: [ 'metadata' ]
8319d6ae
RK
59 }
60}))
61@Scopes(() => ({
62 [ScopeNames.WITH_VIDEO]: {
63 include: [
64 {
65 model: VideoModel.unscoped(),
66 required: true
67 }
68 ]
69 },
90a8bd30
C
70 [ScopeNames.WITH_VIDEO_OR_PLAYLIST]: (options: { whereVideo?: Where } = {}) => {
71 return {
72 include: [
73 {
74 model: VideoModel.unscoped(),
75 required: false,
76 where: options.whereVideo
77 },
78 {
79 model: VideoStreamingPlaylistModel.unscoped(),
80 required: false,
81 include: [
82 {
83 model: VideoModel.unscoped(),
84 required: true,
85 where: options.whereVideo
86 }
87 ]
88 }
89 ]
90 }
91 },
8319d6ae
RK
92 [ScopeNames.WITH_METADATA]: {
93 attributes: {
7b81edc8 94 include: [ 'metadata' ]
8319d6ae
RK
95 }
96 }
97}))
3fd3ab2d
C
98@Table({
99 tableName: 'videoFile',
100 indexes: [
93e1258c 101 {
d7a25329
C
102 fields: [ 'videoId' ],
103 where: {
104 videoId: {
105 [Op.ne]: null
106 }
107 }
108 },
109 {
110 fields: [ 'videoStreamingPlaylistId' ],
111 where: {
112 videoStreamingPlaylistId: {
113 [Op.ne]: null
114 }
115 }
93e1258c 116 },
d7a25329 117
93e1258c 118 {
3fd3ab2d 119 fields: [ 'infoHash' ]
8cd72bd3 120 },
d7a25329 121
90a8bd30
C
122 {
123 fields: [ 'torrentFilename' ],
124 unique: true
125 },
126
127 {
128 fields: [ 'filename' ],
129 unique: true
130 },
131
8cd72bd3
C
132 {
133 fields: [ 'videoId', 'resolution', 'fps' ],
d7a25329
C
134 unique: true,
135 where: {
136 videoId: {
137 [Op.ne]: null
138 }
139 }
140 },
141 {
142 fields: [ 'videoStreamingPlaylistId', 'resolution', 'fps' ],
143 unique: true,
144 where: {
145 videoStreamingPlaylistId: {
146 [Op.ne]: null
147 }
148 }
93e1258c 149 }
93e1258c 150 ]
3fd3ab2d 151})
b49f22d8 152export class VideoFileModel extends Model {
3fd3ab2d
C
153 @CreatedAt
154 createdAt: Date
155
156 @UpdatedAt
157 updatedAt: Date
158
159 @AllowNull(false)
160 @Is('VideoFileResolution', value => throwIfNotValid(value, isVideoFileResolutionValid, 'resolution'))
161 @Column
162 resolution: number
163
164 @AllowNull(false)
165 @Is('VideoFileSize', value => throwIfNotValid(value, isVideoFileSizeValid, 'size'))
166 @Column(DataType.BIGINT)
167 size: number
168
169 @AllowNull(false)
14e2014a
C
170 @Is('VideoFileExtname', value => throwIfNotValid(value, isVideoFileExtnameValid, 'extname'))
171 @Column
3fd3ab2d
C
172 extname: string
173
c6c0fa6c
C
174 @AllowNull(true)
175 @Is('VideoFileInfohash', value => throwIfNotValid(value, isVideoFileInfoHashValid, 'info hash', true))
3fd3ab2d
C
176 @Column
177 infoHash: string
178
2e7cf5ae
C
179 @AllowNull(false)
180 @Default(-1)
3a6f351b
C
181 @Is('VideoFileFPS', value => throwIfNotValid(value, isVideoFPSResolutionValid, 'fps'))
182 @Column
183 fps: number
184
8319d6ae
RK
185 @AllowNull(true)
186 @Column(DataType.JSONB)
187 metadata: any
188
189 @AllowNull(true)
190 @Column
191 metadataUrl: string
192
90a8bd30
C
193 @AllowNull(true)
194 @Column
195 fileUrl: string
196
197 // Could be null for live files
198 @AllowNull(true)
199 @Column
200 filename: string
201
202 @AllowNull(true)
203 @Column
204 torrentUrl: string
205
206 // Could be null for live files
207 @AllowNull(true)
208 @Column
209 torrentFilename: string
210
3fd3ab2d
C
211 @ForeignKey(() => VideoModel)
212 @Column
213 videoId: number
214
215 @BelongsTo(() => VideoModel, {
93e1258c 216 foreignKey: {
d7a25329 217 allowNull: true
93e1258c
C
218 },
219 onDelete: 'CASCADE'
220 })
3fd3ab2d 221 Video: VideoModel
cc43831a 222
d7a25329
C
223 @ForeignKey(() => VideoStreamingPlaylistModel)
224 @Column
225 videoStreamingPlaylistId: number
226
227 @BelongsTo(() => VideoStreamingPlaylistModel, {
228 foreignKey: {
229 allowNull: true
230 },
231 onDelete: 'CASCADE'
232 })
233 VideoStreamingPlaylist: VideoStreamingPlaylistModel
234
c48e82b5
C
235 @HasMany(() => VideoRedundancyModel, {
236 foreignKey: {
09209296 237 allowNull: true
c48e82b5
C
238 },
239 onDelete: 'CASCADE',
240 hooks: true
241 })
242 RedundancyVideos: VideoRedundancyModel[]
243
35f28e94
C
244 static doesInfohashExistCached = memoizee(VideoFileModel.doesInfohashExist, {
245 promise: true,
246 max: MEMOIZE_LENGTH.INFO_HASH_EXISTS,
247 maxAge: MEMOIZE_TTL.INFO_HASH_EXISTS
248 })
249
09209296 250 static doesInfohashExist (infoHash: string) {
cc43831a
C
251 const query = 'SELECT 1 FROM "videoFile" WHERE "infoHash" = $infoHash LIMIT 1'
252 const options = {
d5d9b6d7 253 type: QueryTypes.SELECT as QueryTypes.SELECT,
cc43831a
C
254 bind: { infoHash },
255 raw: true
256 }
257
258 return VideoModel.sequelize.query(query, options)
3acc5084 259 .then(results => results.length === 1)
cc43831a 260 }
e5565833 261
8319d6ae
RK
262 static async doesVideoExistForVideoFile (id: number, videoIdOrUUID: number | string) {
263 const videoFile = await VideoFileModel.loadWithVideoOrPlaylist(id, videoIdOrUUID)
7b81edc8
C
264
265 return !!videoFile
8319d6ae
RK
266 }
267
90a8bd30
C
268 static loadWithVideoOrPlaylistByTorrentFilename (filename: string) {
269 const query = {
270 where: {
271 torrentFilename: filename
272 }
273 }
274
275 return VideoFileModel.scope(ScopeNames.WITH_VIDEO_OR_PLAYLIST).findOne(query)
276 }
277
8319d6ae
RK
278 static loadWithMetadata (id: number) {
279 return VideoFileModel.scope(ScopeNames.WITH_METADATA).findByPk(id)
280 }
281
25378bc8 282 static loadWithVideo (id: number) {
8319d6ae
RK
283 return VideoFileModel.scope(ScopeNames.WITH_VIDEO).findByPk(id)
284 }
25378bc8 285
8319d6ae 286 static loadWithVideoOrPlaylist (id: number, videoIdOrUUID: number | string) {
7b81edc8
C
287 const whereVideo = validator.isUUID(videoIdOrUUID + '')
288 ? { uuid: videoIdOrUUID }
289 : { id: videoIdOrUUID }
290
291 const options = {
292 where: {
293 id
90a8bd30 294 }
7b81edc8
C
295 }
296
90a8bd30
C
297 return VideoFileModel.scope({ method: [ ScopeNames.WITH_VIDEO_OR_PLAYLIST, whereVideo ] })
298 .findOne(options)
7b81edc8
C
299 .then(file => {
300 // We used `required: false` so check we have at least a video or a streaming playlist
301 if (!file.Video && !file.VideoStreamingPlaylist) return null
302
303 return file
304 })
25378bc8
C
305 }
306
3acc5084 307 static listByStreamingPlaylist (streamingPlaylistId: number, transaction: Transaction) {
ae9bbed4
C
308 const query = {
309 include: [
310 {
311 model: VideoModel.unscoped(),
312 required: true,
313 include: [
314 {
315 model: VideoStreamingPlaylistModel.unscoped(),
316 required: true,
317 where: {
318 id: streamingPlaylistId
319 }
320 }
321 ]
322 }
323 ],
324 transaction
325 }
326
327 return VideoFileModel.findAll(query)
328 }
329
3acc5084 330 static getStats () {
0b84383d 331 const webtorrentFilesQuery: FindOptions = {
44b9c0ba
C
332 include: [
333 {
334 attributes: [],
0b84383d 335 required: true,
44b9c0ba
C
336 model: VideoModel.unscoped(),
337 where: {
338 remote: false
339 }
340 }
341 ]
44b9c0ba 342 }
3acc5084 343
0b84383d
C
344 const hlsFilesQuery: FindOptions = {
345 include: [
346 {
347 attributes: [],
348 required: true,
349 model: VideoStreamingPlaylistModel.unscoped(),
350 include: [
351 {
352 attributes: [],
353 model: VideoModel.unscoped(),
354 required: true,
355 where: {
356 remote: false
357 }
358 }
359 ]
360 }
361 ]
362 }
363
364 return Promise.all([
365 VideoFileModel.aggregate('size', 'SUM', webtorrentFilesQuery),
366 VideoFileModel.aggregate('size', 'SUM', hlsFilesQuery)
367 ]).then(([ webtorrentResult, hlsResult ]) => ({
368 totalLocalVideoFilesSize: parseAggregateResult(webtorrentResult) + parseAggregateResult(hlsResult)
369 }))
44b9c0ba
C
370 }
371
d7a25329
C
372 // Redefine upsert because sequelize does not use an appropriate where clause in the update query with 2 unique indexes
373 static async customUpsert (
374 videoFile: MVideoFile,
375 mode: 'streaming-playlist' | 'video',
376 transaction: Transaction
377 ) {
378 const baseWhere = {
379 fps: videoFile.fps,
380 resolution: videoFile.resolution
381 }
382
383 if (mode === 'streaming-playlist') Object.assign(baseWhere, { videoStreamingPlaylistId: videoFile.videoStreamingPlaylistId })
384 else Object.assign(baseWhere, { videoId: videoFile.videoId })
385
386 const element = await VideoFileModel.findOne({ where: baseWhere, transaction })
387 if (!element) return videoFile.save({ transaction })
388
389 for (const k of Object.keys(videoFile.toJSON())) {
390 element[k] = videoFile[k]
391 }
392
393 return element.save({ transaction })
394 }
395
97969c4e
C
396 static removeHLSFilesOfVideoId (videoStreamingPlaylistId: number) {
397 const options = {
398 where: { videoStreamingPlaylistId }
399 }
400
401 return VideoFileModel.destroy(options)
402 }
403
be89e668
C
404 hasTorrent () {
405 return this.infoHash && this.torrentFilename
406 }
407
d7a25329
C
408 getVideoOrStreamingPlaylist (this: MVideoFileVideo | MVideoFileStreamingPlaylistVideo): MVideo | MStreamingPlaylistVideo {
409 if (this.videoId) return (this as MVideoFileVideo).Video
410
411 return (this as MVideoFileStreamingPlaylistVideo).VideoStreamingPlaylist
412 }
413
90a8bd30
C
414 getVideo (this: MVideoFileVideo | MVideoFileStreamingPlaylistVideo): MVideo {
415 return extractVideo(this.getVideoOrStreamingPlaylist())
416 }
417
536598cf
C
418 isAudio () {
419 return !!MIMETYPES.AUDIO.EXT_MIMETYPE[this.extname]
420 }
421
bd54ad19
C
422 isLive () {
423 return this.size === -1
424 }
425
053aed43 426 isHLS () {
9e2b2e76 427 return !!this.videoStreamingPlaylistId
053aed43
C
428 }
429
8efc27bf 430 getFileUrl (video: MVideo) {
90a8bd30
C
431 if (!this.Video) this.Video = video as VideoModel
432
433 if (video.isOwned()) return WEBSERVER.URL + this.getFileStaticPath(video)
90a8bd30 434
d9a2a031 435 return this.fileUrl
90a8bd30
C
436 }
437
438 getFileStaticPath (video: MVideo) {
439 if (this.isHLS()) return join(STATIC_PATHS.STREAMING_PLAYLISTS.HLS, video.uuid, this.filename)
440
441 return join(STATIC_PATHS.WEBSEED, this.filename)
442 }
443
444 getFileDownloadUrl (video: MVideoWithHost) {
445 const basePath = this.isHLS()
446 ? STATIC_DOWNLOAD_PATHS.HLS_VIDEOS
447 : STATIC_DOWNLOAD_PATHS.VIDEOS
448 const path = join(basePath, this.filename)
449
450 if (video.isOwned()) return WEBSERVER.URL + path
451
452 // FIXME: don't guess remote URL
453 return buildRemoteVideoBaseUrl(video, path)
454 }
455
8efc27bf 456 getRemoteTorrentUrl (video: MVideo) {
90a8bd30
C
457 if (video.isOwned()) throw new Error(`Video ${video.url} is not a remote video`)
458
d9a2a031 459 return this.torrentUrl
90a8bd30
C
460 }
461
462 // We proxify torrent requests so use a local URL
463 getTorrentUrl () {
d61893f7
C
464 if (!this.torrentFilename) return null
465
90a8bd30
C
466 return WEBSERVER.URL + this.getTorrentStaticPath()
467 }
468
469 getTorrentStaticPath () {
d61893f7
C
470 if (!this.torrentFilename) return null
471
90a8bd30
C
472 return join(LAZY_STATIC_PATHS.TORRENTS, this.torrentFilename)
473 }
474
475 getTorrentDownloadUrl () {
d61893f7
C
476 if (!this.torrentFilename) return null
477
90a8bd30
C
478 return WEBSERVER.URL + join(STATIC_DOWNLOAD_PATHS.TORRENTS, this.torrentFilename)
479 }
480
481 removeTorrent () {
d61893f7
C
482 if (!this.torrentFilename) return null
483
90a8bd30
C
484 const torrentPath = getTorrentFilePath(this)
485 return remove(torrentPath)
486 .catch(err => logger.warn('Cannot delete torrent %s.', torrentPath, { err }))
487 }
488
453e83ea 489 hasSameUniqueKeysThan (other: MVideoFile) {
e5565833
C
490 return this.fps === other.fps &&
491 this.resolution === other.resolution &&
d7a25329
C
492 (
493 (this.videoId !== null && this.videoId === other.videoId) ||
494 (this.videoStreamingPlaylistId !== null && this.videoStreamingPlaylistId === other.videoStreamingPlaylistId)
495 )
e5565833 496 }
93e1258c 497}