server/core/lib/local-video-creator.ts
import { buildAspectRatio } from '@peertube/peertube-core-utils'
import {
LiveVideoCreate,
LiveVideoLatencyMode,
ThumbnailType,
ThumbnailType_Type,
VideoCreate,
VideoPrivacy,
VideoStateType
} from '@peertube/peertube-models'
import { buildUUID } from '@peertube/peertube-node-utils'
import { retryTransactionWrapper } from '@server/helpers/database-utils.js'
import { LoggerTagsFn, logger } from '@server/helpers/logger.js'
import { CONFIG } from '@server/initializers/config.js'
import { sequelizeTypescript } from '@server/initializers/database.js'
import { ScheduleVideoUpdateModel } from '@server/models/video/schedule-video-update.js'
import { VideoLiveReplaySettingModel } from '@server/models/video/video-live-replay-setting.js'
import { VideoLiveModel } from '@server/models/video/video-live.js'
import { VideoPasswordModel } from '@server/models/video/video-password.js'
import { VideoModel } from '@server/models/video/video.js'
import { MChannel, MChannelAccountLight, MThumbnail, MUser, MVideoFile, MVideoFullLight } from '@server/types/models/index.js'
import { FilteredModelAttributes } from '@server/types/sequelize.js'
import { FfprobeData } from 'fluent-ffmpeg'
import { move } from 'fs-extra/esm'
import { getLocalVideoActivityPubUrl } from './activitypub/url.js'
import { federateVideoIfNeeded } from './activitypub/videos/federate.js'
import { AutomaticTagger } from './automatic-tags/automatic-tagger.js'
import { setAndSaveVideoAutomaticTags } from './automatic-tags/automatic-tags.js'
import { Hooks } from './plugins/hooks.js'
import { generateLocalVideoMiniature, updateLocalVideoMiniatureFromExisting } from './thumbnail.js'
import { autoBlacklistVideoIfNeeded } from './video-blacklist.js'
import { replaceChapters, replaceChaptersFromDescriptionIfNeeded } from './video-chapters.js'
import { buildNewFile, createVideoSource } from './video-file.js'
import { addVideoJobsAfterCreation } from './video-jobs.js'
import { VideoPathManager } from './video-path-manager.js'
import { buildCommentsPolicy, setVideoTags } from './video.js'
type VideoAttributes = Omit<VideoCreate, 'channelId'> & {
duration: number
isLive: boolean
state: VideoStateType
inputFilename: string
}
type LiveAttributes = Pick<LiveVideoCreate, 'permanentLive' | 'latencyMode' | 'saveReplay' | 'replaySettings'> & {
streamKey?: string
}
export type ThumbnailOptions = {
path: string
type: ThumbnailType_Type
automaticallyGenerated: boolean
keepOriginal: boolean
}[]
type ChaptersOption = { timecode: number, title: string }[]
type VideoAttributeHookFilter =
'filter:api.video.user-import.video-attribute.result' |
'filter:api.video.upload.video-attribute.result' |
'filter:api.video.live.video-attribute.result'
export class LocalVideoCreator {
private readonly lTags: LoggerTagsFn
private readonly videoFilePath: string | undefined
private readonly videoFileProbe: FfprobeData
private readonly videoAttributes: VideoAttributes
private readonly liveAttributes: LiveAttributes | undefined
private readonly channel: MChannelAccountLight
private readonly videoAttributeResultHook: VideoAttributeHookFilter
private video: MVideoFullLight
private videoFile: MVideoFile
private videoPath: string
constructor (private readonly options: {
lTags: LoggerTagsFn
videoFile: {
path: string
probe: FfprobeData
}
videoAttributes: VideoAttributes
liveAttributes: LiveAttributes
channel: MChannelAccountLight
user: MUser
videoAttributeResultHook: VideoAttributeHookFilter
thumbnails: ThumbnailOptions
chapters: ChaptersOption | undefined
fallbackChapters: {
fromDescription: boolean
finalFallback: ChaptersOption | undefined
}
}) {
this.videoFilePath = options.videoFile?.path
this.videoFileProbe = options.videoFile?.probe
this.videoAttributes = options.videoAttributes
this.liveAttributes = options.liveAttributes
this.channel = options.channel
this.videoAttributeResultHook = options.videoAttributeResultHook
this.lTags = options.lTags
}
async create () {
this.video = new VideoModel(
await Hooks.wrapObject(this.buildVideo(this.videoAttributes, this.channel), this.videoAttributeResultHook)
) as MVideoFullLight
this.video.VideoChannel = this.channel
this.video.url = getLocalVideoActivityPubUrl(this.video)
if (this.videoFilePath) {
this.videoFile = await buildNewFile({ path: this.videoFilePath, mode: 'web-video', ffprobe: this.videoFileProbe })
this.videoPath = VideoPathManager.Instance.getFSVideoFileOutputPath(this.video, this.videoFile)
await move(this.videoFilePath, this.videoPath)
this.video.aspectRatio = buildAspectRatio({ width: this.videoFile.width, height: this.videoFile.height })
}
const thumbnails = await this.createThumbnails()
await retryTransactionWrapper(() => {
return sequelizeTypescript.transaction(async transaction => {
await this.video.save({ transaction })
for (const thumbnail of thumbnails) {
await this.video.addAndSaveThumbnail(thumbnail, transaction)
}
if (this.videoFile) {
this.videoFile.videoId = this.video.id
await this.videoFile.save({ transaction })
this.video.VideoFiles = [ this.videoFile ]
}
await setVideoTags({ video: this.video, tags: this.videoAttributes.tags, transaction })
const automaticTags = await new AutomaticTagger().buildVideoAutomaticTags({ video: this.video, transaction })
await setAndSaveVideoAutomaticTags({ video: this.video, automaticTags, transaction })
// Schedule an update in the future?
if (this.videoAttributes.scheduleUpdate) {
await ScheduleVideoUpdateModel.create({
videoId: this.video.id,
updateAt: new Date(this.videoAttributes.scheduleUpdate.updateAt),
privacy: this.videoAttributes.scheduleUpdate.privacy || null
}, { transaction })
}
if (this.options.chapters) {
await replaceChapters({ video: this.video, chapters: this.options.chapters, transaction })
} else if (this.options.fallbackChapters.fromDescription) {
if (!await replaceChaptersFromDescriptionIfNeeded({ newDescription: this.video.description, video: this.video, transaction })) {
await replaceChapters({ video: this.video, chapters: this.options.fallbackChapters.finalFallback, transaction })
}
}
await autoBlacklistVideoIfNeeded({
video: this.video,
user: this.options.user,
isRemote: false,
isNew: true,
isNewFile: true,
transaction
})
if (this.videoAttributes.privacy === VideoPrivacy.PASSWORD_PROTECTED) {
await VideoPasswordModel.addPasswords(this.videoAttributes.videoPasswords, this.video.id, transaction)
}
if (this.videoAttributes.isLive) {
const videoLive = new VideoLiveModel({
saveReplay: this.liveAttributes.saveReplay || false,
permanentLive: this.liveAttributes.permanentLive || false,
latencyMode: this.liveAttributes.latencyMode || LiveVideoLatencyMode.DEFAULT,
streamKey: this.liveAttributes.streamKey || buildUUID()
})
if (videoLive.saveReplay) {
const replaySettings = new VideoLiveReplaySettingModel({
privacy: this.liveAttributes.replaySettings?.privacy ?? this.video.privacy
})
await replaySettings.save({ transaction })
videoLive.replaySettingId = replaySettings.id
}
videoLive.videoId = this.video.id
this.video.VideoLive = await videoLive.save({ transaction })
}
if (this.videoFile) {
transaction.afterCommit(() => {
addVideoJobsAfterCreation({
video: this.video,
videoFile: this.videoFile,
generateTranscription: this.videoAttributes.generateTranscription ?? true
}).catch(err => logger.error('Cannot build new video jobs of %s.', this.video.uuid, { err, ...this.lTags(this.video.uuid) }))
})
} else {
await federateVideoIfNeeded(this.video, true, transaction)
}
}).catch(err => {
// Reset elements to reinsert them in the database
this.video.isNewRecord = true
if (this.videoFile) this.videoFile.isNewRecord = true
for (const t of thumbnails) {
t.isNewRecord = true
}
throw err
})
})
if (this.videoAttributes.inputFilename) {
await createVideoSource({
inputFilename: this.videoAttributes.inputFilename,
inputPath: this.videoPath,
inputProbe: this.videoFileProbe,
video: this.video
})
}
// Channel has a new content, set as updated
await this.channel.setAsUpdated()
return { video: this.video, videoFile: this.videoFile }
}
private async createThumbnails () {
const promises: Promise<MThumbnail>[] = []
let toGenerate = [ ThumbnailType.MINIATURE, ThumbnailType.PREVIEW ]
for (const type of [ ThumbnailType.MINIATURE, ThumbnailType.PREVIEW ]) {
const thumbnail = this.options.thumbnails.find(t => t.type === type)
if (!thumbnail) continue
promises.push(
updateLocalVideoMiniatureFromExisting({
inputPath: thumbnail.path,
video: this.video,
type,
automaticallyGenerated: thumbnail.automaticallyGenerated || false,
keepOriginal: thumbnail.keepOriginal
})
)
toGenerate = toGenerate.filter(t => t !== thumbnail.type)
}
return [
...await Promise.all(promises),
...await generateLocalVideoMiniature({
video: this.video,
videoFile: this.videoFile,
types: toGenerate,
ffprobe: this.videoFileProbe
})
]
}
private buildVideo (videoInfo: VideoAttributes, channel: MChannel): FilteredModelAttributes<VideoModel> {
return {
name: videoInfo.name,
state: videoInfo.state,
remote: false,
category: videoInfo.category,
licence: videoInfo.licence ?? CONFIG.DEFAULTS.PUBLISH.LICENCE,
language: videoInfo.language,
commentsPolicy: buildCommentsPolicy(videoInfo),
downloadEnabled: videoInfo.downloadEnabled ?? CONFIG.DEFAULTS.PUBLISH.DOWNLOAD_ENABLED,
waitTranscoding: videoInfo.waitTranscoding || false,
nsfw: videoInfo.nsfw || false,
description: videoInfo.description,
support: videoInfo.support,
privacy: videoInfo.privacy || VideoPrivacy.PRIVATE,
isLive: videoInfo.isLive,
channelId: channel.id,
originallyPublishedAt: videoInfo.originallyPublishedAt
? new Date(videoInfo.originallyPublishedAt)
: null,
uuid: buildUUID(),
duration: videoInfo.duration
}
}
}