1 import { remove } from 'fs-extra'
2 import * as memoizee from 'memoizee'
3 import { join } from 'path'
4 import { FindOptions, Op, QueryTypes, Transaction } from 'sequelize'
20 } from 'sequelize-typescript'
21 import { Where } from 'sequelize/types/lib/utils'
22 import validator from 'validator'
23 import { buildRemoteVideoBaseUrl } from '@server/helpers/activitypub'
24 import { logger } from '@server/helpers/logger'
25 import { extractVideo } from '@server/helpers/video'
26 import { getTorrentFilePath } from '@server/lib/video-paths'
27 import { MStreamingPlaylistVideo, MVideo, MVideoWithHost } from '@server/types/models'
28 import { AttributesOnly } from '@shared/core-utils'
30 isVideoFileExtnameValid,
31 isVideoFileInfoHashValid,
32 isVideoFileResolutionValid,
34 isVideoFPSResolutionValid
35 } from '../../helpers/custom-validators/videos'
41 STATIC_DOWNLOAD_PATHS,
44 } from '../../initializers/constants'
45 import { MVideoFile, MVideoFileStreamingPlaylistVideo, MVideoFileVideo } from '../../types/models/video/video-file'
46 import { VideoRedundancyModel } from '../redundancy/video-redundancy'
47 import { parseAggregateResult, throwIfNotValid } from '../utils'
48 import { VideoModel } from './video'
49 import { VideoStreamingPlaylistModel } from './video-streaming-playlist'
51 export enum ScopeNames {
52 WITH_VIDEO = 'WITH_VIDEO',
53 WITH_METADATA = 'WITH_METADATA',
54 WITH_VIDEO_OR_PLAYLIST = 'WITH_VIDEO_OR_PLAYLIST'
57 @DefaultScope(() => ({
59 exclude: [ 'metadata' ]
63 [ScopeNames.WITH_VIDEO]: {
66 model: VideoModel.unscoped(),
71 [ScopeNames.WITH_VIDEO_OR_PLAYLIST]: (options: { whereVideo?: Where } = {}) => {
75 model: VideoModel.unscoped(),
77 where: options.whereVideo
80 model: VideoStreamingPlaylistModel.unscoped(),
84 model: VideoModel.unscoped(),
86 where: options.whereVideo
93 [ScopeNames.WITH_METADATA]: {
95 include: [ 'metadata' ]
100 tableName: 'videoFile',
103 fields: [ 'videoId' ],
111 fields: [ 'videoStreamingPlaylistId' ],
113 videoStreamingPlaylistId: {
120 fields: [ 'infoHash' ]
124 fields: [ 'torrentFilename' ],
129 fields: [ 'filename' ],
134 fields: [ 'videoId', 'resolution', 'fps' ],
143 fields: [ 'videoStreamingPlaylistId', 'resolution', 'fps' ],
146 videoStreamingPlaylistId: {
153 export class VideoFileModel extends Model<Partial<AttributesOnly<VideoFileModel>>> {
161 @Is('VideoFileResolution', value => throwIfNotValid(value, isVideoFileResolutionValid, 'resolution'))
166 @Is('VideoFileSize', value => throwIfNotValid(value, isVideoFileSizeValid, 'size'))
167 @Column(DataType.BIGINT)
171 @Is('VideoFileExtname', value => throwIfNotValid(value, isVideoFileExtnameValid, 'extname'))
176 @Is('VideoFileInfohash', value => throwIfNotValid(value, isVideoFileInfoHashValid, 'info hash', true))
182 @Is('VideoFileFPS', value => throwIfNotValid(value, isVideoFPSResolutionValid, 'fps'))
187 @Column(DataType.JSONB)
198 // Could be null for live files
207 // Could be null for live files
210 torrentFilename: string
212 @ForeignKey(() => VideoModel)
216 @BelongsTo(() => VideoModel, {
224 @ForeignKey(() => VideoStreamingPlaylistModel)
226 videoStreamingPlaylistId: number
228 @BelongsTo(() => VideoStreamingPlaylistModel, {
234 VideoStreamingPlaylist: VideoStreamingPlaylistModel
236 @HasMany(() => VideoRedundancyModel, {
243 RedundancyVideos: VideoRedundancyModel[]
245 static doesInfohashExistCached = memoizee(VideoFileModel.doesInfohashExist, {
247 max: MEMOIZE_LENGTH.INFO_HASH_EXISTS,
248 maxAge: MEMOIZE_TTL.INFO_HASH_EXISTS
251 static doesInfohashExist (infoHash: string) {
252 const query = 'SELECT 1 FROM "videoFile" WHERE "infoHash" = $infoHash LIMIT 1'
254 type: QueryTypes.SELECT as QueryTypes.SELECT,
259 return VideoModel.sequelize.query(query, options)
260 .then(results => results.length === 1)
263 static async doesVideoExistForVideoFile (id: number, videoIdOrUUID: number | string) {
264 const videoFile = await VideoFileModel.loadWithVideoOrPlaylist(id, videoIdOrUUID)
269 static loadWithVideoOrPlaylistByTorrentFilename (filename: string) {
272 torrentFilename: filename
276 return VideoFileModel.scope(ScopeNames.WITH_VIDEO_OR_PLAYLIST).findOne(query)
279 static loadWithMetadata (id: number) {
280 return VideoFileModel.scope(ScopeNames.WITH_METADATA).findByPk(id)
283 static loadWithVideo (id: number) {
284 return VideoFileModel.scope(ScopeNames.WITH_VIDEO).findByPk(id)
287 static loadWithVideoOrPlaylist (id: number, videoIdOrUUID: number | string) {
288 const whereVideo = validator.isUUID(videoIdOrUUID + '')
289 ? { uuid: videoIdOrUUID }
290 : { id: videoIdOrUUID }
298 return VideoFileModel.scope({ method: [ ScopeNames.WITH_VIDEO_OR_PLAYLIST, whereVideo ] })
301 // We used `required: false` so check we have at least a video or a streaming playlist
302 if (!file.Video && !file.VideoStreamingPlaylist) return null
308 static listByStreamingPlaylist (streamingPlaylistId: number, transaction: Transaction) {
312 model: VideoModel.unscoped(),
316 model: VideoStreamingPlaylistModel.unscoped(),
319 id: streamingPlaylistId
328 return VideoFileModel.findAll(query)
332 const webtorrentFilesQuery: FindOptions = {
337 model: VideoModel.unscoped(),
345 const hlsFilesQuery: FindOptions = {
350 model: VideoStreamingPlaylistModel.unscoped(),
354 model: VideoModel.unscoped(),
366 VideoFileModel.aggregate('size', 'SUM', webtorrentFilesQuery),
367 VideoFileModel.aggregate('size', 'SUM', hlsFilesQuery)
368 ]).then(([ webtorrentResult, hlsResult ]) => ({
369 totalLocalVideoFilesSize: parseAggregateResult(webtorrentResult) + parseAggregateResult(hlsResult)
373 // Redefine upsert because sequelize does not use an appropriate where clause in the update query with 2 unique indexes
374 static async customUpsert (
375 videoFile: MVideoFile,
376 mode: 'streaming-playlist' | 'video',
377 transaction: Transaction
381 resolution: videoFile.resolution
384 if (mode === 'streaming-playlist') Object.assign(baseWhere, { videoStreamingPlaylistId: videoFile.videoStreamingPlaylistId })
385 else Object.assign(baseWhere, { videoId: videoFile.videoId })
387 const element = await VideoFileModel.findOne({ where: baseWhere, transaction })
388 if (!element) return videoFile.save({ transaction })
390 for (const k of Object.keys(videoFile.toJSON())) {
391 element[k] = videoFile[k]
394 return element.save({ transaction })
397 static removeHLSFilesOfVideoId (videoStreamingPlaylistId: number) {
399 where: { videoStreamingPlaylistId }
402 return VideoFileModel.destroy(options)
406 return this.infoHash && this.torrentFilename
409 getVideoOrStreamingPlaylist (this: MVideoFileVideo | MVideoFileStreamingPlaylistVideo): MVideo | MStreamingPlaylistVideo {
410 if (this.videoId) return (this as MVideoFileVideo).Video
412 return (this as MVideoFileStreamingPlaylistVideo).VideoStreamingPlaylist
415 getVideo (this: MVideoFileVideo | MVideoFileStreamingPlaylistVideo): MVideo {
416 return extractVideo(this.getVideoOrStreamingPlaylist())
420 return !!MIMETYPES.AUDIO.EXT_MIMETYPE[this.extname]
424 return this.size === -1
428 return !!this.videoStreamingPlaylistId
431 getFileUrl (video: MVideo) {
432 if (!this.Video) this.Video = video as VideoModel
434 if (video.isOwned()) return WEBSERVER.URL + this.getFileStaticPath(video)
439 getFileStaticPath (video: MVideo) {
440 if (this.isHLS()) return join(STATIC_PATHS.STREAMING_PLAYLISTS.HLS, video.uuid, this.filename)
442 return join(STATIC_PATHS.WEBSEED, this.filename)
445 getFileDownloadUrl (video: MVideoWithHost) {
446 const basePath = this.isHLS()
447 ? STATIC_DOWNLOAD_PATHS.HLS_VIDEOS
448 : STATIC_DOWNLOAD_PATHS.VIDEOS
449 const path = join(basePath, this.filename)
451 if (video.isOwned()) return WEBSERVER.URL + path
453 // FIXME: don't guess remote URL
454 return buildRemoteVideoBaseUrl(video, path)
457 getRemoteTorrentUrl (video: MVideo) {
458 if (video.isOwned()) throw new Error(`Video ${video.url} is not a remote video`)
460 return this.torrentUrl
463 // We proxify torrent requests so use a local URL
465 if (!this.torrentFilename) return null
467 return WEBSERVER.URL + this.getTorrentStaticPath()
470 getTorrentStaticPath () {
471 if (!this.torrentFilename) return null
473 return join(LAZY_STATIC_PATHS.TORRENTS, this.torrentFilename)
476 getTorrentDownloadUrl () {
477 if (!this.torrentFilename) return null
479 return WEBSERVER.URL + join(STATIC_DOWNLOAD_PATHS.TORRENTS, this.torrentFilename)
483 if (!this.torrentFilename) return null
485 const torrentPath = getTorrentFilePath(this)
486 return remove(torrentPath)
487 .catch(err => logger.warn('Cannot delete torrent %s.', torrentPath, { err }))
490 hasSameUniqueKeysThan (other: MVideoFile) {
491 return this.fps === other.fps &&
492 this.resolution === other.resolution &&
494 (this.videoId !== null && this.videoId === other.videoId) ||
495 (this.videoStreamingPlaylistId !== null && this.videoStreamingPlaylistId === other.videoStreamingPlaylistId)