Init commit
This commit is contained in:
335
server/core/lib/local-video-creator.ts
Normal file
335
server/core/lib/local-video-creator.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
import { buildAspectRatio } from '@peertube/peertube-core-utils'
|
||||
import {
|
||||
LiveVideoCreate,
|
||||
LiveVideoLatencyMode,
|
||||
NSFWFlag,
|
||||
PeerTubeError,
|
||||
ThumbnailType,
|
||||
ThumbnailType_Type,
|
||||
VideoChannelActivityAction,
|
||||
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 { VideoChannelActivityModel } from '@server/models/video/video-channel-activity.js'
|
||||
import { VideoLiveReplaySettingModel } from '@server/models/video/video-live-replay-setting.js'
|
||||
import { VideoLiveScheduleModel } from '@server/models/video/video-live-schedule.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, MUserAccountId, 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 { 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' | 'schedules'> & {
|
||||
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: MUserAccountId
|
||||
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 })
|
||||
|
||||
await VideoChannelActivityModel.addVideoActivity({
|
||||
action: VideoChannelActivityAction.CREATE,
|
||||
user: this.options.user,
|
||||
channel: this.channel,
|
||||
video: this.video,
|
||||
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.liveAttributes.schedules) {
|
||||
this.video.VideoLive.LiveSchedules = await VideoLiveScheduleModel.addToLiveId(
|
||||
this.video.VideoLive.id,
|
||||
this.liveAttributes.schedules.map(s => s.startAt),
|
||||
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
|
||||
}).catch(err => {
|
||||
// eslint-disable-next-line @typescript-eslint/only-throw-error
|
||||
throw PeerTubeError.fromError(err, 'INVALID_IMAGE_FILE')
|
||||
})
|
||||
)
|
||||
|
||||
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: videoInfo.commentsPolicy ?? CONFIG.DEFAULTS.PUBLISH.COMMENTS_POLICY,
|
||||
downloadEnabled: videoInfo.downloadEnabled ?? CONFIG.DEFAULTS.PUBLISH.DOWNLOAD_ENABLED,
|
||||
waitTranscoding: videoInfo.waitTranscoding || false,
|
||||
|
||||
nsfw: videoInfo.nsfw || false,
|
||||
nsfwSummary: videoInfo.nsfwSummary,
|
||||
nsfwFlags: videoInfo.nsfwFlags || NSFWFlag.NONE,
|
||||
|
||||
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,
|
||||
|
||||
publishedAt: this.videoAttributes.scheduleUpdate?.updateAt
|
||||
? new Date(this.videoAttributes.scheduleUpdate?.updateAt)
|
||||
: undefined,
|
||||
|
||||
uuid: buildUUID(),
|
||||
duration: videoInfo.duration
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user