From 6a4905602636afd6650c9e6f4d0fcc2105d91100 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Wed, 3 May 2023 15:17:11 +0200 Subject: Add TMP persistent directory To store files that must be preserved between peertube restarts --- server/controllers/api/videos/studio.ts | 40 +++++++--- server/initializers/config.ts | 1 + .../lib/job-queue/handlers/video-studio-edition.ts | 90 ++++++++++++---------- server/lib/video-studio.ts | 23 +++++- .../middlewares/validators/videos/video-studio.ts | 6 +- server/tests/api/transcoding/video-studio.ts | 25 +++++- server/tests/shared/directories.ts | 5 ++ 7 files changed, 133 insertions(+), 57 deletions(-) (limited to 'server') diff --git a/server/controllers/api/videos/studio.ts b/server/controllers/api/videos/studio.ts index 6667532bf..2ccb2fb89 100644 --- a/server/controllers/api/videos/studio.ts +++ b/server/controllers/api/videos/studio.ts @@ -1,8 +1,12 @@ +import Bluebird from 'bluebird' import express from 'express' +import { move } from 'fs-extra' +import { basename, join } from 'path' import { createAnyReqFiles } from '@server/helpers/express-utils' +import { CONFIG } from '@server/initializers/config' import { MIMETYPES } from '@server/initializers/constants' import { JobQueue } from '@server/lib/job-queue' -import { buildTaskFileFieldname, getTaskFile } from '@server/lib/video-studio' +import { buildTaskFileFieldname, getTaskFileFromReq } from '@server/lib/video-studio' import { HttpStatusCode, VideoState, @@ -68,7 +72,7 @@ async function createEditionTasks (req: express.Request, res: express.Response) const payload = { videoUUID: video.uuid, - tasks: body.tasks.map((t, i) => buildTaskPayload(t, i, files)) + tasks: await Bluebird.mapSeries(body.tasks, (t, i) => buildTaskPayload(t, i, files)) } JobQueue.Instance.createJobAsync({ type: 'video-studio-edition', payload }) @@ -77,7 +81,11 @@ async function createEditionTasks (req: express.Request, res: express.Response) } const taskPayloadBuilders: { - [id in VideoStudioTask['name']]: (task: VideoStudioTask, indice?: number, files?: Express.Multer.File[]) => VideoStudioTaskPayload + [id in VideoStudioTask['name']]: ( + task: VideoStudioTask, + indice?: number, + files?: Express.Multer.File[] + ) => Promise } = { 'add-intro': buildIntroOutroTask, 'add-outro': buildIntroOutroTask, @@ -85,34 +93,46 @@ const taskPayloadBuilders: { 'add-watermark': buildWatermarkTask } -function buildTaskPayload (task: VideoStudioTask, indice: number, files: Express.Multer.File[]): VideoStudioTaskPayload { +function buildTaskPayload (task: VideoStudioTask, indice: number, files: Express.Multer.File[]): Promise { return taskPayloadBuilders[task.name](task, indice, files) } -function buildIntroOutroTask (task: VideoStudioTaskIntro | VideoStudioTaskOutro, indice: number, files: Express.Multer.File[]) { +async function buildIntroOutroTask (task: VideoStudioTaskIntro | VideoStudioTaskOutro, indice: number, files: Express.Multer.File[]) { + const destination = await moveStudioFileToPersistentTMP(getTaskFileFromReq(files, indice).path) + return { name: task.name, options: { - file: getTaskFile(files, indice).path + file: destination } } } function buildCutTask (task: VideoStudioTaskCut) { - return { + return Promise.resolve({ name: task.name, options: { start: task.options.start, end: task.options.end } - } + }) } -function buildWatermarkTask (task: VideoStudioTaskWatermark, indice: number, files: Express.Multer.File[]) { +async function buildWatermarkTask (task: VideoStudioTaskWatermark, indice: number, files: Express.Multer.File[]) { + const destination = await moveStudioFileToPersistentTMP(getTaskFileFromReq(files, indice).path) + return { name: task.name, options: { - file: getTaskFile(files, indice).path + file: destination } } } + +async function moveStudioFileToPersistentTMP (file: string) { + const destination = join(CONFIG.STORAGE.TMP_PERSISTENT_DIR, basename(file)) + + await move(file, destination) + + return destination +} diff --git a/server/initializers/config.ts b/server/initializers/config.ts index 699dd4704..f2d8f99b5 100644 --- a/server/initializers/config.ts +++ b/server/initializers/config.ts @@ -98,6 +98,7 @@ const CONFIG = { STORAGE: { TMP_DIR: buildPath(config.get('storage.tmp')), + TMP_PERSISTENT_DIR: buildPath(config.get('storage.tmp_persistent')), BIN_DIR: buildPath(config.get('storage.bin')), ACTOR_IMAGES: buildPath(config.get('storage.avatars')), LOG_DIR: buildPath(config.get('storage.logs')), diff --git a/server/lib/job-queue/handlers/video-studio-edition.ts b/server/lib/job-queue/handlers/video-studio-edition.ts index fbb55a388..5e8dd4f51 100644 --- a/server/lib/job-queue/handlers/video-studio-edition.ts +++ b/server/lib/job-queue/handlers/video-studio-edition.ts @@ -12,7 +12,7 @@ import { VideoTranscodingProfilesManager } from '@server/lib/transcoding/default import { isAbleToUploadVideo } from '@server/lib/user' import { buildFileMetadata, removeHLSPlaylist, removeWebTorrentFile } from '@server/lib/video-file' import { VideoPathManager } from '@server/lib/video-path-manager' -import { approximateIntroOutroAdditionalSize } from '@server/lib/video-studio' +import { approximateIntroOutroAdditionalSize, safeCleanupStudioTMPFiles } from '@server/lib/video-studio' import { UserModel } from '@server/models/user/user' import { VideoModel } from '@server/models/video/video' import { VideoFileModel } from '@server/models/video/video-file' @@ -39,63 +39,73 @@ async function processVideoStudioEdition (job: Job) { logger.info('Process video studio edition of %s in job %s.', payload.videoUUID, job.id, lTags) - const video = await VideoModel.loadFull(payload.videoUUID) + try { + const video = await VideoModel.loadFull(payload.videoUUID) - // No video, maybe deleted? - if (!video) { - logger.info('Can\'t process job %d, video does not exist.', job.id, lTags) - return undefined - } + // No video, maybe deleted? + if (!video) { + logger.info('Can\'t process job %d, video does not exist.', job.id, lTags) - await checkUserQuotaOrThrow(video, payload) + await safeCleanupStudioTMPFiles(payload) + return undefined + } - const inputFile = video.getMaxQualityFile() + await checkUserQuotaOrThrow(video, payload) - const editionResultPath = await VideoPathManager.Instance.makeAvailableVideoFile(inputFile, async originalFilePath => { - let tmpInputFilePath: string - let outputPath: string + const inputFile = video.getMaxQualityFile() - for (const task of payload.tasks) { - const outputFilename = buildUUID() + inputFile.extname - outputPath = join(CONFIG.STORAGE.TMP_DIR, outputFilename) + const editionResultPath = await VideoPathManager.Instance.makeAvailableVideoFile(inputFile, async originalFilePath => { + let tmpInputFilePath: string + let outputPath: string - await processTask({ - inputPath: tmpInputFilePath ?? originalFilePath, - video, - outputPath, - task, - lTags - }) + for (const task of payload.tasks) { + const outputFilename = buildUUID() + inputFile.extname + outputPath = join(CONFIG.STORAGE.TMP_DIR, outputFilename) - if (tmpInputFilePath) await remove(tmpInputFilePath) + await processTask({ + inputPath: tmpInputFilePath ?? originalFilePath, + video, + outputPath, + task, + lTags + }) - // For the next iteration - tmpInputFilePath = outputPath - } + if (tmpInputFilePath) await remove(tmpInputFilePath) - return outputPath - }) + // For the next iteration + tmpInputFilePath = outputPath + } - logger.info('Video edition ended for video %s.', video.uuid, lTags) + return outputPath + }) - const newFile = await buildNewFile(video, editionResultPath) + logger.info('Video edition ended for video %s.', video.uuid, lTags) - const outputPath = VideoPathManager.Instance.getFSVideoFileOutputPath(video, newFile) - await move(editionResultPath, outputPath) + const newFile = await buildNewFile(video, editionResultPath) - await createTorrentAndSetInfoHashFromPath(video, newFile, outputPath) - await removeAllFiles(video, newFile) + const outputPath = VideoPathManager.Instance.getFSVideoFileOutputPath(video, newFile) + await move(editionResultPath, outputPath) - await newFile.save() + await safeCleanupStudioTMPFiles(payload) - video.duration = await getVideoStreamDuration(outputPath) - await video.save() + await createTorrentAndSetInfoHashFromPath(video, newFile, outputPath) + await removeAllFiles(video, newFile) - await federateVideoIfNeeded(video, false, undefined) + await newFile.save() - const user = await UserModel.loadByVideoId(video.id) + video.duration = await getVideoStreamDuration(outputPath) + await video.save() - await createOptimizeOrMergeAudioJobs({ video, videoFile: newFile, isNewVideo: false, user, videoFileAlreadyLocked: false }) + await federateVideoIfNeeded(video, false, undefined) + + const user = await UserModel.loadByVideoId(video.id) + + await createOptimizeOrMergeAudioJobs({ video, videoFile: newFile, isNewVideo: false, user, videoFileAlreadyLocked: false }) + } catch (err) { + await safeCleanupStudioTMPFiles(payload) + + throw err + } } // --------------------------------------------------------------------------- diff --git a/server/lib/video-studio.ts b/server/lib/video-studio.ts index b392bdb00..beda326a0 100644 --- a/server/lib/video-studio.ts +++ b/server/lib/video-studio.ts @@ -1,15 +1,31 @@ +import { logger } from '@server/helpers/logger' import { MVideoFullLight } from '@server/types/models' import { getVideoStreamDuration } from '@shared/ffmpeg' -import { VideoStudioTask } from '@shared/models' +import { VideoStudioEditionPayload, VideoStudioTask } from '@shared/models' +import { remove } from 'fs-extra' function buildTaskFileFieldname (indice: number, fieldName = 'file') { return `tasks[${indice}][options][${fieldName}]` } -function getTaskFile (files: Express.Multer.File[], indice: number, fieldName = 'file') { +function getTaskFileFromReq (files: Express.Multer.File[], indice: number, fieldName = 'file') { return files.find(f => f.fieldname === buildTaskFileFieldname(indice, fieldName)) } +async function safeCleanupStudioTMPFiles (payload: VideoStudioEditionPayload) { + for (const task of payload.tasks) { + try { + if (task.name === 'add-intro' || task.name === 'add-outro') { + await remove(task.options.file) + } else if (task.name === 'add-watermark') { + await remove(task.options.file) + } + } catch (err) { + logger.error('Cannot remove studio file', { err }) + } + } +} + async function approximateIntroOutroAdditionalSize (video: MVideoFullLight, tasks: VideoStudioTask[], fileFinder: (i: number) => string) { let additionalDuration = 0 @@ -28,5 +44,6 @@ async function approximateIntroOutroAdditionalSize (video: MVideoFullLight, task export { approximateIntroOutroAdditionalSize, buildTaskFileFieldname, - getTaskFile + getTaskFileFromReq, + safeCleanupStudioTMPFiles } diff --git a/server/middlewares/validators/videos/video-studio.ts b/server/middlewares/validators/videos/video-studio.ts index 4397e887e..7a68f88e5 100644 --- a/server/middlewares/validators/videos/video-studio.ts +++ b/server/middlewares/validators/videos/video-studio.ts @@ -9,7 +9,7 @@ import { } from '@server/helpers/custom-validators/video-studio' import { cleanUpReqFiles } from '@server/helpers/express-utils' import { CONFIG } from '@server/initializers/config' -import { approximateIntroOutroAdditionalSize, getTaskFile } from '@server/lib/video-studio' +import { approximateIntroOutroAdditionalSize, getTaskFileFromReq } from '@server/lib/video-studio' import { isAudioFile } from '@shared/ffmpeg' import { HttpStatusCode, UserRight, VideoState, VideoStudioCreateEdition, VideoStudioTask } from '@shared/models' import { areValidationErrors, checkUserCanManageVideo, checkUserQuota, doesVideoExist } from '../shared' @@ -49,7 +49,7 @@ const videoStudioAddEditionValidator = [ } if (task.name === 'add-intro' || task.name === 'add-outro') { - const filePath = getTaskFile(files, i).path + const filePath = getTaskFileFromReq(files, i).path // Our concat filter needs a video stream if (await isAudioFile(filePath)) { @@ -79,7 +79,7 @@ const videoStudioAddEditionValidator = [ if (!checkUserCanManageVideo(user, video, UserRight.UPDATE_ANY_VIDEO, res)) return cleanUpReqFiles(req) // Try to make an approximation of bytes added by the intro/outro - const additionalBytes = await approximateIntroOutroAdditionalSize(video, body.tasks, i => getTaskFile(files, i).path) + const additionalBytes = await approximateIntroOutroAdditionalSize(video, body.tasks, i => getTaskFileFromReq(files, i).path) if (await checkUserQuota(user, additionalBytes, res) === false) return cleanUpReqFiles(req) return next() diff --git a/server/tests/api/transcoding/video-studio.ts b/server/tests/api/transcoding/video-studio.ts index ab08e8fb6..30f72e6e9 100644 --- a/server/tests/api/transcoding/video-studio.ts +++ b/server/tests/api/transcoding/video-studio.ts @@ -1,5 +1,5 @@ import { expect } from 'chai' -import { expectStartWith } from '@server/tests/shared' +import { checkPersistentTmpIsEmpty, expectStartWith } from '@server/tests/shared' import { areMockObjectStorageTestsDisabled, getAllFiles } from '@shared/core-utils' import { VideoStudioTask } from '@shared/models' import { @@ -356,6 +356,29 @@ describe('Test video studio', function () { }) }) + describe('Server restart', function () { + + it('Should still be able to run video edition after a server restart', async function () { + this.timeout(240_000) + + await renewVideo() + await servers[0].videoStudio.createEditionTasks({ videoId: videoUUID, tasks: VideoStudioCommand.getComplexTask() }) + + await servers[0].kill() + await servers[0].run() + + await waitJobs(servers) + + for (const server of servers) { + await checkDuration(server, 9) + } + }) + + it('Should have an empty persistent tmp directory', async function () { + await checkPersistentTmpIsEmpty(servers[0]) + }) + }) + after(async function () { await cleanupTests(servers) }) diff --git a/server/tests/shared/directories.ts b/server/tests/shared/directories.ts index 90d534a06..a614cef7c 100644 --- a/server/tests/shared/directories.ts +++ b/server/tests/shared/directories.ts @@ -12,6 +12,10 @@ async function checkTmpIsEmpty (server: PeerTubeServer) { } } +async function checkPersistentTmpIsEmpty (server: PeerTubeServer) { + await checkDirectoryIsEmpty(server, 'tmp-persistent') +} + async function checkDirectoryIsEmpty (server: PeerTubeServer, directory: string, exceptions: string[] = []) { const directoryPath = server.getDirectoryPath(directory) @@ -26,5 +30,6 @@ async function checkDirectoryIsEmpty (server: PeerTubeServer, directory: string, export { checkTmpIsEmpty, + checkPersistentTmpIsEmpty, checkDirectoryIsEmpty } -- cgit v1.2.3