Process video torrents in order

Prevent update before video torrent generation for example
This commit is contained in:
Chocobozzz 2022-03-16 15:34:21 +01:00
parent 8366491890
commit f012319a64
No known key found for this signature in database
GPG Key ID: 583A612D890159BE
7 changed files with 178 additions and 53 deletions

View File

@ -1,12 +1,12 @@
import express from 'express' import express from 'express'
import { Transaction } from 'sequelize/types' import { Transaction } from 'sequelize/types'
import { updateTorrentMetadata } from '@server/helpers/webtorrent'
import { changeVideoChannelShare } from '@server/lib/activitypub/share' import { changeVideoChannelShare } from '@server/lib/activitypub/share'
import { JobQueue } from '@server/lib/job-queue'
import { buildVideoThumbnailsFromReq, setVideoTags } from '@server/lib/video' import { buildVideoThumbnailsFromReq, setVideoTags } from '@server/lib/video'
import { openapiOperationDoc } from '@server/middlewares/doc' import { openapiOperationDoc } from '@server/middlewares/doc'
import { FilteredModelAttributes } from '@server/types' import { FilteredModelAttributes } from '@server/types'
import { MVideoFullLight } from '@server/types/models' import { MVideoFullLight } from '@server/types/models'
import { HttpStatusCode, VideoUpdate } from '@shared/models' import { HttpStatusCode, ManageVideoTorrentPayload, VideoUpdate } from '@shared/models'
import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger' import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger'
import { resetSequelizeInstance } from '../../../helpers/database-utils' import { resetSequelizeInstance } from '../../../helpers/database-utils'
import { createReqFiles } from '../../../helpers/express-utils' import { createReqFiles } from '../../../helpers/express-utils'
@ -139,15 +139,13 @@ async function updateVideo (req: express.Request, res: express.Response) {
return { videoInstanceUpdated, isNewVideo } return { videoInstanceUpdated, isNewVideo }
}) })
if (videoInstanceUpdated.isLive !== true && videoInfoToUpdate.name) { const refreshedVideo = await updateTorrentsMetadataIfNeeded(videoInstanceUpdated, videoInfoToUpdate)
await updateTorrentsMetadata(videoInstanceUpdated)
}
await sequelizeTypescript.transaction(t => federateVideoIfNeeded(videoInstanceUpdated, isNewVideo, t)) await sequelizeTypescript.transaction(t => federateVideoIfNeeded(refreshedVideo, isNewVideo, t))
if (wasConfidentialVideo) Notifier.Instance.notifyOnNewVideoIfNeeded(videoInstanceUpdated) if (wasConfidentialVideo) Notifier.Instance.notifyOnNewVideoIfNeeded(refreshedVideo)
Hooks.runAction('action:api.video.updated', { video: videoInstanceUpdated, body: req.body, req, res }) Hooks.runAction('action:api.video.updated', { video: refreshedVideo, body: req.body, req, res })
} catch (err) { } catch (err) {
// Force fields we want to update // Force fields we want to update
// If the transaction is retried, sequelize will think the object has not changed // If the transaction is retried, sequelize will think the object has not changed
@ -194,19 +192,25 @@ function updateSchedule (videoInstance: MVideoFullLight, videoInfoToUpdate: Vide
} }
} }
async function updateTorrentsMetadata (video: MVideoFullLight) { async function updateTorrentsMetadataIfNeeded (video: MVideoFullLight, videoInfoToUpdate: VideoUpdate) {
for (const file of (video.VideoFiles || [])) { if (video.isLive || !videoInfoToUpdate.name) return video
await updateTorrentMetadata(video, file)
await file.save() for (const file of (video.VideoFiles || [])) {
const payload: ManageVideoTorrentPayload = { action: 'update-metadata', videoId: video.id, videoFileId: file.id }
const job = await JobQueue.Instance.createJobWithPromise({ type: 'manage-video-torrent', payload })
await job.finished()
} }
const hls = video.getHLSPlaylist() const hls = video.getHLSPlaylist()
if (!hls) return
for (const file of (hls.VideoFiles || [])) { for (const file of (hls?.VideoFiles || [])) {
await updateTorrentMetadata(hls, file) const payload: ManageVideoTorrentPayload = { action: 'update-metadata', streamingPlaylistId: hls.id, videoFileId: file.id }
await file.save() const job = await JobQueue.Instance.createJobWithPromise({ type: 'manage-video-torrent', payload })
await job.finished()
} }
// Refresh video since files have changed
return VideoModel.loadAndPopulateAccountAndServerAndTags(video.id)
} }

View File

@ -2,8 +2,8 @@ import express from 'express'
import { move } from 'fs-extra' import { move } from 'fs-extra'
import { basename } from 'path' import { basename } from 'path'
import { getResumableUploadPath } from '@server/helpers/upload' import { getResumableUploadPath } from '@server/helpers/upload'
import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent'
import { getLocalVideoActivityPubUrl } from '@server/lib/activitypub/url' import { getLocalVideoActivityPubUrl } from '@server/lib/activitypub/url'
import { JobQueue } from '@server/lib/job-queue'
import { generateWebTorrentVideoFilename } from '@server/lib/paths' import { generateWebTorrentVideoFilename } from '@server/lib/paths'
import { Redis } from '@server/lib/redis' import { Redis } from '@server/lib/redis'
import { uploadx } from '@server/lib/uploadx' import { uploadx } from '@server/lib/uploadx'
@ -17,10 +17,10 @@ import {
import { VideoPathManager } from '@server/lib/video-path-manager' import { VideoPathManager } from '@server/lib/video-path-manager'
import { buildNextVideoState } from '@server/lib/video-state' import { buildNextVideoState } from '@server/lib/video-state'
import { openapiOperationDoc } from '@server/middlewares/doc' import { openapiOperationDoc } from '@server/middlewares/doc'
import { MVideo, MVideoFile, MVideoFullLight } from '@server/types/models' import { MVideoFile, MVideoFullLight } from '@server/types/models'
import { getLowercaseExtension } from '@shared/core-utils' import { getLowercaseExtension } from '@shared/core-utils'
import { isAudioFile, uuidToShort } from '@shared/extra-utils' import { isAudioFile, uuidToShort } from '@shared/extra-utils'
import { HttpStatusCode, VideoCreate, VideoResolution, VideoState } from '@shared/models' import { HttpStatusCode, ManageVideoTorrentPayload, VideoCreate, VideoResolution, VideoState } from '@shared/models'
import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger' import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger'
import { retryTransactionWrapper } from '../../../helpers/database-utils' import { retryTransactionWrapper } from '../../../helpers/database-utils'
import { createReqFiles } from '../../../helpers/express-utils' import { createReqFiles } from '../../../helpers/express-utils'
@ -209,17 +209,22 @@ async function addVideo (options: {
// Channel has a new content, set as updated // Channel has a new content, set as updated
await videoCreated.VideoChannel.setAsUpdated() await videoCreated.VideoChannel.setAsUpdated()
createTorrentFederate(video, videoFile) createTorrentFederate(videoCreated, videoFile)
.then(() => { .catch(err => {
if (video.state === VideoState.TO_MOVE_TO_EXTERNAL_STORAGE) { logger.error('Cannot create torrent or federate video for %s.', videoCreated.uuid, { err, ...lTags(videoCreated.uuid) })
return addMoveToObjectStorageJob(video)
return videoCreated
}).then(refreshedVideo => {
if (!refreshedVideo) return
if (refreshedVideo.state === VideoState.TO_MOVE_TO_EXTERNAL_STORAGE) {
return addMoveToObjectStorageJob(refreshedVideo)
} }
if (video.state === VideoState.TO_TRANSCODE) { if (refreshedVideo.state === VideoState.TO_TRANSCODE) {
return addOptimizeOrMergeAudioJob(videoCreated, videoFile, user) return addOptimizeOrMergeAudioJob(refreshedVideo, videoFile, user)
} }
}) }).catch(err => logger.error('Cannot add optimize/merge audio job for %s.', videoCreated.uuid, { err, ...lTags(videoCreated.uuid) }))
.catch(err => logger.error('Cannot add optimize/merge audio job for %s.', videoCreated.uuid, { err, ...lTags(videoCreated.uuid) }))
Hooks.runAction('action:api.video.uploaded', { video: videoCreated, req, res }) Hooks.runAction('action:api.video.uploaded', { video: videoCreated, req, res })
@ -254,36 +259,23 @@ async function buildNewFile (videoPhysicalFile: express.VideoUploadFile) {
return videoFile return videoFile
} }
async function createTorrentAndSetInfoHashAsync (video: MVideo, fileArg: MVideoFile) { async function createTorrentFederate (video: MVideoFullLight, videoFile: MVideoFile) {
await createTorrentAndSetInfoHash(video, fileArg) const payload: ManageVideoTorrentPayload = { videoId: video.id, videoFileId: videoFile.id, action: 'create' }
// Refresh videoFile because the createTorrentAndSetInfoHash could be long const job = await JobQueue.Instance.createJobWithPromise({ type: 'manage-video-torrent', payload })
const refreshedFile = await VideoFileModel.loadWithVideo(fileArg.id) await job.finished()
// File does not exist anymore, remove the generated torrent
if (!refreshedFile) return fileArg.removeTorrent()
refreshedFile.infoHash = fileArg.infoHash const refreshedVideo = await VideoModel.loadAndPopulateAccountAndServerAndTags(video.id)
refreshedFile.torrentFilename = fileArg.torrentFilename if (!refreshedVideo) return
return refreshedFile.save() // Only federate and notify after the torrent creation
} Notifier.Instance.notifyOnNewVideoIfNeeded(refreshedVideo)
function createTorrentFederate (video: MVideoFullLight, videoFile: MVideoFile) { await retryTransactionWrapper(() => {
// Create the torrent file in async way because it could be long return sequelizeTypescript.transaction(t => federateVideoIfNeeded(refreshedVideo, true, t))
return createTorrentAndSetInfoHashAsync(video, videoFile) })
.catch(err => logger.error('Cannot create torrent file for video %s', video.url, { err, ...lTags(video.uuid) }))
.then(() => VideoModel.loadAndPopulateAccountAndServerAndTags(video.id))
.then(refreshedVideo => {
if (!refreshedVideo) return
// Only federate and notify after the torrent creation return refreshedVideo
Notifier.Instance.notifyOnNewVideoIfNeeded(refreshedVideo)
return retryTransactionWrapper(() => {
return sequelizeTypescript.transaction(t => federateVideoIfNeeded(refreshedVideo, true, t))
})
})
.catch(err => logger.error('Cannot federate or notify video creation %s', video.url, { err, ...lTags(video.uuid) }))
} }
async function deleteUploadResumableCache (req: express.Request, res: express.Response, next: express.NextFunction) { async function deleteUploadResumableCache (req: express.Request, res: express.Response, next: express.NextFunction) {

View File

@ -153,6 +153,7 @@ const JOB_ATTEMPTS: { [id in JobType]: number } = {
'video-redundancy': 1, 'video-redundancy': 1,
'video-live-ending': 1, 'video-live-ending': 1,
'video-edition': 1, 'video-edition': 1,
'manage-video-torrent': 1,
'move-to-object-storage': 3 'move-to-object-storage': 3
} }
// Excluded keys are jobs that can be configured by admins // Excluded keys are jobs that can be configured by admins
@ -170,6 +171,7 @@ const JOB_CONCURRENCY: { [id in Exclude<JobType, 'video-transcoding' | 'video-im
'video-redundancy': 1, 'video-redundancy': 1,
'video-live-ending': 10, 'video-live-ending': 10,
'video-edition': 1, 'video-edition': 1,
'manage-video-torrent': 1,
'move-to-object-storage': 1 'move-to-object-storage': 1
} }
const JOB_TTL: { [id in JobType]: number } = { const JOB_TTL: { [id in JobType]: number } = {
@ -188,6 +190,7 @@ const JOB_TTL: { [id in JobType]: number } = {
'activitypub-refresher': 60000 * 10, // 10 minutes 'activitypub-refresher': 60000 * 10, // 10 minutes
'video-redundancy': 1000 * 3600 * 3, // 3 hours 'video-redundancy': 1000 * 3600 * 3, // 3 hours
'video-live-ending': 1000 * 60 * 10, // 10 minutes 'video-live-ending': 1000 * 60 * 10, // 10 minutes
'manage-video-torrent': 1000 * 3600 * 3, // 3 hours
'move-to-object-storage': 1000 * 60 * 60 * 3 // 3 hours 'move-to-object-storage': 1000 * 60 * 60 * 3 // 3 hours
} }
const REPEAT_JOBS: { [ id in JobType ]?: EveryRepeatOptions | CronRepeatOptions } = { const REPEAT_JOBS: { [ id in JobType ]?: EveryRepeatOptions | CronRepeatOptions } = {

View File

@ -0,0 +1,88 @@
import { Job } from 'bull'
import { createTorrentAndSetInfoHash, updateTorrentMetadata } from '@server/helpers/webtorrent'
import { VideoModel } from '@server/models/video/video'
import { VideoFileModel } from '@server/models/video/video-file'
import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist'
import { ManageVideoTorrentPayload } from '@shared/models'
import { logger } from '../../../helpers/logger'
async function processManageVideoTorrent (job: Job) {
const payload = job.data as ManageVideoTorrentPayload
logger.info('Processing torrent in job %d.', job.id)
if (payload.action === 'create') return doCreateAction(payload)
if (payload.action === 'update-metadata') return doUpdateMetadataAction(payload)
}
// ---------------------------------------------------------------------------
export {
processManageVideoTorrent
}
// ---------------------------------------------------------------------------
async function doCreateAction (payload: ManageVideoTorrentPayload & { action: 'create' }) {
const [ video, file ] = await Promise.all([
loadVideoOrLog(payload.videoId),
loadFileOrLog(payload.videoFileId)
])
await createTorrentAndSetInfoHash(video, file)
// Refresh videoFile because the createTorrentAndSetInfoHash could be long
const refreshedFile = await VideoFileModel.loadWithVideo(file.id)
// File does not exist anymore, remove the generated torrent
if (!refreshedFile) return file.removeTorrent()
refreshedFile.infoHash = file.infoHash
refreshedFile.torrentFilename = file.torrentFilename
return refreshedFile.save()
}
async function doUpdateMetadataAction (payload: ManageVideoTorrentPayload & { action: 'update-metadata' }) {
const [ video, streamingPlaylist, file ] = await Promise.all([
loadVideoOrLog(payload.videoId),
loadStreamingPlaylistOrLog(payload.streamingPlaylistId),
loadFileOrLog(payload.videoFileId)
])
await updateTorrentMetadata(video || streamingPlaylist, file)
await file.save()
}
async function loadVideoOrLog (videoId: number) {
if (!videoId) return undefined
const video = await VideoModel.load(videoId)
if (!video) {
logger.debug('Do not process torrent for video %d: does not exist anymore.', videoId)
}
return video
}
async function loadStreamingPlaylistOrLog (streamingPlaylistId: number) {
if (!streamingPlaylistId) return undefined
const streamingPlaylist = await VideoStreamingPlaylistModel.loadWithVideo(streamingPlaylistId)
if (!streamingPlaylist) {
logger.debug('Do not process torrent for streaming playlist %d: does not exist anymore.', streamingPlaylistId)
}
return streamingPlaylist
}
async function loadFileOrLog (videoFileId: number) {
if (!videoFileId) return undefined
const file = await VideoFileModel.loadWithVideo(videoFileId)
if (!file) {
logger.debug('Do not process torrent for file %d: does not exist anymore.', videoFileId)
}
return file
}

View File

@ -12,6 +12,7 @@ import {
EmailPayload, EmailPayload,
JobState, JobState,
JobType, JobType,
ManageVideoTorrentPayload,
MoveObjectStoragePayload, MoveObjectStoragePayload,
RefreshPayload, RefreshPayload,
VideoEditionPayload, VideoEditionPayload,
@ -31,6 +32,7 @@ import { processActivityPubHttpUnicast } from './handlers/activitypub-http-unica
import { refreshAPObject } from './handlers/activitypub-refresher' import { refreshAPObject } from './handlers/activitypub-refresher'
import { processActorKeys } from './handlers/actor-keys' import { processActorKeys } from './handlers/actor-keys'
import { processEmail } from './handlers/email' import { processEmail } from './handlers/email'
import { processManageVideoTorrent } from './handlers/manage-video-torrent'
import { processMoveToObjectStorage } from './handlers/move-to-object-storage' import { processMoveToObjectStorage } from './handlers/move-to-object-storage'
import { processVideoEdition } from './handlers/video-edition' import { processVideoEdition } from './handlers/video-edition'
import { processVideoFileImport } from './handlers/video-file-import' import { processVideoFileImport } from './handlers/video-file-import'
@ -56,6 +58,7 @@ type CreateJobArgument =
{ type: 'video-redundancy', payload: VideoRedundancyPayload } | { type: 'video-redundancy', payload: VideoRedundancyPayload } |
{ type: 'delete-resumable-upload-meta-file', payload: DeleteResumableUploadMetaFilePayload } | { type: 'delete-resumable-upload-meta-file', payload: DeleteResumableUploadMetaFilePayload } |
{ type: 'video-edition', payload: VideoEditionPayload } | { type: 'video-edition', payload: VideoEditionPayload } |
{ type: 'manage-video-torrent', payload: ManageVideoTorrentPayload } |
{ type: 'move-to-object-storage', payload: MoveObjectStoragePayload } { type: 'move-to-object-storage', payload: MoveObjectStoragePayload }
export type CreateJobOptions = { export type CreateJobOptions = {
@ -79,6 +82,7 @@ const handlers: { [id in JobType]: (job: Job) => Promise<any> } = {
'actor-keys': processActorKeys, 'actor-keys': processActorKeys,
'video-redundancy': processVideoRedundancy, 'video-redundancy': processVideoRedundancy,
'move-to-object-storage': processMoveToObjectStorage, 'move-to-object-storage': processMoveToObjectStorage,
'manage-video-torrent': processManageVideoTorrent,
'video-edition': processVideoEdition 'video-edition': processVideoEdition
} }
@ -98,6 +102,7 @@ const jobTypes: JobType[] = [
'actor-keys', 'actor-keys',
'video-live-ending', 'video-live-ending',
'move-to-object-storage', 'move-to-object-storage',
'manage-video-torrent',
'video-edition' 'video-edition'
] ]
@ -185,7 +190,7 @@ class JobQueue {
} }
createJobWithPromise (obj: CreateJobArgument, options: CreateJobOptions = {}) { createJobWithPromise (obj: CreateJobArgument, options: CreateJobOptions = {}) {
const queue = this.queues[obj.type] const queue: Queue = this.queues[obj.type]
if (queue === undefined) { if (queue === undefined) {
logger.error('Unknown queue %s: cannot create job.', obj.type) logger.error('Unknown queue %s: cannot create job.', obj.type)
return return

View File

@ -1683,6 +1683,24 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
return peertubeTruncate(this.description, { length: maxLength }) return peertubeTruncate(this.description, { length: maxLength })
} }
getAllFiles () {
let files: MVideoFile[] = []
if (Array.isArray(this.VideoFiles)) {
files = files.concat(this.VideoFiles)
}
if (Array.isArray(this.VideoStreamingPlaylists)) {
for (const p of this.VideoStreamingPlaylists) {
if (Array.isArray(p.VideoFiles)) {
files = files.concat(p.VideoFiles)
}
}
}
return files
}
probeMaxQualityFile () { probeMaxQualityFile () {
const file = this.getMaxQualityFile() const file = this.getMaxQualityFile()
const videoOrPlaylist = file.getVideoOrStreamingPlaylist() const videoOrPlaylist = file.getVideoOrStreamingPlaylist()

View File

@ -20,6 +20,7 @@ export type JobType =
| 'video-redundancy' | 'video-redundancy'
| 'video-live-ending' | 'video-live-ending'
| 'actor-keys' | 'actor-keys'
| 'manage-video-torrent'
| 'move-to-object-storage' | 'move-to-object-storage'
| 'video-edition' | 'video-edition'
@ -96,6 +97,20 @@ export type VideoRedundancyPayload = {
videoId: number videoId: number
} }
export type ManageVideoTorrentPayload =
{
action: 'create'
videoId: number
videoFileId: number
} | {
action: 'update-metadata'
videoId?: number
streamingPlaylistId?: number
videoFileId: number
}
// Video transcoding payloads // Video transcoding payloads
interface BaseTranscodingPayload { interface BaseTranscodingPayload {