mirror of
https://github.com/Chocobozzz/PeerTube.git
synced 2025-10-05 19:42:24 +02:00
Separate HLS audio and video streams
Allows: * The HLS player to propose an "Audio only" resolution * The live to output an "Audio only" resolution * The live to ingest and output an "Audio only" stream This feature is under a config for VOD videos and is enabled by default for lives In the future we can imagine: * To propose multiple audio streams for a specific video * To ingest an audio only VOD and just output an audio only "video" (the player would play the audio file and PeerTube would not generate additional resolutions) This commit introduce a new way to download videos: * Add "/download/videos/generate/:videoId" endpoint where PeerTube can mux an audio only and a video only file to a mp4 container * The download client modal introduces a new default panel where the user can choose resolutions it wants to download
This commit is contained in:
parent
e77ba2dfbc
commit
816f346a60
186 changed files with 5748 additions and 2807 deletions
|
@ -347,7 +347,8 @@ function customConfig (): CustomConfig {
|
|||
enabled: CONFIG.TRANSCODING.WEB_VIDEOS.ENABLED
|
||||
},
|
||||
hls: {
|
||||
enabled: CONFIG.TRANSCODING.HLS.ENABLED
|
||||
enabled: CONFIG.TRANSCODING.HLS.ENABLED,
|
||||
splitAudioAndVideo: CONFIG.TRANSCODING.HLS.SPLIT_AUDIO_AND_VIDEO
|
||||
}
|
||||
},
|
||||
live: {
|
||||
|
@ -367,6 +368,7 @@ function customConfig (): CustomConfig {
|
|||
threads: CONFIG.LIVE.TRANSCODING.THREADS,
|
||||
profile: CONFIG.LIVE.TRANSCODING.PROFILE,
|
||||
resolutions: {
|
||||
'0p': CONFIG.LIVE.TRANSCODING.RESOLUTIONS['0p'],
|
||||
'144p': CONFIG.LIVE.TRANSCODING.RESOLUTIONS['144p'],
|
||||
'240p': CONFIG.LIVE.TRANSCODING.RESOLUTIONS['240p'],
|
||||
'360p': CONFIG.LIVE.TRANSCODING.RESOLUTIONS['360p'],
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import express from 'express'
|
||||
import { FileStorage, RunnerJobState, VideoFileStream } from '@peertube/peertube-models'
|
||||
import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
|
||||
import { proxifyHLS, proxifyWebVideoFile } from '@server/lib/object-storage/index.js'
|
||||
import { VideoPathManager } from '@server/lib/video-path-manager.js'
|
||||
|
@ -9,12 +9,20 @@ import {
|
|||
runnerJobGetVideoStudioTaskFileValidator,
|
||||
runnerJobGetVideoTranscodingFileValidator
|
||||
} from '@server/middlewares/validators/runners/job-files.js'
|
||||
import { RunnerJobState, FileStorage } from '@peertube/peertube-models'
|
||||
import { MVideoFileStreamingPlaylistVideo, MVideoFileVideo, MVideoFullLight } from '@server/types/models/index.js'
|
||||
import express from 'express'
|
||||
|
||||
const lTags = loggerTagsFactory('api', 'runner')
|
||||
|
||||
const runnerJobFilesRouter = express.Router()
|
||||
|
||||
runnerJobFilesRouter.post('/jobs/:jobUUID/files/videos/:videoId/max-quality/audio',
|
||||
apiRateLimiter,
|
||||
asyncMiddleware(jobOfRunnerGetValidatorFactory([ RunnerJobState.PROCESSING ])),
|
||||
asyncMiddleware(runnerJobGetVideoTranscodingFileValidator),
|
||||
asyncMiddleware(getMaxQualitySeparatedAudioFile)
|
||||
)
|
||||
|
||||
runnerJobFilesRouter.post('/jobs/:jobUUID/files/videos/:videoId/max-quality',
|
||||
apiRateLimiter,
|
||||
asyncMiddleware(jobOfRunnerGetValidatorFactory([ RunnerJobState.PROCESSING ])),
|
||||
|
@ -45,6 +53,21 @@ export {
|
|||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function getMaxQualitySeparatedAudioFile (req: express.Request, res: express.Response) {
|
||||
const runnerJob = res.locals.runnerJob
|
||||
const runner = runnerJob.Runner
|
||||
const video = res.locals.videoAll
|
||||
|
||||
logger.info(
|
||||
'Get max quality separated audio file of video %s of job %s for runner %s', video.uuid, runnerJob.uuid, runner.name,
|
||||
lTags(runner.name, runnerJob.id, runnerJob.type)
|
||||
)
|
||||
|
||||
const file = video.getMaxQualityFile(VideoFileStream.AUDIO) || video.getMaxQualityFile(VideoFileStream.VIDEO)
|
||||
|
||||
return serveVideoFile({ video, file, req, res })
|
||||
}
|
||||
|
||||
async function getMaxQualityVideoFile (req: express.Request, res: express.Response) {
|
||||
const runnerJob = res.locals.runnerJob
|
||||
const runner = runnerJob.Runner
|
||||
|
@ -55,7 +78,18 @@ async function getMaxQualityVideoFile (req: express.Request, res: express.Respon
|
|||
lTags(runner.name, runnerJob.id, runnerJob.type)
|
||||
)
|
||||
|
||||
const file = video.getMaxQualityFile()
|
||||
const file = video.getMaxQualityFile(VideoFileStream.VIDEO) || video.getMaxQualityFile(VideoFileStream.AUDIO)
|
||||
|
||||
return serveVideoFile({ video, file, req, res })
|
||||
}
|
||||
|
||||
async function serveVideoFile (options: {
|
||||
video: MVideoFullLight
|
||||
file: MVideoFileVideo | MVideoFileStreamingPlaylistVideo
|
||||
req: express.Request
|
||||
res: express.Response
|
||||
}) {
|
||||
const { video, file, req, res } = options
|
||||
|
||||
if (file.storage === FileStorage.OBJECT_STORAGE) {
|
||||
if (file.isHLS()) {
|
||||
|
@ -82,6 +116,8 @@ async function getMaxQualityVideoFile (req: express.Request, res: express.Respon
|
|||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getMaxQualityVideoPreview (req: express.Request, res: express.Response) {
|
||||
const runnerJob = res.locals.runnerJob
|
||||
const runner = runnerJob.Runner
|
||||
|
|
|
@ -2,7 +2,7 @@ import express from 'express'
|
|||
import validator from 'validator'
|
||||
import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
|
||||
import { federateVideoIfNeeded } from '@server/lib/activitypub/videos/index.js'
|
||||
import { updatePlaylistAfterFileChange } from '@server/lib/hls.js'
|
||||
import { updateM3U8AndShaPlaylist } from '@server/lib/hls.js'
|
||||
import { removeAllWebVideoFiles, removeHLSFile, removeHLSPlaylist, removeWebVideoFile } from '@server/lib/video-file.js'
|
||||
import { VideoFileModel } from '@server/models/video/video-file.js'
|
||||
import { HttpStatusCode, UserRight } from '@peertube/peertube-models'
|
||||
|
@ -89,7 +89,7 @@ async function removeHLSFileController (req: express.Request, res: express.Respo
|
|||
logger.info('Deleting HLS file %d of %s.', videoFileId, video.url, lTags(video.uuid))
|
||||
|
||||
const playlist = await removeHLSFile(video, videoFileId)
|
||||
if (playlist) await updatePlaylistAfterFileChange(video, playlist)
|
||||
if (playlist) await updateM3U8AndShaPlaylist(video, playlist)
|
||||
|
||||
await federateVideoIfNeeded(video, false, undefined)
|
||||
|
||||
|
|
|
@ -146,7 +146,7 @@ async function replaceVideoSourceResumable (req: express.Request, res: express.R
|
|||
|
||||
await regenerateMiniaturesIfNeeded(video, res.locals.ffprobe)
|
||||
await video.VideoChannel.setAsUpdated()
|
||||
await addVideoJobsAfterUpload(video, video.getMaxQualityFile())
|
||||
await addVideoJobsAfterUpload(video, videoFile.withVideoOrPlaylist(video))
|
||||
|
||||
logger.info('Replaced video file of video %s with uuid %s.', video.name, video.uuid, lTags(video.uuid))
|
||||
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import express from 'express'
|
||||
import { HttpStatusCode, UserRight, VideoState, VideoTranscodingCreate } from '@peertube/peertube-models'
|
||||
import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
|
||||
import { Hooks } from '@server/lib/plugins/hooks.js'
|
||||
import { createTranscodingJobs } from '@server/lib/transcoding/create-transcoding-job.js'
|
||||
import { computeResolutionsToTranscode } from '@server/lib/transcoding/transcoding-resolutions.js'
|
||||
import { VideoJobInfoModel } from '@server/models/video/video-job-info.js'
|
||||
import { HttpStatusCode, UserRight, VideoState, VideoTranscodingCreate } from '@peertube/peertube-models'
|
||||
import express from 'express'
|
||||
import { asyncMiddleware, authenticate, createTranscodingValidator, ensureUserHasRight } from '../../../middlewares/index.js'
|
||||
|
||||
const lTags = loggerTagsFactory('api', 'video')
|
||||
|
@ -33,7 +33,8 @@ async function createTranscoding (req: express.Request, res: express.Response) {
|
|||
|
||||
await VideoJobInfoModel.abortAllTasks(video.uuid, 'pendingTranscode')
|
||||
|
||||
const { resolution: maxResolution, hasAudio } = await video.probeMaxQualityFile()
|
||||
const maxResolution = video.getMaxResolution()
|
||||
const hasAudio = video.hasAudio()
|
||||
|
||||
const resolutions = await Hooks.wrapObject(
|
||||
computeResolutionsToTranscode({ input: maxResolution, type: 'vod', includeInput: true, strictLower: false, hasAudio }),
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
import { forceNumber } from '@peertube/peertube-core-utils'
|
||||
import { forceNumber, maxBy } from '@peertube/peertube-core-utils'
|
||||
import { FileStorage, HttpStatusCode, VideoStreamingPlaylistType } from '@peertube/peertube-models'
|
||||
import { logger } from '@server/helpers/logger.js'
|
||||
import { exists } from '@server/helpers/custom-validators/misc.js'
|
||||
import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
|
||||
import { CONFIG } from '@server/initializers/config.js'
|
||||
import { VideoTorrentsSimpleFileCache } from '@server/lib/files-cache/index.js'
|
||||
import {
|
||||
generateHLSFilePresignedUrl,
|
||||
|
@ -10,6 +12,7 @@ import {
|
|||
} from '@server/lib/object-storage/index.js'
|
||||
import { getFSUserExportFilePath } from '@server/lib/paths.js'
|
||||
import { Hooks } from '@server/lib/plugins/hooks.js'
|
||||
import { muxToMergeVideoFiles } from '@server/lib/video-file.js'
|
||||
import { VideoPathManager } from '@server/lib/video-path-manager.js'
|
||||
import {
|
||||
MStreamingPlaylist,
|
||||
|
@ -22,45 +25,67 @@ import {
|
|||
import { MVideoSource } from '@server/types/models/video/video-source.js'
|
||||
import cors from 'cors'
|
||||
import express from 'express'
|
||||
import { STATIC_DOWNLOAD_PATHS } from '../initializers/constants.js'
|
||||
import { DOWNLOAD_PATHS } from '../initializers/constants.js'
|
||||
import {
|
||||
asyncMiddleware, optionalAuthenticate,
|
||||
asyncMiddleware, buildRateLimiter, optionalAuthenticate,
|
||||
originalVideoFileDownloadValidator,
|
||||
userExportDownloadValidator,
|
||||
videosDownloadValidator
|
||||
videosDownloadValidator,
|
||||
videosGenerateDownloadValidator
|
||||
} from '../middlewares/index.js'
|
||||
|
||||
const lTags = loggerTagsFactory('download')
|
||||
|
||||
const downloadRouter = express.Router()
|
||||
|
||||
downloadRouter.use(cors())
|
||||
|
||||
downloadRouter.use(
|
||||
STATIC_DOWNLOAD_PATHS.TORRENTS + ':filename',
|
||||
DOWNLOAD_PATHS.TORRENTS + ':filename',
|
||||
asyncMiddleware(downloadTorrent)
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
downloadRouter.use(
|
||||
STATIC_DOWNLOAD_PATHS.VIDEOS + ':id-:resolution([0-9]+).:extension',
|
||||
DOWNLOAD_PATHS.WEB_VIDEOS + ':id-:resolution([0-9]+).:extension',
|
||||
optionalAuthenticate,
|
||||
asyncMiddleware(videosDownloadValidator),
|
||||
asyncMiddleware(downloadWebVideoFile)
|
||||
)
|
||||
|
||||
downloadRouter.use(
|
||||
STATIC_DOWNLOAD_PATHS.HLS_VIDEOS + ':id-:resolution([0-9]+)-fragmented.:extension',
|
||||
DOWNLOAD_PATHS.HLS_VIDEOS + ':id-:resolution([0-9]+)-fragmented.:extension',
|
||||
optionalAuthenticate,
|
||||
asyncMiddleware(videosDownloadValidator),
|
||||
asyncMiddleware(downloadHLSVideoFile)
|
||||
)
|
||||
|
||||
const downloadGenerateRateLimiter = buildRateLimiter({
|
||||
windowMs: CONFIG.RATES_LIMIT.DOWNLOAD_GENERATE_VIDEO.WINDOW_MS,
|
||||
max: CONFIG.RATES_LIMIT.DOWNLOAD_GENERATE_VIDEO.MAX,
|
||||
skipFailedRequests: true
|
||||
})
|
||||
|
||||
downloadRouter.use(
|
||||
STATIC_DOWNLOAD_PATHS.USER_EXPORTS + ':filename',
|
||||
DOWNLOAD_PATHS.GENERATE_VIDEO + ':id',
|
||||
downloadGenerateRateLimiter,
|
||||
optionalAuthenticate,
|
||||
asyncMiddleware(videosDownloadValidator),
|
||||
videosGenerateDownloadValidator,
|
||||
asyncMiddleware(downloadGeneratedVideoFile)
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
downloadRouter.use(
|
||||
DOWNLOAD_PATHS.USER_EXPORTS + ':filename',
|
||||
asyncMiddleware(userExportDownloadValidator), // Include JWT token authentication
|
||||
asyncMiddleware(downloadUserExport)
|
||||
)
|
||||
|
||||
downloadRouter.use(
|
||||
STATIC_DOWNLOAD_PATHS.ORIGINAL_VIDEO_FILE + ':filename',
|
||||
DOWNLOAD_PATHS.ORIGINAL_VIDEO_FILE + ':filename',
|
||||
optionalAuthenticate,
|
||||
asyncMiddleware(originalVideoFileDownloadValidator),
|
||||
asyncMiddleware(downloadOriginalFile)
|
||||
|
@ -101,10 +126,12 @@ async function downloadTorrent (req: express.Request, res: express.Response) {
|
|||
return res.download(result.path, result.downloadName)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function downloadWebVideoFile (req: express.Request, res: express.Response) {
|
||||
const video = res.locals.videoAll
|
||||
|
||||
const videoFile = getVideoFile(req, video.VideoFiles)
|
||||
const videoFile = getVideoFileFromReq(req, video.VideoFiles)
|
||||
if (!videoFile) {
|
||||
return res.fail({
|
||||
status: HttpStatusCode.NOT_FOUND_404,
|
||||
|
@ -127,9 +154,7 @@ async function downloadWebVideoFile (req: express.Request, res: express.Response
|
|||
|
||||
if (!checkAllowResult(res, allowParameters, allowedResult)) return
|
||||
|
||||
// Express uses basename on filename parameter
|
||||
const videoName = video.name.replace(/[/\\]/g, '_')
|
||||
const downloadFilename = `${videoName}-${videoFile.resolution}p${videoFile.extname}`
|
||||
const downloadFilename = buildDownloadFilename({ video, resolution: videoFile.resolution, extname: videoFile.extname })
|
||||
|
||||
if (videoFile.storage === FileStorage.OBJECT_STORAGE) {
|
||||
return redirectVideoDownloadToObjectStorage({ res, video, file: videoFile, downloadFilename })
|
||||
|
@ -145,7 +170,7 @@ async function downloadHLSVideoFile (req: express.Request, res: express.Response
|
|||
const streamingPlaylist = getHLSPlaylist(video)
|
||||
if (!streamingPlaylist) return res.sendStatus(HttpStatusCode.NOT_FOUND_404)
|
||||
|
||||
const videoFile = getVideoFile(req, streamingPlaylist.VideoFiles)
|
||||
const videoFile = getVideoFileFromReq(req, streamingPlaylist.VideoFiles)
|
||||
if (!videoFile) {
|
||||
return res.fail({
|
||||
status: HttpStatusCode.NOT_FOUND_404,
|
||||
|
@ -169,8 +194,7 @@ async function downloadHLSVideoFile (req: express.Request, res: express.Response
|
|||
|
||||
if (!checkAllowResult(res, allowParameters, allowedResult)) return
|
||||
|
||||
const videoName = video.name.replace(/\//g, '_')
|
||||
const downloadFilename = `${videoName}-${videoFile.resolution}p-${streamingPlaylist.getStringType()}${videoFile.extname}`
|
||||
const downloadFilename = buildDownloadFilename({ video, streamingPlaylist, resolution: videoFile.resolution, extname: videoFile.extname })
|
||||
|
||||
if (videoFile.storage === FileStorage.OBJECT_STORAGE) {
|
||||
return redirectVideoDownloadToObjectStorage({ res, video, streamingPlaylist, file: videoFile, downloadFilename })
|
||||
|
@ -181,6 +205,53 @@ async function downloadHLSVideoFile (req: express.Request, res: express.Response
|
|||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function downloadGeneratedVideoFile (req: express.Request, res: express.Response) {
|
||||
const video = res.locals.videoAll
|
||||
const filesToSelect = req.query.videoFileIds
|
||||
|
||||
const videoFiles = video.getAllFiles()
|
||||
.filter(f => filesToSelect.includes(f.id))
|
||||
|
||||
if (videoFiles.length === 0) {
|
||||
return res.fail({
|
||||
status: HttpStatusCode.NOT_FOUND_404,
|
||||
message: `No files found (${filesToSelect.join(', ')}) to download video ${video.url}`
|
||||
})
|
||||
}
|
||||
|
||||
if (videoFiles.filter(f => f.hasVideo()).length > 1 || videoFiles.filter(f => f.hasAudio()).length > 1) {
|
||||
return res.fail({
|
||||
status: HttpStatusCode.BAD_REQUEST_400,
|
||||
// In theory we could, but ffmpeg-fluent doesn't support multiple input streams so prefer to reject this specific use case
|
||||
message: `Cannot generate a container with multiple video/audio files. PeerTube supports a maximum of 1 audio and 1 video file`
|
||||
})
|
||||
}
|
||||
|
||||
const allowParameters = {
|
||||
req,
|
||||
res,
|
||||
video,
|
||||
videoFiles
|
||||
}
|
||||
|
||||
const allowedResult = await Hooks.wrapFun(
|
||||
isGeneratedVideoDownloadAllowed,
|
||||
allowParameters,
|
||||
'filter:api.download.generated-video.allowed.result'
|
||||
)
|
||||
|
||||
if (!checkAllowResult(res, allowParameters, allowedResult)) return
|
||||
|
||||
const downloadFilename = buildDownloadFilename({ video, extname: maxBy(videoFiles, 'resolution').extname })
|
||||
res.setHeader('Content-disposition', `attachment; filename="${encodeURI(downloadFilename)}`)
|
||||
|
||||
await muxToMergeVideoFiles({ video, videoFiles, output: res })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function downloadUserExport (req: express.Request, res: express.Response) {
|
||||
const userExport = res.locals.userExport
|
||||
|
||||
|
@ -209,7 +280,7 @@ function downloadOriginalFile (req: express.Request, res: express.Response) {
|
|||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getVideoFile (req: express.Request, files: MVideoFile[]) {
|
||||
function getVideoFileFromReq (req: express.Request, files: MVideoFile[]) {
|
||||
const resolution = forceNumber(req.params.resolution)
|
||||
return files.find(f => f.resolution === resolution)
|
||||
}
|
||||
|
@ -240,9 +311,18 @@ function isVideoDownloadAllowed (_object: {
|
|||
return { allowed: true }
|
||||
}
|
||||
|
||||
function isGeneratedVideoDownloadAllowed (_object: {
|
||||
video: MVideo
|
||||
videoFiles: MVideoFile[]
|
||||
}): AllowedResult {
|
||||
return { allowed: true }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function checkAllowResult (res: express.Response, allowParameters: any, result?: AllowedResult) {
|
||||
if (!result || result.allowed !== true) {
|
||||
logger.info('Download is not allowed.', { result, allowParameters })
|
||||
logger.info('Download is not allowed.', { result, allowParameters, ...lTags() })
|
||||
|
||||
res.fail({
|
||||
status: HttpStatusCode.FORBIDDEN_403,
|
||||
|
@ -267,7 +347,7 @@ async function redirectVideoDownloadToObjectStorage (options: {
|
|||
? await generateHLSFilePresignedUrl({ streamingPlaylist, file, downloadFilename })
|
||||
: await generateWebVideoPresignedUrl({ file, downloadFilename })
|
||||
|
||||
logger.debug('Generating pre-signed URL %s for video %s', url, video.uuid)
|
||||
logger.debug('Generating pre-signed URL %s for video %s', url, video.uuid, lTags())
|
||||
|
||||
return res.redirect(url)
|
||||
}
|
||||
|
@ -281,7 +361,7 @@ async function redirectUserExportToObjectStorage (options: {
|
|||
|
||||
const url = await generateUserExportPresignedUrl({ userExport, downloadFilename })
|
||||
|
||||
logger.debug('Generating pre-signed URL %s for user export %s', url, userExport.filename)
|
||||
logger.debug('Generating pre-signed URL %s for user export %s', url, userExport.filename, lTags())
|
||||
|
||||
return res.redirect(url)
|
||||
}
|
||||
|
@ -295,7 +375,29 @@ async function redirectOriginalFileToObjectStorage (options: {
|
|||
|
||||
const url = await generateOriginalFilePresignedUrl({ videoSource, downloadFilename })
|
||||
|
||||
logger.debug('Generating pre-signed URL %s for original video file %s', url, videoSource.keptOriginalFilename)
|
||||
logger.debug('Generating pre-signed URL %s for original video file %s', url, videoSource.keptOriginalFilename, lTags())
|
||||
|
||||
return res.redirect(url)
|
||||
}
|
||||
|
||||
function buildDownloadFilename (options: {
|
||||
video: MVideo
|
||||
streamingPlaylist?: MStreamingPlaylist
|
||||
resolution?: number
|
||||
extname: string
|
||||
}) {
|
||||
const { video, resolution, extname, streamingPlaylist } = options
|
||||
|
||||
// Express uses basename on filename parameter
|
||||
const videoName = video.name.replace(/[/\\]/g, '_')
|
||||
|
||||
const suffixStr = streamingPlaylist
|
||||
? `-${streamingPlaylist.getStringType()}`
|
||||
: ''
|
||||
|
||||
const resolutionStr = exists(resolution)
|
||||
? `-${resolution}p`
|
||||
: ''
|
||||
|
||||
return videoName + resolutionStr + suffixStr + extname
|
||||
}
|
||||
|
|
|
@ -178,7 +178,10 @@ const contextStore: { [ id in ContextType ]: (string | { [ id: string ]: string
|
|||
comments: {
|
||||
'@id': 'as:comments',
|
||||
'@type': '@id'
|
||||
}
|
||||
},
|
||||
|
||||
PropertyValue: 'sc:PropertyValue',
|
||||
value: 'sc:value'
|
||||
}),
|
||||
|
||||
Playlist: buildContext({
|
||||
|
|
|
@ -4,18 +4,18 @@ import { sep } from 'path'
|
|||
import validator from 'validator'
|
||||
import { isShortUUID, shortToUUID } from '@peertube/peertube-node-utils'
|
||||
|
||||
function exists (value: any) {
|
||||
export function exists (value: any) {
|
||||
return value !== undefined && value !== null
|
||||
}
|
||||
|
||||
function isSafePath (p: string) {
|
||||
export function isSafePath (p: string) {
|
||||
return exists(p) &&
|
||||
(p + '').split(sep).every(part => {
|
||||
return [ '..' ].includes(part) === false
|
||||
})
|
||||
}
|
||||
|
||||
function isSafeFilename (filename: string, extension?: string) {
|
||||
export function isSafeFilename (filename: string, extension?: string) {
|
||||
const regex = extension
|
||||
? new RegExp(`^[a-z0-9-]+\\.${extension}$`)
|
||||
: new RegExp(`^[a-z0-9-]+\\.[a-z0-9]{1,8}$`)
|
||||
|
@ -23,57 +23,68 @@ function isSafeFilename (filename: string, extension?: string) {
|
|||
return typeof filename === 'string' && !!filename.match(regex)
|
||||
}
|
||||
|
||||
function isSafePeerTubeFilenameWithoutExtension (filename: string) {
|
||||
export function isSafePeerTubeFilenameWithoutExtension (filename: string) {
|
||||
return filename.match(/^[a-z0-9-]+$/)
|
||||
}
|
||||
|
||||
function isArray (value: any): value is any[] {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function isArray (value: any): value is any[] {
|
||||
return Array.isArray(value)
|
||||
}
|
||||
|
||||
function isNotEmptyIntArray (value: any) {
|
||||
export function isNotEmptyIntArray (value: any) {
|
||||
return Array.isArray(value) && value.every(v => validator.default.isInt('' + v)) && value.length !== 0
|
||||
}
|
||||
|
||||
function isNotEmptyStringArray (value: any) {
|
||||
export function isNotEmptyStringArray (value: any) {
|
||||
return Array.isArray(value) && value.every(v => typeof v === 'string' && v.length !== 0) && value.length !== 0
|
||||
}
|
||||
|
||||
function isArrayOf (value: any, validator: (value: any) => boolean) {
|
||||
export function hasArrayLength (value: unknown[], options: { min?: number, max?: number }) {
|
||||
if (options.min !== undefined && value.length < options.min) return false
|
||||
if (options.max !== undefined && value.length > options.max) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export function isArrayOf (value: any, validator: (value: any) => boolean) {
|
||||
return isArray(value) && value.every(v => validator(v))
|
||||
}
|
||||
|
||||
function isDateValid (value: string) {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function isDateValid (value: string) {
|
||||
return exists(value) && validator.default.isISO8601(value)
|
||||
}
|
||||
|
||||
function isIdValid (value: string) {
|
||||
export function isIdValid (value: string) {
|
||||
return exists(value) && validator.default.isInt('' + value)
|
||||
}
|
||||
|
||||
function isUUIDValid (value: string) {
|
||||
export function isUUIDValid (value: string) {
|
||||
return exists(value) && validator.default.isUUID('' + value, 4)
|
||||
}
|
||||
|
||||
function areUUIDsValid (values: string[]) {
|
||||
export function areUUIDsValid (values: string[]) {
|
||||
return isArray(values) && values.every(v => isUUIDValid(v))
|
||||
}
|
||||
|
||||
function isIdOrUUIDValid (value: string) {
|
||||
export function isIdOrUUIDValid (value: string) {
|
||||
return isIdValid(value) || isUUIDValid(value)
|
||||
}
|
||||
|
||||
function isBooleanValid (value: any) {
|
||||
export function isBooleanValid (value: any) {
|
||||
return typeof value === 'boolean' || (typeof value === 'string' && validator.default.isBoolean(value))
|
||||
}
|
||||
|
||||
function isIntOrNull (value: any) {
|
||||
export function isIntOrNull (value: any) {
|
||||
return value === null || validator.default.isInt('' + value)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function isFileValid (options: {
|
||||
export function isFileValid (options: {
|
||||
files: UploadFilesForCheck
|
||||
|
||||
maxSize: number | null
|
||||
|
@ -108,13 +119,13 @@ function isFileValid (options: {
|
|||
return checkMimetypeRegex(file.mimetype, mimeTypeRegex)
|
||||
}
|
||||
|
||||
function checkMimetypeRegex (fileMimeType: string, mimeTypeRegex: string) {
|
||||
export function checkMimetypeRegex (fileMimeType: string, mimeTypeRegex: string) {
|
||||
return new RegExp(`^${mimeTypeRegex}$`, 'i').test(fileMimeType)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function toCompleteUUID (value: string) {
|
||||
export function toCompleteUUID (value: string) {
|
||||
if (isShortUUID(value)) {
|
||||
try {
|
||||
return shortToUUID(value)
|
||||
|
@ -126,11 +137,11 @@ function toCompleteUUID (value: string) {
|
|||
return value
|
||||
}
|
||||
|
||||
function toCompleteUUIDs (values: string[]) {
|
||||
export function toCompleteUUIDs (values: string[]) {
|
||||
return values.map(v => toCompleteUUID(v))
|
||||
}
|
||||
|
||||
function toIntOrNull (value: string) {
|
||||
export function toIntOrNull (value: string) {
|
||||
const v = toValueOrNull(value)
|
||||
|
||||
if (v === null || v === undefined) return v
|
||||
|
@ -139,7 +150,7 @@ function toIntOrNull (value: string) {
|
|||
return validator.default.toInt('' + v)
|
||||
}
|
||||
|
||||
function toBooleanOrNull (value: any) {
|
||||
export function toBooleanOrNull (value: any) {
|
||||
const v = toValueOrNull(value)
|
||||
|
||||
if (v === null || v === undefined) return v
|
||||
|
@ -148,43 +159,15 @@ function toBooleanOrNull (value: any) {
|
|||
return validator.default.toBoolean('' + v)
|
||||
}
|
||||
|
||||
function toValueOrNull (value: string) {
|
||||
export function toValueOrNull (value: string) {
|
||||
if (value === 'null') return null
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
function toIntArray (value: any) {
|
||||
export function toIntArray (value: any) {
|
||||
if (!value) return []
|
||||
if (isArray(value) === false) return [ validator.default.toInt(value) ]
|
||||
|
||||
return value.map(v => validator.default.toInt(v))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
exists,
|
||||
isArrayOf,
|
||||
isNotEmptyIntArray,
|
||||
isArray,
|
||||
isIntOrNull,
|
||||
isIdValid,
|
||||
isSafePath,
|
||||
isNotEmptyStringArray,
|
||||
isUUIDValid,
|
||||
toCompleteUUIDs,
|
||||
toCompleteUUID,
|
||||
isIdOrUUIDValid,
|
||||
isDateValid,
|
||||
toValueOrNull,
|
||||
toBooleanOrNull,
|
||||
isBooleanValid,
|
||||
toIntOrNull,
|
||||
areUUIDsValid,
|
||||
toIntArray,
|
||||
isFileValid,
|
||||
isSafePeerTubeFilenameWithoutExtension,
|
||||
isSafeFilename,
|
||||
checkMimetypeRegex
|
||||
}
|
||||
|
|
|
@ -3,8 +3,8 @@ import { getAudioStream, getVideoStream } from '@peertube/peertube-ffmpeg'
|
|||
import { logger } from '../logger.js'
|
||||
import { forceNumber } from '@peertube/peertube-core-utils'
|
||||
|
||||
export async function getVideoStreamCodec (path: string) {
|
||||
const videoStream = await getVideoStream(path)
|
||||
export async function getVideoStreamCodec (path: string, existingProbe?: FfprobeData) {
|
||||
const videoStream = await getVideoStream(path, existingProbe)
|
||||
if (!videoStream) return ''
|
||||
|
||||
const videoCodec = videoStream.codec_tag_string
|
||||
|
|
|
@ -17,7 +17,7 @@ export interface PeerTubeRequestError extends Error {
|
|||
requestHeaders?: any
|
||||
}
|
||||
|
||||
type PeerTubeRequestOptions = {
|
||||
export type PeerTubeRequestOptions = {
|
||||
timeout?: number
|
||||
activityPub?: boolean
|
||||
bodyKBLimit?: number // 1MB
|
||||
|
@ -35,7 +35,7 @@ type PeerTubeRequestOptions = {
|
|||
followRedirect?: boolean
|
||||
} & Pick<OptionsInit, 'headers' | 'json' | 'method' | 'searchParams'>
|
||||
|
||||
const peertubeGot = got.extend({
|
||||
export const peertubeGot = got.extend({
|
||||
...getAgent(),
|
||||
|
||||
headers: {
|
||||
|
@ -116,25 +116,21 @@ const peertubeGot = got.extend({
|
|||
}
|
||||
})
|
||||
|
||||
function doRequest (url: string, options: PeerTubeRequestOptions = {}) {
|
||||
export function doRequest (url: string, options: PeerTubeRequestOptions = {}) {
|
||||
const gotOptions = buildGotOptions(options) as OptionsOfTextResponseBody
|
||||
|
||||
return peertubeGot(url, gotOptions)
|
||||
.catch(err => { throw buildRequestError(err) })
|
||||
}
|
||||
|
||||
function doJSONRequest <T> (url: string, options: PeerTubeRequestOptions = {}) {
|
||||
export function doJSONRequest <T> (url: string, options: PeerTubeRequestOptions = {}) {
|
||||
const gotOptions = buildGotOptions(options)
|
||||
|
||||
return peertubeGot<T>(url, { ...gotOptions, responseType: 'json' })
|
||||
.catch(err => { throw buildRequestError(err) })
|
||||
}
|
||||
|
||||
async function doRequestAndSaveToFile (
|
||||
url: string,
|
||||
destPath: string,
|
||||
options: PeerTubeRequestOptions = {}
|
||||
) {
|
||||
export async function doRequestAndSaveToFile (url: string, destPath: string, options: PeerTubeRequestOptions = {}) {
|
||||
const gotOptions = buildGotOptions({ ...options, timeout: options.timeout ?? REQUEST_TIMEOUTS.FILE })
|
||||
|
||||
const outFile = createWriteStream(destPath)
|
||||
|
@ -152,7 +148,13 @@ async function doRequestAndSaveToFile (
|
|||
}
|
||||
}
|
||||
|
||||
function getAgent () {
|
||||
export function generateRequestStream (url: string, options: PeerTubeRequestOptions = {}) {
|
||||
const gotOptions = buildGotOptions({ ...options, timeout: options.timeout ?? REQUEST_TIMEOUTS.DEFAULT })
|
||||
|
||||
return peertubeGot.stream(url, { ...gotOptions, isStream: true })
|
||||
}
|
||||
|
||||
export function getAgent () {
|
||||
if (!isProxyEnabled()) return {}
|
||||
|
||||
const proxy = getProxy()
|
||||
|
@ -176,27 +178,16 @@ function getAgent () {
|
|||
}
|
||||
}
|
||||
|
||||
function getUserAgent () {
|
||||
export function getUserAgent () {
|
||||
return `PeerTube/${PEERTUBE_VERSION} (+${WEBSERVER.URL})`
|
||||
}
|
||||
|
||||
function isBinaryResponse (result: Response<any>) {
|
||||
export function isBinaryResponse (result: Response<any>) {
|
||||
return BINARY_CONTENT_TYPES.has(result.headers['content-type'])
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
type PeerTubeRequestOptions,
|
||||
|
||||
doRequest,
|
||||
doJSONRequest,
|
||||
doRequestAndSaveToFile,
|
||||
isBinaryResponse,
|
||||
getAgent,
|
||||
peertubeGot
|
||||
}
|
||||
|
||||
// Private
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildGotOptions (options: PeerTubeRequestOptions): OptionsOfUnknownResponseBody {
|
||||
|
|
|
@ -135,11 +135,11 @@ async function checkFFmpeg (CONFIG: { TRANSCODING: { ENABLED: boolean } }) {
|
|||
|
||||
for (const codec of canEncode) {
|
||||
if (codecs[codec] === undefined) {
|
||||
throw new Error('Unknown codec ' + codec + ' in FFmpeg.')
|
||||
throw new Error(`Codec ${codec} not found in FFmpeg.`)
|
||||
}
|
||||
|
||||
if (codecs[codec].canEncode !== true) {
|
||||
throw new Error('Unavailable encode codec ' + codec + ' in FFmpeg')
|
||||
throw new Error(`Unavailable encode codec ${codec} in FFmpeg`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -222,6 +222,10 @@ const CONFIG = {
|
|||
CLIENT: {
|
||||
WINDOW_MS: parseDurationToMs(config.get<string>('rates_limit.client.window')),
|
||||
MAX: config.get<number>('rates_limit.client.max')
|
||||
},
|
||||
DOWNLOAD_GENERATE_VIDEO: {
|
||||
WINDOW_MS: parseDurationToMs(config.get<string>('rates_limit.download_generate_video.window')),
|
||||
MAX: config.get<number>('rates_limit.download_generate_video.max')
|
||||
}
|
||||
},
|
||||
TRUST_PROXY: config.get<string[]>('trust_proxy'),
|
||||
|
@ -445,7 +449,8 @@ const CONFIG = {
|
|||
get '2160p' () { return config.get<boolean>('transcoding.resolutions.2160p') }
|
||||
},
|
||||
HLS: {
|
||||
get ENABLED () { return config.get<boolean>('transcoding.hls.enabled') }
|
||||
get ENABLED () { return config.get<boolean>('transcoding.hls.enabled') },
|
||||
get SPLIT_AUDIO_AND_VIDEO () { return config.get<boolean>('transcoding.hls.split_audio_and_video') }
|
||||
},
|
||||
WEB_VIDEOS: {
|
||||
get ENABLED () { return config.get<boolean>('transcoding.web_videos.enabled') }
|
||||
|
@ -491,6 +496,7 @@ const CONFIG = {
|
|||
get ALWAYS_TRANSCODE_ORIGINAL_RESOLUTION () { return config.get<boolean>('live.transcoding.always_transcode_original_resolution') },
|
||||
|
||||
RESOLUTIONS: {
|
||||
get '0p' () { return config.get<boolean>('live.transcoding.resolutions.0p') },
|
||||
get '144p' () { return config.get<boolean>('live.transcoding.resolutions.144p') },
|
||||
get '240p' () { return config.get<boolean>('live.transcoding.resolutions.240p') },
|
||||
get '360p' () { return config.get<boolean>('live.transcoding.resolutions.360p') },
|
||||
|
|
|
@ -47,7 +47,7 @@ import { cpus } from 'os'
|
|||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const LAST_MIGRATION_VERSION = 860
|
||||
const LAST_MIGRATION_VERSION = 865
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
|
@ -214,7 +214,7 @@ const JOB_ATTEMPTS: { [id in JobType]: number } = {
|
|||
'federate-video': 1,
|
||||
'create-user-export': 1,
|
||||
'import-user-archive': 1,
|
||||
'video-transcription': 1
|
||||
'video-transcription': 2
|
||||
}
|
||||
// Excluded keys are jobs that can be configured by admins
|
||||
const JOB_CONCURRENCY: { [id in Exclude<JobType, 'video-transcoding' | 'video-import'>]: number } = {
|
||||
|
@ -327,6 +327,7 @@ const AP_CLEANER = {
|
|||
const REQUEST_TIMEOUTS = {
|
||||
DEFAULT: 7000, // 7 seconds
|
||||
FILE: 30000, // 30 seconds
|
||||
VIDEO_FILE: 60000, // 1 minute
|
||||
REDUNDANCY: JOB_TTL['video-redundancy']
|
||||
}
|
||||
|
||||
|
@ -873,9 +874,10 @@ const STATIC_PATHS = {
|
|||
PRIVATE_HLS: '/static/streaming-playlists/hls/private/'
|
||||
}
|
||||
}
|
||||
const STATIC_DOWNLOAD_PATHS = {
|
||||
const DOWNLOAD_PATHS = {
|
||||
TORRENTS: '/download/torrents/',
|
||||
VIDEOS: '/download/videos/',
|
||||
GENERATE_VIDEO: '/download/videos/generate/',
|
||||
WEB_VIDEOS: '/download/web-videos/',
|
||||
HLS_VIDEOS: '/download/streaming-playlists/hls/videos/',
|
||||
USER_EXPORTS: '/download/user-exports/',
|
||||
ORIGINAL_VIDEO_FILE: '/download/original-video-files/'
|
||||
|
@ -1337,7 +1339,7 @@ export {
|
|||
OVERVIEWS,
|
||||
SCHEDULER_INTERVALS_MS,
|
||||
REPEAT_JOBS,
|
||||
STATIC_DOWNLOAD_PATHS,
|
||||
DOWNLOAD_PATHS,
|
||||
MIMETYPES,
|
||||
CRAWL_REQUEST_CONCURRENCY,
|
||||
DEFAULT_AUDIO_RESOLUTION,
|
||||
|
|
|
@ -0,0 +1,53 @@
|
|||
import * as Sequelize from 'sequelize'
|
||||
|
||||
async function up (utils: {
|
||||
transaction: Sequelize.Transaction
|
||||
queryInterface: Sequelize.QueryInterface
|
||||
sequelize: Sequelize.Sequelize
|
||||
}): Promise<void> {
|
||||
const { transaction } = utils
|
||||
|
||||
{
|
||||
await utils.queryInterface.addColumn('videoFile', 'formatFlags', {
|
||||
type: Sequelize.INTEGER,
|
||||
defaultValue: 2, // fragmented
|
||||
allowNull: false
|
||||
}, { transaction })
|
||||
|
||||
// Web videos
|
||||
const query = 'UPDATE "videoFile" SET "formatFlags" = 1 WHERE "videoId" IS NOT NULL'
|
||||
await utils.sequelize.query(query, { transaction })
|
||||
|
||||
await utils.queryInterface.changeColumn('videoFile', 'formatFlags', {
|
||||
type: Sequelize.INTEGER,
|
||||
defaultValue: null,
|
||||
allowNull: false
|
||||
}, { transaction })
|
||||
}
|
||||
|
||||
{
|
||||
await utils.queryInterface.addColumn('videoFile', 'streams', {
|
||||
type: Sequelize.INTEGER,
|
||||
defaultValue: 3, // audio + video
|
||||
allowNull: false
|
||||
}, { transaction })
|
||||
|
||||
// Case where there is only an audio stream
|
||||
const query = 'UPDATE "videoFile" SET "streams" = 2 WHERE "resolution" = 0'
|
||||
await utils.sequelize.query(query, { transaction })
|
||||
|
||||
await utils.queryInterface.changeColumn('videoFile', 'streams', {
|
||||
type: Sequelize.INTEGER,
|
||||
defaultValue: null,
|
||||
allowNull: false
|
||||
}, { transaction })
|
||||
}
|
||||
}
|
||||
|
||||
function down (options) {
|
||||
throw new Error('Not implemented.')
|
||||
}
|
||||
|
||||
export {
|
||||
down, up
|
||||
}
|
|
@ -7,13 +7,17 @@ import {
|
|||
ActivityTagObject,
|
||||
ActivityUrlObject,
|
||||
ActivityVideoUrlObject,
|
||||
VideoFileFormatFlag,
|
||||
VideoFileStream,
|
||||
VideoObject,
|
||||
VideoPrivacy,
|
||||
VideoResolution,
|
||||
VideoStreamingPlaylistType
|
||||
} from '@peertube/peertube-models'
|
||||
import { AttributesOnly } from '@peertube/peertube-typescript-utils'
|
||||
import { hasAPPublic } from '@server/helpers/activity-pub-utils.js'
|
||||
import { isAPVideoFileUrlMetadataObject } from '@server/helpers/custom-validators/activitypub/videos.js'
|
||||
import { isArray } from '@server/helpers/custom-validators/misc.js'
|
||||
import { exists, isArray } from '@server/helpers/custom-validators/misc.js'
|
||||
import { isVideoFileInfoHashValid } from '@server/helpers/custom-validators/videos.js'
|
||||
import { generateImageFilename } from '@server/helpers/image-utils.js'
|
||||
import { getExtFromMimetype } from '@server/helpers/video.js'
|
||||
|
@ -23,7 +27,7 @@ import { VideoCaptionModel } from '@server/models/video/video-caption.js'
|
|||
import { VideoFileModel } from '@server/models/video/video-file.js'
|
||||
import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist.js'
|
||||
import { FilteredModelAttributes } from '@server/types/index.js'
|
||||
import { MChannelId, MStreamingPlaylistVideo, MVideo, MVideoId, isStreamingPlaylist } from '@server/types/models/index.js'
|
||||
import { MChannelId, MStreamingPlaylistVideo, MVideo, MVideoFile, MVideoId, isStreamingPlaylist } from '@server/types/models/index.js'
|
||||
import { decode as magnetUriDecode } from 'magnet-uri'
|
||||
import { basename, extname } from 'path'
|
||||
import { getDurationFromActivityStream } from '../../activity.js'
|
||||
|
@ -48,6 +52,8 @@ export function getTagsFromObject (videoObject: VideoObject) {
|
|||
.map(t => t.name)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getFileAttributesFromUrl (
|
||||
videoOrPlaylist: MVideo | MStreamingPlaylistVideo,
|
||||
urls: (ActivityTagObject | ActivityUrlObject)[]
|
||||
|
@ -67,20 +73,21 @@ export function getFileAttributesFromUrl (
|
|||
|
||||
const extname = getExtFromMimetype(MIMETYPES.VIDEO.MIMETYPE_EXT, fileUrl.mediaType)
|
||||
const resolution = fileUrl.height
|
||||
const videoId = isStreamingPlaylist(videoOrPlaylist) ? null : videoOrPlaylist.id
|
||||
|
||||
const videoStreamingPlaylistId = isStreamingPlaylist(videoOrPlaylist)
|
||||
? videoOrPlaylist.id
|
||||
: null
|
||||
const [ videoId, videoStreamingPlaylistId ] = isStreamingPlaylist(videoOrPlaylist)
|
||||
? [ null, videoOrPlaylist.id ]
|
||||
: [ videoOrPlaylist.id, null ]
|
||||
|
||||
const { torrentFilename, infoHash, torrentUrl } = getTorrentRelatedInfo({ videoOrPlaylist, urls, fileUrl })
|
||||
|
||||
const attribute = {
|
||||
const attribute: Partial<AttributesOnly<MVideoFile>> = {
|
||||
extname,
|
||||
resolution,
|
||||
|
||||
size: fileUrl.size,
|
||||
fps: fileUrl.fps || -1,
|
||||
fps: exists(fileUrl.fps) && fileUrl.fps >= 0
|
||||
? fileUrl.fps
|
||||
: -1,
|
||||
|
||||
metadataUrl: metadata?.href,
|
||||
|
||||
|
@ -95,6 +102,9 @@ export function getFileAttributesFromUrl (
|
|||
torrentFilename,
|
||||
torrentUrl,
|
||||
|
||||
formatFlags: buildFileFormatFlags(fileUrl, isStreamingPlaylist(videoOrPlaylist)),
|
||||
streams: buildFileStreams(fileUrl, resolution),
|
||||
|
||||
// This is a video file owned by a video or by a streaming playlist
|
||||
videoId,
|
||||
videoStreamingPlaylistId
|
||||
|
@ -106,6 +116,49 @@ export function getFileAttributesFromUrl (
|
|||
return attributes
|
||||
}
|
||||
|
||||
function buildFileFormatFlags (fileUrl: ActivityVideoUrlObject, isStreamingPlaylist: boolean) {
|
||||
const attachment = fileUrl.attachment || []
|
||||
|
||||
const formatHints = attachment.filter(a => a.type === 'PropertyValue' && a.name === 'peertube_format_flag')
|
||||
if (formatHints.length === 0) {
|
||||
return isStreamingPlaylist
|
||||
? VideoFileFormatFlag.FRAGMENTED
|
||||
: VideoFileFormatFlag.WEB_VIDEO
|
||||
}
|
||||
|
||||
let formatFlags = VideoFileFormatFlag.NONE
|
||||
|
||||
for (const hint of formatHints) {
|
||||
if (hint.value === 'fragmented') formatFlags |= VideoFileFormatFlag.FRAGMENTED
|
||||
else if (hint.value === 'web-video') formatFlags |= VideoFileFormatFlag.WEB_VIDEO
|
||||
}
|
||||
|
||||
return formatFlags
|
||||
}
|
||||
|
||||
function buildFileStreams (fileUrl: ActivityVideoUrlObject, resolution: number) {
|
||||
const attachment = fileUrl.attachment || []
|
||||
|
||||
const formatHints = attachment.filter(a => a.type === 'PropertyValue' && a.name === 'ffprobe_codec_type')
|
||||
|
||||
if (formatHints.length === 0) {
|
||||
if (resolution === VideoResolution.H_NOVIDEO) return VideoFileStream.AUDIO
|
||||
|
||||
return VideoFileStream.VIDEO | VideoFileStream.AUDIO
|
||||
}
|
||||
|
||||
let streams = VideoFileStream.NONE
|
||||
|
||||
for (const hint of formatHints) {
|
||||
if (hint.value === 'audio') streams |= VideoFileStream.AUDIO
|
||||
else if (hint.value === 'video') streams |= VideoFileStream.VIDEO
|
||||
}
|
||||
|
||||
return streams
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getStreamingPlaylistAttributesFromObject (video: MVideoId, videoObject: VideoObject) {
|
||||
const playlistUrls = videoObject.url.filter(u => isAPStreamingPlaylistUrlObject(u))
|
||||
if (playlistUrls.length === 0) return []
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { uniqify, uuidRegex } from '@peertube/peertube-core-utils'
|
||||
import { getVideoStreamDimensionsInfo } from '@peertube/peertube-ffmpeg'
|
||||
import { FileStorage } from '@peertube/peertube-models'
|
||||
import { sortBy, uniqify, uuidRegex } from '@peertube/peertube-core-utils'
|
||||
import { ffprobePromise, getVideoStreamDimensionsInfo } from '@peertube/peertube-ffmpeg'
|
||||
import { FileStorage, VideoResolution } from '@peertube/peertube-models'
|
||||
import { sha256 } from '@peertube/peertube-node-utils'
|
||||
import { MStreamingPlaylist, MStreamingPlaylistFilesVideo, MVideo } from '@server/types/models/index.js'
|
||||
import { ensureDir, move, outputJSON, remove } from 'fs-extra/esm'
|
||||
|
@ -23,7 +23,7 @@ import { VideoPathManager } from './video-path-manager.js'
|
|||
|
||||
const lTags = loggerTagsFactory('hls')
|
||||
|
||||
async function updateStreamingPlaylistsInfohashesIfNeeded () {
|
||||
export async function updateStreamingPlaylistsInfohashesIfNeeded () {
|
||||
const playlistsToUpdate = await VideoStreamingPlaylistModel.listByIncorrectPeerVersion()
|
||||
|
||||
// Use separate SQL queries, because we could have many videos to update
|
||||
|
@ -39,7 +39,7 @@ async function updateStreamingPlaylistsInfohashesIfNeeded () {
|
|||
}
|
||||
}
|
||||
|
||||
async function updatePlaylistAfterFileChange (video: MVideo, playlist: MStreamingPlaylist) {
|
||||
export async function updateM3U8AndShaPlaylist (video: MVideo, playlist: MStreamingPlaylist) {
|
||||
try {
|
||||
let playlistWithFiles = await updateMasterHLSPlaylist(video, playlist)
|
||||
playlistWithFiles = await updateSha256VODSegments(video, playlist)
|
||||
|
@ -60,36 +60,62 @@ async function updatePlaylistAfterFileChange (video: MVideo, playlist: MStreamin
|
|||
// Avoid concurrency issues when updating streaming playlist files
|
||||
const playlistFilesQueue = new PQueue({ concurrency: 1 })
|
||||
|
||||
function updateMasterHLSPlaylist (video: MVideo, playlistArg: MStreamingPlaylist): Promise<MStreamingPlaylistFilesVideo> {
|
||||
export function updateMasterHLSPlaylist (video: MVideo, playlistArg: MStreamingPlaylist): Promise<MStreamingPlaylistFilesVideo> {
|
||||
return playlistFilesQueue.add(async () => {
|
||||
const playlist = await VideoStreamingPlaylistModel.loadWithVideoAndFiles(playlistArg.id)
|
||||
|
||||
const masterPlaylists: string[] = [ '#EXTM3U', '#EXT-X-VERSION:3' ]
|
||||
const extMedia: string[] = []
|
||||
const extStreamInfo: string[] = []
|
||||
let separatedAudioCodec: string
|
||||
|
||||
for (const file of playlist.VideoFiles) {
|
||||
const splitAudioAndVideo = playlist.hasAudioAndVideoSplitted()
|
||||
|
||||
// Sort to have the audio resolution first (if it exists)
|
||||
for (const file of sortBy(playlist.VideoFiles, 'resolution')) {
|
||||
const playlistFilename = getHlsResolutionPlaylistFilename(file.filename)
|
||||
|
||||
await VideoPathManager.Instance.makeAvailableVideoFile(file.withVideoOrPlaylist(playlist), async videoFilePath => {
|
||||
const size = await getVideoStreamDimensionsInfo(videoFilePath)
|
||||
const probe = await ffprobePromise(videoFilePath)
|
||||
|
||||
if (splitAudioAndVideo && file.resolution === VideoResolution.H_NOVIDEO) {
|
||||
separatedAudioCodec = await getAudioStreamCodec(videoFilePath, probe)
|
||||
}
|
||||
|
||||
const size = await getVideoStreamDimensionsInfo(videoFilePath, probe)
|
||||
|
||||
const bandwidth = 'BANDWIDTH=' + video.getBandwidthBits(file)
|
||||
const resolution = `RESOLUTION=${size?.width || 0}x${size?.height || 0}`
|
||||
const resolution = file.resolution === VideoResolution.H_NOVIDEO
|
||||
? ''
|
||||
: `,RESOLUTION=${size?.width || 0}x${size?.height || 0}`
|
||||
|
||||
let line = `#EXT-X-STREAM-INF:${bandwidth},${resolution}`
|
||||
let line = `#EXT-X-STREAM-INF:${bandwidth}${resolution}`
|
||||
if (file.fps) line += ',FRAME-RATE=' + file.fps
|
||||
|
||||
const codecs = await Promise.all([
|
||||
getVideoStreamCodec(videoFilePath),
|
||||
getAudioStreamCodec(videoFilePath)
|
||||
getVideoStreamCodec(videoFilePath, probe),
|
||||
separatedAudioCodec || getAudioStreamCodec(videoFilePath, probe)
|
||||
])
|
||||
|
||||
line += `,CODECS="${codecs.filter(c => !!c).join(',')}"`
|
||||
|
||||
masterPlaylists.push(line)
|
||||
masterPlaylists.push(playlistFilename)
|
||||
if (splitAudioAndVideo) {
|
||||
line += `,AUDIO="audio"`
|
||||
}
|
||||
|
||||
// Don't include audio only resolution as a regular "video" resolution
|
||||
// Some player may use it automatically and so the user would not have a video stream
|
||||
// But if it's the only resolution we can treat it as a regular stream
|
||||
if (resolution || playlist.VideoFiles.length === 1) {
|
||||
extStreamInfo.push(line)
|
||||
extStreamInfo.push(playlistFilename)
|
||||
} else if (splitAudioAndVideo) {
|
||||
extMedia.push(`#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",NAME="Audio",AUTOSELECT=YES,DEFAULT=YES,URI="${playlistFilename}"`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const masterPlaylists = [ '#EXTM3U', '#EXT-X-VERSION:3', '', ...extMedia, '', ...extStreamInfo ]
|
||||
|
||||
if (playlist.playlistFilename) {
|
||||
await video.removeStreamingPlaylistFile(playlist, playlist.playlistFilename)
|
||||
}
|
||||
|
@ -111,7 +137,7 @@ function updateMasterHLSPlaylist (video: MVideo, playlistArg: MStreamingPlaylist
|
|||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function updateSha256VODSegments (video: MVideo, playlistArg: MStreamingPlaylist): Promise<MStreamingPlaylistFilesVideo> {
|
||||
export function updateSha256VODSegments (video: MVideo, playlistArg: MStreamingPlaylist): Promise<MStreamingPlaylistFilesVideo> {
|
||||
return playlistFilesQueue.add(async () => {
|
||||
const json: { [filename: string]: { [range: string]: string } } = {}
|
||||
|
||||
|
@ -162,12 +188,12 @@ function updateSha256VODSegments (video: MVideo, playlistArg: MStreamingPlaylist
|
|||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function buildSha256Segment (segmentPath: string) {
|
||||
export async function buildSha256Segment (segmentPath: string) {
|
||||
const buf = await readFile(segmentPath)
|
||||
return sha256(buf)
|
||||
}
|
||||
|
||||
function downloadPlaylistSegments (playlistUrl: string, destinationDir: string, timeout: number, bodyKBLimit: number) {
|
||||
export function downloadPlaylistSegments (playlistUrl: string, destinationDir: string, timeout: number, bodyKBLimit: number) {
|
||||
let timer
|
||||
let remainingBodyKBLimit = bodyKBLimit
|
||||
|
||||
|
@ -240,7 +266,7 @@ function downloadPlaylistSegments (playlistUrl: string, destinationDir: string,
|
|||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function renameVideoFileInPlaylist (playlistPath: string, newVideoFilename: string) {
|
||||
export async function renameVideoFileInPlaylist (playlistPath: string, newVideoFilename: string) {
|
||||
const content = await readFile(playlistPath, 'utf8')
|
||||
|
||||
const newContent = content.replace(new RegExp(`${uuidRegex}-\\d+-fragmented.mp4`, 'g'), newVideoFilename)
|
||||
|
@ -250,23 +276,12 @@ async function renameVideoFileInPlaylist (playlistPath: string, newVideoFilename
|
|||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function injectQueryToPlaylistUrls (content: string, queryString: string) {
|
||||
export function injectQueryToPlaylistUrls (content: string, queryString: string) {
|
||||
return content.replace(/\.(m3u8|ts|mp4)/gm, '.$1?' + queryString)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
updateMasterHLSPlaylist,
|
||||
updateSha256VODSegments,
|
||||
buildSha256Segment,
|
||||
downloadPlaylistSegments,
|
||||
updateStreamingPlaylistsInfohashesIfNeeded,
|
||||
updatePlaylistAfterFileChange,
|
||||
injectQueryToPlaylistUrls,
|
||||
renameVideoFileInPlaylist
|
||||
}
|
||||
|
||||
// Private
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getRangesFromPlaylist (playlistContent: string) {
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { FFmpegImage, ffprobePromise, getVideoStreamDimensionsInfo, isAudioFile } from '@peertube/peertube-ffmpeg'
|
||||
import { GenerateStoryboardPayload } from '@peertube/peertube-models'
|
||||
import { FFmpegImage, ffprobePromise, getVideoStreamDimensionsInfo } from '@peertube/peertube-ffmpeg'
|
||||
import { GenerateStoryboardPayload, VideoFileStream } from '@peertube/peertube-models'
|
||||
import { retryTransactionWrapper } from '@server/helpers/database-utils.js'
|
||||
import { getFFmpegCommandWrapperOptions } from '@server/helpers/ffmpeg/index.js'
|
||||
import { generateImageFilename } from '@server/helpers/image-utils.js'
|
||||
|
@ -34,16 +34,14 @@ async function processGenerateStoryboard (job: Job): Promise<void> {
|
|||
return
|
||||
}
|
||||
|
||||
const inputFile = video.getMaxQualityFile()
|
||||
const inputFile = video.getMaxQualityFile(VideoFileStream.VIDEO)
|
||||
if (!inputFile) {
|
||||
logger.info('Do not generate a storyboard of %s since the video does not have a video stream', payload.videoUUID, lTags)
|
||||
return
|
||||
}
|
||||
|
||||
await VideoPathManager.Instance.makeAvailableVideoFile(inputFile, async videoPath => {
|
||||
const probe = await ffprobePromise(videoPath)
|
||||
const isAudio = await isAudioFile(videoPath, probe)
|
||||
|
||||
if (isAudio) {
|
||||
logger.info('Do not generate a storyboard of %s since the video does not have a video stream', payload.videoUUID, lTags)
|
||||
return
|
||||
}
|
||||
|
||||
const videoStreamInfo = await getVideoStreamDimensionsInfo(videoPath, probe)
|
||||
let spriteHeight: number
|
||||
|
|
|
@ -44,7 +44,7 @@ export async function moveToJob (options: {
|
|||
|
||||
try {
|
||||
const source = await VideoSourceModel.loadLatest(video.id)
|
||||
if (source) {
|
||||
if (source?.keptOriginalFilename) {
|
||||
logger.debug(`Moving video source ${source.keptOriginalFilename} file of video ${video.uuid}`, lTags)
|
||||
|
||||
await moveVideoSourceFile(source)
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import { Job } from 'bullmq'
|
||||
import { pick } from '@peertube/peertube-core-utils'
|
||||
import { TranscodingJobBuilderPayload, VideoFileStream } from '@peertube/peertube-models'
|
||||
import { createOptimizeOrMergeAudioJobs } from '@server/lib/transcoding/create-transcoding-job.js'
|
||||
import { UserModel } from '@server/models/user/user.js'
|
||||
import { VideoJobInfoModel } from '@server/models/video/video-job-info.js'
|
||||
import { VideoModel } from '@server/models/video/video.js'
|
||||
import { pick } from '@peertube/peertube-core-utils'
|
||||
import { TranscodingJobBuilderPayload } from '@peertube/peertube-models'
|
||||
import { Job } from 'bullmq'
|
||||
import { logger } from '../../../helpers/logger.js'
|
||||
import { JobQueue } from '../job-queue.js'
|
||||
|
||||
|
@ -16,7 +16,7 @@ async function processTranscodingJobBuilder (job: Job) {
|
|||
if (payload.optimizeJob) {
|
||||
const video = await VideoModel.loadFull(payload.videoUUID)
|
||||
const user = await UserModel.loadByVideoId(video.id)
|
||||
const videoFile = video.getMaxQualityFile()
|
||||
const videoFile = video.getMaxQualityFile(VideoFileStream.VIDEO) || video.getMaxQualityFile(VideoFileStream.AUDIO)
|
||||
|
||||
await createOptimizeOrMergeAudioJobs({
|
||||
...pick(payload.optimizeJob, [ 'isNewVideo' ]),
|
||||
|
|
|
@ -129,7 +129,7 @@ type ProcessFileOptions = {
|
|||
}
|
||||
async function processFile (downloader: () => Promise<string>, videoImport: MVideoImportDefault, options: ProcessFileOptions) {
|
||||
let tmpVideoPath: string
|
||||
let videoFile: VideoFileModel
|
||||
let videoFile: MVideoFile
|
||||
|
||||
try {
|
||||
// Download video from youtubeDL
|
||||
|
@ -163,7 +163,7 @@ async function processFile (downloader: () => Promise<string>, videoImport: MVid
|
|||
videoImport,
|
||||
video: videoImport.Video,
|
||||
videoFilePath: tmpVideoPath,
|
||||
videoFile,
|
||||
videoFile: videoFile as VideoFileModel,
|
||||
user: videoImport.User
|
||||
}
|
||||
const acceptedResult = await Hooks.wrapFun(isPostImportVideoAccepted, acceptParameters, hookName)
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { ffprobePromise, getAudioStream, getVideoStreamDimensionsInfo, getVideoStreamFPS } from '@peertube/peertube-ffmpeg'
|
||||
import { ThumbnailType, VideoLiveEndingPayload, VideoState } from '@peertube/peertube-models'
|
||||
import { ThumbnailType, VideoFileStream, VideoLiveEndingPayload, VideoState } from '@peertube/peertube-models'
|
||||
import { peertubeTruncate } from '@server/helpers/core-utils.js'
|
||||
import { CONSTRAINTS_FIELDS } from '@server/initializers/constants.js'
|
||||
import { getLocalVideoActivityPubUrl } from '@server/lib/activitypub/url.js'
|
||||
|
@ -11,7 +11,7 @@ import {
|
|||
getHLSDirectory,
|
||||
getLiveReplayBaseDirectory
|
||||
} from '@server/lib/paths.js'
|
||||
import { generateLocalVideoMiniature, regenerateMiniaturesIfNeeded } from '@server/lib/thumbnail.js'
|
||||
import { generateLocalVideoMiniature, regenerateMiniaturesIfNeeded, updateLocalVideoMiniatureFromExisting } from '@server/lib/thumbnail.js'
|
||||
import { generateHlsPlaylistResolutionFromTS } from '@server/lib/transcoding/hls-transcoding.js'
|
||||
import { createTranscriptionTaskIfNeeded } from '@server/lib/video-captions.js'
|
||||
import { buildStoryboardJobIfNeeded } from '@server/lib/video-jobs.js'
|
||||
|
@ -25,7 +25,15 @@ import { VideoLiveSessionModel } from '@server/models/video/video-live-session.j
|
|||
import { VideoLiveModel } from '@server/models/video/video-live.js'
|
||||
import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist.js'
|
||||
import { VideoModel } from '@server/models/video/video.js'
|
||||
import { MVideo, MVideoLive, MVideoLiveSession, MVideoWithAllFiles } from '@server/types/models/index.js'
|
||||
import {
|
||||
MThumbnail,
|
||||
MVideo,
|
||||
MVideoLive,
|
||||
MVideoLiveSession,
|
||||
MVideoThumbnail,
|
||||
MVideoWithAllFiles,
|
||||
MVideoWithFileThumbnail
|
||||
} from '@server/types/models/index.js'
|
||||
import { Job } from 'bullmq'
|
||||
import { remove } from 'fs-extra/esm'
|
||||
import { readdir } from 'fs/promises'
|
||||
|
@ -97,7 +105,7 @@ export {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function saveReplayToExternalVideo (options: {
|
||||
liveVideo: MVideo
|
||||
liveVideo: MVideoThumbnail
|
||||
liveSession: MVideoLiveSession
|
||||
publishedAt: string
|
||||
replayDirectory: string
|
||||
|
@ -159,21 +167,13 @@ async function saveReplayToExternalVideo (options: {
|
|||
try {
|
||||
await assignReplayFilesToVideo({ video: replayVideo, replayDirectory })
|
||||
|
||||
logger.info(`Removing replay directory ${replayDirectory}`, lTags(liveVideo.uuid))
|
||||
await remove(replayDirectory)
|
||||
} finally {
|
||||
inputFileMutexReleaser()
|
||||
}
|
||||
|
||||
const thumbnails = await generateLocalVideoMiniature({
|
||||
video: replayVideo,
|
||||
videoFile: replayVideo.getMaxQualityFile(),
|
||||
types: [ ThumbnailType.MINIATURE, ThumbnailType.PREVIEW ],
|
||||
ffprobe: undefined
|
||||
})
|
||||
|
||||
for (const thumbnail of thumbnails) {
|
||||
await replayVideo.addAndSaveThumbnail(thumbnail)
|
||||
}
|
||||
await copyOrRegenerateThumbnails({ liveVideo, replayVideo })
|
||||
|
||||
await createStoryboardJob(replayVideo)
|
||||
await createTranscriptionTaskIfNeeded(replayVideo)
|
||||
|
@ -181,6 +181,40 @@ async function saveReplayToExternalVideo (options: {
|
|||
await moveToNextState({ video: replayVideo, isNewVideo: true })
|
||||
}
|
||||
|
||||
async function copyOrRegenerateThumbnails (options: {
|
||||
liveVideo: MVideoThumbnail
|
||||
replayVideo: MVideoWithFileThumbnail
|
||||
}) {
|
||||
const { liveVideo, replayVideo } = options
|
||||
|
||||
let thumbnails: MThumbnail[] = []
|
||||
const preview = liveVideo.getPreview()
|
||||
|
||||
if (preview?.automaticallyGenerated === false) {
|
||||
thumbnails = await Promise.all(
|
||||
[ ThumbnailType.MINIATURE, ThumbnailType.PREVIEW ].map(type => {
|
||||
return updateLocalVideoMiniatureFromExisting({
|
||||
inputPath: preview.getPath(),
|
||||
video: replayVideo,
|
||||
type,
|
||||
automaticallyGenerated: false
|
||||
})
|
||||
})
|
||||
)
|
||||
} else {
|
||||
thumbnails = await generateLocalVideoMiniature({
|
||||
video: replayVideo,
|
||||
videoFile: replayVideo.getMaxQualityFile(VideoFileStream.VIDEO) || replayVideo.getMaxQualityFile(VideoFileStream.AUDIO),
|
||||
types: [ ThumbnailType.MINIATURE, ThumbnailType.PREVIEW ],
|
||||
ffprobe: undefined
|
||||
})
|
||||
}
|
||||
|
||||
for (const thumbnail of thumbnails) {
|
||||
await replayVideo.addAndSaveThumbnail(thumbnail)
|
||||
}
|
||||
}
|
||||
|
||||
async function replaceLiveByReplay (options: {
|
||||
video: MVideo
|
||||
liveSession: MVideoLiveSession
|
||||
|
|
|
@ -1,17 +1,4 @@
|
|||
import { Job } from 'bullmq'
|
||||
import { remove } from 'fs-extra/esm'
|
||||
import { join } from 'path'
|
||||
import { getFFmpegCommandWrapperOptions } from '@server/helpers/ffmpeg/index.js'
|
||||
import { CONFIG } from '@server/initializers/config.js'
|
||||
import { VideoTranscodingProfilesManager } from '@server/lib/transcoding/default-transcoding-profiles.js'
|
||||
import { isUserQuotaValid } from '@server/lib/user.js'
|
||||
import { VideoPathManager } from '@server/lib/video-path-manager.js'
|
||||
import { approximateIntroOutroAdditionalSize, onVideoStudioEnded, safeCleanupStudioTMPFiles } from '@server/lib/video-studio.js'
|
||||
import { UserModel } from '@server/models/user/user.js'
|
||||
import { VideoModel } from '@server/models/video/video.js'
|
||||
import { MVideo, MVideoFullLight } from '@server/types/models/index.js'
|
||||
import { pick } from '@peertube/peertube-core-utils'
|
||||
import { buildUUID } from '@peertube/peertube-node-utils'
|
||||
import { FFmpegEdition } from '@peertube/peertube-ffmpeg'
|
||||
import {
|
||||
VideoStudioEditionPayload,
|
||||
|
@ -22,6 +9,20 @@ import {
|
|||
VideoStudioTaskPayload,
|
||||
VideoStudioTaskWatermarkPayload
|
||||
} from '@peertube/peertube-models'
|
||||
import { buildUUID } from '@peertube/peertube-node-utils'
|
||||
import { getFFmpegCommandWrapperOptions } from '@server/helpers/ffmpeg/index.js'
|
||||
import { CONFIG } from '@server/initializers/config.js'
|
||||
import { VideoTranscodingProfilesManager } from '@server/lib/transcoding/default-transcoding-profiles.js'
|
||||
import { isUserQuotaValid } from '@server/lib/user.js'
|
||||
import { VideoPathManager } from '@server/lib/video-path-manager.js'
|
||||
import { approximateIntroOutroAdditionalSize, onVideoStudioEnded, safeCleanupStudioTMPFiles } from '@server/lib/video-studio.js'
|
||||
import { UserModel } from '@server/models/user/user.js'
|
||||
import { VideoModel } from '@server/models/video/video.js'
|
||||
import { MVideo, MVideoFullLight } from '@server/types/models/index.js'
|
||||
import { MutexInterface } from 'async-mutex'
|
||||
import { Job } from 'bullmq'
|
||||
import { remove } from 'fs-extra/esm'
|
||||
import { extname, join } from 'path'
|
||||
import { logger, loggerTagsFactory } from '../../../helpers/logger.js'
|
||||
|
||||
const lTagsBase = loggerTagsFactory('video-studio')
|
||||
|
@ -32,6 +33,8 @@ async function processVideoStudioEdition (job: Job) {
|
|||
|
||||
logger.info('Process video studio edition of %s in job %s.', payload.videoUUID, job.id, lTags)
|
||||
|
||||
let inputFileMutexReleaser = await VideoPathManager.Instance.lockFiles(payload.videoUUID)
|
||||
|
||||
try {
|
||||
const video = await VideoModel.loadFull(payload.videoUUID)
|
||||
|
||||
|
@ -45,18 +48,28 @@ async function processVideoStudioEdition (job: Job) {
|
|||
|
||||
await checkUserQuotaOrThrow(video, payload)
|
||||
|
||||
const inputFile = video.getMaxQualityFile()
|
||||
await video.reload()
|
||||
|
||||
const editionResultPath = await VideoPathManager.Instance.makeAvailableVideoFile(inputFile, async originalFilePath => {
|
||||
const editionResultPath = await VideoPathManager.Instance.makeAvailableMaxQualityFiles(video, async ({
|
||||
videoPath: originalVideoFilePath,
|
||||
separatedAudioPath
|
||||
}) => {
|
||||
let tmpInputFilePath: string
|
||||
let outputPath: string
|
||||
|
||||
for (const task of payload.tasks) {
|
||||
const outputFilename = buildUUID() + inputFile.extname
|
||||
const outputFilename = buildUUID() + extname(originalVideoFilePath)
|
||||
outputPath = join(CONFIG.STORAGE.TMP_DIR, outputFilename)
|
||||
|
||||
await processTask({
|
||||
inputPath: tmpInputFilePath ?? originalFilePath,
|
||||
videoInputPath: tmpInputFilePath ?? originalVideoFilePath,
|
||||
|
||||
separatedAudioInputPath: tmpInputFilePath
|
||||
? undefined
|
||||
: separatedAudioPath,
|
||||
|
||||
inputFileMutexReleaser,
|
||||
|
||||
video,
|
||||
outputPath,
|
||||
task,
|
||||
|
@ -67,6 +80,7 @@ async function processVideoStudioEdition (job: Job) {
|
|||
|
||||
// For the next iteration
|
||||
tmpInputFilePath = outputPath
|
||||
inputFileMutexReleaser = undefined
|
||||
}
|
||||
|
||||
return outputPath
|
||||
|
@ -79,6 +93,8 @@ async function processVideoStudioEdition (job: Job) {
|
|||
await safeCleanupStudioTMPFiles(payload.tasks)
|
||||
|
||||
throw err
|
||||
} finally {
|
||||
if (inputFileMutexReleaser) inputFileMutexReleaser()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -91,7 +107,11 @@ export {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
type TaskProcessorOptions <T extends VideoStudioTaskPayload = VideoStudioTaskPayload> = {
|
||||
inputPath: string
|
||||
videoInputPath: string
|
||||
separatedAudioInputPath?: string
|
||||
|
||||
inputFileMutexReleaser: MutexInterface.Releaser
|
||||
|
||||
outputPath: string
|
||||
video: MVideo
|
||||
task: T
|
||||
|
@ -122,7 +142,7 @@ function processAddIntroOutro (options: TaskProcessorOptions<VideoStudioTaskIntr
|
|||
logger.debug('Will add intro/outro to the video.', { options, ...lTags })
|
||||
|
||||
return buildFFmpegEdition().addIntroOutro({
|
||||
...pick(options, [ 'inputPath', 'outputPath' ]),
|
||||
...pick(options, [ 'inputFileMutexReleaser', 'videoInputPath', 'separatedAudioInputPath', 'outputPath' ]),
|
||||
|
||||
introOutroPath: task.options.file,
|
||||
type: task.name === 'add-intro'
|
||||
|
@ -137,7 +157,7 @@ function processCut (options: TaskProcessorOptions<VideoStudioTaskCutPayload>) {
|
|||
logger.debug('Will cut the video.', { options, ...lTags })
|
||||
|
||||
return buildFFmpegEdition().cutVideo({
|
||||
...pick(options, [ 'inputPath', 'outputPath' ]),
|
||||
...pick(options, [ 'inputFileMutexReleaser', 'videoInputPath', 'separatedAudioInputPath', 'outputPath' ]),
|
||||
|
||||
start: task.options.start,
|
||||
end: task.options.end
|
||||
|
@ -150,7 +170,7 @@ function processAddWatermark (options: TaskProcessorOptions<VideoStudioTaskWater
|
|||
logger.debug('Will add watermark to the video.', { options, ...lTags })
|
||||
|
||||
return buildFFmpegEdition().addWatermark({
|
||||
...pick(options, [ 'inputPath', 'outputPath' ]),
|
||||
...pick(options, [ 'inputFileMutexReleaser', 'videoInputPath', 'separatedAudioInputPath', 'outputPath' ]),
|
||||
|
||||
watermarkPath: task.options.file,
|
||||
|
||||
|
|
|
@ -1,4 +1,10 @@
|
|||
import { Job } from 'bullmq'
|
||||
import {
|
||||
HLSTranscodingPayload,
|
||||
MergeAudioTranscodingPayload,
|
||||
NewWebVideoResolutionTranscodingPayload,
|
||||
OptimizeTranscodingPayload,
|
||||
VideoTranscodingPayload
|
||||
} from '@peertube/peertube-models'
|
||||
import { onTranscodingEnded } from '@server/lib/transcoding/ended-transcoding.js'
|
||||
import { generateHlsPlaylistResolution } from '@server/lib/transcoding/hls-transcoding.js'
|
||||
import { mergeAudioVideofile, optimizeOriginalVideofile, transcodeNewWebVideoResolution } from '@server/lib/transcoding/web-transcoding.js'
|
||||
|
@ -8,13 +14,7 @@ import { moveToFailedTranscodingState } from '@server/lib/video-state.js'
|
|||
import { UserModel } from '@server/models/user/user.js'
|
||||
import { VideoJobInfoModel } from '@server/models/video/video-job-info.js'
|
||||
import { MUser, MUserId, MVideoFullLight } from '@server/types/models/index.js'
|
||||
import {
|
||||
HLSTranscodingPayload,
|
||||
MergeAudioTranscodingPayload,
|
||||
NewWebVideoResolutionTranscodingPayload,
|
||||
OptimizeTranscodingPayload,
|
||||
VideoTranscodingPayload
|
||||
} from '@peertube/peertube-models'
|
||||
import { Job } from 'bullmq'
|
||||
import { logger, loggerTagsFactory } from '../../../helpers/logger.js'
|
||||
import { VideoModel } from '../../../models/video/video.js'
|
||||
|
||||
|
@ -87,7 +87,7 @@ async function handleWebVideoMergeAudioJob (job: Job, payload: MergeAudioTransco
|
|||
async function handleWebVideoOptimizeJob (job: Job, payload: OptimizeTranscodingPayload, video: MVideoFullLight, user: MUserId) {
|
||||
logger.info('Handling optimize transcoding job for %s.', video.uuid, lTags(video.uuid), { payload })
|
||||
|
||||
await optimizeOriginalVideofile({ video, inputVideoFile: video.getMaxQualityFile(), quickTranscode: payload.quickTranscode, job })
|
||||
await optimizeOriginalVideofile({ video, quickTranscode: payload.quickTranscode, job })
|
||||
|
||||
logger.info('Optimize transcoding job for %s ended.', video.uuid, lTags(video.uuid), { payload })
|
||||
|
||||
|
@ -103,7 +103,7 @@ async function handleNewWebVideoResolutionJob (job: Job, payload: NewWebVideoRes
|
|||
|
||||
logger.info('Web Video transcoding job for %s ended.', video.uuid, lTags(video.uuid), { payload })
|
||||
|
||||
await onTranscodingEnded({ isNewVideo: payload.isNewVideo, moveVideoToNextState: true, video })
|
||||
await onTranscodingEnded({ isNewVideo: payload.isNewVideo, moveVideoToNextState: !payload.hasChildren, video })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
@ -117,20 +117,24 @@ async function handleHLSJob (job: Job, payload: HLSTranscodingPayload, videoArg:
|
|||
try {
|
||||
video = await VideoModel.loadFull(videoArg.uuid)
|
||||
|
||||
const videoFileInput = payload.copyCodecs
|
||||
? video.getWebVideoFile(payload.resolution)
|
||||
: video.getMaxQualityFile()
|
||||
const { videoFile, separatedAudioFile } = video.getMaxQualityAudioAndVideoFiles()
|
||||
|
||||
const videoOrStreamingPlaylist = videoFileInput.getVideoOrStreamingPlaylist()
|
||||
const videoFileInputs = payload.copyCodecs
|
||||
? [ video.getWebVideoFileMinResolution(payload.resolution) ]
|
||||
: [ videoFile, separatedAudioFile ].filter(v => !!v)
|
||||
|
||||
await VideoPathManager.Instance.makeAvailableVideoFile(videoFileInput.withVideoOrPlaylist(videoOrStreamingPlaylist), videoInputPath => {
|
||||
await VideoPathManager.Instance.makeAvailableVideoFiles(videoFileInputs, ([ videoPath, separatedAudioPath ]) => {
|
||||
return generateHlsPlaylistResolution({
|
||||
video,
|
||||
videoInputPath,
|
||||
|
||||
videoInputPath: videoPath,
|
||||
separatedAudioInputPath: separatedAudioPath,
|
||||
|
||||
inputFileMutexReleaser,
|
||||
resolution: payload.resolution,
|
||||
fps: payload.fps,
|
||||
copyCodecs: payload.copyCodecs,
|
||||
separatedAudio: payload.separatedAudio,
|
||||
job
|
||||
})
|
||||
})
|
||||
|
@ -146,5 +150,5 @@ async function handleHLSJob (job: Job, payload: HLSTranscodingPayload, videoArg:
|
|||
await removeAllWebVideoFiles(video)
|
||||
}
|
||||
|
||||
await onTranscodingEnded({ isNewVideo: payload.isNewVideo, moveVideoToNextState: true, video })
|
||||
await onTranscodingEnded({ isNewVideo: payload.isNewVideo, moveVideoToNextState: !payload.hasChildren, video })
|
||||
}
|
||||
|
|
|
@ -368,6 +368,8 @@ class JobQueue {
|
|||
createSequentialJobFlow (...jobs: ((CreateJobArgument & CreateJobOptions) | undefined)[]) {
|
||||
let lastJob: FlowJob
|
||||
|
||||
logger.debug('Creating jobs in local job queue', { jobs })
|
||||
|
||||
for (const job of jobs) {
|
||||
if (!job) continue
|
||||
|
||||
|
|
|
@ -4,9 +4,10 @@ import {
|
|||
getVideoStreamBitrate,
|
||||
getVideoStreamDimensionsInfo,
|
||||
getVideoStreamFPS,
|
||||
hasAudioStream
|
||||
hasAudioStream,
|
||||
hasVideoStream
|
||||
} from '@peertube/peertube-ffmpeg'
|
||||
import { LiveVideoError, LiveVideoErrorType, VideoState } from '@peertube/peertube-models'
|
||||
import { LiveVideoError, LiveVideoErrorType, VideoResolution, VideoState } from '@peertube/peertube-models'
|
||||
import { retryTransactionWrapper } from '@server/helpers/database-utils.js'
|
||||
import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
|
||||
import { CONFIG, registerConfigChangedHandler } from '@server/initializers/config.js'
|
||||
|
@ -286,13 +287,24 @@ class LiveManager {
|
|||
const now = Date.now()
|
||||
const probe = await ffprobePromise(inputLocalUrl)
|
||||
|
||||
const [ { resolution, ratio }, fps, bitrate, hasAudio ] = await Promise.all([
|
||||
const [ { resolution, ratio }, fps, bitrate, hasAudio, hasVideo ] = await Promise.all([
|
||||
getVideoStreamDimensionsInfo(inputLocalUrl, probe),
|
||||
getVideoStreamFPS(inputLocalUrl, probe),
|
||||
getVideoStreamBitrate(inputLocalUrl, probe),
|
||||
hasAudioStream(inputLocalUrl, probe)
|
||||
hasAudioStream(inputLocalUrl, probe),
|
||||
hasVideoStream(inputLocalUrl, probe)
|
||||
])
|
||||
|
||||
if (!hasAudio && !hasVideo) {
|
||||
logger.warn(
|
||||
'Not audio and video streams were found for video %s. Refusing stream %s.',
|
||||
video.uuid, streamKey, lTags(sessionId, video.uuid)
|
||||
)
|
||||
|
||||
this.videoSessions.delete(video.uuid)
|
||||
return this.abortSession(sessionId)
|
||||
}
|
||||
|
||||
logger.info(
|
||||
'%s probing took %d ms (bitrate: %d, fps: %d, resolution: %d)',
|
||||
inputLocalUrl, Date.now() - now, bitrate, fps, resolution, lTags(sessionId, video.uuid)
|
||||
|
@ -304,6 +316,16 @@ class LiveManager {
|
|||
{ video }
|
||||
)
|
||||
|
||||
if (!hasAudio && allResolutions.length === 1 && allResolutions[0] === VideoResolution.H_NOVIDEO) {
|
||||
logger.warn(
|
||||
'Cannot stream live to audio only because no video stream is available for video %s. Refusing stream %s.',
|
||||
video.uuid, streamKey, lTags(sessionId, video.uuid)
|
||||
)
|
||||
|
||||
this.videoSessions.delete(video.uuid)
|
||||
return this.abortSession(sessionId)
|
||||
}
|
||||
|
||||
logger.info(
|
||||
'Handling live video of original resolution %d.', resolution,
|
||||
{ allResolutions, ...lTags(sessionId, video.uuid) }
|
||||
|
@ -322,6 +344,7 @@ class LiveManager {
|
|||
ratio,
|
||||
allResolutions,
|
||||
hasAudio,
|
||||
hasVideo,
|
||||
probe
|
||||
})
|
||||
}
|
||||
|
@ -340,12 +363,15 @@ class LiveManager {
|
|||
ratio: number
|
||||
allResolutions: number[]
|
||||
hasAudio: boolean
|
||||
hasVideo: boolean
|
||||
probe: FfprobeData
|
||||
}) {
|
||||
const { sessionId, videoLive, user, ratio } = options
|
||||
const { sessionId, videoLive, user, ratio, allResolutions } = options
|
||||
const videoUUID = videoLive.Video.uuid
|
||||
const localLTags = lTags(sessionId, videoUUID)
|
||||
|
||||
const audioOnlyOutput = allResolutions.every(r => r === VideoResolution.H_NOVIDEO)
|
||||
|
||||
const liveSession = await this.saveStartingSession(videoLive)
|
||||
|
||||
LiveQuotaStore.Instance.addNewLive(user.id, sessionId)
|
||||
|
@ -356,10 +382,10 @@ class LiveManager {
|
|||
videoLive,
|
||||
user,
|
||||
|
||||
...pick(options, [ 'inputLocalUrl', 'inputPublicUrl', 'bitrate', 'ratio', 'fps', 'allResolutions', 'hasAudio', 'probe' ])
|
||||
...pick(options, [ 'inputLocalUrl', 'inputPublicUrl', 'bitrate', 'ratio', 'fps', 'allResolutions', 'hasAudio', 'hasVideo', 'probe' ])
|
||||
})
|
||||
|
||||
muxingSession.on('live-ready', () => this.publishAndFederateLive({ live: videoLive, ratio, localLTags }))
|
||||
muxingSession.on('live-ready', () => this.publishAndFederateLive({ live: videoLive, ratio, audioOnlyOutput, localLTags }))
|
||||
|
||||
muxingSession.on('bad-socket-health', ({ videoUUID }) => {
|
||||
logger.error(
|
||||
|
@ -421,10 +447,11 @@ class LiveManager {
|
|||
|
||||
private async publishAndFederateLive (options: {
|
||||
live: MVideoLiveVideo
|
||||
audioOnlyOutput: boolean
|
||||
ratio: number
|
||||
localLTags: { tags: (string | number)[] }
|
||||
}) {
|
||||
const { live, ratio, localLTags } = options
|
||||
const { live, ratio, audioOnlyOutput, localLTags } = options
|
||||
|
||||
const videoId = live.videoId
|
||||
|
||||
|
@ -435,7 +462,10 @@ class LiveManager {
|
|||
|
||||
video.state = VideoState.PUBLISHED
|
||||
video.publishedAt = new Date()
|
||||
video.aspectRatio = ratio
|
||||
video.aspectRatio = audioOnlyOutput
|
||||
? 0
|
||||
: ratio
|
||||
|
||||
await video.save()
|
||||
|
||||
live.Video = video
|
||||
|
@ -546,16 +576,24 @@ class LiveManager {
|
|||
}
|
||||
|
||||
private buildAllResolutionsToTranscode (originResolution: number, hasAudio: boolean) {
|
||||
if (!CONFIG.LIVE.TRANSCODING.ENABLED) return [ originResolution ]
|
||||
|
||||
const includeInput = CONFIG.LIVE.TRANSCODING.ALWAYS_TRANSCODE_ORIGINAL_RESOLUTION
|
||||
|
||||
const resolutionsEnabled = CONFIG.LIVE.TRANSCODING.ENABLED
|
||||
? computeResolutionsToTranscode({ input: originResolution, type: 'live', includeInput, strictLower: false, hasAudio })
|
||||
: []
|
||||
const resolutionsEnabled = computeResolutionsToTranscode({
|
||||
input: originResolution,
|
||||
type: 'live',
|
||||
includeInput,
|
||||
strictLower: false,
|
||||
hasAudio
|
||||
})
|
||||
|
||||
if (resolutionsEnabled.length === 0) {
|
||||
return [ originResolution ]
|
||||
if (hasAudio && resolutionsEnabled.length !== 0 && !resolutionsEnabled.includes(VideoResolution.H_NOVIDEO)) {
|
||||
resolutionsEnabled.push(VideoResolution.H_NOVIDEO)
|
||||
}
|
||||
|
||||
if (resolutionsEnabled.length === 0) return [ originResolution ]
|
||||
|
||||
return resolutionsEnabled
|
||||
}
|
||||
|
||||
|
|
|
@ -1,5 +1,12 @@
|
|||
import { wait } from '@peertube/peertube-core-utils'
|
||||
import { FileStorage, LiveVideoError, VideoStreamingPlaylistType } from '@peertube/peertube-models'
|
||||
import {
|
||||
FileStorage,
|
||||
LiveVideoError,
|
||||
VideoFileFormatFlag,
|
||||
VideoFileStream,
|
||||
VideoResolution,
|
||||
VideoStreamingPlaylistType
|
||||
} from '@peertube/peertube-models'
|
||||
import { computeOutputFPS } from '@server/helpers/ffmpeg/index.js'
|
||||
import { LoggerTagsFn, logger, loggerTagsFactory } from '@server/helpers/logger.js'
|
||||
import { CONFIG } from '@server/initializers/config.js'
|
||||
|
@ -71,6 +78,7 @@ class MuxingSession extends EventEmitter {
|
|||
private readonly ratio: number
|
||||
|
||||
private readonly hasAudio: boolean
|
||||
private readonly hasVideo: boolean
|
||||
|
||||
private readonly probe: FfprobeData
|
||||
|
||||
|
@ -119,6 +127,7 @@ class MuxingSession extends EventEmitter {
|
|||
ratio: number
|
||||
allResolutions: number[]
|
||||
hasAudio: boolean
|
||||
hasVideo: boolean
|
||||
probe: FfprobeData
|
||||
}) {
|
||||
super()
|
||||
|
@ -137,6 +146,7 @@ class MuxingSession extends EventEmitter {
|
|||
this.ratio = options.ratio
|
||||
this.probe = options.probe
|
||||
|
||||
this.hasVideo = options.hasVideo
|
||||
this.hasAudio = options.hasAudio
|
||||
|
||||
this.allResolutions = options.allResolutions
|
||||
|
@ -154,12 +164,14 @@ class MuxingSession extends EventEmitter {
|
|||
async runMuxing () {
|
||||
this.streamingPlaylist = await this.createLivePlaylist()
|
||||
|
||||
const toTranscode = this.buildToTranscode()
|
||||
|
||||
this.createLiveShaStore()
|
||||
this.createFiles()
|
||||
this.createFiles(toTranscode)
|
||||
|
||||
await this.prepareDirectories()
|
||||
|
||||
this.transcodingWrapper = this.buildTranscodingWrapper()
|
||||
this.transcodingWrapper = this.buildTranscodingWrapper(toTranscode)
|
||||
|
||||
this.transcodingWrapper.on('end', () => this.onTranscodedEnded())
|
||||
this.transcodingWrapper.on('error', () => this.onTranscodingError())
|
||||
|
@ -295,16 +307,18 @@ class MuxingSession extends EventEmitter {
|
|||
}
|
||||
}
|
||||
|
||||
private createFiles () {
|
||||
for (let i = 0; i < this.allResolutions.length; i++) {
|
||||
const resolution = this.allResolutions[i]
|
||||
|
||||
private createFiles (toTranscode: { fps: number, resolution: number }[]) {
|
||||
for (const { resolution, fps } of toTranscode) {
|
||||
const file = new VideoFileModel({
|
||||
resolution,
|
||||
fps,
|
||||
size: -1,
|
||||
extname: '.ts',
|
||||
infoHash: null,
|
||||
fps: this.fps,
|
||||
formatFlags: VideoFileFormatFlag.NONE,
|
||||
streams: resolution === VideoResolution.H_NOVIDEO
|
||||
? VideoFileStream.AUDIO
|
||||
: VideoFileStream.VIDEO,
|
||||
storage: this.streamingPlaylist.storage,
|
||||
videoStreamingPlaylistId: this.streamingPlaylist.id
|
||||
})
|
||||
|
@ -484,7 +498,7 @@ class MuxingSession extends EventEmitter {
|
|||
})
|
||||
}
|
||||
|
||||
private buildTranscodingWrapper () {
|
||||
private buildTranscodingWrapper (toTranscode: { fps: number, resolution: number }[]) {
|
||||
const options = {
|
||||
streamingPlaylist: this.streamingPlaylist,
|
||||
videoLive: this.videoLive,
|
||||
|
@ -495,26 +509,12 @@ class MuxingSession extends EventEmitter {
|
|||
inputLocalUrl: this.inputLocalUrl,
|
||||
inputPublicUrl: this.inputPublicUrl,
|
||||
|
||||
toTranscode: this.allResolutions.map(resolution => {
|
||||
let toTranscodeFPS: number
|
||||
toTranscode,
|
||||
|
||||
try {
|
||||
toTranscodeFPS = computeOutputFPS({ inputFPS: this.fps, resolution })
|
||||
} catch (err) {
|
||||
err.liveVideoErrorCode = LiveVideoError.INVALID_INPUT_VIDEO_STREAM
|
||||
throw err
|
||||
}
|
||||
|
||||
return {
|
||||
resolution,
|
||||
fps: toTranscodeFPS
|
||||
}
|
||||
}),
|
||||
|
||||
fps: this.fps,
|
||||
bitrate: this.bitrate,
|
||||
ratio: this.ratio,
|
||||
hasAudio: this.hasAudio,
|
||||
hasVideo: this.hasVideo,
|
||||
probe: this.probe,
|
||||
|
||||
segmentListSize: VIDEO_LIVE.SEGMENTS_LIST_SIZE,
|
||||
|
@ -537,6 +537,25 @@ class MuxingSession extends EventEmitter {
|
|||
private getPlaylistNameFromTS (segmentPath: string) {
|
||||
return `${this.getPlaylistIdFromTS(segmentPath)}.m3u8`
|
||||
}
|
||||
|
||||
private buildToTranscode () {
|
||||
return this.allResolutions.map(resolution => {
|
||||
let toTranscodeFPS: number
|
||||
|
||||
if (resolution === VideoResolution.H_NOVIDEO) {
|
||||
return { resolution, fps: 0 }
|
||||
}
|
||||
|
||||
try {
|
||||
toTranscodeFPS = computeOutputFPS({ inputFPS: this.fps, resolution })
|
||||
} catch (err) {
|
||||
err.liveVideoErrorCode = LiveVideoError.INVALID_INPUT_VIDEO_STREAM
|
||||
throw err
|
||||
}
|
||||
|
||||
return { resolution, fps: toTranscodeFPS }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
@ -30,7 +30,6 @@ interface AbstractTranscodingWrapperOptions {
|
|||
inputLocalUrl: string
|
||||
inputPublicUrl: string
|
||||
|
||||
fps: number
|
||||
toTranscode: {
|
||||
resolution: number
|
||||
fps: number
|
||||
|
@ -38,7 +37,9 @@ interface AbstractTranscodingWrapperOptions {
|
|||
|
||||
bitrate: number
|
||||
ratio: number
|
||||
|
||||
hasAudio: boolean
|
||||
hasVideo: boolean
|
||||
probe: FfprobeData
|
||||
|
||||
segmentListSize: number
|
||||
|
@ -59,10 +60,10 @@ abstract class AbstractTranscodingWrapper extends EventEmitter {
|
|||
protected readonly inputLocalUrl: string
|
||||
protected readonly inputPublicUrl: string
|
||||
|
||||
protected readonly fps: number
|
||||
protected readonly bitrate: number
|
||||
protected readonly ratio: number
|
||||
protected readonly hasAudio: boolean
|
||||
protected readonly hasVideo: boolean
|
||||
protected readonly probe: FfprobeData
|
||||
|
||||
protected readonly segmentListSize: number
|
||||
|
@ -89,12 +90,12 @@ abstract class AbstractTranscodingWrapper extends EventEmitter {
|
|||
this.inputLocalUrl = options.inputLocalUrl
|
||||
this.inputPublicUrl = options.inputPublicUrl
|
||||
|
||||
this.fps = options.fps
|
||||
this.toTranscode = options.toTranscode
|
||||
|
||||
this.bitrate = options.bitrate
|
||||
this.ratio = options.ratio
|
||||
this.hasAudio = options.hasAudio
|
||||
this.hasVideo = options.hasVideo
|
||||
this.probe = options.probe
|
||||
|
||||
this.segmentListSize = options.segmentListSize
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import { FfmpegCommand } from 'fluent-ffmpeg'
|
||||
import { FFmpegLive } from '@peertube/peertube-ffmpeg'
|
||||
import { getFFmpegCommandWrapperOptions } from '@server/helpers/ffmpeg/index.js'
|
||||
import { logger } from '@server/helpers/logger.js'
|
||||
import { CONFIG } from '@server/initializers/config.js'
|
||||
import { VIDEO_LIVE } from '@server/initializers/constants.js'
|
||||
import { VideoTranscodingProfilesManager } from '@server/lib/transcoding/default-transcoding-profiles.js'
|
||||
import { FFmpegLive } from '@peertube/peertube-ffmpeg'
|
||||
import { FfmpegCommand } from 'fluent-ffmpeg'
|
||||
import { getLiveSegmentTime } from '../../live-utils.js'
|
||||
import { AbstractTranscodingWrapper } from './abstract-transcoding-wrapper.js'
|
||||
|
||||
|
@ -32,7 +32,10 @@ export class FFmpegTranscodingWrapper extends AbstractTranscodingWrapper {
|
|||
ratio: this.ratio,
|
||||
probe: this.probe,
|
||||
|
||||
hasAudio: this.hasAudio
|
||||
hasAudio: this.hasAudio,
|
||||
hasVideo: this.hasVideo,
|
||||
|
||||
splitAudioAndVideo: true
|
||||
})
|
||||
: this.buildFFmpegLive().getLiveMuxingCommand({
|
||||
inputUrl: this.inputLocalUrl,
|
||||
|
|
|
@ -329,11 +329,11 @@ class Notifier {
|
|||
private isEmailEnabled (user: MUser, value: UserNotificationSettingValueType) {
|
||||
if (CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION === true && user.emailVerified === false) return false
|
||||
|
||||
return value & UserNotificationSettingValue.EMAIL
|
||||
return (value & UserNotificationSettingValue.EMAIL) === UserNotificationSettingValue.EMAIL
|
||||
}
|
||||
|
||||
private isWebNotificationEnabled (value: UserNotificationSettingValueType) {
|
||||
return value & UserNotificationSettingValue.WEB
|
||||
return (value & UserNotificationSettingValue.WEB) === UserNotificationSettingValue.WEB
|
||||
}
|
||||
|
||||
private async sendNotifications <T> (models: (new (payload: T) => AbstractNotification<T>)[], payload: T) {
|
||||
|
|
|
@ -77,7 +77,7 @@ export abstract class AbstractJobHandler <C, U extends RunnerJobUpdatePayload, S
|
|||
}): Promise<MRunnerJob> {
|
||||
const { priority, dependsOnRunnerJob } = options
|
||||
|
||||
logger.debug('Creating runner job', { options, ...this.lTags(options.type) })
|
||||
logger.debug('Creating runner job', { options, dependsOnRunnerJob, ...this.lTags(options.type) })
|
||||
|
||||
const runnerJob = new RunnerJobModel({
|
||||
...pick(options, [ 'type', 'payload', 'privatePayload' ]),
|
||||
|
|
|
@ -12,7 +12,7 @@ import { onTranscriptionEnded } from '@server/lib/video-captions.js'
|
|||
import { VideoJobInfoModel } from '@server/models/video/video-job-info.js'
|
||||
import { MVideoUUID } from '@server/types/models/index.js'
|
||||
import { MRunnerJob } from '@server/types/models/runners/index.js'
|
||||
import { generateRunnerTranscodingVideoInputFileUrl } from '../runner-urls.js'
|
||||
import { generateRunnerTranscodingAudioInputFileUrl } from '../runner-urls.js'
|
||||
import { AbstractJobHandler } from './abstract-job-handler.js'
|
||||
import { loadRunnerVideo } from './shared/utils.js'
|
||||
|
||||
|
@ -59,7 +59,7 @@ export class TranscriptionJobHandler extends AbstractJobHandler<CreateOptions, R
|
|||
const jobUUID = buildUUID()
|
||||
const payload: RunnerJobTranscriptionPayload = {
|
||||
input: {
|
||||
videoFileUrl: generateRunnerTranscodingVideoInputFileUrl(jobUUID, video.uuid)
|
||||
videoFileUrl: generateRunnerTranscodingAudioInputFileUrl(jobUUID, video.uuid)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -14,15 +14,19 @@ import {
|
|||
import { buildUUID } from '@peertube/peertube-node-utils'
|
||||
import { logger } from '@server/helpers/logger.js'
|
||||
import { onVideoStudioEnded, safeCleanupStudioTMPFiles } from '@server/lib/video-studio.js'
|
||||
import { MVideo } from '@server/types/models/index.js'
|
||||
import { MVideoWithFile } from '@server/types/models/index.js'
|
||||
import { MRunnerJob } from '@server/types/models/runners/index.js'
|
||||
import { basename } from 'path'
|
||||
import { generateRunnerEditionTranscodingVideoInputFileUrl, generateRunnerTranscodingVideoInputFileUrl } from '../runner-urls.js'
|
||||
import {
|
||||
generateRunnerEditionTranscodingVideoInputFileUrl,
|
||||
generateRunnerTranscodingAudioInputFileUrl,
|
||||
generateRunnerTranscodingVideoInputFileUrl
|
||||
} from '../runner-urls.js'
|
||||
import { AbstractJobHandler } from './abstract-job-handler.js'
|
||||
import { loadRunnerVideo } from './shared/utils.js'
|
||||
|
||||
type CreateOptions = {
|
||||
video: MVideo
|
||||
video: MVideoWithFile
|
||||
tasks: VideoStudioTaskPayload[]
|
||||
priority: number
|
||||
}
|
||||
|
@ -34,9 +38,15 @@ export class VideoStudioTranscodingJobHandler extends AbstractJobHandler<CreateO
|
|||
const { video, priority, tasks } = options
|
||||
|
||||
const jobUUID = buildUUID()
|
||||
const { separatedAudioFile } = video.getMaxQualityAudioAndVideoFiles()
|
||||
|
||||
const payload: RunnerJobStudioTranscodingPayload = {
|
||||
input: {
|
||||
videoFileUrl: generateRunnerTranscodingVideoInputFileUrl(jobUUID, video.uuid)
|
||||
videoFileUrl: generateRunnerTranscodingVideoInputFileUrl(jobUUID, video.uuid),
|
||||
|
||||
separatedAudioFileUrl: separatedAudioFile
|
||||
? [ generateRunnerTranscodingAudioInputFileUrl(jobUUID, video.uuid) ]
|
||||
: []
|
||||
},
|
||||
tasks: tasks.map(t => {
|
||||
if (isVideoStudioTaskIntro(t) || isVideoStudioTaskOutro(t)) {
|
||||
|
|
|
@ -11,19 +11,20 @@ import { onTranscodingEnded } from '@server/lib/transcoding/ended-transcoding.js
|
|||
import { onHLSVideoFileTranscoding } from '@server/lib/transcoding/hls-transcoding.js'
|
||||
import { removeAllWebVideoFiles } from '@server/lib/video-file.js'
|
||||
import { VideoJobInfoModel } from '@server/models/video/video-job-info.js'
|
||||
import { MVideo } from '@server/types/models/index.js'
|
||||
import { MVideoWithFile } from '@server/types/models/index.js'
|
||||
import { MRunnerJob } from '@server/types/models/runners/index.js'
|
||||
import { generateRunnerTranscodingVideoInputFileUrl } from '../runner-urls.js'
|
||||
import { generateRunnerTranscodingAudioInputFileUrl, generateRunnerTranscodingVideoInputFileUrl } from '../runner-urls.js'
|
||||
import { AbstractVODTranscodingJobHandler } from './abstract-vod-transcoding-job-handler.js'
|
||||
import { loadRunnerVideo } from './shared/utils.js'
|
||||
|
||||
type CreateOptions = {
|
||||
video: MVideo
|
||||
video: MVideoWithFile
|
||||
isNewVideo: boolean
|
||||
deleteWebVideoFiles: boolean
|
||||
resolution: number
|
||||
fps: number
|
||||
priority: number
|
||||
separatedAudio: boolean
|
||||
dependsOnRunnerJob?: MRunnerJob
|
||||
}
|
||||
|
||||
|
@ -31,17 +32,24 @@ type CreateOptions = {
|
|||
export class VODHLSTranscodingJobHandler extends AbstractVODTranscodingJobHandler<CreateOptions, RunnerJobUpdatePayload, VODHLSTranscodingSuccess> {
|
||||
|
||||
async create (options: CreateOptions) {
|
||||
const { video, resolution, fps, dependsOnRunnerJob, priority } = options
|
||||
const { video, resolution, fps, dependsOnRunnerJob, separatedAudio, priority } = options
|
||||
|
||||
const jobUUID = buildUUID()
|
||||
|
||||
const { separatedAudioFile } = video.getMaxQualityAudioAndVideoFiles()
|
||||
|
||||
const payload: RunnerJobVODHLSTranscodingPayload = {
|
||||
input: {
|
||||
videoFileUrl: generateRunnerTranscodingVideoInputFileUrl(jobUUID, video.uuid)
|
||||
videoFileUrl: generateRunnerTranscodingVideoInputFileUrl(jobUUID, video.uuid),
|
||||
|
||||
separatedAudioFileUrl: separatedAudioFile
|
||||
? [ generateRunnerTranscodingAudioInputFileUrl(jobUUID, video.uuid) ]
|
||||
: []
|
||||
},
|
||||
output: {
|
||||
resolution,
|
||||
fps
|
||||
fps,
|
||||
separatedAudio
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -8,14 +8,14 @@ import {
|
|||
import { buildUUID } from '@peertube/peertube-node-utils'
|
||||
import { logger } from '@server/helpers/logger.js'
|
||||
import { VideoJobInfoModel } from '@server/models/video/video-job-info.js'
|
||||
import { MVideo } from '@server/types/models/index.js'
|
||||
import { MVideoWithFile } from '@server/types/models/index.js'
|
||||
import { MRunnerJob } from '@server/types/models/runners/index.js'
|
||||
import { generateRunnerTranscodingVideoInputFileUrl } from '../runner-urls.js'
|
||||
import { generateRunnerTranscodingAudioInputFileUrl, generateRunnerTranscodingVideoInputFileUrl } from '../runner-urls.js'
|
||||
import { AbstractVODTranscodingJobHandler } from './abstract-vod-transcoding-job-handler.js'
|
||||
import { loadRunnerVideo, onVODWebVideoOrAudioMergeTranscodingJob } from './shared/utils.js'
|
||||
|
||||
type CreateOptions = {
|
||||
video: MVideo
|
||||
video: MVideoWithFile
|
||||
isNewVideo: boolean
|
||||
resolution: number
|
||||
fps: number
|
||||
|
@ -31,9 +31,15 @@ export class VODWebVideoTranscodingJobHandler extends AbstractVODTranscodingJobH
|
|||
const { video, resolution, fps, priority, dependsOnRunnerJob } = options
|
||||
|
||||
const jobUUID = buildUUID()
|
||||
const { separatedAudioFile } = video.getMaxQualityAudioAndVideoFiles()
|
||||
|
||||
const payload: RunnerJobVODWebVideoTranscodingPayload = {
|
||||
input: {
|
||||
videoFileUrl: generateRunnerTranscodingVideoInputFileUrl(jobUUID, video.uuid)
|
||||
videoFileUrl: generateRunnerTranscodingVideoInputFileUrl(jobUUID, video.uuid),
|
||||
|
||||
separatedAudioFileUrl: separatedAudioFile
|
||||
? [ generateRunnerTranscodingAudioInputFileUrl(jobUUID, video.uuid) ]
|
||||
: []
|
||||
},
|
||||
output: {
|
||||
resolution,
|
||||
|
|
|
@ -4,6 +4,10 @@ export function generateRunnerTranscodingVideoInputFileUrl (jobUUID: string, vid
|
|||
return WEBSERVER.URL + '/api/v1/runners/jobs/' + jobUUID + '/files/videos/' + videoUUID + '/max-quality'
|
||||
}
|
||||
|
||||
export function generateRunnerTranscodingAudioInputFileUrl (jobUUID: string, videoUUID: string) {
|
||||
return WEBSERVER.URL + '/api/v1/runners/jobs/' + jobUUID + '/files/videos/' + videoUUID + '/max-quality/audio'
|
||||
}
|
||||
|
||||
export function generateRunnerTranscodingVideoPreviewFileUrl (jobUUID: string, videoUUID: string) {
|
||||
return WEBSERVER.URL + '/api/v1/runners/jobs/' + jobUUID + '/files/videos/' + videoUUID + '/previews/max-quality'
|
||||
}
|
||||
|
|
|
@ -1,5 +1,10 @@
|
|||
import { ThumbnailType, ThumbnailType_Type, VideoFileStream } from '@peertube/peertube-models'
|
||||
import { generateThumbnailFromVideo } from '@server/helpers/ffmpeg/ffmpeg-image.js'
|
||||
import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
|
||||
import Bluebird from 'bluebird'
|
||||
import { FfprobeData } from 'fluent-ffmpeg'
|
||||
import { remove } from 'fs-extra/esm'
|
||||
import { join } from 'path'
|
||||
import { ThumbnailType, ThumbnailType_Type } from '@peertube/peertube-models'
|
||||
import { generateImageFilename } from '../helpers/image-utils.js'
|
||||
import { CONFIG } from '../initializers/config.js'
|
||||
import { ASSETS_PATH, PREVIEWS_SIZE, THUMBNAILS_SIZE } from '../initializers/constants.js'
|
||||
|
@ -9,17 +14,12 @@ import { MThumbnail } from '../types/models/video/thumbnail.js'
|
|||
import { MVideoPlaylistThumbnail } from '../types/models/video/video-playlist.js'
|
||||
import { VideoPathManager } from './video-path-manager.js'
|
||||
import { downloadImageFromWorker, processImageFromWorker } from './worker/parent-process.js'
|
||||
import { generateThumbnailFromVideo } from '@server/helpers/ffmpeg/ffmpeg-image.js'
|
||||
import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
|
||||
import { remove } from 'fs-extra/esm'
|
||||
import { FfprobeData } from 'fluent-ffmpeg'
|
||||
import Bluebird from 'bluebird'
|
||||
|
||||
const lTags = loggerTagsFactory('thumbnail')
|
||||
|
||||
type ImageSize = { height?: number, width?: number }
|
||||
|
||||
function updateLocalPlaylistMiniatureFromExisting (options: {
|
||||
export function updateLocalPlaylistMiniatureFromExisting (options: {
|
||||
inputPath: string
|
||||
playlist: MVideoPlaylistThumbnail
|
||||
automaticallyGenerated: boolean
|
||||
|
@ -46,7 +46,7 @@ function updateLocalPlaylistMiniatureFromExisting (options: {
|
|||
})
|
||||
}
|
||||
|
||||
function updateRemotePlaylistMiniatureFromUrl (options: {
|
||||
export function updateRemotePlaylistMiniatureFromUrl (options: {
|
||||
downloadUrl: string
|
||||
playlist: MVideoPlaylistThumbnail
|
||||
size?: ImageSize
|
||||
|
@ -67,7 +67,9 @@ function updateRemotePlaylistMiniatureFromUrl (options: {
|
|||
return updateThumbnailFromFunction({ thumbnailCreator, filename, height, width, type, existingThumbnail, fileUrl, onDisk: true })
|
||||
}
|
||||
|
||||
function updateLocalVideoMiniatureFromExisting (options: {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function updateLocalVideoMiniatureFromExisting (options: {
|
||||
inputPath: string
|
||||
video: MVideoThumbnail
|
||||
type: ThumbnailType_Type
|
||||
|
@ -96,7 +98,7 @@ function updateLocalVideoMiniatureFromExisting (options: {
|
|||
}
|
||||
|
||||
// Returns thumbnail models sorted by their size (height) in descendent order (biggest first)
|
||||
function generateLocalVideoMiniature (options: {
|
||||
export function generateLocalVideoMiniature (options: {
|
||||
video: MVideoThumbnail
|
||||
videoFile: MVideoFile
|
||||
types: ThumbnailType_Type[]
|
||||
|
@ -163,7 +165,7 @@ function generateLocalVideoMiniature (options: {
|
|||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function updateLocalVideoMiniatureFromUrl (options: {
|
||||
export function updateLocalVideoMiniatureFromUrl (options: {
|
||||
downloadUrl: string
|
||||
video: MVideoThumbnail
|
||||
type: ThumbnailType_Type
|
||||
|
@ -195,7 +197,7 @@ function updateLocalVideoMiniatureFromUrl (options: {
|
|||
return updateThumbnailFromFunction({ thumbnailCreator, filename, height, width, type, existingThumbnail, fileUrl, onDisk: true })
|
||||
}
|
||||
|
||||
function updateRemoteVideoThumbnail (options: {
|
||||
export function updateRemoteVideoThumbnail (options: {
|
||||
fileUrl: string
|
||||
video: MVideoThumbnail
|
||||
type: ThumbnailType_Type
|
||||
|
@ -223,7 +225,7 @@ function updateRemoteVideoThumbnail (options: {
|
|||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function regenerateMiniaturesIfNeeded (video: MVideoWithAllFiles, ffprobe: FfprobeData) {
|
||||
export async function regenerateMiniaturesIfNeeded (video: MVideoWithAllFiles, ffprobe: FfprobeData) {
|
||||
const thumbnailsToGenerate: ThumbnailType_Type[] = []
|
||||
|
||||
if (video.getMiniature().automaticallyGenerated === true) {
|
||||
|
@ -236,7 +238,7 @@ async function regenerateMiniaturesIfNeeded (video: MVideoWithAllFiles, ffprobe:
|
|||
|
||||
const models = await generateLocalVideoMiniature({
|
||||
video,
|
||||
videoFile: video.getMaxQualityFile(),
|
||||
videoFile: video.getMaxQualityFile(VideoFileStream.VIDEO) || video.getMaxQualityFile(VideoFileStream.AUDIO),
|
||||
ffprobe,
|
||||
types: thumbnailsToGenerate
|
||||
})
|
||||
|
@ -246,18 +248,6 @@ async function regenerateMiniaturesIfNeeded (video: MVideoWithAllFiles, ffprobe:
|
|||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
generateLocalVideoMiniature,
|
||||
regenerateMiniaturesIfNeeded,
|
||||
updateLocalVideoMiniatureFromUrl,
|
||||
updateLocalVideoMiniatureFromExisting,
|
||||
updateRemoteVideoThumbnail,
|
||||
updateRemotePlaylistMiniatureFromUrl,
|
||||
updateLocalPlaylistMiniatureFromExisting
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
@ -1,17 +1,17 @@
|
|||
import { MutexInterface } from 'async-mutex'
|
||||
import { Job } from 'bullmq'
|
||||
import { ensureDir, move } from 'fs-extra/esm'
|
||||
import { join } from 'path'
|
||||
import { pick } from '@peertube/peertube-core-utils'
|
||||
import { getVideoStreamDuration, HLSFromTSTranscodeOptions, HLSTranscodeOptions } from '@peertube/peertube-ffmpeg'
|
||||
import { retryTransactionWrapper } from '@server/helpers/database-utils.js'
|
||||
import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent.js'
|
||||
import { sequelizeTypescript } from '@server/initializers/database.js'
|
||||
import { MVideo } from '@server/types/models/index.js'
|
||||
import { getVideoStreamDuration } from '@peertube/peertube-ffmpeg'
|
||||
import { MutexInterface } from 'async-mutex'
|
||||
import { Job } from 'bullmq'
|
||||
import { ensureDir, move } from 'fs-extra/esm'
|
||||
import { join } from 'path'
|
||||
import { CONFIG } from '../../initializers/config.js'
|
||||
import { VideoFileModel } from '../../models/video/video-file.js'
|
||||
import { VideoStreamingPlaylistModel } from '../../models/video/video-streaming-playlist.js'
|
||||
import { renameVideoFileInPlaylist, updatePlaylistAfterFileChange } from '../hls.js'
|
||||
import { renameVideoFileInPlaylist, updateM3U8AndShaPlaylist } from '../hls.js'
|
||||
import { generateHLSVideoFilename, getHlsResolutionPlaylistFilename } from '../paths.js'
|
||||
import { buildNewFile } from '../video-file.js'
|
||||
import { VideoPathManager } from '../video-path-manager.js'
|
||||
|
@ -28,7 +28,8 @@ export async function generateHlsPlaylistResolutionFromTS (options: {
|
|||
}) {
|
||||
return generateHlsPlaylistCommon({
|
||||
type: 'hls-from-ts' as 'hls-from-ts',
|
||||
inputPath: options.concatenatedTsFilePath,
|
||||
|
||||
videoInputPath: options.concatenatedTsFilePath,
|
||||
|
||||
...pick(options, [ 'video', 'resolution', 'fps', 'inputFileMutexReleaser', 'isAAC' ])
|
||||
})
|
||||
|
@ -37,18 +38,31 @@ export async function generateHlsPlaylistResolutionFromTS (options: {
|
|||
// Generate an HLS playlist from an input file, and update the master playlist
|
||||
export function generateHlsPlaylistResolution (options: {
|
||||
video: MVideo
|
||||
|
||||
videoInputPath: string
|
||||
separatedAudioInputPath: string
|
||||
|
||||
resolution: number
|
||||
fps: number
|
||||
copyCodecs: boolean
|
||||
inputFileMutexReleaser: MutexInterface.Releaser
|
||||
separatedAudio: boolean
|
||||
job?: Job
|
||||
}) {
|
||||
return generateHlsPlaylistCommon({
|
||||
type: 'hls' as 'hls',
|
||||
inputPath: options.videoInputPath,
|
||||
|
||||
...pick(options, [ 'video', 'resolution', 'fps', 'copyCodecs', 'inputFileMutexReleaser', 'job' ])
|
||||
...pick(options, [
|
||||
'videoInputPath',
|
||||
'separatedAudioInputPath',
|
||||
'video',
|
||||
'resolution',
|
||||
'fps',
|
||||
'copyCodecs',
|
||||
'separatedAudio',
|
||||
'inputFileMutexReleaser',
|
||||
'job'
|
||||
])
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -113,7 +127,7 @@ export async function onHLSVideoFileTranscoding (options: {
|
|||
|
||||
const savedVideoFile = await VideoFileModel.customUpsert(newVideoFile, 'streaming-playlist', undefined)
|
||||
|
||||
await updatePlaylistAfterFileChange(video, playlist)
|
||||
await updateM3U8AndShaPlaylist(video, playlist)
|
||||
|
||||
return { resolutionPlaylistPath, videoFile: savedVideoFile }
|
||||
} finally {
|
||||
|
@ -121,24 +135,43 @@ export async function onHLSVideoFileTranscoding (options: {
|
|||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function generateHlsPlaylistCommon (options: {
|
||||
type: 'hls' | 'hls-from-ts'
|
||||
video: MVideo
|
||||
inputPath: string
|
||||
|
||||
videoInputPath: string
|
||||
separatedAudioInputPath?: string
|
||||
|
||||
resolution: number
|
||||
fps: number
|
||||
|
||||
inputFileMutexReleaser: MutexInterface.Releaser
|
||||
|
||||
separatedAudio?: boolean
|
||||
|
||||
copyCodecs?: boolean
|
||||
isAAC?: boolean
|
||||
|
||||
job?: Job
|
||||
}) {
|
||||
const { type, video, inputPath, resolution, fps, copyCodecs, isAAC, job, inputFileMutexReleaser } = options
|
||||
const {
|
||||
type,
|
||||
video,
|
||||
videoInputPath,
|
||||
separatedAudioInputPath,
|
||||
resolution,
|
||||
fps,
|
||||
copyCodecs,
|
||||
separatedAudio,
|
||||
isAAC,
|
||||
job,
|
||||
inputFileMutexReleaser
|
||||
} = options
|
||||
|
||||
const transcodeDirectory = CONFIG.STORAGE.TMP_DIR
|
||||
|
||||
const videoTranscodedBasePath = join(transcodeDirectory, type)
|
||||
|
@ -150,15 +183,18 @@ async function generateHlsPlaylistCommon (options: {
|
|||
const resolutionPlaylistFilename = getHlsResolutionPlaylistFilename(videoFilename)
|
||||
const m3u8OutputPath = join(videoTranscodedBasePath, resolutionPlaylistFilename)
|
||||
|
||||
const transcodeOptions = {
|
||||
const transcodeOptions: HLSTranscodeOptions | HLSFromTSTranscodeOptions = {
|
||||
type,
|
||||
|
||||
inputPath,
|
||||
videoInputPath,
|
||||
separatedAudioInputPath,
|
||||
|
||||
outputPath: m3u8OutputPath,
|
||||
|
||||
resolution,
|
||||
fps,
|
||||
copyCodecs,
|
||||
separatedAudio,
|
||||
|
||||
isAAC,
|
||||
|
||||
|
|
|
@ -1,20 +1,283 @@
|
|||
import { ffprobePromise } from '@peertube/peertube-ffmpeg'
|
||||
import { VideoResolution } from '@peertube/peertube-models'
|
||||
import { computeOutputFPS } from '@server/helpers/ffmpeg/framerate.js'
|
||||
import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
|
||||
import { CONFIG } from '@server/initializers/config.js'
|
||||
import { DEFAULT_AUDIO_RESOLUTION, VIDEO_TRANSCODING_FPS } from '@server/initializers/constants.js'
|
||||
import { Hooks } from '@server/lib/plugins/hooks.js'
|
||||
import { VideoPathManager } from '@server/lib/video-path-manager.js'
|
||||
import { MUserId, MVideoFile, MVideoFullLight } from '@server/types/models/index.js'
|
||||
import { canDoQuickTranscode } from '../../transcoding-quick-transcode.js'
|
||||
import { buildOriginalFileResolution, computeResolutionsToTranscode } from '../../transcoding-resolutions.js'
|
||||
|
||||
export abstract class AbstractJobBuilder {
|
||||
const lTags = loggerTagsFactory('transcoding')
|
||||
|
||||
abstract createOptimizeOrMergeAudioJobs (options: {
|
||||
export abstract class AbstractJobBuilder <P> {
|
||||
|
||||
async createOptimizeOrMergeAudioJobs (options: {
|
||||
video: MVideoFullLight
|
||||
videoFile: MVideoFile
|
||||
isNewVideo: boolean
|
||||
user: MUserId
|
||||
videoFileAlreadyLocked: boolean
|
||||
}): Promise<any>
|
||||
}) {
|
||||
const { video, videoFile, isNewVideo, user, videoFileAlreadyLocked } = options
|
||||
|
||||
abstract createTranscodingJobs (options: {
|
||||
let mergeOrOptimizePayload: P
|
||||
let children: P[][] = []
|
||||
|
||||
const mutexReleaser = videoFileAlreadyLocked
|
||||
? () => {}
|
||||
: await VideoPathManager.Instance.lockFiles(video.uuid)
|
||||
|
||||
try {
|
||||
await video.reload()
|
||||
await videoFile.reload()
|
||||
|
||||
await VideoPathManager.Instance.makeAvailableVideoFile(videoFile.withVideoOrPlaylist(video), async videoFilePath => {
|
||||
const probe = await ffprobePromise(videoFilePath)
|
||||
const quickTranscode = await canDoQuickTranscode(videoFilePath, probe)
|
||||
|
||||
let inputFPS: number
|
||||
|
||||
let maxFPS: number
|
||||
let maxResolution: number
|
||||
|
||||
let hlsAudioAlreadyGenerated = false
|
||||
|
||||
if (videoFile.isAudio()) {
|
||||
inputFPS = maxFPS = VIDEO_TRANSCODING_FPS.AUDIO_MERGE // The first transcoding job will transcode to this FPS value
|
||||
maxResolution = DEFAULT_AUDIO_RESOLUTION
|
||||
|
||||
mergeOrOptimizePayload = this.buildMergeAudioPayload({
|
||||
video,
|
||||
isNewVideo,
|
||||
inputFile: videoFile,
|
||||
resolution: maxResolution,
|
||||
fps: maxFPS
|
||||
})
|
||||
} else {
|
||||
inputFPS = videoFile.fps
|
||||
maxResolution = buildOriginalFileResolution(videoFile.resolution)
|
||||
maxFPS = computeOutputFPS({ inputFPS, resolution: maxResolution })
|
||||
|
||||
mergeOrOptimizePayload = this.buildOptimizePayload({
|
||||
video,
|
||||
isNewVideo,
|
||||
quickTranscode,
|
||||
inputFile: videoFile,
|
||||
resolution: maxResolution,
|
||||
fps: maxFPS
|
||||
})
|
||||
}
|
||||
|
||||
// HLS version of max resolution
|
||||
if (CONFIG.TRANSCODING.HLS.ENABLED === true) {
|
||||
// We had some issues with a web video quick transcoded while producing a HLS version of it
|
||||
const copyCodecs = !quickTranscode
|
||||
|
||||
const hlsPayloads: P[] = []
|
||||
|
||||
hlsPayloads.push(
|
||||
this.buildHLSJobPayload({
|
||||
deleteWebVideoFiles: !CONFIG.TRANSCODING.HLS.SPLIT_AUDIO_AND_VIDEO && !CONFIG.TRANSCODING.WEB_VIDEOS.ENABLED,
|
||||
separatedAudio: CONFIG.TRANSCODING.HLS.SPLIT_AUDIO_AND_VIDEO,
|
||||
|
||||
copyCodecs,
|
||||
|
||||
resolution: maxResolution,
|
||||
fps: maxFPS,
|
||||
video,
|
||||
isNewVideo
|
||||
})
|
||||
)
|
||||
|
||||
if (CONFIG.TRANSCODING.HLS.SPLIT_AUDIO_AND_VIDEO && videoFile.hasAudio()) {
|
||||
hlsAudioAlreadyGenerated = true
|
||||
|
||||
hlsPayloads.push(
|
||||
this.buildHLSJobPayload({
|
||||
deleteWebVideoFiles: !CONFIG.TRANSCODING.WEB_VIDEOS.ENABLED,
|
||||
separatedAudio: CONFIG.TRANSCODING.HLS.SPLIT_AUDIO_AND_VIDEO,
|
||||
|
||||
copyCodecs,
|
||||
resolution: 0,
|
||||
fps: 0,
|
||||
video,
|
||||
isNewVideo
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
children.push(hlsPayloads)
|
||||
}
|
||||
|
||||
const lowerResolutionJobPayloads = await this.buildLowerResolutionJobPayloads({
|
||||
video,
|
||||
inputVideoResolution: maxResolution,
|
||||
inputVideoFPS: inputFPS,
|
||||
hasAudio: videoFile.hasAudio(),
|
||||
isNewVideo,
|
||||
hlsAudioAlreadyGenerated
|
||||
})
|
||||
|
||||
children = children.concat(lowerResolutionJobPayloads)
|
||||
})
|
||||
} finally {
|
||||
mutexReleaser()
|
||||
}
|
||||
|
||||
await this.createJobs({
|
||||
parent: mergeOrOptimizePayload,
|
||||
children,
|
||||
user,
|
||||
video
|
||||
})
|
||||
}
|
||||
|
||||
async createTranscodingJobs (options: {
|
||||
transcodingType: 'hls' | 'webtorrent' | 'web-video' // TODO: remove webtorrent in v7
|
||||
video: MVideoFullLight
|
||||
resolutions: number[]
|
||||
isNewVideo: boolean
|
||||
user: MUserId | null
|
||||
}): Promise<any>
|
||||
}) {
|
||||
const { video, transcodingType, resolutions, isNewVideo } = options
|
||||
const separatedAudio = CONFIG.TRANSCODING.HLS.SPLIT_AUDIO_AND_VIDEO
|
||||
|
||||
const maxResolution = Math.max(...resolutions)
|
||||
const childrenResolutions = resolutions.filter(r => r !== maxResolution)
|
||||
|
||||
logger.info('Manually creating transcoding jobs for %s.', transcodingType, { childrenResolutions, maxResolution, ...lTags(video.uuid) })
|
||||
|
||||
const inputFPS = video.getMaxFPS()
|
||||
|
||||
const children = childrenResolutions.map(resolution => {
|
||||
const fps = computeOutputFPS({ inputFPS, resolution })
|
||||
|
||||
if (transcodingType === 'hls') {
|
||||
return this.buildHLSJobPayload({ video, resolution, fps, isNewVideo, separatedAudio })
|
||||
}
|
||||
|
||||
if (transcodingType === 'webtorrent' || transcodingType === 'web-video') {
|
||||
return this.buildWebVideoJobPayload({ video, resolution, fps, isNewVideo })
|
||||
}
|
||||
|
||||
throw new Error('Unknown transcoding type')
|
||||
})
|
||||
|
||||
const fps = computeOutputFPS({ inputFPS, resolution: maxResolution })
|
||||
|
||||
const parent = transcodingType === 'hls'
|
||||
? this.buildHLSJobPayload({ video, resolution: maxResolution, fps, isNewVideo, separatedAudio })
|
||||
: this.buildWebVideoJobPayload({ video, resolution: maxResolution, fps, isNewVideo })
|
||||
|
||||
// Process the last resolution after the other ones to prevent concurrency issue
|
||||
// Because low resolutions use the biggest one as ffmpeg input
|
||||
await this.createJobs({ video, parent, children: [ children ], user: null })
|
||||
}
|
||||
|
||||
private async buildLowerResolutionJobPayloads (options: {
|
||||
video: MVideoFullLight
|
||||
inputVideoResolution: number
|
||||
inputVideoFPS: number
|
||||
hasAudio: boolean
|
||||
isNewVideo: boolean
|
||||
hlsAudioAlreadyGenerated: boolean
|
||||
}) {
|
||||
const { video, inputVideoResolution, inputVideoFPS, isNewVideo, hlsAudioAlreadyGenerated, hasAudio } = options
|
||||
|
||||
// Create transcoding jobs if there are enabled resolutions
|
||||
const resolutionsEnabled = await Hooks.wrapObject(
|
||||
computeResolutionsToTranscode({ input: inputVideoResolution, type: 'vod', includeInput: false, strictLower: true, hasAudio }),
|
||||
'filter:transcoding.auto.resolutions-to-transcode.result',
|
||||
options
|
||||
)
|
||||
|
||||
logger.debug('Lower resolutions built for %s.', video.uuid, { resolutionsEnabled, ...lTags(video.uuid) })
|
||||
|
||||
const sequentialPayloads: P[][] = []
|
||||
|
||||
for (const resolution of resolutionsEnabled) {
|
||||
const fps = computeOutputFPS({ inputFPS: inputVideoFPS, resolution })
|
||||
|
||||
let generateHLS = CONFIG.TRANSCODING.HLS.ENABLED
|
||||
if (resolution === VideoResolution.H_NOVIDEO && hlsAudioAlreadyGenerated) generateHLS = false
|
||||
|
||||
const parallelPayloads: P[] = []
|
||||
|
||||
if (CONFIG.TRANSCODING.WEB_VIDEOS.ENABLED) {
|
||||
parallelPayloads.push(
|
||||
this.buildWebVideoJobPayload({
|
||||
video,
|
||||
resolution,
|
||||
fps,
|
||||
isNewVideo
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// Create a subsequent job to create HLS resolution that will just copy web video codecs
|
||||
if (generateHLS) {
|
||||
parallelPayloads.push(
|
||||
this.buildHLSJobPayload({
|
||||
video,
|
||||
resolution,
|
||||
fps,
|
||||
isNewVideo,
|
||||
separatedAudio: CONFIG.TRANSCODING.HLS.SPLIT_AUDIO_AND_VIDEO,
|
||||
copyCodecs: CONFIG.TRANSCODING.WEB_VIDEOS.ENABLED
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
sequentialPayloads.push(parallelPayloads)
|
||||
}
|
||||
|
||||
return sequentialPayloads
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
protected abstract createJobs (options: {
|
||||
video: MVideoFullLight
|
||||
parent: P
|
||||
children: P[][]
|
||||
user: MUserId | null
|
||||
}): Promise<void>
|
||||
|
||||
protected abstract buildMergeAudioPayload (options: {
|
||||
video: MVideoFullLight
|
||||
inputFile: MVideoFile
|
||||
isNewVideo: boolean
|
||||
resolution: number
|
||||
fps: number
|
||||
}): P
|
||||
|
||||
protected abstract buildOptimizePayload (options: {
|
||||
video: MVideoFullLight
|
||||
isNewVideo: boolean
|
||||
quickTranscode: boolean
|
||||
inputFile: MVideoFile
|
||||
resolution: number
|
||||
fps: number
|
||||
}): P
|
||||
|
||||
protected abstract buildHLSJobPayload (options: {
|
||||
video: MVideoFullLight
|
||||
resolution: number
|
||||
fps: number
|
||||
isNewVideo: boolean
|
||||
separatedAudio: boolean
|
||||
deleteWebVideoFiles?: boolean // default false
|
||||
copyCodecs?: boolean // default false
|
||||
}): P
|
||||
|
||||
protected abstract buildWebVideoJobPayload (options: {
|
||||
video: MVideoFullLight
|
||||
resolution: number
|
||||
fps: number
|
||||
isNewVideo: boolean
|
||||
}): P
|
||||
|
||||
}
|
||||
|
|
|
@ -1,14 +1,3 @@
|
|||
import Bluebird from 'bluebird'
|
||||
import { computeOutputFPS } from '@server/helpers/ffmpeg/index.js'
|
||||
import { logger } from '@server/helpers/logger.js'
|
||||
import { CONFIG } from '@server/initializers/config.js'
|
||||
import { DEFAULT_AUDIO_RESOLUTION, VIDEO_TRANSCODING_FPS } from '@server/initializers/constants.js'
|
||||
import { CreateJobArgument, JobQueue } from '@server/lib/job-queue/index.js'
|
||||
import { Hooks } from '@server/lib/plugins/hooks.js'
|
||||
import { VideoPathManager } from '@server/lib/video-path-manager.js'
|
||||
import { VideoJobInfoModel } from '@server/models/video/video-job-info.js'
|
||||
import { MUserId, MVideoFile, MVideoFullLight, MVideoWithFileThumbnail } from '@server/types/models/index.js'
|
||||
import { ffprobePromise, getVideoStreamDimensionsInfo, getVideoStreamFPS, hasAudioStream, isAudioFile } from '@peertube/peertube-ffmpeg'
|
||||
import {
|
||||
HLSTranscodingPayload,
|
||||
MergeAudioTranscodingPayload,
|
||||
|
@ -16,83 +5,30 @@ import {
|
|||
OptimizeTranscodingPayload,
|
||||
VideoTranscodingPayload
|
||||
} from '@peertube/peertube-models'
|
||||
import { CreateJobArgument, JobQueue } from '@server/lib/job-queue/index.js'
|
||||
import { VideoJobInfoModel } from '@server/models/video/video-job-info.js'
|
||||
import { MUserId, MVideo } from '@server/types/models/index.js'
|
||||
import Bluebird from 'bluebird'
|
||||
import { getTranscodingJobPriority } from '../../transcoding-priority.js'
|
||||
import { canDoQuickTranscode } from '../../transcoding-quick-transcode.js'
|
||||
import { buildOriginalFileResolution, computeResolutionsToTranscode } from '../../transcoding-resolutions.js'
|
||||
import { AbstractJobBuilder } from './abstract-job-builder.js'
|
||||
|
||||
export class TranscodingJobQueueBuilder extends AbstractJobBuilder {
|
||||
type Payload =
|
||||
MergeAudioTranscodingPayload |
|
||||
OptimizeTranscodingPayload |
|
||||
NewWebVideoResolutionTranscodingPayload |
|
||||
HLSTranscodingPayload
|
||||
|
||||
async createOptimizeOrMergeAudioJobs (options: {
|
||||
video: MVideoFullLight
|
||||
videoFile: MVideoFile
|
||||
isNewVideo: boolean
|
||||
user: MUserId
|
||||
videoFileAlreadyLocked: boolean
|
||||
}) {
|
||||
const { video, videoFile, isNewVideo, user, videoFileAlreadyLocked } = options
|
||||
export class TranscodingJobQueueBuilder extends AbstractJobBuilder <Payload> {
|
||||
|
||||
let mergeOrOptimizePayload: MergeAudioTranscodingPayload | OptimizeTranscodingPayload
|
||||
let nextTranscodingSequentialJobPayloads: (NewWebVideoResolutionTranscodingPayload | HLSTranscodingPayload)[][] = []
|
||||
protected async createJobs (options: {
|
||||
video: MVideo
|
||||
parent: Payload
|
||||
children: Payload[][]
|
||||
user: MUserId | null
|
||||
}): Promise<void> {
|
||||
const { video, parent, children, user } = options
|
||||
|
||||
const mutexReleaser = videoFileAlreadyLocked
|
||||
? () => {}
|
||||
: await VideoPathManager.Instance.lockFiles(video.uuid)
|
||||
|
||||
try {
|
||||
await video.reload()
|
||||
await videoFile.reload()
|
||||
|
||||
await VideoPathManager.Instance.makeAvailableVideoFile(videoFile.withVideoOrPlaylist(video), async videoFilePath => {
|
||||
const probe = await ffprobePromise(videoFilePath)
|
||||
|
||||
const { resolution } = await getVideoStreamDimensionsInfo(videoFilePath, probe)
|
||||
const hasAudio = await hasAudioStream(videoFilePath, probe)
|
||||
const quickTranscode = await canDoQuickTranscode(videoFilePath, probe)
|
||||
const inputFPS = videoFile.isAudio()
|
||||
? VIDEO_TRANSCODING_FPS.AUDIO_MERGE // The first transcoding job will transcode to this FPS value
|
||||
: await getVideoStreamFPS(videoFilePath, probe)
|
||||
|
||||
const maxResolution = await isAudioFile(videoFilePath, probe)
|
||||
? DEFAULT_AUDIO_RESOLUTION
|
||||
: buildOriginalFileResolution(resolution)
|
||||
|
||||
if (CONFIG.TRANSCODING.HLS.ENABLED === true) {
|
||||
nextTranscodingSequentialJobPayloads.push([
|
||||
this.buildHLSJobPayload({
|
||||
deleteWebVideoFiles: CONFIG.TRANSCODING.WEB_VIDEOS.ENABLED === false,
|
||||
|
||||
// We had some issues with a web video quick transcoded while producing a HLS version of it
|
||||
copyCodecs: !quickTranscode,
|
||||
|
||||
resolution: maxResolution,
|
||||
fps: computeOutputFPS({ inputFPS, resolution: maxResolution }),
|
||||
videoUUID: video.uuid,
|
||||
isNewVideo
|
||||
})
|
||||
])
|
||||
}
|
||||
|
||||
const lowerResolutionJobPayloads = await this.buildLowerResolutionJobPayloads({
|
||||
video,
|
||||
inputVideoResolution: maxResolution,
|
||||
inputVideoFPS: inputFPS,
|
||||
hasAudio,
|
||||
isNewVideo
|
||||
})
|
||||
|
||||
nextTranscodingSequentialJobPayloads = [ ...nextTranscodingSequentialJobPayloads, ...lowerResolutionJobPayloads ]
|
||||
|
||||
const hasChildren = nextTranscodingSequentialJobPayloads.length !== 0
|
||||
mergeOrOptimizePayload = videoFile.isAudio()
|
||||
? this.buildMergeAudioPayload({ videoUUID: video.uuid, isNewVideo, hasChildren })
|
||||
: this.buildOptimizePayload({ videoUUID: video.uuid, isNewVideo, quickTranscode, hasChildren })
|
||||
})
|
||||
} finally {
|
||||
mutexReleaser()
|
||||
}
|
||||
|
||||
const nextTranscodingSequentialJobs = await Bluebird.mapSeries(nextTranscodingSequentialJobPayloads, payloads => {
|
||||
const nextTranscodingSequentialJobs = await Bluebird.mapSeries(children, payloads => {
|
||||
return Bluebird.mapSeries(payloads, payload => {
|
||||
return this.buildTranscodingJob({ payload, user })
|
||||
})
|
||||
|
@ -106,217 +42,109 @@ export class TranscodingJobQueueBuilder extends AbstractJobBuilder {
|
|||
}
|
||||
}
|
||||
|
||||
const mergeOrOptimizeJob = await this.buildTranscodingJob({ payload: mergeOrOptimizePayload, user })
|
||||
const mergeOrOptimizeJob = await this.buildTranscodingJob({ payload: parent, user, hasChildren: !!children.length })
|
||||
|
||||
await JobQueue.Instance.createSequentialJobFlow(...[ mergeOrOptimizeJob, transcodingJobBuilderJob ])
|
||||
await JobQueue.Instance.createSequentialJobFlow(mergeOrOptimizeJob, transcodingJobBuilderJob)
|
||||
|
||||
// transcoding-job-builder job will increase pendingTranscode
|
||||
await VideoJobInfoModel.increaseOrCreate(video.uuid, 'pendingTranscode')
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async createTranscodingJobs (options: {
|
||||
transcodingType: 'hls' | 'webtorrent' | 'web-video' // TODO: remove webtorrent in v7
|
||||
video: MVideoFullLight
|
||||
resolutions: number[]
|
||||
isNewVideo: boolean
|
||||
user: MUserId | null
|
||||
}) {
|
||||
const { video, transcodingType, resolutions, isNewVideo } = options
|
||||
|
||||
const maxResolution = Math.max(...resolutions)
|
||||
const childrenResolutions = resolutions.filter(r => r !== maxResolution)
|
||||
|
||||
logger.info('Manually creating transcoding jobs for %s.', transcodingType, { childrenResolutions, maxResolution })
|
||||
|
||||
const { fps: inputFPS } = await video.probeMaxQualityFile()
|
||||
|
||||
const children = childrenResolutions.map(resolution => {
|
||||
const fps = computeOutputFPS({ inputFPS, resolution })
|
||||
|
||||
if (transcodingType === 'hls') {
|
||||
return this.buildHLSJobPayload({ videoUUID: video.uuid, resolution, fps, isNewVideo })
|
||||
}
|
||||
|
||||
if (transcodingType === 'webtorrent' || transcodingType === 'web-video') {
|
||||
return this.buildWebVideoJobPayload({ videoUUID: video.uuid, resolution, fps, isNewVideo })
|
||||
}
|
||||
|
||||
throw new Error('Unknown transcoding type')
|
||||
})
|
||||
|
||||
const fps = computeOutputFPS({ inputFPS, resolution: maxResolution })
|
||||
|
||||
const parent = transcodingType === 'hls'
|
||||
? this.buildHLSJobPayload({ videoUUID: video.uuid, resolution: maxResolution, fps, isNewVideo })
|
||||
: this.buildWebVideoJobPayload({ videoUUID: video.uuid, resolution: maxResolution, fps, isNewVideo })
|
||||
|
||||
// Process the last resolution after the other ones to prevent concurrency issue
|
||||
// Because low resolutions use the biggest one as ffmpeg input
|
||||
await this.createTranscodingJobsWithChildren({ videoUUID: video.uuid, parent, children, user: null })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private async createTranscodingJobsWithChildren (options: {
|
||||
videoUUID: string
|
||||
parent: (HLSTranscodingPayload | NewWebVideoResolutionTranscodingPayload)
|
||||
children: (HLSTranscodingPayload | NewWebVideoResolutionTranscodingPayload)[]
|
||||
user: MUserId | null
|
||||
}) {
|
||||
const { videoUUID, parent, children, user } = options
|
||||
|
||||
const parentJob = await this.buildTranscodingJob({ payload: parent, user })
|
||||
const childrenJobs = await Bluebird.mapSeries(children, c => this.buildTranscodingJob({ payload: c, user }))
|
||||
|
||||
await JobQueue.Instance.createJobWithChildren(parentJob, childrenJobs)
|
||||
|
||||
await VideoJobInfoModel.increaseOrCreate(videoUUID, 'pendingTranscode', 1 + children.length)
|
||||
}
|
||||
|
||||
private async buildTranscodingJob (options: {
|
||||
payload: VideoTranscodingPayload
|
||||
hasChildren?: boolean
|
||||
user: MUserId | null // null means we don't want priority
|
||||
}) {
|
||||
const { user, payload } = options
|
||||
const { user, payload, hasChildren = false } = options
|
||||
|
||||
return {
|
||||
type: 'video-transcoding' as 'video-transcoding',
|
||||
priority: await getTranscodingJobPriority({ user, type: 'vod', fallback: undefined }),
|
||||
payload
|
||||
payload: { ...payload, hasChildren }
|
||||
}
|
||||
}
|
||||
|
||||
private async buildLowerResolutionJobPayloads (options: {
|
||||
video: MVideoWithFileThumbnail
|
||||
inputVideoResolution: number
|
||||
inputVideoFPS: number
|
||||
hasAudio: boolean
|
||||
isNewVideo: boolean
|
||||
}) {
|
||||
const { video, inputVideoResolution, inputVideoFPS, isNewVideo, hasAudio } = options
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Create transcoding jobs if there are enabled resolutions
|
||||
const resolutionsEnabled = await Hooks.wrapObject(
|
||||
computeResolutionsToTranscode({ input: inputVideoResolution, type: 'vod', includeInput: false, strictLower: true, hasAudio }),
|
||||
'filter:transcoding.auto.resolutions-to-transcode.result',
|
||||
options
|
||||
)
|
||||
|
||||
const sequentialPayloads: (NewWebVideoResolutionTranscodingPayload | HLSTranscodingPayload)[][] = []
|
||||
|
||||
for (const resolution of resolutionsEnabled) {
|
||||
const fps = computeOutputFPS({ inputFPS: inputVideoFPS, resolution })
|
||||
|
||||
if (CONFIG.TRANSCODING.WEB_VIDEOS.ENABLED) {
|
||||
const payloads: (NewWebVideoResolutionTranscodingPayload | HLSTranscodingPayload)[] = [
|
||||
this.buildWebVideoJobPayload({
|
||||
videoUUID: video.uuid,
|
||||
resolution,
|
||||
fps,
|
||||
isNewVideo
|
||||
})
|
||||
]
|
||||
|
||||
// Create a subsequent job to create HLS resolution that will just copy web video codecs
|
||||
if (CONFIG.TRANSCODING.HLS.ENABLED) {
|
||||
payloads.push(
|
||||
this.buildHLSJobPayload({
|
||||
videoUUID: video.uuid,
|
||||
resolution,
|
||||
fps,
|
||||
isNewVideo,
|
||||
copyCodecs: true
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
sequentialPayloads.push(payloads)
|
||||
} else if (CONFIG.TRANSCODING.HLS.ENABLED) {
|
||||
sequentialPayloads.push([
|
||||
this.buildHLSJobPayload({
|
||||
videoUUID: video.uuid,
|
||||
resolution,
|
||||
fps,
|
||||
copyCodecs: false,
|
||||
isNewVideo
|
||||
})
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
return sequentialPayloads
|
||||
}
|
||||
|
||||
private buildHLSJobPayload (options: {
|
||||
videoUUID: string
|
||||
protected buildHLSJobPayload (options: {
|
||||
video: MVideo
|
||||
resolution: number
|
||||
fps: number
|
||||
isNewVideo: boolean
|
||||
separatedAudio: boolean
|
||||
deleteWebVideoFiles?: boolean // default false
|
||||
copyCodecs?: boolean // default false
|
||||
}): HLSTranscodingPayload {
|
||||
const { videoUUID, resolution, fps, isNewVideo, deleteWebVideoFiles = false, copyCodecs = false } = options
|
||||
const { video, resolution, fps, isNewVideo, separatedAudio, deleteWebVideoFiles = false, copyCodecs = false } = options
|
||||
|
||||
return {
|
||||
type: 'new-resolution-to-hls',
|
||||
videoUUID,
|
||||
videoUUID: video.uuid,
|
||||
resolution,
|
||||
fps,
|
||||
copyCodecs,
|
||||
isNewVideo,
|
||||
separatedAudio,
|
||||
deleteWebVideoFiles
|
||||
}
|
||||
}
|
||||
|
||||
private buildWebVideoJobPayload (options: {
|
||||
videoUUID: string
|
||||
protected buildWebVideoJobPayload (options: {
|
||||
video: MVideo
|
||||
resolution: number
|
||||
fps: number
|
||||
isNewVideo: boolean
|
||||
}): NewWebVideoResolutionTranscodingPayload {
|
||||
const { videoUUID, resolution, fps, isNewVideo } = options
|
||||
const { video, resolution, fps, isNewVideo } = options
|
||||
|
||||
return {
|
||||
type: 'new-resolution-to-web-video',
|
||||
videoUUID,
|
||||
videoUUID: video.uuid,
|
||||
isNewVideo,
|
||||
resolution,
|
||||
fps
|
||||
}
|
||||
}
|
||||
|
||||
private buildMergeAudioPayload (options: {
|
||||
videoUUID: string
|
||||
protected buildMergeAudioPayload (options: {
|
||||
video: MVideo
|
||||
isNewVideo: boolean
|
||||
hasChildren: boolean
|
||||
fps: number
|
||||
resolution: number
|
||||
}): MergeAudioTranscodingPayload {
|
||||
const { videoUUID, isNewVideo, hasChildren } = options
|
||||
const { video, isNewVideo, resolution, fps } = options
|
||||
|
||||
return {
|
||||
type: 'merge-audio-to-web-video',
|
||||
resolution: DEFAULT_AUDIO_RESOLUTION,
|
||||
fps: VIDEO_TRANSCODING_FPS.AUDIO_MERGE,
|
||||
videoUUID,
|
||||
isNewVideo,
|
||||
hasChildren
|
||||
resolution,
|
||||
fps,
|
||||
videoUUID: video.uuid,
|
||||
|
||||
// Will be set later
|
||||
hasChildren: undefined,
|
||||
|
||||
isNewVideo
|
||||
}
|
||||
}
|
||||
|
||||
private buildOptimizePayload (options: {
|
||||
videoUUID: string
|
||||
protected buildOptimizePayload (options: {
|
||||
video: MVideo
|
||||
quickTranscode: boolean
|
||||
isNewVideo: boolean
|
||||
hasChildren: boolean
|
||||
}): OptimizeTranscodingPayload {
|
||||
const { videoUUID, quickTranscode, isNewVideo, hasChildren } = options
|
||||
const { video, quickTranscode, isNewVideo } = options
|
||||
|
||||
return {
|
||||
type: 'optimize-to-web-video',
|
||||
videoUUID,
|
||||
|
||||
videoUUID: video.uuid,
|
||||
isNewVideo,
|
||||
hasChildren,
|
||||
|
||||
// Will be set later
|
||||
hasChildren: undefined,
|
||||
|
||||
quickTranscode
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -1,19 +1,11 @@
|
|||
import { ffprobePromise, getVideoStreamDimensionsInfo, getVideoStreamFPS, hasAudioStream, isAudioFile } from '@peertube/peertube-ffmpeg'
|
||||
import { computeOutputFPS } from '@server/helpers/ffmpeg/index.js'
|
||||
import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
|
||||
import { CONFIG } from '@server/initializers/config.js'
|
||||
import { DEFAULT_AUDIO_RESOLUTION, VIDEO_TRANSCODING_FPS } from '@server/initializers/constants.js'
|
||||
import { Hooks } from '@server/lib/plugins/hooks.js'
|
||||
import {
|
||||
VODAudioMergeTranscodingJobHandler,
|
||||
VODHLSTranscodingJobHandler,
|
||||
VODWebVideoTranscodingJobHandler
|
||||
} from '@server/lib/runners/index.js'
|
||||
import { VideoPathManager } from '@server/lib/video-path-manager.js'
|
||||
import { MUserId, MVideoFile, MVideoFullLight, MVideoWithFileThumbnail } from '@server/types/models/index.js'
|
||||
import { MRunnerJob } from '@server/types/models/runners/index.js'
|
||||
} from '@server/lib/runners/job-handlers/index.js'
|
||||
import { MUserId, MVideo, MVideoFile, MVideoFullLight } from '@server/types/models/index.js'
|
||||
import { MRunnerJob } from '@server/types/models/runners/runner-job.js'
|
||||
import { getTranscodingJobPriority } from '../../transcoding-priority.js'
|
||||
import { buildOriginalFileResolution, computeResolutionsToTranscode } from '../../transcoding-resolutions.js'
|
||||
import { AbstractJobBuilder } from './abstract-job-builder.js'
|
||||
|
||||
/**
|
||||
|
@ -22,185 +14,150 @@ import { AbstractJobBuilder } from './abstract-job-builder.js'
|
|||
*
|
||||
*/
|
||||
|
||||
const lTags = loggerTagsFactory('transcoding')
|
||||
type Payload = {
|
||||
Builder: new () => VODHLSTranscodingJobHandler
|
||||
options: Omit<Parameters<VODHLSTranscodingJobHandler['create']>[0], 'priority'>
|
||||
} | {
|
||||
Builder: new () => VODAudioMergeTranscodingJobHandler
|
||||
options: Omit<Parameters<VODAudioMergeTranscodingJobHandler['create']>[0], 'priority'>
|
||||
} |
|
||||
{
|
||||
Builder: new () => VODWebVideoTranscodingJobHandler
|
||||
options: Omit<Parameters<VODWebVideoTranscodingJobHandler['create']>[0], 'priority'>
|
||||
}
|
||||
|
||||
export class TranscodingRunnerJobBuilder extends AbstractJobBuilder {
|
||||
// eslint-disable-next-line max-len
|
||||
export class TranscodingRunnerJobBuilder extends AbstractJobBuilder <Payload> {
|
||||
|
||||
async createOptimizeOrMergeAudioJobs (options: {
|
||||
video: MVideoFullLight
|
||||
videoFile: MVideoFile
|
||||
isNewVideo: boolean
|
||||
user: MUserId
|
||||
videoFileAlreadyLocked: boolean
|
||||
}) {
|
||||
const { video, videoFile, isNewVideo, user, videoFileAlreadyLocked } = options
|
||||
protected async createJobs (options: {
|
||||
video: MVideo
|
||||
parent: Payload
|
||||
children: Payload[][] // Array of sequential jobs to create that depend on parent job
|
||||
user: MUserId | null
|
||||
}): Promise<void> {
|
||||
const { parent, children, user } = options
|
||||
|
||||
const mutexReleaser = videoFileAlreadyLocked
|
||||
? () => {}
|
||||
: await VideoPathManager.Instance.lockFiles(video.uuid)
|
||||
const parentJob = await this.createJob({ payload: parent, user })
|
||||
|
||||
try {
|
||||
await video.reload()
|
||||
await videoFile.reload()
|
||||
for (const parallelPayloads of children) {
|
||||
let lastJob = parentJob
|
||||
|
||||
await VideoPathManager.Instance.makeAvailableVideoFile(videoFile.withVideoOrPlaylist(video), async videoFilePath => {
|
||||
const probe = await ffprobePromise(videoFilePath)
|
||||
|
||||
const { resolution } = await getVideoStreamDimensionsInfo(videoFilePath, probe)
|
||||
const hasAudio = await hasAudioStream(videoFilePath, probe)
|
||||
const inputFPS = videoFile.isAudio()
|
||||
? VIDEO_TRANSCODING_FPS.AUDIO_MERGE // The first transcoding job will transcode to this FPS value
|
||||
: await getVideoStreamFPS(videoFilePath, probe)
|
||||
|
||||
const isAudioInput = await isAudioFile(videoFilePath, probe)
|
||||
const maxResolution = isAudioInput
|
||||
? DEFAULT_AUDIO_RESOLUTION
|
||||
: buildOriginalFileResolution(resolution)
|
||||
|
||||
const fps = computeOutputFPS({ inputFPS, resolution: maxResolution })
|
||||
const priority = await getTranscodingJobPriority({ user, type: 'vod', fallback: 0 })
|
||||
|
||||
const jobPayload = { video, resolution: maxResolution, fps, isNewVideo, priority, deleteInputFileId: videoFile.id }
|
||||
|
||||
const mainRunnerJob = videoFile.isAudio()
|
||||
? await new VODAudioMergeTranscodingJobHandler().create(jobPayload)
|
||||
: await new VODWebVideoTranscodingJobHandler().create(jobPayload)
|
||||
|
||||
if (CONFIG.TRANSCODING.HLS.ENABLED === true) {
|
||||
await new VODHLSTranscodingJobHandler().create({
|
||||
video,
|
||||
deleteWebVideoFiles: CONFIG.TRANSCODING.WEB_VIDEOS.ENABLED === false,
|
||||
resolution: maxResolution,
|
||||
fps,
|
||||
isNewVideo,
|
||||
dependsOnRunnerJob: mainRunnerJob,
|
||||
priority: await getTranscodingJobPriority({ user, type: 'vod', fallback: 0 })
|
||||
})
|
||||
}
|
||||
|
||||
await this.buildLowerResolutionJobPayloads({
|
||||
video,
|
||||
inputVideoResolution: maxResolution,
|
||||
inputVideoFPS: inputFPS,
|
||||
hasAudio,
|
||||
isNewVideo,
|
||||
mainRunnerJob,
|
||||
for (const parallelPayload of parallelPayloads) {
|
||||
lastJob = await this.createJob({
|
||||
payload: parallelPayload,
|
||||
dependsOnRunnerJob: lastJob,
|
||||
user
|
||||
})
|
||||
})
|
||||
} finally {
|
||||
mutexReleaser()
|
||||
}
|
||||
|
||||
lastJob = undefined
|
||||
}
|
||||
}
|
||||
|
||||
private async createJob (options: {
|
||||
payload: Payload
|
||||
user: MUserId | null
|
||||
dependsOnRunnerJob?: MRunnerJob
|
||||
}) {
|
||||
const { dependsOnRunnerJob, payload, user } = options
|
||||
|
||||
const builder = new payload.Builder()
|
||||
|
||||
return builder.create({
|
||||
...(payload.options as any), // FIXME: typings
|
||||
|
||||
dependsOnRunnerJob,
|
||||
priority: await getTranscodingJobPriority({ user, type: 'vod', fallback: 0 })
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async createTranscodingJobs (options: {
|
||||
transcodingType: 'hls' | 'webtorrent' | 'web-video' // TODO: remove webtorrent in v7
|
||||
protected buildHLSJobPayload (options: {
|
||||
video: MVideoFullLight
|
||||
resolutions: number[]
|
||||
resolution: number
|
||||
fps: number
|
||||
isNewVideo: boolean
|
||||
user: MUserId | null
|
||||
}) {
|
||||
const { video, transcodingType, resolutions, isNewVideo, user } = options
|
||||
separatedAudio: boolean
|
||||
deleteWebVideoFiles?: boolean // default false
|
||||
copyCodecs?: boolean // default false
|
||||
}): Payload {
|
||||
const { video, resolution, fps, isNewVideo, separatedAudio, deleteWebVideoFiles = false } = options
|
||||
|
||||
const maxResolution = Math.max(...resolutions)
|
||||
const { fps: inputFPS } = await video.probeMaxQualityFile()
|
||||
const maxFPS = computeOutputFPS({ inputFPS, resolution: maxResolution })
|
||||
const priority = await getTranscodingJobPriority({ user, type: 'vod', fallback: 0 })
|
||||
return {
|
||||
Builder: VODHLSTranscodingJobHandler,
|
||||
|
||||
const childrenResolutions = resolutions.filter(r => r !== maxResolution)
|
||||
|
||||
logger.info('Manually creating transcoding jobs for %s.', transcodingType, { childrenResolutions, maxResolution })
|
||||
|
||||
const jobPayload = { video, resolution: maxResolution, fps: maxFPS, isNewVideo, priority, deleteInputFileId: null }
|
||||
|
||||
// Process the last resolution before the other ones to prevent concurrency issue
|
||||
// Because low resolutions use the biggest one as ffmpeg input
|
||||
const mainJob = transcodingType === 'hls'
|
||||
// eslint-disable-next-line max-len
|
||||
? await new VODHLSTranscodingJobHandler().create({ ...jobPayload, deleteWebVideoFiles: false })
|
||||
: await new VODWebVideoTranscodingJobHandler().create(jobPayload)
|
||||
|
||||
for (const resolution of childrenResolutions) {
|
||||
const dependsOnRunnerJob = mainJob
|
||||
const fps = computeOutputFPS({ inputFPS, resolution })
|
||||
|
||||
if (transcodingType === 'hls') {
|
||||
await new VODHLSTranscodingJobHandler().create({
|
||||
video,
|
||||
resolution,
|
||||
fps,
|
||||
isNewVideo,
|
||||
deleteWebVideoFiles: false,
|
||||
dependsOnRunnerJob,
|
||||
priority: await getTranscodingJobPriority({ user, type: 'vod', fallback: 0 })
|
||||
})
|
||||
continue
|
||||
options: {
|
||||
video,
|
||||
resolution,
|
||||
fps,
|
||||
isNewVideo,
|
||||
separatedAudio,
|
||||
deleteWebVideoFiles
|
||||
}
|
||||
|
||||
if (transcodingType === 'webtorrent' || transcodingType === 'web-video') {
|
||||
await new VODWebVideoTranscodingJobHandler().create({
|
||||
video,
|
||||
resolution,
|
||||
fps,
|
||||
isNewVideo,
|
||||
dependsOnRunnerJob,
|
||||
deleteInputFileId: null,
|
||||
priority: await getTranscodingJobPriority({ user, type: 'vod', fallback: 0 })
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
throw new Error('Unknown transcoding type')
|
||||
}
|
||||
}
|
||||
|
||||
private async buildLowerResolutionJobPayloads (options: {
|
||||
mainRunnerJob: MRunnerJob
|
||||
video: MVideoWithFileThumbnail
|
||||
inputVideoResolution: number
|
||||
inputVideoFPS: number
|
||||
hasAudio: boolean
|
||||
protected buildWebVideoJobPayload (options: {
|
||||
video: MVideoFullLight
|
||||
resolution: number
|
||||
fps: number
|
||||
isNewVideo: boolean
|
||||
user: MUserId
|
||||
}) {
|
||||
const { video, inputVideoResolution, inputVideoFPS, isNewVideo, hasAudio, mainRunnerJob, user } = options
|
||||
}): Payload {
|
||||
const { video, resolution, fps, isNewVideo } = options
|
||||
|
||||
// Create transcoding jobs if there are enabled resolutions
|
||||
const resolutionsEnabled = await Hooks.wrapObject(
|
||||
computeResolutionsToTranscode({ input: inputVideoResolution, type: 'vod', includeInput: false, strictLower: true, hasAudio }),
|
||||
'filter:transcoding.auto.resolutions-to-transcode.result',
|
||||
options
|
||||
)
|
||||
return {
|
||||
Builder: VODWebVideoTranscodingJobHandler,
|
||||
|
||||
logger.debug('Lower resolutions build for %s.', video.uuid, { resolutionsEnabled, ...lTags(video.uuid) })
|
||||
|
||||
for (const resolution of resolutionsEnabled) {
|
||||
const fps = computeOutputFPS({ inputFPS: inputVideoFPS, resolution })
|
||||
|
||||
if (CONFIG.TRANSCODING.WEB_VIDEOS.ENABLED) {
|
||||
await new VODWebVideoTranscodingJobHandler().create({
|
||||
video,
|
||||
resolution,
|
||||
fps,
|
||||
isNewVideo,
|
||||
dependsOnRunnerJob: mainRunnerJob,
|
||||
deleteInputFileId: null,
|
||||
priority: await getTranscodingJobPriority({ user, type: 'vod', fallback: 0 })
|
||||
})
|
||||
options: {
|
||||
video,
|
||||
resolution,
|
||||
fps,
|
||||
isNewVideo,
|
||||
deleteInputFileId: null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (CONFIG.TRANSCODING.HLS.ENABLED) {
|
||||
await new VODHLSTranscodingJobHandler().create({
|
||||
video,
|
||||
resolution,
|
||||
fps,
|
||||
isNewVideo,
|
||||
deleteWebVideoFiles: false,
|
||||
dependsOnRunnerJob: mainRunnerJob,
|
||||
priority: await getTranscodingJobPriority({ user, type: 'vod', fallback: 0 })
|
||||
})
|
||||
protected buildMergeAudioPayload (options: {
|
||||
video: MVideoFullLight
|
||||
inputFile: MVideoFile
|
||||
isNewVideo: boolean
|
||||
fps: number
|
||||
resolution: number
|
||||
}): Payload {
|
||||
const { video, isNewVideo, inputFile, resolution, fps } = options
|
||||
|
||||
return {
|
||||
Builder: VODAudioMergeTranscodingJobHandler,
|
||||
options: {
|
||||
video,
|
||||
resolution,
|
||||
fps,
|
||||
isNewVideo,
|
||||
deleteInputFileId: inputFile.id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected buildOptimizePayload (options: {
|
||||
video: MVideoFullLight
|
||||
inputFile: MVideoFile
|
||||
quickTranscode: boolean
|
||||
isNewVideo: boolean
|
||||
fps: number
|
||||
resolution: number
|
||||
}): Payload {
|
||||
const { video, isNewVideo, inputFile, fps, resolution } = options
|
||||
|
||||
return {
|
||||
Builder: VODWebVideoTranscodingJobHandler,
|
||||
options: {
|
||||
video,
|
||||
resolution,
|
||||
fps,
|
||||
isNewVideo,
|
||||
deleteInputFileId: inputFile.id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,11 @@
|
|||
import { buildAspectRatio } from '@peertube/peertube-core-utils'
|
||||
import { TranscodeVODOptionsType, getVideoStreamDuration } from '@peertube/peertube-ffmpeg'
|
||||
import {
|
||||
MergeAudioTranscodeOptions,
|
||||
TranscodeVODOptionsType,
|
||||
VideoTranscodeOptions,
|
||||
getVideoStreamDuration
|
||||
} from '@peertube/peertube-ffmpeg'
|
||||
import { VideoFileStream } from '@peertube/peertube-models'
|
||||
import { computeOutputFPS } from '@server/helpers/ffmpeg/index.js'
|
||||
import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent.js'
|
||||
import { VideoModel } from '@server/models/video/video.js'
|
||||
|
@ -21,25 +27,22 @@ import { buildOriginalFileResolution } from './transcoding-resolutions.js'
|
|||
// Optimize the original video file and replace it. The resolution is not changed.
|
||||
export async function optimizeOriginalVideofile (options: {
|
||||
video: MVideoFullLight
|
||||
inputVideoFile: MVideoFile
|
||||
quickTranscode: boolean
|
||||
job: Job
|
||||
}) {
|
||||
const { video, inputVideoFile, quickTranscode, job } = options
|
||||
const { quickTranscode, job } = options
|
||||
|
||||
const transcodeDirectory = CONFIG.STORAGE.TMP_DIR
|
||||
const newExtname = '.mp4'
|
||||
|
||||
// Will be released by our transcodeVOD function once ffmpeg is ran
|
||||
const inputFileMutexReleaser = await VideoPathManager.Instance.lockFiles(video.uuid)
|
||||
const inputFileMutexReleaser = await VideoPathManager.Instance.lockFiles(options.video.uuid)
|
||||
|
||||
try {
|
||||
await video.reload()
|
||||
await inputVideoFile.reload()
|
||||
const video = await VideoModel.loadFull(options.video.id)
|
||||
const inputVideoFile = video.getMaxQualityFile(VideoFileStream.VIDEO)
|
||||
|
||||
const fileWithVideoOrPlaylist = inputVideoFile.withVideoOrPlaylist(video)
|
||||
|
||||
const result = await VideoPathManager.Instance.makeAvailableVideoFile(fileWithVideoOrPlaylist, async videoInputPath => {
|
||||
const result = await VideoPathManager.Instance.makeAvailableVideoFile(inputVideoFile, async videoInputPath => {
|
||||
const videoOutputPath = join(transcodeDirectory, video.id + '-transcoded' + newExtname)
|
||||
|
||||
const transcodeType: TranscodeVODOptionsType = quickTranscode
|
||||
|
@ -53,7 +56,7 @@ export async function optimizeOriginalVideofile (options: {
|
|||
await buildFFmpegVOD(job).transcode({
|
||||
type: transcodeType,
|
||||
|
||||
inputPath: videoInputPath,
|
||||
videoInputPath,
|
||||
outputPath: videoOutputPath,
|
||||
|
||||
inputFileMutexReleaser,
|
||||
|
@ -89,16 +92,17 @@ export async function transcodeNewWebVideoResolution (options: {
|
|||
|
||||
try {
|
||||
const video = await VideoModel.loadFull(videoArg.uuid)
|
||||
const file = video.getMaxQualityFile().withVideoOrPlaylist(video)
|
||||
|
||||
const result = await VideoPathManager.Instance.makeAvailableVideoFile(file, async videoInputPath => {
|
||||
const result = await VideoPathManager.Instance.makeAvailableMaxQualityFiles(video, async ({ videoPath, separatedAudioPath }) => {
|
||||
const filename = generateWebVideoFilename(resolution, newExtname)
|
||||
const videoOutputPath = join(transcodeDirectory, filename)
|
||||
|
||||
const transcodeOptions = {
|
||||
type: 'video' as 'video',
|
||||
const transcodeOptions: VideoTranscodeOptions = {
|
||||
type: 'video',
|
||||
|
||||
videoInputPath: videoPath,
|
||||
separatedAudioInputPath: separatedAudioPath,
|
||||
|
||||
inputPath: videoInputPath,
|
||||
outputPath: videoOutputPath,
|
||||
|
||||
inputFileMutexReleaser,
|
||||
|
@ -134,11 +138,9 @@ export async function mergeAudioVideofile (options: {
|
|||
|
||||
try {
|
||||
const video = await VideoModel.loadFull(videoArg.uuid)
|
||||
const inputVideoFile = video.getMinQualityFile()
|
||||
const inputVideoFile = video.getMaxQualityFile(VideoFileStream.AUDIO)
|
||||
|
||||
const fileWithVideoOrPlaylist = inputVideoFile.withVideoOrPlaylist(video)
|
||||
|
||||
const result = await VideoPathManager.Instance.makeAvailableVideoFile(fileWithVideoOrPlaylist, async audioInputPath => {
|
||||
const result = await VideoPathManager.Instance.makeAvailableVideoFile(inputVideoFile, async audioInputPath => {
|
||||
const videoOutputPath = join(transcodeDirectory, video.id + '-transcoded' + newExtname)
|
||||
|
||||
// If the user updates the video preview during transcoding
|
||||
|
@ -146,15 +148,16 @@ export async function mergeAudioVideofile (options: {
|
|||
const tmpPreviewPath = join(CONFIG.STORAGE.TMP_DIR, basename(previewPath))
|
||||
await copyFile(previewPath, tmpPreviewPath)
|
||||
|
||||
const transcodeOptions = {
|
||||
type: 'merge-audio' as 'merge-audio',
|
||||
const transcodeOptions: MergeAudioTranscodeOptions = {
|
||||
type: 'merge-audio',
|
||||
|
||||
videoInputPath: tmpPreviewPath,
|
||||
audioPath: audioInputPath,
|
||||
|
||||
inputPath: tmpPreviewPath,
|
||||
outputPath: videoOutputPath,
|
||||
|
||||
inputFileMutexReleaser,
|
||||
|
||||
audioPath: audioInputPath,
|
||||
resolution,
|
||||
fps
|
||||
}
|
||||
|
|
|
@ -8,7 +8,7 @@ export type ExportResult <T> = {
|
|||
|
||||
staticFiles: {
|
||||
archivePath: string
|
||||
createrReadStream: () => Promise<Readable>
|
||||
readStreamFactory: () => Promise<Readable>
|
||||
}[]
|
||||
|
||||
activityPub?: ActivityPubActor | ActivityPubOrderedCollection<string>
|
||||
|
|
|
@ -59,7 +59,7 @@ export abstract class ActorExporter <T> extends AbstractUserExporter<T> {
|
|||
|
||||
staticFiles.push({
|
||||
archivePath: archivePathBuilder(image.filename),
|
||||
createrReadStream: () => Promise.resolve(createReadStream(image.getPath()))
|
||||
readStreamFactory: () => Promise.resolve(createReadStream(image.getPath()))
|
||||
})
|
||||
|
||||
const relativePath = join(this.relativeStaticDirPath, archivePathBuilder(image.filename))
|
||||
|
|
|
@ -26,7 +26,7 @@ export class VideoPlaylistsExporter extends AbstractUserExporter <VideoPlaylists
|
|||
|
||||
staticFiles.push({
|
||||
archivePath: this.getArchiveThumbnailPath(playlist, thumbnail),
|
||||
createrReadStream: () => Promise.resolve(createReadStream(thumbnail.getPath()))
|
||||
readStreamFactory: () => Promise.resolve(createReadStream(thumbnail.getPath()))
|
||||
})
|
||||
|
||||
archiveFiles.thumbnail = join(this.relativeStaticDirPath, this.getArchiveThumbnailPath(playlist, thumbnail))
|
||||
|
|
|
@ -6,6 +6,7 @@ import { audiencify, getAudience } from '@server/lib/activitypub/audience.js'
|
|||
import { buildCreateActivity } from '@server/lib/activitypub/send/send-create.js'
|
||||
import { buildChaptersAPHasPart } from '@server/lib/activitypub/video-chapters.js'
|
||||
import { getHLSFileReadStream, getOriginalFileReadStream, getWebVideoFileReadStream } from '@server/lib/object-storage/videos.js'
|
||||
import { muxToMergeVideoFiles } from '@server/lib/video-file.js'
|
||||
import { VideoPathManager } from '@server/lib/video-path-manager.js'
|
||||
import { VideoCaptionModel } from '@server/models/video/video-caption.js'
|
||||
import { VideoChannelModel } from '@server/models/video/video-channel.js'
|
||||
|
@ -16,7 +17,8 @@ import { VideoSourceModel } from '@server/models/video/video-source.js'
|
|||
import { VideoModel } from '@server/models/video/video.js'
|
||||
import {
|
||||
MStreamingPlaylistFiles,
|
||||
MThumbnail, MVideo, MVideoAP, MVideoCaption,
|
||||
MThumbnail,
|
||||
MVideo, MVideoAP, MVideoCaption,
|
||||
MVideoCaptionLanguageUrl,
|
||||
MVideoChapter,
|
||||
MVideoFile,
|
||||
|
@ -27,7 +29,7 @@ import { MVideoSource } from '@server/types/models/video/video-source.js'
|
|||
import Bluebird from 'bluebird'
|
||||
import { createReadStream } from 'fs'
|
||||
import { extname, join } from 'path'
|
||||
import { Readable } from 'stream'
|
||||
import { PassThrough, Readable } from 'stream'
|
||||
import { AbstractUserExporter, ExportResult } from './abstract-user-exporter.js'
|
||||
|
||||
export class VideosExporter extends AbstractUserExporter <VideoExportJSON> {
|
||||
|
@ -89,13 +91,13 @@ export class VideosExporter extends AbstractUserExporter <VideoExportJSON> {
|
|||
// Then fetch more attributes for AP serialization
|
||||
const videoAP = await video.lightAPToFullAP(undefined)
|
||||
|
||||
const { relativePathsFromJSON, staticFiles } = await this.exportVideoFiles({ video, captions })
|
||||
const { relativePathsFromJSON, staticFiles, exportedVideoFileOrSource } = await this.exportVideoFiles({ video, captions })
|
||||
|
||||
return {
|
||||
json: this.exportVideoJSON({ video, captions, live, passwords, source, chapters, archiveFiles: relativePathsFromJSON }),
|
||||
staticFiles,
|
||||
relativePathsFromJSON,
|
||||
activityPubOutbox: await this.exportVideoAP(videoAP, chapters)
|
||||
activityPubOutbox: await this.exportVideoAP(videoAP, chapters, exportedVideoFileOrSource)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -250,8 +252,11 @@ export class VideosExporter extends AbstractUserExporter <VideoExportJSON> {
|
|||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private async exportVideoAP (video: MVideoAP, chapters: MVideoChapter[]): Promise<ActivityCreate<VideoObject>> {
|
||||
const videoFile = video.getMaxQualityFile()
|
||||
private async exportVideoAP (
|
||||
video: MVideoAP,
|
||||
chapters: MVideoChapter[],
|
||||
exportedVideoFileOrSource: MVideoFile | MVideoSource
|
||||
): Promise<ActivityCreate<VideoObject>> {
|
||||
const icon = video.getPreview()
|
||||
|
||||
const audience = getAudience(video.VideoChannel.Account.Actor, video.privacy === VideoPrivacy.PUBLIC)
|
||||
|
@ -274,13 +279,19 @@ export class VideosExporter extends AbstractUserExporter <VideoExportJSON> {
|
|||
|
||||
hasParts: buildChaptersAPHasPart(video, chapters),
|
||||
|
||||
attachment: this.options.withVideoFiles && videoFile
|
||||
attachment: this.options.withVideoFiles && exportedVideoFileOrSource
|
||||
? [
|
||||
{
|
||||
type: 'Video' as 'Video',
|
||||
url: join(this.options.relativeStaticDirPath, this.getArchiveVideoFilePath(video, videoFile)),
|
||||
url: join(this.options.relativeStaticDirPath, this.getArchiveVideoFilePath(video, exportedVideoFileOrSource)),
|
||||
|
||||
...pick(videoFile.toActivityPubObject(video), [ 'mediaType', 'height', 'size', 'fps' ])
|
||||
// FIXME: typings
|
||||
...pick((exportedVideoFileOrSource as MVideoFile & MVideoSource).toActivityPubObject(video), [
|
||||
'mediaType',
|
||||
'height',
|
||||
'size',
|
||||
'fps'
|
||||
])
|
||||
}
|
||||
]
|
||||
: undefined
|
||||
|
@ -298,6 +309,9 @@ export class VideosExporter extends AbstractUserExporter <VideoExportJSON> {
|
|||
const { video, captions } = options
|
||||
|
||||
const staticFiles: ExportResult<VideoExportJSON>['staticFiles'] = []
|
||||
|
||||
let exportedVideoFileOrSource: MVideoFile | MVideoSource
|
||||
|
||||
const relativePathsFromJSON = {
|
||||
videoFile: null as string,
|
||||
thumbnail: null as string,
|
||||
|
@ -305,32 +319,32 @@ export class VideosExporter extends AbstractUserExporter <VideoExportJSON> {
|
|||
}
|
||||
|
||||
if (this.options.withVideoFiles) {
|
||||
const source = await VideoSourceModel.loadLatest(video.id)
|
||||
const maxQualityFile = video.getMaxQualityFile()
|
||||
const { source, videoFile, separatedAudioFile } = await this.getArchiveVideo(video)
|
||||
|
||||
// Prefer using original file if possible
|
||||
const file = source?.keptOriginalFilename
|
||||
? source
|
||||
: maxQualityFile
|
||||
|
||||
if (file) {
|
||||
const videoPath = this.getArchiveVideoFilePath(video, file)
|
||||
if (source || videoFile || separatedAudioFile) {
|
||||
const videoPath = this.getArchiveVideoFilePath(video, source || videoFile || separatedAudioFile)
|
||||
|
||||
staticFiles.push({
|
||||
archivePath: videoPath,
|
||||
createrReadStream: () => file === source
|
||||
|
||||
// Prefer using original file if possible
|
||||
readStreamFactory: () => source?.keptOriginalFilename
|
||||
? this.generateVideoSourceReadStream(source)
|
||||
: this.generateVideoFileReadStream(video, maxQualityFile)
|
||||
: this.generateVideoFileReadStream({ video, videoFile, separatedAudioFile })
|
||||
})
|
||||
|
||||
relativePathsFromJSON.videoFile = join(this.relativeStaticDirPath, videoPath)
|
||||
|
||||
exportedVideoFileOrSource = source?.keptOriginalFilename
|
||||
? source
|
||||
: videoFile || separatedAudioFile
|
||||
}
|
||||
}
|
||||
|
||||
for (const caption of captions) {
|
||||
staticFiles.push({
|
||||
archivePath: this.getArchiveCaptionFilePath(video, caption),
|
||||
createrReadStream: () => Promise.resolve(createReadStream(caption.getFSPath()))
|
||||
readStreamFactory: () => Promise.resolve(createReadStream(caption.getFSPath()))
|
||||
})
|
||||
|
||||
relativePathsFromJSON.captions[caption.language] = join(this.relativeStaticDirPath, this.getArchiveCaptionFilePath(video, caption))
|
||||
|
@ -340,13 +354,13 @@ export class VideosExporter extends AbstractUserExporter <VideoExportJSON> {
|
|||
if (thumbnail) {
|
||||
staticFiles.push({
|
||||
archivePath: this.getArchiveThumbnailFilePath(video, thumbnail),
|
||||
createrReadStream: () => Promise.resolve(createReadStream(thumbnail.getPath()))
|
||||
readStreamFactory: () => Promise.resolve(createReadStream(thumbnail.getPath()))
|
||||
})
|
||||
|
||||
relativePathsFromJSON.thumbnail = join(this.relativeStaticDirPath, this.getArchiveThumbnailFilePath(video, thumbnail))
|
||||
}
|
||||
|
||||
return { staticFiles, relativePathsFromJSON }
|
||||
return { staticFiles, relativePathsFromJSON, exportedVideoFileOrSource }
|
||||
}
|
||||
|
||||
private async generateVideoSourceReadStream (source: MVideoSource): Promise<Readable> {
|
||||
|
@ -359,7 +373,22 @@ export class VideosExporter extends AbstractUserExporter <VideoExportJSON> {
|
|||
return stream
|
||||
}
|
||||
|
||||
private async generateVideoFileReadStream (video: MVideoFullLight, videoFile: MVideoFile): Promise<Readable> {
|
||||
private async generateVideoFileReadStream (options: {
|
||||
videoFile: MVideoFile
|
||||
separatedAudioFile: MVideoFile
|
||||
video: MVideoFullLight
|
||||
}): Promise<Readable> {
|
||||
const { video, videoFile, separatedAudioFile } = options
|
||||
|
||||
if (separatedAudioFile) {
|
||||
const stream = new PassThrough()
|
||||
|
||||
muxToMergeVideoFiles({ video, videoFiles: [ videoFile, separatedAudioFile ], output: stream })
|
||||
.catch(err => logger.error('Cannot mux video files', { err }))
|
||||
|
||||
return Promise.resolve(stream)
|
||||
}
|
||||
|
||||
if (videoFile.storage === FileStorage.FILE_SYSTEM) {
|
||||
return createReadStream(VideoPathManager.Instance.getFSVideoFileOutputPath(video, videoFile))
|
||||
}
|
||||
|
@ -371,8 +400,18 @@ export class VideosExporter extends AbstractUserExporter <VideoExportJSON> {
|
|||
return stream
|
||||
}
|
||||
|
||||
private getArchiveVideoFilePath (video: MVideo, file: { filename?: string, keptOriginalFilename?: string }) {
|
||||
return join('video-files', video.uuid + extname(file.filename || file.keptOriginalFilename))
|
||||
private async getArchiveVideo (video: MVideoFullLight) {
|
||||
const source = await VideoSourceModel.loadLatest(video.id)
|
||||
|
||||
const { videoFile, separatedAudioFile } = video.getMaxQualityAudioAndVideoFiles()
|
||||
|
||||
if (source?.keptOriginalFilename) return { source }
|
||||
|
||||
return { videoFile, separatedAudioFile }
|
||||
}
|
||||
|
||||
private getArchiveVideoFilePath (video: MVideo, file: { keptOriginalFilename?: string, filename?: string }) {
|
||||
return join('video-files', video.uuid + extname(file.keptOriginalFilename || file.filename))
|
||||
}
|
||||
|
||||
private getArchiveCaptionFilePath (video: MVideo, caption: MVideoCaptionLanguageUrl) {
|
||||
|
|
|
@ -114,7 +114,7 @@ export class UserExporter {
|
|||
|
||||
return new Promise<void>(async (res, rej) => {
|
||||
this.archive.on('warning', err => {
|
||||
logger.warn('Warning to archive a file in ' + exportModel.filename, { err })
|
||||
logger.warn('Warning to archive a file in ' + exportModel.filename, { ...lTags(), err })
|
||||
})
|
||||
|
||||
this.archive.on('error', err => {
|
||||
|
@ -127,7 +127,7 @@ export class UserExporter {
|
|||
for (const { exporter, jsonFilename } of this.buildExporters(exportModel, user)) {
|
||||
const { json, staticFiles, activityPub, activityPubOutbox } = await exporter.export()
|
||||
|
||||
logger.debug('Adding JSON file ' + jsonFilename + ' in archive ' + exportModel.filename)
|
||||
logger.debug(`Adding JSON file ${jsonFilename} in archive ${exportModel.filename}`, lTags())
|
||||
this.appendJSON(json, join('peertube', jsonFilename))
|
||||
|
||||
if (activityPub) {
|
||||
|
@ -144,12 +144,12 @@ export class UserExporter {
|
|||
for (const file of staticFiles) {
|
||||
const archivePath = join('files', parse(jsonFilename).name, file.archivePath)
|
||||
|
||||
logger.debug(`Adding static file ${archivePath} in archive`)
|
||||
logger.debug(`Adding static file ${archivePath} in archive`, lTags())
|
||||
|
||||
try {
|
||||
await this.addToArchiveAndWait(await file.createrReadStream(), archivePath)
|
||||
await this.addToArchiveAndWait(await file.readStreamFactory(), archivePath)
|
||||
} catch (err) {
|
||||
logger.error(`Cannot add ${archivePath} in archive`, { err })
|
||||
logger.error(`Cannot add ${archivePath} in archive`, { err, ...lTags() })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -287,10 +287,14 @@ export class UserExporter {
|
|||
|
||||
this.archive.on('entry', entryListener)
|
||||
|
||||
logger.error('Adding stream ' + archivePath)
|
||||
|
||||
// Prevent sending a stream that has an error on open resulting in a stucked archiving process
|
||||
stream.once('readable', () => {
|
||||
if (errored) return
|
||||
|
||||
logger.error('Readable stream ' + archivePath)
|
||||
|
||||
this.archive.append(stream, { name: archivePath })
|
||||
})
|
||||
})
|
||||
|
|
|
@ -73,7 +73,7 @@ export class UserImporter {
|
|||
importModel.resultSummary = resultSummary
|
||||
await saveInTransactionWithRetries(importModel)
|
||||
} catch (err) {
|
||||
logger.error('Cannot import user archive', { toto: 'coucou', err, ...lTags() })
|
||||
logger.error('Cannot import user archive', { err, ...lTags() })
|
||||
|
||||
try {
|
||||
importModel.state = UserImportState.ERRORED
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { hasAudioStream } from '@peertube/peertube-ffmpeg'
|
||||
import { VideoFileStream } from '@peertube/peertube-models'
|
||||
import { buildSUUID } from '@peertube/peertube-node-utils'
|
||||
import { AbstractTranscriber, TranscriptionModel, WhisperBuiltinModel, transcriberFactory } from '@peertube/peertube-transcription'
|
||||
import { moveAndProcessCaptionFile } from '@server/helpers/captions-utils.js'
|
||||
|
@ -96,25 +96,30 @@ export async function generateSubtitle (options: {
|
|||
inputFileMutexReleaser = await VideoPathManager.Instance.lockFiles(options.video.uuid)
|
||||
|
||||
const video = await VideoModel.loadFull(options.video.uuid)
|
||||
const file = video.getMaxQualityFile().withVideoOrPlaylist(video)
|
||||
if (!video) {
|
||||
logger.info('Do not process transcription, video does not exist anymore.', lTags(options.video.uuid))
|
||||
return undefined
|
||||
}
|
||||
|
||||
await VideoPathManager.Instance.makeAvailableVideoFile(file, async videoInputPath => {
|
||||
if (await hasAudioStream(videoInputPath) !== true) {
|
||||
logger.info(
|
||||
`Do not run transcription for ${video.uuid} in ${outputPath} because it does not contain an audio stream`,
|
||||
lTags(video.uuid)
|
||||
)
|
||||
const file = video.getMaxQualityFile(VideoFileStream.AUDIO)
|
||||
|
||||
return
|
||||
}
|
||||
if (!file) {
|
||||
logger.info(
|
||||
`Do not run transcription for ${video.uuid} in ${outputPath} because it does not contain an audio stream`,
|
||||
{ video, ...lTags(video.uuid) }
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
await VideoPathManager.Instance.makeAvailableVideoFile(file, async inputPath => {
|
||||
// Release input file mutex now we are going to run the command
|
||||
setTimeout(() => inputFileMutexReleaser(), 1000)
|
||||
|
||||
logger.info(`Running transcription for ${video.uuid} in ${outputPath}`, lTags(video.uuid))
|
||||
|
||||
const transcriptFile = await transcriber.transcribe({
|
||||
mediaFilePath: videoInputPath,
|
||||
mediaFilePath: inputPath,
|
||||
|
||||
model: CONFIG.VIDEO_TRANSCRIPTION.MODEL_PATH
|
||||
? await TranscriptionModel.fromPath(CONFIG.VIDEO_TRANSCRIPTION.MODEL_PATH)
|
||||
|
|
|
@ -1,16 +1,33 @@
|
|||
import { ffprobePromise, getVideoStreamDimensionsInfo, getVideoStreamFPS, isAudioFile } from '@peertube/peertube-ffmpeg'
|
||||
import { FileStorage, VideoFileMetadata, VideoResolution } from '@peertube/peertube-models'
|
||||
import {
|
||||
FFmpegContainer,
|
||||
ffprobePromise,
|
||||
getVideoStreamDimensionsInfo,
|
||||
getVideoStreamFPS,
|
||||
hasAudioStream,
|
||||
hasVideoStream,
|
||||
isAudioFile
|
||||
} from '@peertube/peertube-ffmpeg'
|
||||
import { FileStorage, VideoFileFormatFlag, VideoFileMetadata, VideoFileStream, VideoResolution } from '@peertube/peertube-models'
|
||||
import { getFileSize, getLowercaseExtension } from '@peertube/peertube-node-utils'
|
||||
import { getFFmpegCommandWrapperOptions } from '@server/helpers/ffmpeg/ffmpeg-options.js'
|
||||
import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
|
||||
import { doRequestAndSaveToFile, generateRequestStream } from '@server/helpers/requests.js'
|
||||
import { CONFIG } from '@server/initializers/config.js'
|
||||
import { MIMETYPES } from '@server/initializers/constants.js'
|
||||
import { MIMETYPES, REQUEST_TIMEOUTS } from '@server/initializers/constants.js'
|
||||
import { VideoFileModel } from '@server/models/video/video-file.js'
|
||||
import { VideoSourceModel } from '@server/models/video/video-source.js'
|
||||
import { MVideo, MVideoFile, MVideoId, MVideoWithAllFiles } from '@server/types/models/index.js'
|
||||
import { FfprobeData } from 'fluent-ffmpeg'
|
||||
import { move, remove } from 'fs-extra/esm'
|
||||
import { Readable, Writable } from 'stream'
|
||||
import { lTags } from './object-storage/shared/index.js'
|
||||
import { storeOriginalVideoFile } from './object-storage/videos.js'
|
||||
import {
|
||||
getHLSFileReadStream,
|
||||
getWebVideoFileReadStream,
|
||||
makeHLSFileAvailable,
|
||||
makeWebVideoFileAvailable,
|
||||
storeOriginalVideoFile
|
||||
} from './object-storage/videos.js'
|
||||
import { generateHLSVideoFilename, generateWebVideoFilename } from './paths.js'
|
||||
import { VideoPathManager } from './video-path-manager.js'
|
||||
|
||||
|
@ -18,7 +35,7 @@ export async function buildNewFile (options: {
|
|||
path: string
|
||||
mode: 'web-video' | 'hls'
|
||||
ffprobe?: FfprobeData
|
||||
}) {
|
||||
}): Promise<MVideoFile> {
|
||||
const { path, mode, ffprobe: probeArg } = options
|
||||
|
||||
const probe = probeArg ?? await ffprobePromise(path)
|
||||
|
@ -27,9 +44,23 @@ export async function buildNewFile (options: {
|
|||
const videoFile = new VideoFileModel({
|
||||
extname: getLowercaseExtension(path),
|
||||
size,
|
||||
metadata: await buildFileMetadata(path, probe)
|
||||
metadata: await buildFileMetadata(path, probe),
|
||||
|
||||
streams: VideoFileStream.NONE,
|
||||
|
||||
formatFlags: mode === 'web-video'
|
||||
? VideoFileFormatFlag.WEB_VIDEO
|
||||
: VideoFileFormatFlag.FRAGMENTED
|
||||
})
|
||||
|
||||
if (await hasAudioStream(path, probe)) {
|
||||
videoFile.streams |= VideoFileStream.AUDIO
|
||||
}
|
||||
|
||||
if (await hasVideoStream(path, probe)) {
|
||||
videoFile.streams |= VideoFileStream.VIDEO
|
||||
}
|
||||
|
||||
if (await isAudioFile(path, probe)) {
|
||||
videoFile.fps = 0
|
||||
videoFile.resolution = VideoResolution.H_NOVIDEO
|
||||
|
@ -69,8 +100,6 @@ export async function removeHLSPlaylist (video: MVideoWithAllFiles) {
|
|||
}
|
||||
|
||||
export async function removeHLSFile (video: MVideoWithAllFiles, fileToDeleteId: number) {
|
||||
logger.info('Deleting HLS file %d of %s.', fileToDeleteId, video.url, lTags(video.uuid))
|
||||
|
||||
const hls = video.getHLSPlaylist()
|
||||
const files = hls.VideoFiles
|
||||
|
||||
|
@ -231,3 +260,134 @@ export async function saveNewOriginalFileIfNeeded (video: MVideo, videoFile: MVi
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function muxToMergeVideoFiles (options: {
|
||||
video: MVideo
|
||||
videoFiles: MVideoFile[]
|
||||
output: Writable
|
||||
}) {
|
||||
const { video, videoFiles, output } = options
|
||||
|
||||
const inputs: (string | Readable)[] = []
|
||||
const tmpDestinations: string[] = []
|
||||
|
||||
try {
|
||||
for (const videoFile of videoFiles) {
|
||||
if (!videoFile) continue
|
||||
|
||||
const { input, isTmpDestination } = await buildMuxInput(video, videoFile)
|
||||
|
||||
inputs.push(input)
|
||||
|
||||
if (isTmpDestination === true) tmpDestinations.push(input)
|
||||
}
|
||||
|
||||
const inputsToLog = inputs.map(i => {
|
||||
if (typeof i === 'string') return i
|
||||
|
||||
return 'ReadableStream'
|
||||
})
|
||||
|
||||
logger.info(`Muxing files for video ${video.url}`, { inputs: inputsToLog, ...lTags(video.uuid) })
|
||||
|
||||
try {
|
||||
await new FFmpegContainer(getFFmpegCommandWrapperOptions('vod')).mergeInputs({ inputs, output, logError: true })
|
||||
|
||||
logger.info(`Mux ended for video ${video.url}`, { inputs: inputsToLog, ...lTags(video.uuid) })
|
||||
} catch (err) {
|
||||
const message = err?.message || ''
|
||||
|
||||
if (message.includes('Output stream closed')) {
|
||||
logger.info(`Client aborted mux for video ${video.url}`, lTags(video.uuid))
|
||||
return
|
||||
}
|
||||
|
||||
logger.warn(`Cannot mux files of video ${video.url}`, { err, inputs: inputsToLog, ...lTags(video.uuid) })
|
||||
|
||||
throw err
|
||||
}
|
||||
|
||||
} finally {
|
||||
for (const destination of tmpDestinations) {
|
||||
await remove(destination)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function buildMuxInput (
|
||||
video: MVideo,
|
||||
videoFile: MVideoFile
|
||||
): Promise<{ input: Readable, isTmpDestination: false } | { input: string, isTmpDestination: boolean }> {
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Remote
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
if (video.remote === true) {
|
||||
const timeout = REQUEST_TIMEOUTS.VIDEO_FILE
|
||||
|
||||
const videoSizeKB = videoFile.size / 1000
|
||||
const bodyKBLimit = videoSizeKB + 0.1 * videoSizeKB
|
||||
|
||||
// FFmpeg doesn't support multiple input streams, so download the audio file on disk directly
|
||||
if (videoFile.isAudio()) {
|
||||
const destination = VideoPathManager.Instance.buildTMPDestination(videoFile.filename)
|
||||
|
||||
// > 1GB
|
||||
if (bodyKBLimit > 1000 * 1000) {
|
||||
throw new Error('Cannot download remote video file > 1GB')
|
||||
}
|
||||
|
||||
await doRequestAndSaveToFile(videoFile.fileUrl, destination, { timeout, bodyKBLimit })
|
||||
|
||||
return { input: destination, isTmpDestination: true }
|
||||
}
|
||||
|
||||
return { input: generateRequestStream(videoFile.fileUrl, { timeout, bodyKBLimit }), isTmpDestination: false }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Local on FS
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
if (videoFile.storage === FileStorage.FILE_SYSTEM) {
|
||||
return { input: VideoPathManager.Instance.getFSVideoFileOutputPath(video, videoFile), isTmpDestination: false }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Local on object storage
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// FFmpeg doesn't support multiple input streams, so download the audio file on disk directly
|
||||
if (videoFile.hasAudio() && !videoFile.hasVideo()) {
|
||||
const destination = VideoPathManager.Instance.buildTMPDestination(videoFile.filename)
|
||||
|
||||
if (videoFile.isHLS()) {
|
||||
await makeHLSFileAvailable(video.getHLSPlaylist(), videoFile.filename, destination)
|
||||
} else {
|
||||
await makeWebVideoFileAvailable(videoFile.filename, destination)
|
||||
}
|
||||
|
||||
return { input: destination, isTmpDestination: true }
|
||||
}
|
||||
|
||||
if (videoFile.isHLS()) {
|
||||
const { stream } = await getHLSFileReadStream({
|
||||
playlist: video.getHLSPlaylist().withVideo(video),
|
||||
filename: videoFile.filename,
|
||||
rangeHeader: undefined
|
||||
})
|
||||
|
||||
return { input: stream, isTmpDestination: false }
|
||||
}
|
||||
|
||||
// Web video
|
||||
const { stream } = await getWebVideoFileReadStream({
|
||||
filename: videoFile.filename,
|
||||
rangeHeader: undefined
|
||||
})
|
||||
|
||||
return { input: stream, isTmpDestination: false }
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { FileStorage } from '@peertube/peertube-models'
|
||||
import { buildUUID } from '@peertube/peertube-node-utils'
|
||||
import { Awaitable } from '@peertube/peertube-typescript-utils'
|
||||
import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
|
||||
import { extractVideo } from '@server/helpers/video.js'
|
||||
import { CONFIG } from '@server/initializers/config.js'
|
||||
|
@ -9,7 +10,8 @@ import {
|
|||
MVideo,
|
||||
MVideoFile,
|
||||
MVideoFileStreamingPlaylistVideo,
|
||||
MVideoFileVideo
|
||||
MVideoFileVideo,
|
||||
MVideoWithFile
|
||||
} from '@server/types/models/index.js'
|
||||
import { Mutex } from 'async-mutex'
|
||||
import { remove } from 'fs-extra/esm'
|
||||
|
@ -18,7 +20,9 @@ import { makeHLSFileAvailable, makeWebVideoFileAvailable } from './object-storag
|
|||
import { getHLSDirectory, getHLSRedundancyDirectory, getHlsResolutionPlaylistFilename } from './paths.js'
|
||||
import { isVideoInPrivateDirectory } from './video-privacy.js'
|
||||
|
||||
type MakeAvailableCB <T> = (path: string) => Promise<T> | T
|
||||
type MakeAvailableCB <T> = (path: string) => Awaitable<T>
|
||||
type MakeAvailableMultipleCB <T> = (paths: string[]) => Awaitable<T>
|
||||
type MakeAvailableCreateMethod = { method: () => Awaitable<string>, clean: boolean }
|
||||
|
||||
const lTags = loggerTagsFactory('video-path-manager')
|
||||
|
||||
|
@ -66,69 +70,114 @@ class VideoPathManager {
|
|||
return join(DIRECTORIES.ORIGINAL_VIDEOS, filename)
|
||||
}
|
||||
|
||||
async makeAvailableVideoFile <T> (videoFile: MVideoFileVideo | MVideoFileStreamingPlaylistVideo, cb: MakeAvailableCB<T>) {
|
||||
if (videoFile.storage === FileStorage.FILE_SYSTEM) {
|
||||
return this.makeAvailableFactory(
|
||||
() => this.getFSVideoFileOutputPath(videoFile.getVideoOrStreamingPlaylist(), videoFile),
|
||||
false,
|
||||
cb
|
||||
)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async makeAvailableVideoFiles <T> (videoFiles: (MVideoFileVideo | MVideoFileStreamingPlaylistVideo)[], cb: MakeAvailableMultipleCB<T>) {
|
||||
const createMethods: MakeAvailableCreateMethod[] = []
|
||||
|
||||
for (const videoFile of videoFiles) {
|
||||
if (videoFile.storage === FileStorage.FILE_SYSTEM) {
|
||||
createMethods.push({
|
||||
method: () => this.getFSVideoFileOutputPath(videoFile.getVideoOrStreamingPlaylist(), videoFile),
|
||||
clean: false
|
||||
})
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
const destination = this.buildTMPDestination(videoFile.filename)
|
||||
|
||||
if (videoFile.isHLS()) {
|
||||
const playlist = (videoFile as MVideoFileStreamingPlaylistVideo).VideoStreamingPlaylist
|
||||
|
||||
createMethods.push({
|
||||
method: () => makeHLSFileAvailable(playlist, videoFile.filename, destination),
|
||||
clean: true
|
||||
})
|
||||
} else {
|
||||
createMethods.push({
|
||||
method: () => makeWebVideoFileAvailable(videoFile.filename, destination),
|
||||
clean: true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const destination = this.buildTMPDestination(videoFile.filename)
|
||||
|
||||
if (videoFile.isHLS()) {
|
||||
const playlist = (videoFile as MVideoFileStreamingPlaylistVideo).VideoStreamingPlaylist
|
||||
|
||||
return this.makeAvailableFactory(
|
||||
() => makeHLSFileAvailable(playlist, videoFile.filename, destination),
|
||||
true,
|
||||
cb
|
||||
)
|
||||
}
|
||||
|
||||
return this.makeAvailableFactory(
|
||||
() => makeWebVideoFileAvailable(videoFile.filename, destination),
|
||||
true,
|
||||
cb
|
||||
)
|
||||
return this.makeAvailableFactory({ createMethods, cbContext: cb })
|
||||
}
|
||||
|
||||
async makeAvailableVideoFile <T> (videoFile: MVideoFileVideo | MVideoFileStreamingPlaylistVideo, cb: MakeAvailableCB<T>) {
|
||||
return this.makeAvailableVideoFiles([ videoFile ], paths => cb(paths[0]))
|
||||
}
|
||||
|
||||
async makeAvailableMaxQualityFiles <T> (
|
||||
video: MVideoWithFile,
|
||||
cb: (options: { videoPath: string, separatedAudioPath: string }) => Awaitable<T>
|
||||
) {
|
||||
const { videoFile, separatedAudioFile } = video.getMaxQualityAudioAndVideoFiles()
|
||||
|
||||
const files = [ videoFile ]
|
||||
if (separatedAudioFile) files.push(separatedAudioFile)
|
||||
|
||||
return this.makeAvailableVideoFiles(files, ([ videoPath, separatedAudioPath ]) => {
|
||||
return cb({ videoPath, separatedAudioPath })
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async makeAvailableResolutionPlaylistFile <T> (videoFile: MVideoFileStreamingPlaylistVideo, cb: MakeAvailableCB<T>) {
|
||||
const filename = getHlsResolutionPlaylistFilename(videoFile.filename)
|
||||
|
||||
if (videoFile.storage === FileStorage.FILE_SYSTEM) {
|
||||
return this.makeAvailableFactory(
|
||||
() => join(getHLSDirectory(videoFile.getVideo()), filename),
|
||||
false,
|
||||
cb
|
||||
)
|
||||
return this.makeAvailableFactory({
|
||||
createMethods: [
|
||||
{
|
||||
method: () => join(getHLSDirectory(videoFile.getVideo()), filename),
|
||||
clean: false
|
||||
}
|
||||
],
|
||||
cbContext: paths => cb(paths[0])
|
||||
})
|
||||
}
|
||||
|
||||
const playlist = videoFile.VideoStreamingPlaylist
|
||||
return this.makeAvailableFactory(
|
||||
() => makeHLSFileAvailable(playlist, filename, this.buildTMPDestination(filename)),
|
||||
true,
|
||||
cb
|
||||
)
|
||||
return this.makeAvailableFactory({
|
||||
createMethods: [
|
||||
{
|
||||
method: () => makeHLSFileAvailable(playlist, filename, this.buildTMPDestination(filename)),
|
||||
clean: true
|
||||
}
|
||||
],
|
||||
cbContext: paths => cb(paths[0])
|
||||
})
|
||||
}
|
||||
|
||||
async makeAvailablePlaylistFile <T> (playlist: MStreamingPlaylistVideo, filename: string, cb: MakeAvailableCB<T>) {
|
||||
if (playlist.storage === FileStorage.FILE_SYSTEM) {
|
||||
return this.makeAvailableFactory(
|
||||
() => join(getHLSDirectory(playlist.Video), filename),
|
||||
false,
|
||||
cb
|
||||
)
|
||||
return this.makeAvailableFactory({
|
||||
createMethods: [
|
||||
{
|
||||
method: () => join(getHLSDirectory(playlist.Video), filename),
|
||||
clean: false
|
||||
}
|
||||
],
|
||||
cbContext: paths => cb(paths[0])
|
||||
})
|
||||
}
|
||||
|
||||
return this.makeAvailableFactory(
|
||||
() => makeHLSFileAvailable(playlist, filename, this.buildTMPDestination(filename)),
|
||||
true,
|
||||
cb
|
||||
)
|
||||
return this.makeAvailableFactory({
|
||||
createMethods: [
|
||||
{
|
||||
method: () => makeHLSFileAvailable(playlist, filename, this.buildTMPDestination(filename)),
|
||||
clean: true
|
||||
}
|
||||
],
|
||||
cbContext: paths => cb(paths[0])
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async lockFiles (videoUUID: string) {
|
||||
if (!this.videoFileMutexStore.has(videoUUID)) {
|
||||
this.videoFileMutexStore.set(videoUUID, new Mutex())
|
||||
|
@ -150,26 +199,50 @@ class VideoPathManager {
|
|||
logger.debug('Released lockfiles of %s.', videoUUID, lTags(videoUUID))
|
||||
}
|
||||
|
||||
private async makeAvailableFactory <T> (method: () => Promise<string> | string, clean: boolean, cb: MakeAvailableCB<T>) {
|
||||
private async makeAvailableFactory <T> (options: {
|
||||
createMethods: MakeAvailableCreateMethod[]
|
||||
cbContext: MakeAvailableMultipleCB<T>
|
||||
}) {
|
||||
const { cbContext, createMethods } = options
|
||||
|
||||
let result: T
|
||||
|
||||
const destination = await method()
|
||||
const created: { destination: string, clean: boolean }[] = []
|
||||
|
||||
const cleanup = async () => {
|
||||
for (const { destination, clean } of created) {
|
||||
if (!destination || !clean) continue
|
||||
|
||||
try {
|
||||
await remove(destination)
|
||||
} catch (err) {
|
||||
logger.error('Cannot remove ' + destination, { err })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const { method, clean } of createMethods) {
|
||||
created.push({
|
||||
destination: await method(),
|
||||
clean
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
result = await cb(destination)
|
||||
result = await cbContext(created.map(c => c.destination))
|
||||
} catch (err) {
|
||||
if (destination && clean) await remove(destination)
|
||||
await cleanup()
|
||||
|
||||
throw err
|
||||
}
|
||||
|
||||
if (clean) await remove(destination)
|
||||
await cleanup()
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private buildTMPDestination (filename: string) {
|
||||
buildTMPDestination (filename: string) {
|
||||
return join(CONFIG.STORAGE.TMP_DIR, buildUUID() + extname(filename))
|
||||
|
||||
}
|
||||
|
||||
static get Instance () {
|
||||
|
|
|
@ -5,7 +5,7 @@ import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
|
|||
import { createTorrentAndSetInfoHashFromPath } from '@server/helpers/webtorrent.js'
|
||||
import { CONFIG } from '@server/initializers/config.js'
|
||||
import { VideoCaptionModel } from '@server/models/video/video-caption.js'
|
||||
import { MUser, MVideo, MVideoFile, MVideoFullLight, MVideoWithAllFiles } from '@server/types/models/index.js'
|
||||
import { MUser, MVideoFile, MVideoFullLight, MVideoWithAllFiles, MVideoWithFile } from '@server/types/models/index.js'
|
||||
import { move, remove } from 'fs-extra/esm'
|
||||
import { join } from 'path'
|
||||
import { JobQueue } from './job-queue/index.js'
|
||||
|
@ -31,7 +31,7 @@ export function getStudioTaskFilePath (filename: string) {
|
|||
}
|
||||
|
||||
export async function safeCleanupStudioTMPFiles (tasks: VideoStudioTaskPayload[]) {
|
||||
logger.info('Removing studio task files', { tasks, ...lTags() })
|
||||
logger.info('Removing TMP studio task files', { tasks, ...lTags() })
|
||||
|
||||
for (const task of tasks) {
|
||||
try {
|
||||
|
@ -64,13 +64,13 @@ export async function approximateIntroOutroAdditionalSize (
|
|||
additionalDuration += await getVideoStreamDuration(filePath)
|
||||
}
|
||||
|
||||
return (video.getMaxQualityFile().size / video.duration) * additionalDuration
|
||||
return (video.getMaxQualityBytes() / video.duration) * additionalDuration
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function createVideoStudioJob (options: {
|
||||
video: MVideo
|
||||
video: MVideoWithFile
|
||||
user: MUser
|
||||
payload: VideoStudioEditionPayload
|
||||
}) {
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
import express from 'express'
|
||||
import { param } from 'express-validator'
|
||||
import { HttpStatusCode, VideoResolution } from '@peertube/peertube-models'
|
||||
import { isIdValid } from '@server/helpers/custom-validators/misc.js'
|
||||
import { MVideo } from '@server/types/models/index.js'
|
||||
import { HttpStatusCode } from '@peertube/peertube-models'
|
||||
import express from 'express'
|
||||
import { param } from 'express-validator'
|
||||
import { areValidationErrors, doesVideoExist, isValidVideoIdParam } from '../shared/index.js'
|
||||
|
||||
const videoFilesDeleteWebVideoValidator = [
|
||||
export const videoFilesDeleteWebVideoValidator = [
|
||||
isValidVideoIdParam('id'),
|
||||
|
||||
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
|
@ -34,7 +34,7 @@ const videoFilesDeleteWebVideoValidator = [
|
|||
}
|
||||
]
|
||||
|
||||
const videoFilesDeleteWebVideoFileValidator = [
|
||||
export const videoFilesDeleteWebVideoFileValidator = [
|
||||
isValidVideoIdParam('id'),
|
||||
|
||||
param('videoFileId')
|
||||
|
@ -69,7 +69,7 @@ const videoFilesDeleteWebVideoFileValidator = [
|
|||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const videoFilesDeleteHLSValidator = [
|
||||
export const videoFilesDeleteHLSValidator = [
|
||||
isValidVideoIdParam('id'),
|
||||
|
||||
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
|
@ -98,7 +98,7 @@ const videoFilesDeleteHLSValidator = [
|
|||
}
|
||||
]
|
||||
|
||||
const videoFilesDeleteHLSFileValidator = [
|
||||
export const videoFilesDeleteHLSFileValidator = [
|
||||
isValidVideoIdParam('id'),
|
||||
|
||||
param('videoFileId')
|
||||
|
@ -112,15 +112,19 @@ const videoFilesDeleteHLSFileValidator = [
|
|||
|
||||
if (!checkLocalVideo(video, res)) return
|
||||
|
||||
if (!video.getHLSPlaylist()) {
|
||||
const hls = video.getHLSPlaylist()
|
||||
|
||||
if (!hls) {
|
||||
return res.fail({
|
||||
status: HttpStatusCode.BAD_REQUEST_400,
|
||||
message: 'This video does not have HLS files'
|
||||
})
|
||||
}
|
||||
|
||||
const hlsFiles = video.getHLSPlaylist().VideoFiles
|
||||
if (!hlsFiles.find(f => f.id === +req.params.videoFileId)) {
|
||||
const hlsFiles = hls.VideoFiles
|
||||
const file = hlsFiles.find(f => f.id === +req.params.videoFileId)
|
||||
|
||||
if (!file) {
|
||||
return res.fail({
|
||||
status: HttpStatusCode.NOT_FOUND_404,
|
||||
message: 'This HLS playlist does not have this file id'
|
||||
|
@ -135,18 +139,19 @@ const videoFilesDeleteHLSFileValidator = [
|
|||
})
|
||||
}
|
||||
|
||||
if (hls.hasAudioAndVideoSplitted() && file.resolution === VideoResolution.H_NOVIDEO) {
|
||||
return res.fail({
|
||||
status: HttpStatusCode.BAD_REQUEST_400,
|
||||
message: 'Cannot delete audio file of HLS playlist with splitted audio/video. Delete all the videos first'
|
||||
})
|
||||
}
|
||||
|
||||
return next()
|
||||
}
|
||||
]
|
||||
|
||||
export {
|
||||
videoFilesDeleteWebVideoValidator,
|
||||
videoFilesDeleteWebVideoFileValidator,
|
||||
|
||||
videoFilesDeleteHLSValidator,
|
||||
videoFilesDeleteHLSFileValidator
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function checkLocalVideo (video: MVideo, res: express.Response) {
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import express from 'express'
|
||||
import { param } from 'express-validator'
|
||||
import { HttpStatusCode, UserRight, VideoChangeOwnershipAccept, VideoChangeOwnershipStatus, VideoState } from '@peertube/peertube-models'
|
||||
import { isIdValid } from '@server/helpers/custom-validators/misc.js'
|
||||
import { checkUserCanTerminateOwnershipChange } from '@server/helpers/custom-validators/video-ownership.js'
|
||||
import { AccountModel } from '@server/models/account/account.js'
|
||||
import { MVideoWithAllFiles } from '@server/types/models/index.js'
|
||||
import { HttpStatusCode, UserRight, VideoChangeOwnershipAccept, VideoChangeOwnershipStatus, VideoState } from '@peertube/peertube-models'
|
||||
import express from 'express'
|
||||
import { param } from 'express-validator'
|
||||
import {
|
||||
areValidationErrors,
|
||||
checkUserCanManageVideo,
|
||||
|
@ -15,7 +15,7 @@ import {
|
|||
isValidVideoIdParam
|
||||
} from '../shared/index.js'
|
||||
|
||||
const videosChangeOwnershipValidator = [
|
||||
export const videosChangeOwnershipValidator = [
|
||||
isValidVideoIdParam('videoId'),
|
||||
|
||||
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
|
@ -36,7 +36,7 @@ const videosChangeOwnershipValidator = [
|
|||
}
|
||||
]
|
||||
|
||||
const videosTerminateChangeOwnershipValidator = [
|
||||
export const videosTerminateChangeOwnershipValidator = [
|
||||
param('id')
|
||||
.custom(isIdValid),
|
||||
|
||||
|
@ -61,7 +61,7 @@ const videosTerminateChangeOwnershipValidator = [
|
|||
}
|
||||
]
|
||||
|
||||
const videosAcceptChangeOwnershipValidator = [
|
||||
export const videosAcceptChangeOwnershipValidator = [
|
||||
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
const body = req.body as VideoChangeOwnershipAccept
|
||||
if (!await doesVideoChannelOfAccountExist(body.channelId, res.locals.oauth.token.User, res)) return
|
||||
|
@ -76,12 +76,8 @@ const videosAcceptChangeOwnershipValidator = [
|
|||
}
|
||||
]
|
||||
|
||||
export {
|
||||
videosChangeOwnershipValidator,
|
||||
videosTerminateChangeOwnershipValidator,
|
||||
videosAcceptChangeOwnershipValidator
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function checkCanAccept (video: MVideoWithAllFiles, res: express.Response): Promise<boolean> {
|
||||
|
@ -101,7 +97,7 @@ async function checkCanAccept (video: MVideoWithAllFiles, res: express.Response)
|
|||
|
||||
const user = res.locals.oauth.token.User
|
||||
|
||||
if (!await checkUserQuota(user, video.getMaxQualityFile().size, res)) return false
|
||||
if (!await checkUserQuota(user, video.getMaxQualityBytes(), res)) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
|
|
@ -9,11 +9,14 @@ import express from 'express'
|
|||
import { ValidationChain, body, param, query } from 'express-validator'
|
||||
import {
|
||||
exists,
|
||||
hasArrayLength,
|
||||
isBooleanValid,
|
||||
isDateValid,
|
||||
isFileValid,
|
||||
isIdValid,
|
||||
isNotEmptyIntArray,
|
||||
toBooleanOrNull,
|
||||
toIntArray,
|
||||
toIntOrNull,
|
||||
toValueOrNull
|
||||
} from '../../../helpers/custom-validators/misc.js'
|
||||
|
@ -52,8 +55,9 @@ import {
|
|||
isValidVideoPasswordHeader
|
||||
} from '../shared/index.js'
|
||||
import { addDurationToVideoFileIfNeeded, commonVideoFileChecks, isVideoFileAccepted } from './shared/index.js'
|
||||
import { VideoLoadType } from '@server/lib/model-loaders/video.js'
|
||||
|
||||
const videosAddLegacyValidator = getCommonVideoEditAttributes().concat([
|
||||
export const videosAddLegacyValidator = getCommonVideoEditAttributes().concat([
|
||||
body('videofile')
|
||||
.custom((_, { req }) => isFileValid({ files: req.files, field: 'videofile', mimeTypeRegex: null, maxSize: null }))
|
||||
.withMessage('Should have a file'),
|
||||
|
@ -92,7 +96,7 @@ const videosAddLegacyValidator = getCommonVideoEditAttributes().concat([
|
|||
/**
|
||||
* Gets called after the last PUT request
|
||||
*/
|
||||
const videosAddResumableValidator = [
|
||||
export const videosAddResumableValidator = [
|
||||
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
const user = res.locals.oauth.token.User
|
||||
const file = buildUploadXFile(req.body as express.CustomUploadXFile<express.UploadNewVideoXFileMetadata>)
|
||||
|
@ -130,7 +134,7 @@ const videosAddResumableValidator = [
|
|||
* see https://github.com/kukhariev/node-uploadx/blob/dc9fb4a8ac5a6f481902588e93062f591ec6ef03/packages/core/src/handlers/base-handler.ts
|
||||
*
|
||||
*/
|
||||
const videosAddResumableInitValidator = getCommonVideoEditAttributes().concat([
|
||||
export const videosAddResumableInitValidator = getCommonVideoEditAttributes().concat([
|
||||
body('filename')
|
||||
.custom(isVideoSourceFilenameValid),
|
||||
body('name')
|
||||
|
@ -175,7 +179,7 @@ const videosAddResumableInitValidator = getCommonVideoEditAttributes().concat([
|
|||
}
|
||||
])
|
||||
|
||||
const videosUpdateValidator = getCommonVideoEditAttributes().concat([
|
||||
export const videosUpdateValidator = getCommonVideoEditAttributes().concat([
|
||||
isValidVideoIdParam('id'),
|
||||
|
||||
body('name')
|
||||
|
@ -215,7 +219,7 @@ const videosUpdateValidator = getCommonVideoEditAttributes().concat([
|
|||
}
|
||||
])
|
||||
|
||||
async function checkVideoFollowConstraints (req: express.Request, res: express.Response, next: express.NextFunction) {
|
||||
export async function checkVideoFollowConstraints (req: express.Request, res: express.Response, next: express.NextFunction) {
|
||||
const video = getVideoWithAttributes(res)
|
||||
|
||||
// Anybody can watch local videos
|
||||
|
@ -244,7 +248,8 @@ async function checkVideoFollowConstraints (req: express.Request, res: express.R
|
|||
})
|
||||
}
|
||||
|
||||
const videosCustomGetValidator = (fetchType: 'for-api' | 'all' | 'only-video-and-blacklist' | 'unsafe-only-immutable-attributes') => {
|
||||
type FetchType = Extract<VideoLoadType, 'for-api' | 'all' | 'only-video-and-blacklist' | 'unsafe-only-immutable-attributes'>
|
||||
export const videosCustomGetValidator = (fetchType: FetchType) => {
|
||||
return [
|
||||
isValidVideoIdParam('id'),
|
||||
|
||||
|
@ -266,9 +271,9 @@ const videosCustomGetValidator = (fetchType: 'for-api' | 'all' | 'only-video-and
|
|||
]
|
||||
}
|
||||
|
||||
const videosGetValidator = videosCustomGetValidator('all')
|
||||
export const videosGetValidator = videosCustomGetValidator('all')
|
||||
|
||||
const videoFileMetadataGetValidator = getCommonVideoEditAttributes().concat([
|
||||
export const videoFileMetadataGetValidator = getCommonVideoEditAttributes().concat([
|
||||
isValidVideoIdParam('id'),
|
||||
|
||||
param('videoFileId')
|
||||
|
@ -282,7 +287,7 @@ const videoFileMetadataGetValidator = getCommonVideoEditAttributes().concat([
|
|||
}
|
||||
])
|
||||
|
||||
const videosDownloadValidator = [
|
||||
export const videosDownloadValidator = [
|
||||
isValidVideoIdParam('id'),
|
||||
|
||||
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
|
@ -297,7 +302,20 @@ const videosDownloadValidator = [
|
|||
}
|
||||
]
|
||||
|
||||
const videosRemoveValidator = [
|
||||
export const videosGenerateDownloadValidator = [
|
||||
query('videoFileIds')
|
||||
.customSanitizer(toIntArray)
|
||||
.custom(isNotEmptyIntArray)
|
||||
.custom(v => hasArrayLength(v, { max: 2 })),
|
||||
|
||||
(req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
if (areValidationErrors(req, res)) return
|
||||
|
||||
return next()
|
||||
}
|
||||
]
|
||||
|
||||
export const videosRemoveValidator = [
|
||||
isValidVideoIdParam('id'),
|
||||
|
||||
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
|
@ -311,7 +329,7 @@ const videosRemoveValidator = [
|
|||
}
|
||||
]
|
||||
|
||||
const videosOverviewValidator = [
|
||||
export const videosOverviewValidator = [
|
||||
query('page')
|
||||
.optional()
|
||||
.isInt({ min: 1, max: OVERVIEWS.VIDEOS.SAMPLES_COUNT }),
|
||||
|
@ -323,7 +341,7 @@ const videosOverviewValidator = [
|
|||
}
|
||||
]
|
||||
|
||||
function getCommonVideoEditAttributes () {
|
||||
export function getCommonVideoEditAttributes () {
|
||||
return [
|
||||
body('thumbnailfile')
|
||||
.custom((value, { req }) => isVideoImageValid(req.files, 'thumbnailfile')).withMessage(
|
||||
|
@ -406,7 +424,7 @@ function getCommonVideoEditAttributes () {
|
|||
] as (ValidationChain | ExpressPromiseHandler)[]
|
||||
}
|
||||
|
||||
const commonVideosFiltersValidator = [
|
||||
export const commonVideosFiltersValidator = [
|
||||
query('categoryOneOf')
|
||||
.optional()
|
||||
.customSanitizer(arrayify)
|
||||
|
@ -508,23 +526,7 @@ const commonVideosFiltersValidator = [
|
|||
]
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
checkVideoFollowConstraints,
|
||||
commonVideosFiltersValidator,
|
||||
getCommonVideoEditAttributes,
|
||||
videoFileMetadataGetValidator,
|
||||
videosAddLegacyValidator,
|
||||
videosAddResumableInitValidator,
|
||||
videosAddResumableValidator,
|
||||
videosCustomGetValidator,
|
||||
videosDownloadValidator,
|
||||
videosGetValidator,
|
||||
videosOverviewValidator,
|
||||
videosRemoveValidator,
|
||||
videosUpdateValidator
|
||||
}
|
||||
|
||||
// Private
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function areErrorsInScheduleUpdate (req: express.Request, res: express.Response) {
|
||||
|
|
|
@ -3,7 +3,7 @@ import { logger } from '@server/helpers/logger.js'
|
|||
import { CONFIG } from '@server/initializers/config.js'
|
||||
import {
|
||||
JWT_TOKEN_USER_EXPORT_FILE_LIFETIME,
|
||||
STATIC_DOWNLOAD_PATHS,
|
||||
DOWNLOAD_PATHS,
|
||||
USER_EXPORT_FILE_PREFIX,
|
||||
USER_EXPORT_STATES,
|
||||
WEBSERVER
|
||||
|
@ -203,7 +203,7 @@ export class UserExportModel extends SequelizeModel<UserExportModel> {
|
|||
getFileDownloadUrl () {
|
||||
if (this.state !== UserExportState.COMPLETED) return null
|
||||
|
||||
return WEBSERVER.URL + join(STATIC_DOWNLOAD_PATHS.USER_EXPORTS, this.filename) + '?jwt=' + this.generateJWT()
|
||||
return WEBSERVER.URL + join(DOWNLOAD_PATHS.USER_EXPORTS, this.filename) + '?jwt=' + this.generateJWT()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
@ -249,7 +249,10 @@ export function videoFilesModelToFormattedJSON (
|
|||
fileUrl: videoFile.getFileUrl(video),
|
||||
fileDownloadUrl: videoFile.getFileDownloadUrl(video),
|
||||
|
||||
metadataUrl: videoFile.metadataUrl ?? getLocalVideoFileMetadataUrl(video, videoFile)
|
||||
metadataUrl: videoFile.metadataUrl ?? getLocalVideoFileMetadataUrl(video, videoFile),
|
||||
|
||||
hasAudio: videoFile.hasAudio(),
|
||||
hasVideo: videoFile.hasVideo()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
@ -91,6 +91,8 @@ export class VideoTableAttributes {
|
|||
'videoId',
|
||||
'width',
|
||||
'height',
|
||||
'formatFlags',
|
||||
'streams',
|
||||
'storage'
|
||||
]
|
||||
}
|
||||
|
|
|
@ -1,4 +1,13 @@
|
|||
import { ActivityVideoUrlObject, FileStorage, VideoResolution, type FileStorageType } from '@peertube/peertube-models'
|
||||
import {
|
||||
ActivityVideoUrlObject,
|
||||
FileStorage,
|
||||
type FileStorageType,
|
||||
VideoFileFormatFlag,
|
||||
type VideoFileFormatFlagType,
|
||||
VideoFileStream,
|
||||
type VideoFileStreamType,
|
||||
VideoResolution
|
||||
} from '@peertube/peertube-models'
|
||||
import { logger } from '@server/helpers/logger.js'
|
||||
import { extractVideo } from '@server/helpers/video.js'
|
||||
import { CONFIG } from '@server/initializers/config.js'
|
||||
|
@ -39,10 +48,10 @@ import {
|
|||
isVideoFileSizeValid
|
||||
} from '../../helpers/custom-validators/videos.js'
|
||||
import {
|
||||
DOWNLOAD_PATHS,
|
||||
LAZY_STATIC_PATHS,
|
||||
MEMOIZE_LENGTH,
|
||||
MEMOIZE_TTL,
|
||||
STATIC_DOWNLOAD_PATHS,
|
||||
STATIC_PATHS,
|
||||
WEBSERVER
|
||||
} from '../../initializers/constants.js'
|
||||
|
@ -195,6 +204,14 @@ export class VideoFileModel extends SequelizeModel<VideoFileModel> {
|
|||
@Column
|
||||
fps: number
|
||||
|
||||
@AllowNull(false)
|
||||
@Column
|
||||
formatFlags: VideoFileFormatFlagType
|
||||
|
||||
@AllowNull(false)
|
||||
@Column
|
||||
streams: VideoFileStreamType
|
||||
|
||||
@AllowNull(true)
|
||||
@Column(DataType.JSONB)
|
||||
metadata: any
|
||||
|
@ -503,6 +520,8 @@ export class VideoFileModel extends SequelizeModel<VideoFileModel> {
|
|||
return extractVideo(this.getVideoOrStreamingPlaylist())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
isAudio () {
|
||||
return this.resolution === VideoResolution.H_NOVIDEO
|
||||
}
|
||||
|
@ -515,6 +534,14 @@ export class VideoFileModel extends SequelizeModel<VideoFileModel> {
|
|||
return !!this.videoStreamingPlaylistId
|
||||
}
|
||||
|
||||
hasAudio () {
|
||||
return (this.streams & VideoFileStream.AUDIO) === VideoFileStream.AUDIO
|
||||
}
|
||||
|
||||
hasVideo () {
|
||||
return (this.streams & VideoFileStream.VIDEO) === VideoFileStream.VIDEO
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
getObjectStorageUrl (video: MVideo) {
|
||||
|
@ -583,8 +610,8 @@ export class VideoFileModel extends SequelizeModel<VideoFileModel> {
|
|||
|
||||
getFileDownloadUrl (video: MVideoWithHost) {
|
||||
const path = this.isHLS()
|
||||
? join(STATIC_DOWNLOAD_PATHS.HLS_VIDEOS, `${video.uuid}-${this.resolution}-fragmented${this.extname}`)
|
||||
: join(STATIC_DOWNLOAD_PATHS.VIDEOS, `${video.uuid}-${this.resolution}${this.extname}`)
|
||||
? join(DOWNLOAD_PATHS.HLS_VIDEOS, `${video.uuid}-${this.resolution}-fragmented${this.extname}`)
|
||||
: join(DOWNLOAD_PATHS.WEB_VIDEOS, `${video.uuid}-${this.resolution}${this.extname}`)
|
||||
|
||||
if (video.isOwned()) return WEBSERVER.URL + path
|
||||
|
||||
|
@ -614,7 +641,7 @@ export class VideoFileModel extends SequelizeModel<VideoFileModel> {
|
|||
getTorrentDownloadUrl () {
|
||||
if (!this.torrentFilename) return null
|
||||
|
||||
return WEBSERVER.URL + join(STATIC_DOWNLOAD_PATHS.TORRENTS, this.torrentFilename)
|
||||
return WEBSERVER.URL + join(DOWNLOAD_PATHS.TORRENTS, this.torrentFilename)
|
||||
}
|
||||
|
||||
removeTorrent () {
|
||||
|
@ -645,6 +672,40 @@ export class VideoFileModel extends SequelizeModel<VideoFileModel> {
|
|||
toActivityPubObject (this: MVideoFile, video: MVideo): ActivityVideoUrlObject {
|
||||
const mimeType = getVideoFileMimeType(this.extname, false)
|
||||
|
||||
const attachment: ActivityVideoUrlObject['attachment'] = []
|
||||
|
||||
if (this.hasAudio()) {
|
||||
attachment.push({
|
||||
type: 'PropertyValue',
|
||||
name: 'ffprobe_codec_type',
|
||||
value: 'audio'
|
||||
})
|
||||
}
|
||||
|
||||
if (this.hasVideo()) {
|
||||
attachment.push({
|
||||
type: 'PropertyValue',
|
||||
name: 'ffprobe_codec_type',
|
||||
value: 'video'
|
||||
})
|
||||
}
|
||||
|
||||
if (this.formatFlags & VideoFileFormatFlag.FRAGMENTED) {
|
||||
attachment.push({
|
||||
type: 'PropertyValue',
|
||||
name: 'peertube_format_flag',
|
||||
value: 'fragmented'
|
||||
})
|
||||
}
|
||||
|
||||
if (this.formatFlags & VideoFileFormatFlag.WEB_VIDEO) {
|
||||
attachment.push({
|
||||
type: 'PropertyValue',
|
||||
name: 'peertube_format_flag',
|
||||
value: 'web-video'
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'Link',
|
||||
mediaType: mimeType as ActivityVideoUrlObject['mediaType'],
|
||||
|
@ -652,7 +713,8 @@ export class VideoFileModel extends SequelizeModel<VideoFileModel> {
|
|||
height: this.height || this.resolution,
|
||||
width: this.width,
|
||||
size: this.size,
|
||||
fps: this.fps
|
||||
fps: this.fps,
|
||||
attachment
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
import { type FileStorageType, type VideoSource } from '@peertube/peertube-models'
|
||||
import { STATIC_DOWNLOAD_PATHS, WEBSERVER } from '@server/initializers/constants.js'
|
||||
import { ActivityVideoUrlObject, type FileStorageType, type VideoSource } from '@peertube/peertube-models'
|
||||
import { DOWNLOAD_PATHS, WEBSERVER } from '@server/initializers/constants.js'
|
||||
import { getVideoFileMimeType } from '@server/lib/video-file.js'
|
||||
import { MVideoSource } from '@server/types/models/video/video-source.js'
|
||||
import { join } from 'path'
|
||||
import { extname, join } from 'path'
|
||||
import { Transaction } from 'sequelize'
|
||||
import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Table, UpdatedAt } from 'sequelize-typescript'
|
||||
import { SequelizeModel, doesExist, getSort } from '../shared/index.js'
|
||||
|
@ -118,10 +119,25 @@ export class VideoSourceModel extends SequelizeModel<VideoSourceModel> {
|
|||
getFileDownloadUrl () {
|
||||
if (!this.keptOriginalFilename) return null
|
||||
|
||||
return WEBSERVER.URL + join(STATIC_DOWNLOAD_PATHS.ORIGINAL_VIDEO_FILE, this.keptOriginalFilename)
|
||||
return WEBSERVER.URL + join(DOWNLOAD_PATHS.ORIGINAL_VIDEO_FILE, this.keptOriginalFilename)
|
||||
}
|
||||
|
||||
toFormattedJSON (): VideoSource {
|
||||
toActivityPubObject (this: MVideoSource): ActivityVideoUrlObject {
|
||||
const mimeType = getVideoFileMimeType(extname(this.inputFilename), false)
|
||||
|
||||
return {
|
||||
type: 'Link',
|
||||
mediaType: mimeType as ActivityVideoUrlObject['mediaType'],
|
||||
href: null,
|
||||
height: this.height || this.resolution,
|
||||
width: this.width,
|
||||
size: this.size,
|
||||
fps: this.fps,
|
||||
attachment: []
|
||||
}
|
||||
}
|
||||
|
||||
toFormattedJSON (this: MVideoSource): VideoSource {
|
||||
return {
|
||||
filename: this.inputFilename,
|
||||
inputFilename: this.inputFilename,
|
||||
|
|
|
@ -1,16 +1,18 @@
|
|||
import {
|
||||
FileStorage,
|
||||
VideoResolution,
|
||||
VideoStreamingPlaylistType,
|
||||
type FileStorageType,
|
||||
type VideoStreamingPlaylistType_Type
|
||||
} from '@peertube/peertube-models'
|
||||
import { sha1 } from '@peertube/peertube-node-utils'
|
||||
import { logger } from '@server/helpers/logger.js'
|
||||
import { CONFIG } from '@server/initializers/config.js'
|
||||
import { getHLSPrivateFileUrl, getObjectStoragePublicFileUrl } from '@server/lib/object-storage/index.js'
|
||||
import { generateHLSMasterPlaylistFilename, generateHlsSha256SegmentsFilename } from '@server/lib/paths.js'
|
||||
import { isVideoInPrivateDirectory } from '@server/lib/video-privacy.js'
|
||||
import { VideoFileModel } from '@server/models/video/video-file.js'
|
||||
import { MStreamingPlaylist, MStreamingPlaylistFilesVideo, MVideo } from '@server/types/models/index.js'
|
||||
import { MStreamingPlaylist, MStreamingPlaylistFiles, MStreamingPlaylistFilesVideo, MVideo } from '@server/types/models/index.js'
|
||||
import memoizee from 'memoizee'
|
||||
import { join } from 'path'
|
||||
import { Op, Transaction } from 'sequelize'
|
||||
|
@ -147,6 +149,8 @@ export class VideoStreamingPlaylistModel extends SequelizeModel<VideoStreamingPl
|
|||
hashes.push(sha1(`${P2P_MEDIA_LOADER_PEER_VERSION}${playlistUrl}+V${i}`))
|
||||
}
|
||||
|
||||
logger.debug('Assigned P2P Media Loader info hashes', { playlistUrl, hashes })
|
||||
|
||||
return hashes
|
||||
}
|
||||
|
||||
|
@ -292,6 +296,26 @@ export class VideoStreamingPlaylistModel extends SequelizeModel<VideoStreamingPl
|
|||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
hasAudioAndVideoSplitted (this: MStreamingPlaylistFiles) {
|
||||
// We need at least 2 files to have audio and video splitted
|
||||
if (this.VideoFiles.length === 1) return false
|
||||
|
||||
let hasAudio = false
|
||||
let hasVideo = false
|
||||
|
||||
for (const file of this.VideoFiles) {
|
||||
// File contains both streams: audio and video is not splitted
|
||||
if (file.hasAudio() && file.hasVideo()) return false
|
||||
|
||||
if (file.resolution === VideoResolution.H_NOVIDEO) hasAudio = true
|
||||
else if (file.hasVideo()) hasVideo = true
|
||||
|
||||
if (hasVideo && hasAudio) return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
getStringType () {
|
||||
if (this.type === VideoStreamingPlaylistType.HLS) return 'hls'
|
||||
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import { buildVideoEmbedPath, buildVideoWatchPath, maxBy, minBy, pick, wait } from '@peertube/peertube-core-utils'
|
||||
import { ffprobePromise, getAudioStream, getVideoStreamDimensionsInfo, getVideoStreamFPS, hasAudioStream } from '@peertube/peertube-ffmpeg'
|
||||
import { buildVideoEmbedPath, buildVideoWatchPath, maxBy, pick, sortBy, wait } from '@peertube/peertube-core-utils'
|
||||
import {
|
||||
FileStorage,
|
||||
ResultList,
|
||||
|
@ -8,6 +7,8 @@ import {
|
|||
Video,
|
||||
VideoDetails,
|
||||
VideoFile,
|
||||
VideoFileStream,
|
||||
VideoFileStreamType,
|
||||
VideoInclude,
|
||||
VideoIncludeType,
|
||||
VideoObject,
|
||||
|
@ -73,7 +74,7 @@ import {
|
|||
isVideoStateValid,
|
||||
isVideoSupportValid
|
||||
} from '../../helpers/custom-validators/videos.js'
|
||||
import { logger } from '../../helpers/logger.js'
|
||||
import { logger, loggerTagsFactory } from '../../helpers/logger.js'
|
||||
import { CONFIG } from '../../initializers/config.js'
|
||||
import { ACTIVITY_PUB, API_VERSION, CONSTRAINTS_FIELDS, WEBSERVER } from '../../initializers/constants.js'
|
||||
import { sendDeleteVideo } from '../../lib/activitypub/send/index.js'
|
||||
|
@ -162,6 +163,8 @@ import { VideoSourceModel } from './video-source.js'
|
|||
import { VideoStreamingPlaylistModel } from './video-streaming-playlist.js'
|
||||
import { VideoTagModel } from './video-tag.js'
|
||||
|
||||
const lTags = loggerTagsFactory('video')
|
||||
|
||||
export enum ScopeNames {
|
||||
FOR_API = 'FOR_API',
|
||||
WITH_ACCOUNT_DETAILS = 'WITH_ACCOUNT_DETAILS',
|
||||
|
@ -1735,8 +1738,43 @@ export class VideoModel extends SequelizeModel<VideoModel> {
|
|||
return this.VideoChannel.Account.Actor.Server?.isBlocked() || this.VideoChannel.Account.isBlocked()
|
||||
}
|
||||
|
||||
getQualityFileBy<T extends MVideoWithFile> (this: T, fun: (files: MVideoFile[], property: 'resolution') => MVideoFile) {
|
||||
const files = this.getAllFiles()
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
getMaxQualityAudioAndVideoFiles <T extends MVideoWithFile> (this: T) {
|
||||
const videoFile = this.getMaxQualityFile(VideoFileStream.VIDEO)
|
||||
if (!videoFile) return { videoFile: undefined }
|
||||
|
||||
// File also has audio, we can return it
|
||||
if (videoFile.hasAudio()) return { videoFile }
|
||||
|
||||
const separatedAudioFile = this.getMaxQualityFile(VideoFileStream.AUDIO)
|
||||
if (!separatedAudioFile) return { videoFile }
|
||||
|
||||
return { videoFile, separatedAudioFile }
|
||||
}
|
||||
|
||||
getMaxQualityFile<T extends MVideoWithFile> (
|
||||
this: T,
|
||||
streamFilter: VideoFileStreamType
|
||||
): MVideoFileVideo | MVideoFileStreamingPlaylistVideo {
|
||||
return this.getQualityFileBy(streamFilter, maxBy)
|
||||
}
|
||||
|
||||
getMaxQualityBytes <T extends MVideoWithFile> (this: T) {
|
||||
const { videoFile, separatedAudioFile } = this.getMaxQualityAudioAndVideoFiles()
|
||||
|
||||
let size = videoFile.size
|
||||
if (separatedAudioFile) size += separatedAudioFile.size
|
||||
|
||||
return size
|
||||
}
|
||||
|
||||
getQualityFileBy<T extends MVideoWithFile> (
|
||||
this: T,
|
||||
streamFilter: VideoFileStreamType,
|
||||
fun: (files: MVideoFile[], property: 'resolution') => MVideoFile
|
||||
) {
|
||||
const files = this.getAllFiles().filter(f => f.streams & streamFilter)
|
||||
const file = fun(files, 'resolution')
|
||||
if (!file) return undefined
|
||||
|
||||
|
@ -1753,27 +1791,40 @@ export class VideoModel extends SequelizeModel<VideoModel> {
|
|||
throw new Error('File is not associated to a video of a playlist')
|
||||
}
|
||||
|
||||
getMaxQualityFile<T extends MVideoWithFile> (this: T): MVideoFileVideo | MVideoFileStreamingPlaylistVideo {
|
||||
return this.getQualityFileBy(maxBy)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
getMaxFPS () {
|
||||
return this.getMaxQualityFile(VideoFileStream.VIDEO).fps
|
||||
}
|
||||
|
||||
getMinQualityFile<T extends MVideoWithFile> (this: T): MVideoFileVideo | MVideoFileStreamingPlaylistVideo {
|
||||
return this.getQualityFileBy(minBy)
|
||||
getMaxResolution () {
|
||||
return this.getMaxQualityFile(VideoFileStream.VIDEO).resolution
|
||||
}
|
||||
|
||||
getWebVideoFile<T extends MVideoWithFile> (this: T, resolution: number): MVideoFileVideo {
|
||||
hasAudio () {
|
||||
return !!this.getMaxQualityFile(VideoFileStream.AUDIO)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
getWebVideoFileMinResolution<T extends MVideoWithFile> (this: T, resolution: number): MVideoFileVideo {
|
||||
if (Array.isArray(this.VideoFiles) === false) return undefined
|
||||
|
||||
const file = this.VideoFiles.find(f => f.resolution === resolution)
|
||||
if (!file) return undefined
|
||||
for (const file of sortBy(this.VideoFiles, 'resolution')) {
|
||||
if (file.resolution < resolution) continue
|
||||
|
||||
return Object.assign(file, { Video: this })
|
||||
return Object.assign(file, { Video: this })
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
hasWebVideoFiles () {
|
||||
return Array.isArray(this.VideoFiles) === true && this.VideoFiles.length !== 0
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async addAndSaveThumbnail (thumbnail: MThumbnail, transaction?: Transaction) {
|
||||
thumbnail.videoId = this.id
|
||||
|
||||
|
@ -1787,21 +1838,21 @@ export class VideoModel extends SequelizeModel<VideoModel> {
|
|||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
hasMiniature () {
|
||||
hasMiniature (this: MVideoThumbnail) {
|
||||
return !!this.getMiniature()
|
||||
}
|
||||
|
||||
getMiniature () {
|
||||
getMiniature (this: MVideoThumbnail) {
|
||||
if (Array.isArray(this.Thumbnails) === false) return undefined
|
||||
|
||||
return this.Thumbnails.find(t => t.type === ThumbnailType.MINIATURE)
|
||||
}
|
||||
|
||||
hasPreview () {
|
||||
hasPreview (this: MVideoThumbnail) {
|
||||
return !!this.getPreview()
|
||||
}
|
||||
|
||||
getPreview () {
|
||||
getPreview (this: MVideoThumbnail) {
|
||||
if (Array.isArray(this.Thumbnails) === false) return undefined
|
||||
|
||||
return this.Thumbnails.find(t => t.type === ThumbnailType.PREVIEW)
|
||||
|
@ -1930,27 +1981,6 @@ export class VideoModel extends SequelizeModel<VideoModel> {
|
|||
return files
|
||||
}
|
||||
|
||||
probeMaxQualityFile () {
|
||||
const file = this.getMaxQualityFile()
|
||||
const videoOrPlaylist = file.getVideoOrStreamingPlaylist()
|
||||
|
||||
return VideoPathManager.Instance.makeAvailableVideoFile(file.withVideoOrPlaylist(videoOrPlaylist), async originalFilePath => {
|
||||
const probe = await ffprobePromise(originalFilePath)
|
||||
|
||||
const { audioStream } = await getAudioStream(originalFilePath, probe)
|
||||
const hasAudio = await hasAudioStream(originalFilePath, probe)
|
||||
const fps = await getVideoStreamFPS(originalFilePath, probe)
|
||||
|
||||
return {
|
||||
audioStream,
|
||||
hasAudio,
|
||||
fps,
|
||||
|
||||
...await getVideoStreamDimensionsInfo(originalFilePath, probe)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
getDescriptionAPIPath () {
|
||||
return `/api/${API_VERSION}/videos/${this.uuid}/description`
|
||||
}
|
||||
|
@ -1977,6 +2007,8 @@ export class VideoModel extends SequelizeModel<VideoModel> {
|
|||
.concat(toAdd)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
removeWebVideoFile (videoFile: MVideoFile, isRedundancy = false) {
|
||||
const filePath = isRedundancy
|
||||
? VideoPathManager.Instance.getFSRedundancyVideoFilePath(this, videoFile)
|
||||
|
@ -1989,6 +2021,8 @@ export class VideoModel extends SequelizeModel<VideoModel> {
|
|||
promises.push(removeWebVideoObjectStorage(videoFile))
|
||||
}
|
||||
|
||||
logger.debug(`Removing files associated to web video ${videoFile.filename}`, { videoFile, isRedundancy, ...lTags(this.uuid) })
|
||||
|
||||
return Promise.all(promises)
|
||||
}
|
||||
|
||||
|
@ -2029,6 +2063,11 @@ export class VideoModel extends SequelizeModel<VideoModel> {
|
|||
await removeHLSObjectStorage(streamingPlaylist.withVideo(this))
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`Removing files associated to streaming playlist of video ${this.url}`,
|
||||
{ streamingPlaylist, isRedundancy, ...lTags(this.uuid) }
|
||||
)
|
||||
}
|
||||
|
||||
async removeStreamingPlaylistVideoFile (streamingPlaylist: MStreamingPlaylist, videoFile: MVideoFile) {
|
||||
|
@ -2043,6 +2082,11 @@ export class VideoModel extends SequelizeModel<VideoModel> {
|
|||
await removeHLSFileObjectStorageByFilename(streamingPlaylist.withVideo(this), videoFile.filename)
|
||||
await removeHLSFileObjectStorageByFilename(streamingPlaylist.withVideo(this), resolutionFilename)
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`Removing files associated to streaming playlist video file ${videoFile.filename}`,
|
||||
{ streamingPlaylist, ...lTags(this.uuid) }
|
||||
)
|
||||
}
|
||||
|
||||
async removeStreamingPlaylistFile (streamingPlaylist: MStreamingPlaylist, filename: string) {
|
||||
|
@ -2052,6 +2096,8 @@ export class VideoModel extends SequelizeModel<VideoModel> {
|
|||
if (streamingPlaylist.storage === FileStorage.OBJECT_STORAGE) {
|
||||
await removeHLSFileObjectStorageByFilename(streamingPlaylist.withVideo(this), filename)
|
||||
}
|
||||
|
||||
logger.debug(`Removing streaming playlist file ${filename}`, lTags(this.uuid))
|
||||
}
|
||||
|
||||
async removeOriginalFile (videoSource: MVideoSource) {
|
||||
|
@ -2063,8 +2109,12 @@ export class VideoModel extends SequelizeModel<VideoModel> {
|
|||
if (videoSource.storage === FileStorage.OBJECT_STORAGE) {
|
||||
await removeOriginalFileObjectStorage(videoSource)
|
||||
}
|
||||
|
||||
logger.debug(`Removing original video file ${videoSource.keptOriginalFilename}`, lTags(this.uuid))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
isOutdated () {
|
||||
if (this.isOwned()) return false
|
||||
|
||||
|
|
|
@ -66,8 +66,7 @@ export type MVideoIdThumbnail =
|
|||
Use<'Thumbnails', MThumbnail[]>
|
||||
|
||||
export type MVideoWithFileThumbnail =
|
||||
MVideo &
|
||||
Use<'VideoFiles', MVideoFile[]> &
|
||||
MVideoWithFile &
|
||||
Use<'Thumbnails', MThumbnail[]>
|
||||
|
||||
export type MVideoThumbnailBlacklist =
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue