Chocobozzz/PeerTube

View on GitHub
server/core/controllers/api/videos/source.ts

Summary

Maintainability
C
7 hrs
Test Coverage
import { buildAspectRatio } from '@peertube/peertube-core-utils'
import { HttpStatusCode, UserRight, VideoState } from '@peertube/peertube-models'
import { sequelizeTypescript } from '@server/initializers/database.js'
import { CreateJobArgument, CreateJobOptions, JobQueue } from '@server/lib/job-queue/index.js'
import { Hooks } from '@server/lib/plugins/hooks.js'
import { regenerateMiniaturesIfNeeded } from '@server/lib/thumbnail.js'
import { setupUploadResumableRoutes } from '@server/lib/uploadx.js'
import { autoBlacklistVideoIfNeeded } from '@server/lib/video-blacklist.js'
import { buildNewFile, createVideoSource } from '@server/lib/video-file.js'
import { buildMoveJob, buildStoryboardJobIfNeeded } from '@server/lib/video-jobs.js'
import { VideoPathManager } from '@server/lib/video-path-manager.js'
import { buildNextVideoState } from '@server/lib/video-state.js'
import { openapiOperationDoc } from '@server/middlewares/doc.js'
import { VideoModel } from '@server/models/video/video.js'
import { MStreamingPlaylistFiles, MVideo, MVideoFile, MVideoFullLight } from '@server/types/models/index.js'
import express from 'express'
import { move } from 'fs-extra/esm'
import { logger, loggerTagsFactory } from '../../../helpers/logger.js'
import {
  asyncMiddleware,
  authenticate,
  ensureUserHasRight,
  replaceVideoSourceResumableInitValidator,
  replaceVideoSourceResumableValidator,
  videoSourceGetLatestValidator
} from '../../../middlewares/index.js'

const lTags = loggerTagsFactory('api', 'video')

const videoSourceRouter = express.Router()

videoSourceRouter.get('/:id/source',
  openapiOperationDoc({ operationId: 'getVideoSource' }),
  authenticate,
  asyncMiddleware(videoSourceGetLatestValidator),
  getVideoLatestSource
)

videoSourceRouter.delete('/:id/source/file',
  openapiOperationDoc({ operationId: 'deleteVideoSourceFile' }),
  authenticate,
  ensureUserHasRight(UserRight.MANAGE_VIDEO_FILES),
  asyncMiddleware(videoSourceGetLatestValidator),
  asyncMiddleware(deleteVideoLatestSourceFile)
)

setupUploadResumableRoutes({
  routePath: '/:id/source/replace-resumable',
  router: videoSourceRouter,

  uploadInitAfterMiddlewares: [ asyncMiddleware(replaceVideoSourceResumableInitValidator) ],
  uploadedMiddlewares: [ asyncMiddleware(replaceVideoSourceResumableValidator) ],
  uploadedController: asyncMiddleware(replaceVideoSourceResumable)
})

// ---------------------------------------------------------------------------

export {
  videoSourceRouter
}

// ---------------------------------------------------------------------------

async function deleteVideoLatestSourceFile (req: express.Request, res: express.Response) {
  const videoSource = res.locals.videoSource
  const video = res.locals.videoAll

  await video.removeOriginalFile(videoSource)

  videoSource.keptOriginalFilename = null
  videoSource.storage = null

  await videoSource.save()

  return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}

function getVideoLatestSource (req: express.Request, res: express.Response) {
  return res.json(res.locals.videoSource.toFormattedJSON())
}

