mirror of
https://github.com/Chocobozzz/PeerTube.git
synced 2025-10-03 09:49:20 +02:00
Support chapter import/export
This commit is contained in:
parent
967702d6c7
commit
7986ab8452
14 changed files with 651 additions and 446 deletions
268
server/core/lib/local-video-creator.ts
Normal file
268
server/core/lib/local-video-creator.ts
Normal file
|
@ -0,0 +1,268 @@
|
|||
import { ffprobePromise } from '@peertube/peertube-ffmpeg'
|
||||
import {
|
||||
LiveVideoCreate,
|
||||
LiveVideoLatencyMode,
|
||||
ThumbnailType,
|
||||
ThumbnailType_Type,
|
||||
VideoCreate,
|
||||
VideoPrivacy,
|
||||
VideoStateType
|
||||
} from '@peertube/peertube-models'
|
||||
import { buildUUID } from '@peertube/peertube-node-utils'
|
||||
import { sequelizeTypescript } from '@server/initializers/database.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 { VideoSourceModel } from '@server/models/video/video-source.js'
|
||||
import { VideoModel } from '@server/models/video/video.js'
|
||||
import { MVideoFullLight, MThumbnail, MChannel, MChannelAccountLight, MVideoFile, MUser } from '@server/types/models/index.js'
|
||||
import { move } from 'fs-extra/esm'
|
||||
import { getLocalVideoActivityPubUrl } from './activitypub/url.js'
|
||||
import { generateLocalVideoMiniature, updateLocalVideoMiniatureFromExisting } from './thumbnail.js'
|
||||
import { autoBlacklistVideoIfNeeded } from './video-blacklist.js'
|
||||
import { buildNewFile } from './video-file.js'
|
||||
import { addVideoJobsAfterCreation } from './video-jobs.js'
|
||||
import { VideoPathManager } from './video-path-manager.js'
|
||||
import { setVideoTags } from './video.js'
|
||||
import { FilteredModelAttributes } from '@server/types/sequelize.js'
|
||||
import { CONFIG } from '@server/initializers/config.js'
|
||||
import { Hooks } from './plugins/hooks.js'
|
||||
import Ffmpeg from 'fluent-ffmpeg'
|
||||
import { ScheduleVideoUpdateModel } from '@server/models/video/schedule-video-update.js'
|
||||
import { replaceChapters, replaceChaptersFromDescriptionIfNeeded } from './video-chapters.js'
|
||||
import { LoggerTagsFn, logger } from '@server/helpers/logger.js'
|
||||
import { retryTransactionWrapper } from '@server/helpers/database-utils.js'
|
||||
import { federateVideoIfNeeded } from './activitypub/videos/federate.js'
|
||||
|
||||
type VideoAttributes = Omit<VideoCreate, 'channelId'> & {
|
||||
duration: number
|
||||
isLive: boolean
|
||||
state: VideoStateType
|
||||
filename: 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 videoAttributes: VideoAttributes
|
||||
private readonly liveAttributes: LiveAttributes | undefined
|
||||
|
||||
private readonly channel: MChannelAccountLight
|
||||
private readonly videoAttributeResultHook: VideoAttributeHookFilter
|
||||
|
||||
private video: MVideoFullLight
|
||||
private videoFile: MVideoFile
|
||||
private ffprobe: Ffmpeg.FfprobeData
|
||||
|
||||
constructor (private readonly options: {
|
||||
lTags: LoggerTagsFn
|
||||
|
||||
videoFilePath: string
|
||||
|
||||
videoAttributes: VideoAttributes
|
||||
liveAttributes: LiveAttributes
|
||||
|
||||
channel: MChannelAccountLight
|
||||
user: MUser
|
||||
videoAttributeResultHook: VideoAttributeHookFilter
|
||||
thumbnails: ThumbnailOptions
|
||||
|
||||
chapters: ChaptersOption | undefined
|
||||
fallbackChapters: {
|
||||
fromDescription: boolean
|
||||
finalFallback: ChaptersOption | undefined
|
||||
}
|
||||
}) {
|
||||
this.videoFilePath = options.videoFilePath
|
||||
|
||||
this.videoAttributes = options.videoAttributes
|
||||
this.liveAttributes = options.liveAttributes
|
||||
|
||||
this.channel = options.channel
|
||||
|
||||
this.videoAttributeResultHook = options.videoAttributeResultHook
|
||||
}
|
||||
|
||||
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.ffprobe = await ffprobePromise(this.videoFilePath)
|
||||
this.videoFile = await buildNewFile({ path: this.videoFilePath, mode: 'web-video', ffprobe: this.ffprobe })
|
||||
|
||||
const destination = VideoPathManager.Instance.getFSVideoFileOutputPath(this.video, this.videoFile)
|
||||
await move(this.videoFilePath, destination)
|
||||
}
|
||||
|
||||
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 })
|
||||
|
||||
// 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.filename) {
|
||||
await VideoSourceModel.create({
|
||||
filename: this.videoAttributes.filename,
|
||||
videoId: this.video.id
|
||||
}, { 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 })
|
||||
.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)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// 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.ffprobe })
|
||||
]
|
||||
}
|
||||
|
||||
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,
|
||||
commentsEnabled: videoInfo.commentsEnabled ?? CONFIG.DEFAULTS.PUBLISH.COMMENTS_ENABLED,
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue