1
0
Fork 0
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:
Chocobozzz 2024-07-23 16:38:51 +02:00 committed by Chocobozzz
parent e77ba2dfbc
commit 816f346a60
186 changed files with 5748 additions and 2807 deletions

View file

@ -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'],

View file

@ -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

View file

@ -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)

View file

@ -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))

View file

@ -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 }),

View file

@ -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
}

View file

@ -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({

View file

@ -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
}

View file

@ -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

View file

@ -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 {

View file

@ -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`)
}
}
}

View file

@ -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') },

View file

@ -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,

View file

@ -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
}

View file

@ -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 []

View file

@ -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) {

View file

@ -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

View file

@ -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)

View file

@ -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' ]),

View file

@ -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)

View file

@ -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

View file

@ -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,

View 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 })
}

View file

@ -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

View file

@ -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
}

View file

@ -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 }
})
}
}
// ---------------------------------------------------------------------------

View file

@ -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

View file

@ -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,

View file

@ -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) {

View file

@ -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' ]),

View file

@ -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)
}
}

View file

@ -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)) {

View file

@ -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
}
}

View file

@ -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,

View file

@ -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'
}

View file

@ -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
// ---------------------------------------------------------------------------

View file

@ -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,

View file

@ -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
}

View file

@ -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
}
}
}

View file

@ -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
}
}
}

View file

@ -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
}

View file

@ -8,7 +8,7 @@ export type ExportResult <T> = {
staticFiles: {
archivePath: string
createrReadStream: () => Promise<Readable>
readStreamFactory: () => Promise<Readable>
}[]
activityPub?: ActivityPubActor | ActivityPubOrderedCollection<string>

View file

@ -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))

View file

@ -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))

View file

@ -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) {

View file

@ -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 })
})
})

View file

@ -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

View file

@ -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)

View file

@ -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 }
}

View file

@ -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 () {

View file

@ -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
}) {

View file

@ -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) {

View file

@ -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
}

View file

@ -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) {

View file

@ -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()
}
// ---------------------------------------------------------------------------

View file

@ -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()
}
})
}

View file

@ -91,6 +91,8 @@ export class VideoTableAttributes {
'videoId',
'width',
'height',
'formatFlags',
'streams',
'storage'
]
}

View file

@ -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
}
}
}

View file

@ -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,

View file

@ -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'

View file

@ -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

View file

@ -66,8 +66,7 @@ export type MVideoIdThumbnail =
Use<'Thumbnails', MThumbnail[]>
export type MVideoWithFileThumbnail =
MVideo &
Use<'VideoFiles', MVideoFile[]> &
MVideoWithFile &
Use<'Thumbnails', MThumbnail[]>
export type MVideoThumbnailBlacklist =