async function replaceVideoSourceResumable (req: express.Request, res: express.Response) {
  const videoPhysicalFile = res.locals.updateVideoFileResumable
  const user = res.locals.oauth.token.User

  const videoFile = await buildNewFile({ path: videoPhysicalFile.path, mode: 'web-video', ffprobe: res.locals.ffprobe })
  const originalFilename = videoPhysicalFile.originalname

  const videoFileMutexReleaser = await VideoPathManager.Instance.lockFiles(res.locals.videoAll.uuid)

  try {
    const destination = VideoPathManager.Instance.getFSVideoFileOutputPath(res.locals.videoAll, videoFile)
    await move(videoPhysicalFile.path, destination)

    let oldWebVideoFiles: MVideoFile[] = []
    let oldStreamingPlaylists: MStreamingPlaylistFiles[] = []

    const inputFileUpdatedAt = new Date()

    const video = await sequelizeTypescript.transaction(async transaction => {
      const video = await VideoModel.loadFull(res.locals.videoAll.id, transaction)

      oldWebVideoFiles = video.VideoFiles
      oldStreamingPlaylists = video.VideoStreamingPlaylists

      for (const file of video.VideoFiles) {
        await file.destroy({ transaction })
      }
      for (const playlist of oldStreamingPlaylists) {
        await playlist.destroy({ transaction })
      }

      videoFile.videoId = video.id
      await videoFile.save({ transaction })

      video.VideoFiles = [ videoFile ]
      video.VideoStreamingPlaylists = []

      video.state = buildNextVideoState()
      video.duration = videoPhysicalFile.duration
      video.inputFileUpdatedAt = inputFileUpdatedAt
      video.aspectRatio = buildAspectRatio({ width: videoFile.width, height: videoFile.height })
      await video.save({ transaction })

      await autoBlacklistVideoIfNeeded({
        video,
        user,
        isRemote: false,
        isNew: false,
        isNewFile: true,
        transaction
      })

      return video
    })

    await removeOldFiles({ video, files: oldWebVideoFiles, playlists: oldStreamingPlaylists })

    const source = await createVideoSource({
      inputFilename: originalFilename,
      inputProbe: res.locals.ffprobe,
      inputPath: destination,
      video,
      createdAt: inputFileUpdatedAt
    })

    await regenerateMiniaturesIfNeeded(video, res.locals.ffprobe)
    await video.VideoChannel.setAsUpdated()
    await addVideoJobsAfterUpload(video, videoFile.withVideoOrPlaylist(video))

    logger.info('Replaced video file of video %s with uuid %s.', video.name, video.uuid, lTags(video.uuid))

    Hooks.runAction('action:api.video.file-updated', { video, req, res })

    return res.json(source.toFormattedJSON())
  } finally {
    videoFileMutexReleaser()
  }
}

async function addVideoJobsAfterUpload (video: MVideoFullLight, videoFile: MVideoFile) {
  const jobs: (CreateJobArgument & CreateJobOptions)[] = [
    {
      type: 'manage-video-torrent' as 'manage-video-torrent',
      payload: {
        videoId: video.id,
        videoFileId: videoFile.id,
        action: 'create'
      }
    },

    buildStoryboardJobIfNeeded({ video, federate: false }),

    {
      type: 'federate-video' as 'federate-video',
      payload: {
        videoUUID: video.uuid,
        isNewVideoForFederation: false
      }
    }
  ]

  if (video.state === VideoState.TO_MOVE_TO_EXTERNAL_STORAGE) {
    jobs.push(await buildMoveJob({ video, isNewVideo: false, previousVideoState: undefined, type: 'move-to-object-storage' }))
  }

  if (video.state === VideoState.TO_TRANSCODE) {
    jobs.push({
      type: 'transcoding-job-builder' as 'transcoding-job-builder',
      payload: {
        videoUUID: video.uuid,
        optimizeJob: {
          isNewVideo: false
        }
      }
    })
  }

  return JobQueue.Instance.createSequentialJobFlow(...jobs)
}

async function removeOldFiles (options: {
  video: MVideo
  files: MVideoFile[]
  playlists: MStreamingPlaylistFiles[]
}) {
  const { video, files, playlists } = options

  for (const file of files) {
    await video.removeWebVideoFile(file)
  }

  for (const playlist of playlists) {
    await video.removeStreamingPlaylistFiles(playlist)
  }
}