From d7a25329f9e607894d29ab342b9cb66638b56dc0 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Fri, 15 Nov 2019 15:06:03 +0100 Subject: Add ability to disable webtorrent In favour of HLS --- server/lib/activitypub/videos.ts | 141 ++++++++++++--------- server/lib/hls.ts | 13 +- server/lib/job-queue/handlers/video-file-import.ts | 6 +- server/lib/job-queue/handlers/video-import.ts | 12 +- server/lib/job-queue/handlers/video-transcoding.ts | 119 +++++++++-------- server/lib/schedulers/update-videos-scheduler.ts | 8 +- .../lib/schedulers/videos-redundancy-scheduler.ts | 7 +- server/lib/thumbnail.ts | 3 +- server/lib/video-paths.ts | 64 ++++++++++ server/lib/video-transcoding.ts | 91 +++++++++---- server/lib/videos.ts | 11 ++ 11 files changed, 314 insertions(+), 161 deletions(-) create mode 100644 server/lib/video-paths.ts create mode 100644 server/lib/videos.ts (limited to 'server/lib') diff --git a/server/lib/activitypub/videos.ts b/server/lib/activitypub/videos.ts index c318978fd..d80173e03 100644 --- a/server/lib/activitypub/videos.ts +++ b/server/lib/activitypub/videos.ts @@ -3,8 +3,10 @@ import * as sequelize from 'sequelize' import * as magnetUtil from 'magnet-uri' import * as request from 'request' import { + ActivityHashTagObject, + ActivityMagnetUrlObject, ActivityPlaylistSegmentHashesObject, - ActivityPlaylistUrlObject, + ActivityPlaylistUrlObject, ActivityTagObject, ActivityUrlObject, ActivityVideoUrlObject, VideoState @@ -13,7 +15,7 @@ import { VideoTorrentObject } from '../../../shared/models/activitypub/objects' import { VideoPrivacy } from '../../../shared/models/videos' import { sanitizeAndCheckVideoTorrentObject } from '../../helpers/custom-validators/activitypub/videos' import { isVideoFileInfoHashValid } from '../../helpers/custom-validators/videos' -import { resetSequelizeInstance, retryTransactionWrapper } from '../../helpers/database-utils' +import { deleteNonExistingModels, resetSequelizeInstance, retryTransactionWrapper } from '../../helpers/database-utils' import { logger } from '../../helpers/logger' import { doRequest, doRequestAndSaveToFile } from '../../helpers/requests' import { @@ -57,6 +59,7 @@ import { MChannelAccountLight, MChannelDefault, MChannelId, + MStreamingPlaylist, MVideo, MVideoAccountLight, MVideoAccountLightBlacklistAllFiles, @@ -330,21 +333,15 @@ async function updateVideoFromAP (options: { await videoUpdated.addAndSaveThumbnail(previewModel, t) { - const videoFileAttributes = videoFileActivityUrlToDBAttributes(videoUpdated, videoObject) + const videoFileAttributes = videoFileActivityUrlToDBAttributes(videoUpdated, videoObject.url) const newVideoFiles = videoFileAttributes.map(a => new VideoFileModel(a)) // Remove video files that do not exist anymore - const destroyTasks = videoUpdated.VideoFiles - .filter(f => !newVideoFiles.find(newFile => newFile.hasSameUniqueKeysThan(f))) - .map(f => f.destroy(sequelizeOptions)) + const destroyTasks = deleteNonExistingModels(videoUpdated.VideoFiles, newVideoFiles, t) await Promise.all(destroyTasks) // Update or add other one - const upsertTasks = videoFileAttributes.map(a => { - return VideoFileModel.upsert(a, { returning: true, transaction: t }) - .then(([ file ]) => file) - }) - + const upsertTasks = newVideoFiles.map(f => VideoFileModel.customUpsert(f, 'video', t)) videoUpdated.VideoFiles = await Promise.all(upsertTasks) } @@ -352,24 +349,39 @@ async function updateVideoFromAP (options: { const streamingPlaylistAttributes = streamingPlaylistActivityUrlToDBAttributes(videoUpdated, videoObject, videoUpdated.VideoFiles) const newStreamingPlaylists = streamingPlaylistAttributes.map(a => new VideoStreamingPlaylistModel(a)) - // Remove video files that do not exist anymore - const destroyTasks = videoUpdated.VideoStreamingPlaylists - .filter(f => !newStreamingPlaylists.find(newPlaylist => newPlaylist.hasSameUniqueKeysThan(f))) - .map(f => f.destroy(sequelizeOptions)) + // Remove video playlists that do not exist anymore + const destroyTasks = deleteNonExistingModels(videoUpdated.VideoStreamingPlaylists, newStreamingPlaylists, t) await Promise.all(destroyTasks) - // Update or add other one - const upsertTasks = streamingPlaylistAttributes.map(a => { - return VideoStreamingPlaylistModel.upsert(a, { returning: true, transaction: t }) - .then(([ streamingPlaylist ]) => streamingPlaylist) - }) + let oldStreamingPlaylistFiles: MVideoFile[] = [] + for (const videoStreamingPlaylist of videoUpdated.VideoStreamingPlaylists) { + oldStreamingPlaylistFiles = oldStreamingPlaylistFiles.concat(videoStreamingPlaylist.VideoFiles) + } + + videoUpdated.VideoStreamingPlaylists = [] + + for (const playlistAttributes of streamingPlaylistAttributes) { + const streamingPlaylistModel = await VideoStreamingPlaylistModel.upsert(playlistAttributes, { returning: true, transaction: t }) + .then(([ streamingPlaylist ]) => streamingPlaylist) - videoUpdated.VideoStreamingPlaylists = await Promise.all(upsertTasks) + const newVideoFiles: MVideoFile[] = videoFileActivityUrlToDBAttributes(streamingPlaylistModel, playlistAttributes.tagAPObject) + .map(a => new VideoFileModel(a)) + const destroyTasks = deleteNonExistingModels(oldStreamingPlaylistFiles, newVideoFiles, t) + await Promise.all(destroyTasks) + + // Update or add other one + const upsertTasks = newVideoFiles.map(f => VideoFileModel.customUpsert(f, 'streaming-playlist', t)) + streamingPlaylistModel.VideoFiles = await Promise.all(upsertTasks) + + videoUpdated.VideoStreamingPlaylists.push(streamingPlaylistModel) + } } { // Update Tags - const tags = videoObject.tag.map(tag => tag.name) + const tags = videoObject.tag + .filter(isAPHashTagObject) + .map(tag => tag.name) const tagInstances = await TagModel.findOrCreateTags(tags, t) await videoUpdated.$set('Tags', tagInstances, sequelizeOptions) } @@ -478,23 +490,27 @@ export { // --------------------------------------------------------------------------- -function isAPVideoUrlObject (url: ActivityUrlObject): url is ActivityVideoUrlObject { +function isAPVideoUrlObject (url: any): url is ActivityVideoUrlObject { const mimeTypes = Object.keys(MIMETYPES.VIDEO.MIMETYPE_EXT) - const urlMediaType = url.mediaType || url.mimeType + const urlMediaType = url.mediaType return mimeTypes.indexOf(urlMediaType) !== -1 && urlMediaType.startsWith('video/') } function isAPStreamingPlaylistUrlObject (url: ActivityUrlObject): url is ActivityPlaylistUrlObject { - const urlMediaType = url.mediaType || url.mimeType - - return urlMediaType === 'application/x-mpegURL' + return url && url.mediaType === 'application/x-mpegURL' } function isAPPlaylistSegmentHashesUrlObject (tag: any): tag is ActivityPlaylistSegmentHashesObject { - const urlMediaType = tag.mediaType || tag.mimeType + return tag && tag.name === 'sha256' && tag.type === 'Link' && tag.mediaType === 'application/json' +} + +function isAPMagnetUrlObject (url: any): url is ActivityMagnetUrlObject { + return url && url.mediaType === 'application/x-bittorrent;x-scheme-handler/magnet' +} - return tag.name === 'sha256' && tag.type === 'Link' && urlMediaType === 'application/json' +function isAPHashTagObject (url: any): url is ActivityHashTagObject { + return url && url.type === 'Hashtag' } async function createVideo (videoObject: VideoTorrentObject, channel: MChannelAccountLight, waitThumbnail = false) { @@ -524,21 +540,27 @@ async function createVideo (videoObject: VideoTorrentObject, channel: MChannelAc if (thumbnailModel) await videoCreated.addAndSaveThumbnail(previewModel, t) // Process files - const videoFileAttributes = videoFileActivityUrlToDBAttributes(videoCreated, videoObject) - if (videoFileAttributes.length === 0) { - throw new Error('Cannot find valid files for video %s ' + videoObject.url) - } + const videoFileAttributes = videoFileActivityUrlToDBAttributes(videoCreated, videoObject.url) const videoFilePromises = videoFileAttributes.map(f => VideoFileModel.create(f, { transaction: t })) const videoFiles = await Promise.all(videoFilePromises) - const videoStreamingPlaylists = streamingPlaylistActivityUrlToDBAttributes(videoCreated, videoObject, videoFiles) - const playlistPromises = videoStreamingPlaylists.map(p => VideoStreamingPlaylistModel.create(p, { transaction: t })) - const streamingPlaylists = await Promise.all(playlistPromises) + const streamingPlaylistsAttributes = streamingPlaylistActivityUrlToDBAttributes(videoCreated, videoObject, videoFiles) + videoCreated.VideoStreamingPlaylists = [] + + for (const playlistAttributes of streamingPlaylistsAttributes) { + const playlistModel = await VideoStreamingPlaylistModel.create(playlistAttributes, { transaction: t }) + + const playlistFiles = videoFileActivityUrlToDBAttributes(playlistModel, playlistAttributes.tagAPObject) + const videoFilePromises = playlistFiles.map(f => VideoFileModel.create(f, { transaction: t })) + playlistModel.VideoFiles = await Promise.all(videoFilePromises) + + videoCreated.VideoStreamingPlaylists.push(playlistModel) + } // Process tags const tags = videoObject.tag - .filter(t => t.type === 'Hashtag') + .filter(isAPHashTagObject) .map(t => t.name) const tagInstances = await TagModel.findOrCreateTags(tags, t) await videoCreated.$set('Tags', tagInstances, sequelizeOptions) @@ -550,7 +572,6 @@ async function createVideo (videoObject: VideoTorrentObject, channel: MChannelAc await Promise.all(videoCaptionsPromises) videoCreated.VideoFiles = videoFiles - videoCreated.VideoStreamingPlaylists = streamingPlaylists videoCreated.Tags = tagInstances const autoBlacklisted = await autoBlacklistVideoIfNeeded({ @@ -628,20 +649,19 @@ async function videoActivityObjectToDBAttributes (videoChannel: MChannelId, vide } } -function videoFileActivityUrlToDBAttributes (video: MVideo, videoObject: VideoTorrentObject) { - const fileUrls = videoObject.url.filter(u => isAPVideoUrlObject(u)) as ActivityVideoUrlObject[] +function videoFileActivityUrlToDBAttributes ( + videoOrPlaylist: MVideo | MStreamingPlaylist, + urls: (ActivityTagObject | ActivityUrlObject)[] +) { + const fileUrls = urls.filter(u => isAPVideoUrlObject(u)) as ActivityVideoUrlObject[] - if (fileUrls.length === 0) { - throw new Error('Cannot find video files for ' + video.url) - } + if (fileUrls.length === 0) return [] const attributes: FilteredModelAttributes[] = [] for (const fileUrl of fileUrls) { // Fetch associated magnet uri - const magnet = videoObject.url.find(u => { - const mediaType = u.mediaType || u.mimeType - return mediaType === 'application/x-bittorrent;x-scheme-handler/magnet' && (u as any).height === fileUrl.height - }) + const magnet = urls.filter(isAPMagnetUrlObject) + .find(u => u.height === fileUrl.height) if (!magnet) throw new Error('Cannot find associated magnet uri for file ' + fileUrl.href) @@ -650,14 +670,17 @@ function videoFileActivityUrlToDBAttributes (video: MVideo, videoObject: VideoTo throw new Error('Cannot parse magnet URI ' + magnet.href) } - const mediaType = fileUrl.mediaType || fileUrl.mimeType + const mediaType = fileUrl.mediaType const attribute = { extname: MIMETYPES.VIDEO.MIMETYPE_EXT[ mediaType ], infoHash: parsed.infoHash, resolution: fileUrl.height, size: fileUrl.size, - videoId: video.id, - fps: fileUrl.fps || -1 + fps: fileUrl.fps || -1, + + // This is a video file owned by a video or by a streaming playlist + videoId: (videoOrPlaylist as MStreamingPlaylist).playlistUrl ? null : videoOrPlaylist.id, + videoStreamingPlaylistId: (videoOrPlaylist as MStreamingPlaylist).playlistUrl ? videoOrPlaylist.id : null } attributes.push(attribute) @@ -670,12 +693,15 @@ function streamingPlaylistActivityUrlToDBAttributes (video: MVideoId, videoObjec const playlistUrls = videoObject.url.filter(u => isAPStreamingPlaylistUrlObject(u)) as ActivityPlaylistUrlObject[] if (playlistUrls.length === 0) return [] - const attributes: FilteredModelAttributes[] = [] + const attributes: (FilteredModelAttributes & { tagAPObject?: ActivityTagObject[] })[] = [] for (const playlistUrlObject of playlistUrls) { - const segmentsSha256UrlObject = playlistUrlObject.tag - .find(t => { - return isAPPlaylistSegmentHashesUrlObject(t) - }) as ActivityPlaylistSegmentHashesObject + const segmentsSha256UrlObject = playlistUrlObject.tag.find(isAPPlaylistSegmentHashesUrlObject) + + let files: unknown[] = playlistUrlObject.tag.filter(u => isAPVideoUrlObject(u)) as ActivityVideoUrlObject[] + + // FIXME: backward compatibility introduced in v2.1.0 + if (files.length === 0) files = videoFiles + if (!segmentsSha256UrlObject) { logger.warn('No segment sha256 URL found in AP playlist object.', { playlistUrl: playlistUrlObject }) continue @@ -685,9 +711,10 @@ function streamingPlaylistActivityUrlToDBAttributes (video: MVideoId, videoObjec type: VideoStreamingPlaylistType.HLS, playlistUrl: playlistUrlObject.href, segmentsSha256Url: segmentsSha256UrlObject.href, - p2pMediaLoaderInfohashes: VideoStreamingPlaylistModel.buildP2PMediaLoaderInfoHashes(playlistUrlObject.href, videoFiles), + p2pMediaLoaderInfohashes: VideoStreamingPlaylistModel.buildP2PMediaLoaderInfoHashes(playlistUrlObject.href, files), p2pMediaLoaderPeerVersion: P2P_MEDIA_LOADER_PEER_VERSION, - videoId: video.id + videoId: video.id, + tagAPObject: playlistUrlObject.tag } attributes.push(attribute) diff --git a/server/lib/hls.ts b/server/lib/hls.ts index 05136c21c..943721dd7 100644 --- a/server/lib/hls.ts +++ b/server/lib/hls.ts @@ -12,6 +12,7 @@ import { VideoFileModel } from '../models/video/video-file' import { CONFIG } from '../initializers/config' import { sequelizeTypescript } from '../initializers/database' import { MVideoWithFile } from '@server/typings/models' +import { getVideoFilename, getVideoFilePath } from './video-paths' async function updateStreamingPlaylistsInfohashesIfNeeded () { const playlistsToUpdate = await VideoStreamingPlaylistModel.listByIncorrectPeerVersion() @@ -32,13 +33,14 @@ async function updateMasterHLSPlaylist (video: MVideoWithFile) { const directory = join(HLS_STREAMING_PLAYLIST_DIRECTORY, video.uuid) const masterPlaylists: string[] = [ '#EXTM3U', '#EXT-X-VERSION:3' ] const masterPlaylistPath = join(directory, VideoStreamingPlaylistModel.getMasterHlsPlaylistFilename()) + const streamingPlaylist = video.getHLSPlaylist() - for (const file of video.VideoFiles) { + for (const file of streamingPlaylist.VideoFiles) { // If we did not generated a playlist for this resolution, skip const filePlaylistPath = join(directory, VideoStreamingPlaylistModel.getHlsPlaylistFilename(file.resolution)) if (await pathExists(filePlaylistPath) === false) continue - const videoFilePath = video.getVideoFilePath(file) + const videoFilePath = getVideoFilePath(streamingPlaylist, file) const size = await getVideoFileSize(videoFilePath) @@ -59,12 +61,13 @@ async function updateSha256Segments (video: MVideoWithFile) { const json: { [filename: string]: { [range: string]: string } } = {} const playlistDirectory = join(HLS_STREAMING_PLAYLIST_DIRECTORY, video.uuid) + const hlsPlaylist = video.getHLSPlaylist() // For all the resolutions available for this video - for (const file of video.VideoFiles) { + for (const file of hlsPlaylist.VideoFiles) { const rangeHashes: { [range: string]: string } = {} - const videoPath = join(playlistDirectory, VideoStreamingPlaylistModel.getHlsVideoName(video.uuid, file.resolution)) + const videoPath = getVideoFilePath(hlsPlaylist, file) const playlistPath = join(playlistDirectory, VideoStreamingPlaylistModel.getHlsPlaylistFilename(file.resolution)) // Maybe the playlist is not generated for this resolution yet @@ -82,7 +85,7 @@ async function updateSha256Segments (video: MVideoWithFile) { } await close(fd) - const videoFilename = VideoStreamingPlaylistModel.getHlsVideoName(video.uuid, file.resolution) + const videoFilename = getVideoFilename(hlsPlaylist, file) json[videoFilename] = rangeHashes } diff --git a/server/lib/job-queue/handlers/video-file-import.ts b/server/lib/job-queue/handlers/video-file-import.ts index 5c5b7dccb..99c991e72 100644 --- a/server/lib/job-queue/handlers/video-file-import.ts +++ b/server/lib/job-queue/handlers/video-file-import.ts @@ -7,6 +7,8 @@ import { copy, stat } from 'fs-extra' import { VideoFileModel } from '../../../models/video/video-file' import { extname } from 'path' import { MVideoFile, MVideoWithFile } from '@server/typings/models' +import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent' +import { getVideoFilePath } from '@server/lib/video-paths' export type VideoFileImportPayload = { videoUUID: string, @@ -68,10 +70,10 @@ async function updateVideoFile (video: MVideoWithFile, inputFilePath: string) { updatedVideoFile = currentVideoFile } - const outputPath = video.getVideoFilePath(updatedVideoFile) + const outputPath = getVideoFilePath(video, updatedVideoFile) await copy(inputFilePath, outputPath) - await video.createTorrentAndSetInfoHash(updatedVideoFile) + await createTorrentAndSetInfoHash(video, updatedVideoFile) await updatedVideoFile.save() diff --git a/server/lib/job-queue/handlers/video-import.ts b/server/lib/job-queue/handlers/video-import.ts index 93a3e9d90..1fca17584 100644 --- a/server/lib/job-queue/handlers/video-import.ts +++ b/server/lib/job-queue/handlers/video-import.ts @@ -4,14 +4,14 @@ import { downloadYoutubeDLVideo } from '../../../helpers/youtube-dl' import { VideoImportModel } from '../../../models/video/video-import' import { VideoImportState } from '../../../../shared/models/videos' import { getDurationFromVideoFile, getVideoFileFPS, getVideoFileResolution } from '../../../helpers/ffmpeg-utils' -import { extname, join } from 'path' +import { extname } from 'path' import { VideoFileModel } from '../../../models/video/video-file' import { VIDEO_IMPORT_TIMEOUT } from '../../../initializers/constants' import { VideoState } from '../../../../shared' import { JobQueue } from '../index' import { federateVideoIfNeeded } from '../../activitypub' import { VideoModel } from '../../../models/video/video' -import { downloadWebTorrentVideo } from '../../../helpers/webtorrent' +import { createTorrentAndSetInfoHash, downloadWebTorrentVideo } from '../../../helpers/webtorrent' import { getSecureTorrentName } from '../../../helpers/utils' import { move, remove, stat } from 'fs-extra' import { Notifier } from '../../notifier' @@ -21,7 +21,7 @@ import { createVideoMiniatureFromUrl, generateVideoMiniature } from '../../thumb import { ThumbnailType } from '../../../../shared/models/videos/thumbnail.type' import { MThumbnail } from '../../../typings/models/video/thumbnail' import { MVideoImportDefault, MVideoImportDefaultFiles, MVideoImportVideo } from '@server/typings/models/video/video-import' -import { MVideoBlacklistVideo, MVideoBlacklist } from '@server/typings/models' +import { getVideoFilePath } from '@server/lib/video-paths' type VideoImportYoutubeDLPayload = { type: 'youtube-dl' @@ -142,12 +142,12 @@ async function processFile (downloader: () => Promise, videoImport: MVid } videoFile = new VideoFileModel(videoFileData) - const videoWithFiles = Object.assign(videoImport.Video, { VideoFiles: [ videoFile ] }) + const videoWithFiles = Object.assign(videoImport.Video, { VideoFiles: [ videoFile ], VideoStreamingPlaylists: [] }) // To clean files if the import fails const videoImportWithFiles: MVideoImportDefaultFiles = Object.assign(videoImport, { Video: videoWithFiles }) // Move file - videoDestFile = join(CONFIG.STORAGE.VIDEOS_DIR, videoImportWithFiles.Video.getVideoFilename(videoFile)) + videoDestFile = getVideoFilePath(videoImportWithFiles.Video, videoFile) await move(tempVideoPath, videoDestFile) tempVideoPath = null // This path is not used anymore @@ -168,7 +168,7 @@ async function processFile (downloader: () => Promise, videoImport: MVid } // Create torrent - await videoImportWithFiles.Video.createTorrentAndSetInfoHash(videoFile) + await createTorrentAndSetInfoHash(videoImportWithFiles.Video, videoFile) const { videoImportUpdated, video } = await sequelizeTypescript.transaction(async t => { const videoImportToUpdate = videoImportWithFiles as MVideoImportVideo diff --git a/server/lib/job-queue/handlers/video-transcoding.ts b/server/lib/job-queue/handlers/video-transcoding.ts index 2ebe15bcb..39b9fac98 100644 --- a/server/lib/job-queue/handlers/video-transcoding.ts +++ b/server/lib/job-queue/handlers/video-transcoding.ts @@ -1,5 +1,5 @@ import * as Bull from 'bull' -import { VideoResolution, VideoState } from '../../../../shared' +import { VideoResolution } from '../../../../shared' import { logger } from '../../../helpers/logger' import { VideoModel } from '../../../models/video/video' import { JobQueue } from '../job-queue' @@ -8,10 +8,10 @@ import { retryTransactionWrapper } from '../../../helpers/database-utils' import { sequelizeTypescript } from '../../../initializers' import * as Bluebird from 'bluebird' import { computeResolutionsToTranscode } from '../../../helpers/ffmpeg-utils' -import { generateHlsPlaylist, optimizeVideofile, transcodeOriginalVideofile, mergeAudioVideofile } from '../../video-transcoding' +import { generateHlsPlaylist, mergeAudioVideofile, optimizeOriginalVideofile, transcodeNewResolution } from '../../video-transcoding' import { Notifier } from '../../notifier' import { CONFIG } from '../../../initializers/config' -import { MVideoUUID, MVideoWithFile } from '@server/typings/models' +import { MVideoFullLight, MVideoUUID, MVideoWithFile } from '@server/typings/models' interface BaseTranscodingPayload { videoUUID: string @@ -22,6 +22,7 @@ interface HLSTranscodingPayload extends BaseTranscodingPayload { type: 'hls' isPortraitMode?: boolean resolution: VideoResolution + copyCodecs: boolean } interface NewResolutionTranscodingPayload extends BaseTranscodingPayload { @@ -54,11 +55,11 @@ async function processVideoTranscoding (job: Bull.Job) { } if (payload.type === 'hls') { - await generateHlsPlaylist(video, payload.resolution, payload.isPortraitMode || false) + await generateHlsPlaylist(video, payload.resolution, payload.copyCodecs, payload.isPortraitMode || false) await retryTransactionWrapper(onHlsPlaylistGenerationSuccess, video) } else if (payload.type === 'new-resolution') { - await transcodeOriginalVideofile(video, payload.resolution, payload.isPortraitMode || false) + await transcodeNewResolution(video, payload.resolution, payload.isPortraitMode || false) await retryTransactionWrapper(publishNewResolutionIfNeeded, video, payload) } else if (payload.type === 'merge-audio') { @@ -66,7 +67,7 @@ async function processVideoTranscoding (job: Bull.Job) { await retryTransactionWrapper(publishNewResolutionIfNeeded, video, payload) } else { - await optimizeVideofile(video) + await optimizeOriginalVideofile(video) await retryTransactionWrapper(onVideoFileOptimizerSuccess, video, payload) } @@ -74,48 +75,24 @@ async function processVideoTranscoding (job: Bull.Job) { return video } -async function onHlsPlaylistGenerationSuccess (video: MVideoUUID) { +async function onHlsPlaylistGenerationSuccess (video: MVideoFullLight) { if (video === undefined) return undefined - await sequelizeTypescript.transaction(async t => { - // Maybe the video changed in database, refresh it - let videoDatabase = await VideoModel.loadAndPopulateAccountAndServerAndTags(video.uuid, t) - // Video does not exist anymore - if (!videoDatabase) return undefined - - // If the video was not published, we consider it is a new one for other instances - await federateVideoIfNeeded(videoDatabase, false, t) - }) -} - -async function publishNewResolutionIfNeeded (video: MVideoUUID, payload?: NewResolutionTranscodingPayload | MergeAudioTranscodingPayload) { - const { videoDatabase, videoPublished } = await sequelizeTypescript.transaction(async t => { - // Maybe the video changed in database, refresh it - let videoDatabase = await VideoModel.loadAndPopulateAccountAndServerAndTags(video.uuid, t) - // Video does not exist anymore - if (!videoDatabase) return undefined - - let videoPublished = false - - // We transcoded the video file in another format, now we can publish it - if (videoDatabase.state !== VideoState.PUBLISHED) { - videoPublished = true - - videoDatabase.state = VideoState.PUBLISHED - videoDatabase.publishedAt = new Date() - videoDatabase = await videoDatabase.save({ transaction: t }) + // We generated the HLS playlist, we don't need the webtorrent files anymore if the admin disabled it + if (CONFIG.TRANSCODING.WEBTORRENT.ENABLED === false) { + for (const file of video.VideoFiles) { + await video.removeFile(file) + await file.destroy() } - // If the video was not published, we consider it is a new one for other instances - await federateVideoIfNeeded(videoDatabase, videoPublished, t) + video.VideoFiles = [] + } - return { videoDatabase, videoPublished } - }) + return publishAndFederateIfNeeded(video) +} - if (videoPublished) { - Notifier.Instance.notifyOnNewVideoIfNeeded(videoDatabase) - Notifier.Instance.notifyOnVideoPublishedAfterTranscoding(videoDatabase) - } +async function publishNewResolutionIfNeeded (video: MVideoUUID, payload?: NewResolutionTranscodingPayload | MergeAudioTranscodingPayload) { + await publishAndFederateIfNeeded(video) await createHlsJobIfEnabled(payload) } @@ -124,7 +101,7 @@ async function onVideoFileOptimizerSuccess (videoArg: MVideoWithFile, payload: O if (videoArg === undefined) return undefined // Outside the transaction (IO on disk) - const { videoFileResolution } = await videoArg.getOriginalFileResolution() + const { videoFileResolution } = await videoArg.getMaxQualityResolution() const { videoDatabase, videoPublished } = await sequelizeTypescript.transaction(async t => { // Maybe the video changed in database, refresh it @@ -141,14 +118,29 @@ async function onVideoFileOptimizerSuccess (videoArg: MVideoWithFile, payload: O let videoPublished = false + const hlsPayload = Object.assign({}, payload, { resolution: videoDatabase.getMaxQualityFile().resolution }) + await createHlsJobIfEnabled(hlsPayload) + if (resolutionsEnabled.length !== 0) { const tasks: (Bluebird> | Promise>)[] = [] for (const resolution of resolutionsEnabled) { - const dataInput = { - type: 'new-resolution' as 'new-resolution', - videoUUID: videoDatabase.uuid, - resolution + let dataInput: VideoTranscodingPayload + + if (CONFIG.TRANSCODING.WEBTORRENT.ENABLED) { + dataInput = { + type: 'new-resolution' as 'new-resolution', + videoUUID: videoDatabase.uuid, + resolution + } + } else if (CONFIG.TRANSCODING.HLS.ENABLED) { + dataInput = { + type: 'hls', + videoUUID: videoDatabase.uuid, + resolution, + isPortraitMode: false, + copyCodecs: false + } } const p = JobQueue.Instance.createJob({ type: 'video-transcoding', payload: dataInput }) @@ -159,11 +151,8 @@ async function onVideoFileOptimizerSuccess (videoArg: MVideoWithFile, payload: O logger.info('Transcoding jobs created for uuid %s.', videoDatabase.uuid, { resolutionsEnabled }) } else { - videoPublished = true - // No transcoding to do, it's now published - videoDatabase.state = VideoState.PUBLISHED - videoDatabase = await videoDatabase.save({ transaction: t }) + videoPublished = await videoDatabase.publishIfNeededAndSave(t) logger.info('No transcoding jobs created for video %s (no resolutions).', videoDatabase.uuid, { privacy: videoDatabase.privacy }) } @@ -175,9 +164,6 @@ async function onVideoFileOptimizerSuccess (videoArg: MVideoWithFile, payload: O if (payload.isNewVideo) Notifier.Instance.notifyOnNewVideoIfNeeded(videoDatabase) if (videoPublished) Notifier.Instance.notifyOnVideoPublishedAfterTranscoding(videoDatabase) - - const hlsPayload = Object.assign({}, payload, { resolution: videoDatabase.getOriginalFile().resolution }) - await createHlsJobIfEnabled(hlsPayload) } // --------------------------------------------------------------------------- @@ -196,9 +182,32 @@ function createHlsJobIfEnabled (payload?: { videoUUID: string, resolution: numbe type: 'hls' as 'hls', videoUUID: payload.videoUUID, resolution: payload.resolution, - isPortraitMode: payload.isPortraitMode + isPortraitMode: payload.isPortraitMode, + copyCodecs: true } return JobQueue.Instance.createJob({ type: 'video-transcoding', payload: hlsTranscodingPayload }) } } + +async function publishAndFederateIfNeeded (video: MVideoUUID) { + const { videoDatabase, videoPublished } = await sequelizeTypescript.transaction(async t => { + // Maybe the video changed in database, refresh it + const videoDatabase = await VideoModel.loadAndPopulateAccountAndServerAndTags(video.uuid, t) + // Video does not exist anymore + if (!videoDatabase) return undefined + + // We transcoded the video file in another format, now we can publish it + const videoPublished = await videoDatabase.publishIfNeededAndSave(t) + + // If the video was not published, we consider it is a new one for other instances + await federateVideoIfNeeded(videoDatabase, videoPublished, t) + + return { videoDatabase, videoPublished } + }) + + if (videoPublished) { + Notifier.Instance.notifyOnNewVideoIfNeeded(videoDatabase) + Notifier.Instance.notifyOnVideoPublishedAfterTranscoding(videoDatabase) + } +} diff --git a/server/lib/schedulers/update-videos-scheduler.ts b/server/lib/schedulers/update-videos-scheduler.ts index 5b673b913..293bba91f 100644 --- a/server/lib/schedulers/update-videos-scheduler.ts +++ b/server/lib/schedulers/update-videos-scheduler.ts @@ -6,8 +6,8 @@ import { federateVideoIfNeeded } from '../activitypub' import { SCHEDULER_INTERVALS_MS } from '../../initializers/constants' import { VideoPrivacy } from '../../../shared/models/videos' import { Notifier } from '../notifier' -import { VideoModel } from '../../models/video/video' import { sequelizeTypescript } from '../../initializers/database' +import { MVideoFullLight } from '@server/typings/models' export class UpdateVideosScheduler extends AbstractScheduler { @@ -28,7 +28,7 @@ export class UpdateVideosScheduler extends AbstractScheduler { const publishedVideos = await sequelizeTypescript.transaction(async t => { const schedules = await ScheduleVideoUpdateModel.listVideosToUpdate(t) - const publishedVideos: VideoModel[] = [] + const publishedVideos: MVideoFullLight[] = [] for (const schedule of schedules) { const video = schedule.Video @@ -45,8 +45,8 @@ export class UpdateVideosScheduler extends AbstractScheduler { await federateVideoIfNeeded(video, isNewVideo, t) if (oldPrivacy === VideoPrivacy.UNLISTED || oldPrivacy === VideoPrivacy.PRIVATE) { - video.ScheduleVideoUpdate = schedule - publishedVideos.push(video) + const videoToPublish: MVideoFullLight = Object.assign(video, { ScheduleVideoUpdate: schedule, UserVideoHistories: [] }) + publishedVideos.push(videoToPublish) } } diff --git a/server/lib/schedulers/videos-redundancy-scheduler.ts b/server/lib/schedulers/videos-redundancy-scheduler.ts index 1e30f6ebc..f2bd75cb4 100644 --- a/server/lib/schedulers/videos-redundancy-scheduler.ts +++ b/server/lib/schedulers/videos-redundancy-scheduler.ts @@ -3,7 +3,7 @@ import { HLS_REDUNDANCY_DIRECTORY, REDUNDANCY, VIDEO_IMPORT_TIMEOUT, WEBSERVER } import { logger } from '../../helpers/logger' import { VideosRedundancy } from '../../../shared/models/redundancy' import { VideoRedundancyModel } from '../../models/redundancy/video-redundancy' -import { downloadWebTorrentVideo } from '../../helpers/webtorrent' +import { downloadWebTorrentVideo, generateMagnetUri } from '../../helpers/webtorrent' import { join } from 'path' import { move } from 'fs-extra' import { getServerActor } from '../../helpers/utils' @@ -24,6 +24,7 @@ import { MVideoRedundancyVideo, MVideoWithAllFiles } from '@server/typings/models' +import { getVideoFilename } from '../video-paths' type CandidateToDuplicate = { redundancy: VideosRedundancy, @@ -195,11 +196,11 @@ export class VideosRedundancyScheduler extends AbstractScheduler { logger.info('Duplicating %s - %d in videos redundancy with "%s" strategy.', video.url, file.resolution, redundancy.strategy) const { baseUrlHttp, baseUrlWs } = video.getBaseUrls() - const magnetUri = video.generateMagnetUri(file, baseUrlHttp, baseUrlWs) + const magnetUri = await generateMagnetUri(video, file, baseUrlHttp, baseUrlWs) const tmpPath = await downloadWebTorrentVideo({ magnetUri }, VIDEO_IMPORT_TIMEOUT) - const destPath = join(CONFIG.STORAGE.REDUNDANCY_DIR, video.getVideoFilename(file)) + const destPath = join(CONFIG.STORAGE.REDUNDANCY_DIR, getVideoFilename(video, file)) await move(tmpPath, destPath, { overwrite: true }) const createdModel: MVideoRedundancyFileVideo = await VideoRedundancyModel.create({ diff --git a/server/lib/thumbnail.ts b/server/lib/thumbnail.ts index 84791955e..a99f71629 100644 --- a/server/lib/thumbnail.ts +++ b/server/lib/thumbnail.ts @@ -9,6 +9,7 @@ import { downloadImage } from '../helpers/requests' import { MVideoPlaylistThumbnail } from '../typings/models/video/video-playlist' import { MVideoFile, MVideoThumbnail } from '../typings/models' import { MThumbnail } from '../typings/models/video/thumbnail' +import { getVideoFilePath } from './video-paths' type ImageSize = { height: number, width: number } @@ -55,7 +56,7 @@ function createVideoMiniatureFromExisting ( } function generateVideoMiniature (video: MVideoThumbnail, videoFile: MVideoFile, type: ThumbnailType) { - const input = video.getVideoFilePath(videoFile) + const input = getVideoFilePath(video, videoFile) const { filename, basePath, height, width, existingThumbnail, outputPath } = buildMetadataFromVideo(video, type) const thumbnailCreator = videoFile.isAudio() diff --git a/server/lib/video-paths.ts b/server/lib/video-paths.ts new file mode 100644 index 000000000..63011cdb2 --- /dev/null +++ b/server/lib/video-paths.ts @@ -0,0 +1,64 @@ +import { isStreamingPlaylist, MStreamingPlaylistVideo, MVideo, MVideoFile } from '@server/typings/models' +import { extractVideo } from './videos' +import { join } from 'path' +import { CONFIG } from '@server/initializers/config' +import { HLS_STREAMING_PLAYLIST_DIRECTORY } from '@server/initializers/constants' + +// ################## Video file name ################## + +function getVideoFilename (videoOrPlaylist: MVideo | MStreamingPlaylistVideo, videoFile: MVideoFile) { + const video = extractVideo(videoOrPlaylist) + + if (isStreamingPlaylist(videoOrPlaylist)) { + return generateVideoStreamingPlaylistName(video.uuid, videoFile.resolution) + } + + return generateWebTorrentVideoName(video.uuid, videoFile.resolution, videoFile.extname) +} + +function generateVideoStreamingPlaylistName (uuid: string, resolution: number) { + return `${uuid}-${resolution}-fragmented.mp4` +} + +function generateWebTorrentVideoName (uuid: string, resolution: number, extname: string) { + return uuid + '-' + resolution + extname +} + +function getVideoFilePath (videoOrPlaylist: MVideo | MStreamingPlaylistVideo, videoFile: MVideoFile, isRedundancy = false) { + if (isStreamingPlaylist(videoOrPlaylist)) { + const video = extractVideo(videoOrPlaylist) + return join(HLS_STREAMING_PLAYLIST_DIRECTORY, video.uuid, getVideoFilename(videoOrPlaylist, videoFile)) + } + + const baseDir = isRedundancy ? CONFIG.STORAGE.REDUNDANCY_DIR : CONFIG.STORAGE.VIDEOS_DIR + return join(baseDir, getVideoFilename(videoOrPlaylist, videoFile)) +} + +// ################## Torrents ################## + +function getTorrentFileName (videoOrPlaylist: MVideo | MStreamingPlaylistVideo, videoFile: MVideoFile) { + const video = extractVideo(videoOrPlaylist) + const extension = '.torrent' + + if (isStreamingPlaylist(videoOrPlaylist)) { + return `${video.uuid}-${videoFile.resolution}-${videoOrPlaylist.getStringType()}${extension}` + } + + return video.uuid + '-' + videoFile.resolution + extension +} + +function getTorrentFilePath (videoOrPlaylist: MVideo | MStreamingPlaylistVideo, videoFile: MVideoFile) { + return join(CONFIG.STORAGE.TORRENTS_DIR, getTorrentFileName(videoOrPlaylist, videoFile)) +} + +// --------------------------------------------------------------------------- + +export { + generateVideoStreamingPlaylistName, + generateWebTorrentVideoName, + getVideoFilename, + getVideoFilePath, + + getTorrentFileName, + getTorrentFilePath +} diff --git a/server/lib/video-transcoding.ts b/server/lib/video-transcoding.ts index 612d388ee..9243d1742 100644 --- a/server/lib/video-transcoding.ts +++ b/server/lib/video-transcoding.ts @@ -1,5 +1,5 @@ import { HLS_STREAMING_PLAYLIST_DIRECTORY, P2P_MEDIA_LOADER_PEER_VERSION, WEBSERVER } from '../initializers/constants' -import { basename, join } from 'path' +import { basename, extname as extnameUtil, join } from 'path' import { canDoQuickTranscode, getDurationFromVideoFile, @@ -16,18 +16,19 @@ import { updateMasterHLSPlaylist, updateSha256Segments } from './hls' import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-playlist' import { VideoStreamingPlaylistType } from '../../shared/models/videos/video-streaming-playlist.type' import { CONFIG } from '../initializers/config' -import { MVideoFile, MVideoWithFile, MVideoWithFileThumbnail } from '@server/typings/models' +import { MStreamingPlaylistFilesVideo, MVideoFile, MVideoWithAllFiles, MVideoWithFile } from '@server/typings/models' +import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent' +import { generateVideoStreamingPlaylistName, getVideoFilename, getVideoFilePath } from './video-paths' /** * Optimize the original video file and replace it. The resolution is not changed. */ -async function optimizeVideofile (video: MVideoWithFile, inputVideoFileArg?: MVideoFile) { - const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR +async function optimizeOriginalVideofile (video: MVideoWithFile, inputVideoFileArg?: MVideoFile) { const transcodeDirectory = CONFIG.STORAGE.TMP_DIR const newExtname = '.mp4' - const inputVideoFile = inputVideoFileArg ? inputVideoFileArg : video.getOriginalFile() - const videoInputPath = join(videosDirectory, video.getVideoFilename(inputVideoFile)) + const inputVideoFile = inputVideoFileArg || video.getMaxQualityFile() + const videoInputPath = getVideoFilePath(video, inputVideoFile) const videoTranscodedPath = join(transcodeDirectory, video.id + '-transcoded' + newExtname) const transcodeType: TranscodeOptionsType = await canDoQuickTranscode(videoInputPath) @@ -35,7 +36,7 @@ async function optimizeVideofile (video: MVideoWithFile, inputVideoFileArg?: MVi : 'video' const transcodeOptions: TranscodeOptions = { - type: transcodeType as any, // FIXME: typing issue + type: transcodeType, inputPath: videoInputPath, outputPath: videoTranscodedPath, resolution: inputVideoFile.resolution @@ -50,7 +51,7 @@ async function optimizeVideofile (video: MVideoWithFile, inputVideoFileArg?: MVi // Important to do this before getVideoFilename() to take in account the new file extension inputVideoFile.extname = newExtname - const videoOutputPath = video.getVideoFilePath(inputVideoFile) + const videoOutputPath = getVideoFilePath(video, inputVideoFile) await onVideoFileTranscoding(video, inputVideoFile, videoTranscodedPath, videoOutputPath) } catch (err) { @@ -64,13 +65,12 @@ async function optimizeVideofile (video: MVideoWithFile, inputVideoFileArg?: MVi /** * Transcode the original video file to a lower resolution. */ -async function transcodeOriginalVideofile (video: MVideoWithFile, resolution: VideoResolution, isPortrait: boolean) { - const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR +async function transcodeNewResolution (video: MVideoWithFile, resolution: VideoResolution, isPortrait: boolean) { const transcodeDirectory = CONFIG.STORAGE.TMP_DIR const extname = '.mp4' // We are sure it's x264 in mp4 because optimizeOriginalVideofile was already executed - const videoInputPath = join(videosDirectory, video.getVideoFilename(video.getOriginalFile())) + const videoInputPath = getVideoFilePath(video, video.getMaxQualityFile()) const newVideoFile = new VideoFileModel({ resolution, @@ -78,8 +78,8 @@ async function transcodeOriginalVideofile (video: MVideoWithFile, resolution: Vi size: 0, videoId: video.id }) - const videoOutputPath = join(CONFIG.STORAGE.VIDEOS_DIR, video.getVideoFilename(newVideoFile)) - const videoTranscodedPath = join(transcodeDirectory, video.getVideoFilename(newVideoFile)) + const videoOutputPath = getVideoFilePath(video, newVideoFile) + const videoTranscodedPath = join(transcodeDirectory, getVideoFilename(video, newVideoFile)) const transcodeOptions = { type: 'video' as 'video', @@ -94,14 +94,13 @@ async function transcodeOriginalVideofile (video: MVideoWithFile, resolution: Vi return onVideoFileTranscoding(video, newVideoFile, videoTranscodedPath, videoOutputPath) } -async function mergeAudioVideofile (video: MVideoWithFileThumbnail, resolution: VideoResolution) { - const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR +async function mergeAudioVideofile (video: MVideoWithAllFiles, resolution: VideoResolution) { const transcodeDirectory = CONFIG.STORAGE.TMP_DIR const newExtname = '.mp4' - const inputVideoFile = video.getOriginalFile() + const inputVideoFile = video.getMaxQualityFile() - const audioInputPath = join(videosDirectory, video.getVideoFilename(video.getOriginalFile())) + const audioInputPath = getVideoFilePath(video, inputVideoFile) const videoTranscodedPath = join(transcodeDirectory, video.id + '-transcoded' + newExtname) // If the user updates the video preview during transcoding @@ -130,7 +129,7 @@ async function mergeAudioVideofile (video: MVideoWithFileThumbnail, resolution: // Important to do this before getVideoFilename() to take in account the new file extension inputVideoFile.extname = newExtname - const videoOutputPath = video.getVideoFilePath(inputVideoFile) + const videoOutputPath = getVideoFilePath(video, inputVideoFile) // ffmpeg generated a new video file, so update the video duration // See https://trac.ffmpeg.org/ticket/5456 video.duration = await getDurationFromVideoFile(videoTranscodedPath) @@ -139,33 +138,40 @@ async function mergeAudioVideofile (video: MVideoWithFileThumbnail, resolution: return onVideoFileTranscoding(video, inputVideoFile, videoTranscodedPath, videoOutputPath) } -async function generateHlsPlaylist (video: MVideoWithFile, resolution: VideoResolution, isPortraitMode: boolean) { +async function generateHlsPlaylist (video: MVideoWithFile, resolution: VideoResolution, copyCodecs: boolean, isPortraitMode: boolean) { const baseHlsDirectory = join(HLS_STREAMING_PLAYLIST_DIRECTORY, video.uuid) await ensureDir(join(HLS_STREAMING_PLAYLIST_DIRECTORY, video.uuid)) - const videoInputPath = join(CONFIG.STORAGE.VIDEOS_DIR, video.getVideoFilename(video.getFile(resolution))) + const videoFileInput = copyCodecs + ? video.getWebTorrentFile(resolution) + : video.getMaxQualityFile() + + const videoOrStreamingPlaylist = videoFileInput.getVideoOrStreamingPlaylist() + const videoInputPath = getVideoFilePath(videoOrStreamingPlaylist, videoFileInput) + const outputPath = join(baseHlsDirectory, VideoStreamingPlaylistModel.getHlsPlaylistFilename(resolution)) + const videoFilename = generateVideoStreamingPlaylistName(video.uuid, resolution) const transcodeOptions = { type: 'hls' as 'hls', inputPath: videoInputPath, outputPath, resolution, + copyCodecs, isPortraitMode, hlsPlaylist: { - videoFilename: VideoStreamingPlaylistModel.getHlsVideoName(video.uuid, resolution) + videoFilename } } - await transcode(transcodeOptions) + logger.debug('Will run transcode.', { transcodeOptions }) - await updateMasterHLSPlaylist(video) - await updateSha256Segments(video) + await transcode(transcodeOptions) const playlistUrl = WEBSERVER.URL + VideoStreamingPlaylistModel.getHlsMasterPlaylistStaticPath(video.uuid) - await VideoStreamingPlaylistModel.upsert({ + const [ videoStreamingPlaylist ] = await VideoStreamingPlaylistModel.upsert({ videoId: video.id, playlistUrl, segmentsSha256Url: WEBSERVER.URL + VideoStreamingPlaylistModel.getHlsSha256SegmentsStaticPath(video.uuid), @@ -173,15 +179,44 @@ async function generateHlsPlaylist (video: MVideoWithFile, resolution: VideoReso p2pMediaLoaderPeerVersion: P2P_MEDIA_LOADER_PEER_VERSION, type: VideoStreamingPlaylistType.HLS + }, { returning: true }) as [ MStreamingPlaylistFilesVideo, boolean ] + videoStreamingPlaylist.Video = video + + const newVideoFile = new VideoFileModel({ + resolution, + extname: extnameUtil(videoFilename), + size: 0, + fps: -1, + videoStreamingPlaylistId: videoStreamingPlaylist.id }) + + const videoFilePath = getVideoFilePath(videoStreamingPlaylist, newVideoFile) + const stats = await stat(videoFilePath) + + newVideoFile.size = stats.size + newVideoFile.fps = await getVideoFileFPS(videoFilePath) + + await createTorrentAndSetInfoHash(videoStreamingPlaylist, newVideoFile) + + const updatedVideoFile = await newVideoFile.save() + + videoStreamingPlaylist.VideoFiles = await videoStreamingPlaylist.$get('VideoFiles') as VideoFileModel[] + videoStreamingPlaylist.VideoFiles.push(updatedVideoFile) + + video.setHLSPlaylist(videoStreamingPlaylist) + + await updateMasterHLSPlaylist(video) + await updateSha256Segments(video) + + return video } // --------------------------------------------------------------------------- export { generateHlsPlaylist, - optimizeVideofile, - transcodeOriginalVideofile, + optimizeOriginalVideofile, + transcodeNewResolution, mergeAudioVideofile } @@ -196,7 +231,7 @@ async function onVideoFileTranscoding (video: MVideoWithFile, videoFile: MVideoF videoFile.size = stats.size videoFile.fps = fps - await video.createTorrentAndSetInfoHash(videoFile) + await createTorrentAndSetInfoHash(video, videoFile) const updatedVideoFile = await videoFile.save() diff --git a/server/lib/videos.ts b/server/lib/videos.ts new file mode 100644 index 000000000..22e9afbf9 --- /dev/null +++ b/server/lib/videos.ts @@ -0,0 +1,11 @@ +import { isStreamingPlaylist, MStreamingPlaylistVideo, MVideo } from '@server/typings/models' + +function extractVideo (videoOrPlaylist: MVideo | MStreamingPlaylistVideo) { + return isStreamingPlaylist(videoOrPlaylist) + ? videoOrPlaylist.Video + : videoOrPlaylist +} + +export { + extractVideo +} -- cgit v1.2.3