diff --git a/packages/ffmpeg/src/ffmpeg-container.ts b/packages/ffmpeg/src/ffmpeg-container.ts index 0dbf51a72..6c4d840e6 100644 --- a/packages/ffmpeg/src/ffmpeg-container.ts +++ b/packages/ffmpeg/src/ffmpeg-container.ts @@ -12,8 +12,10 @@ export class FFmpegContainer { inputs: (Readable | string)[] output: Writable logError: boolean + + coverPath?: string }) { - const { inputs, output, logError } = options + const { inputs, output, logError, coverPath } = options this.commandWrapper.buildCommand(inputs) .outputOption('-c copy') @@ -21,6 +23,11 @@ export class FFmpegContainer { .format('mp4') .output(output) + if (coverPath) { + this.commandWrapper.getCommand() + .addInput(coverPath) + } + return this.commandWrapper.runCommand({ silent: !logError }) } } diff --git a/packages/tests/src/api/videos/generate-download.ts b/packages/tests/src/api/videos/generate-download.ts index 34c32c00a..7f88c3333 100644 --- a/packages/tests/src/api/videos/generate-download.ts +++ b/packages/tests/src/api/videos/generate-download.ts @@ -83,45 +83,46 @@ describe('Test generate download', function () { return probeResBody(body) } - function checkProbe (probe: FfprobeData, options: { hasVideo: boolean, hasAudio: boolean }) { - expect(probe.streams.some(s => s.codec_type === 'video')).to.equal(options.hasVideo) + function checkProbe (probe: FfprobeData, options: { hasVideo: boolean, hasAudio: boolean, hasImage: boolean }) { + expect(probe.streams.some(s => s.codec_type === 'video' && s.codec_name !== 'mjpeg')).to.equal(options.hasVideo) expect(probe.streams.some(s => s.codec_type === 'audio')).to.equal(options.hasAudio) + expect(probe.streams.some(s => s.codec_name === 'mjpeg')).to.equal(options.hasImage) } it('Should generate a classic web video file', async function () { const probe = await getProbe('common', video => [ getVideoFile(video.files).id ]) - checkProbe(probe, { hasAudio: true, hasVideo: true }) + checkProbe(probe, { hasAudio: true, hasVideo: true, hasImage: false }) }) it('Should generate a classic HLS file', async function () { const probe = await getProbe('common', video => [ getVideoFile(getHLS(video).files).id ]) - checkProbe(probe, { hasAudio: true, hasVideo: true }) + checkProbe(probe, { hasAudio: true, hasVideo: true, hasImage: false }) }) it('Should generate an audio only web video file', async function () { const probe = await getProbe('common', video => [ getAudioOnlyFile(video.files).id ]) - checkProbe(probe, { hasAudio: true, hasVideo: false }) + checkProbe(probe, { hasAudio: true, hasVideo: false, hasImage: true }) }) it('Should generate an audio only HLS file', async function () { const probe = await getProbe('common', video => [ getAudioOnlyFile(getHLS(video).files).id ]) - checkProbe(probe, { hasAudio: true, hasVideo: false }) + checkProbe(probe, { hasAudio: true, hasVideo: false, hasImage: true }) }) it('Should generate a video only file', async function () { const probe = await getProbe('splitted', video => [ getVideoFile(getHLS(video).files).id ]) - checkProbe(probe, { hasAudio: false, hasVideo: true }) + checkProbe(probe, { hasAudio: false, hasVideo: true, hasImage: false }) }) it('Should merge audio and video files', async function () { const probe = await getProbe('splitted', video => [ getVideoFile(getHLS(video).files).id, getAudioFile(getHLS(video).files).id ]) - checkProbe(probe, { hasAudio: true, hasVideo: true }) + checkProbe(probe, { hasAudio: true, hasVideo: true, hasImage: false }) }) it('Should have cleaned the TMP directory', async function () { diff --git a/server/core/controllers/download.ts b/server/core/controllers/download.ts index f2a6bda99..f3a32cc0e 100644 --- a/server/core/controllers/download.ts +++ b/server/core/controllers/download.ts @@ -1,5 +1,5 @@ import { forceNumber, maxBy } from '@peertube/peertube-core-utils' -import { FileStorage, HttpStatusCode, VideoStreamingPlaylistType } from '@peertube/peertube-models' +import { FileStorage, HttpStatusCode, VideoResolution, VideoStreamingPlaylistType } from '@peertube/peertube-models' import { exists } from '@server/helpers/custom-validators/misc.js' import { logger, loggerTagsFactory } from '@server/helpers/logger.js' import { CONFIG } from '@server/initializers/config.js' @@ -244,7 +244,14 @@ async function downloadGeneratedVideoFile (req: express.Request, res: express.Re if (!checkAllowResult(res, allowParameters, allowedResult)) return - const downloadFilename = buildDownloadFilename({ video, extname: maxBy(videoFiles, 'resolution').extname }) + const maxResolutionFile = maxBy(videoFiles, 'resolution') + + // Prefer m4a extension for the user if this is a mp4 audio file only + const extname = maxResolutionFile.resolution === VideoResolution.H_NOVIDEO && maxResolutionFile.extname === '.mp4' + ? '.m4a' + : maxResolutionFile.extname + + const downloadFilename = buildDownloadFilename({ video, extname }) res.setHeader('Content-disposition', `attachment; filename="${encodeURI(downloadFilename)}`) await muxToMergeVideoFiles({ video, videoFiles, output: res }) diff --git a/server/core/helpers/requests.ts b/server/core/helpers/requests.ts index 9863f53bf..a0b30e802 100644 --- a/server/core/helpers/requests.ts +++ b/server/core/helpers/requests.ts @@ -191,7 +191,7 @@ export function isBinaryResponse (result: Response) { // --------------------------------------------------------------------------- function buildGotOptions (options: PeerTubeRequestOptions): OptionsOfUnknownResponseBody { - const { activityPub, bodyKBLimit = 1000 } = options + const { activityPub, bodyKBLimit = 3000 } = options const context = { bodyKBLimit, httpSignature: options.httpSignature } diff --git a/server/core/lib/video-file.ts b/server/core/lib/video-file.ts index c98cc293d..ff7c26f06 100644 --- a/server/core/lib/video-file.ts +++ b/server/core/lib/video-file.ts @@ -16,7 +16,7 @@ import { CONFIG } from '@server/initializers/config.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 { MVideo, MVideoFile, MVideoId, MVideoThumbnail, MVideoWithAllFiles } from '@server/types/models/index.js' import { FfprobeData } from 'fluent-ffmpeg' import { move, remove } from 'fs-extra/esm' import { Readable, Writable } from 'stream' @@ -264,7 +264,7 @@ export async function saveNewOriginalFileIfNeeded (video: MVideo, videoFile: MVi // --------------------------------------------------------------------------- export async function muxToMergeVideoFiles (options: { - video: MVideo + video: MVideoThumbnail videoFiles: MVideoFile[] output: Writable }) { @@ -274,9 +274,13 @@ export async function muxToMergeVideoFiles (options: { const tmpDestinations: string[] = [] try { + let maxResolution = 0 + for (const videoFile of videoFiles) { if (!videoFile) continue + maxResolution = Math.max(maxResolution, videoFile.resolution) + const { input, isTmpDestination } = await buildMuxInput(video, videoFile) inputs.push(input) @@ -284,6 +288,13 @@ export async function muxToMergeVideoFiles (options: { if (isTmpDestination === true) tmpDestinations.push(input) } + // Include cover to audio file? + const { coverPath, isTmpDestination } = maxResolution === 0 + ? await buildCoverInput(video) + : { coverPath: undefined, isTmpDestination: false } + + if (coverPath && isTmpDestination) tmpDestinations.push(coverPath) + const inputsToLog = inputs.map(i => { if (typeof i === 'string') return i @@ -293,7 +304,14 @@ export async function muxToMergeVideoFiles (options: { logger.info(`Muxing files for video ${video.url}`, { inputs: inputsToLog, ...lTags(video.uuid) }) try { - await new FFmpegContainer(getFFmpegCommandWrapperOptions('vod')).mergeInputs({ inputs, output, logError: true }) + await new FFmpegContainer(getFFmpegCommandWrapperOptions('vod')).mergeInputs({ + inputs, + output, + logError: true, + + // Include a cover if this is an audio file + coverPath + }) logger.info(`Mux ended for video ${video.url}`, { inputs: inputsToLog, ...lTags(video.uuid) }) } catch (err) { @@ -391,3 +409,19 @@ async function buildMuxInput ( return { input: stream, isTmpDestination: false } } + +async function buildCoverInput (video: MVideoThumbnail) { + const preview = video.getPreview() + + if (video.isOwned()) return { coverPath: preview?.getPath() } + + if (preview.fileUrl) { + const destination = VideoPathManager.Instance.buildTMPDestination(preview.filename) + + await doRequestAndSaveToFile(preview.fileUrl, destination) + + return { coverPath: destination, isTmpDestination: true } + } + + return { coverPath: undefined } +}