local-video-creator.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291
  1. import { buildAspectRatio } from '@peertube/peertube-core-utils'
  2. import {
  3. LiveVideoCreate,
  4. LiveVideoLatencyMode,
  5. ThumbnailType,
  6. ThumbnailType_Type,
  7. VideoCreate,
  8. VideoPrivacy,
  9. VideoStateType
  10. } from '@peertube/peertube-models'
  11. import { buildUUID } from '@peertube/peertube-node-utils'
  12. import { retryTransactionWrapper } from '@server/helpers/database-utils.js'
  13. import { LoggerTagsFn, logger } from '@server/helpers/logger.js'
  14. import { CONFIG } from '@server/initializers/config.js'
  15. import { sequelizeTypescript } from '@server/initializers/database.js'
  16. import { ScheduleVideoUpdateModel } from '@server/models/video/schedule-video-update.js'
  17. import { VideoLiveReplaySettingModel } from '@server/models/video/video-live-replay-setting.js'
  18. import { VideoLiveModel } from '@server/models/video/video-live.js'
  19. import { VideoPasswordModel } from '@server/models/video/video-password.js'
  20. import { VideoModel } from '@server/models/video/video.js'
  21. import { MChannel, MChannelAccountLight, MThumbnail, MUser, MVideoFile, MVideoFullLight } from '@server/types/models/index.js'
  22. import { FilteredModelAttributes } from '@server/types/sequelize.js'
  23. import { FfprobeData } from 'fluent-ffmpeg'
  24. import { move } from 'fs-extra/esm'
  25. import { getLocalVideoActivityPubUrl } from './activitypub/url.js'
  26. import { federateVideoIfNeeded } from './activitypub/videos/federate.js'
  27. import { Hooks } from './plugins/hooks.js'
  28. import { generateLocalVideoMiniature, updateLocalVideoMiniatureFromExisting } from './thumbnail.js'
  29. import { autoBlacklistVideoIfNeeded } from './video-blacklist.js'
  30. import { replaceChapters, replaceChaptersFromDescriptionIfNeeded } from './video-chapters.js'
  31. import { buildNewFile, createVideoSource } from './video-file.js'
  32. import { addVideoJobsAfterCreation } from './video-jobs.js'
  33. import { VideoPathManager } from './video-path-manager.js'
  34. import { setVideoTags } from './video.js'
  35. type VideoAttributes = Omit<VideoCreate, 'channelId'> & {
  36. duration: number
  37. isLive: boolean
  38. state: VideoStateType
  39. inputFilename: string
  40. }
  41. type LiveAttributes = Pick<LiveVideoCreate, 'permanentLive' | 'latencyMode' | 'saveReplay' | 'replaySettings'> & {
  42. streamKey?: string
  43. }
  44. export type ThumbnailOptions = {
  45. path: string
  46. type: ThumbnailType_Type
  47. automaticallyGenerated: boolean
  48. keepOriginal: boolean
  49. }[]
  50. type ChaptersOption = { timecode: number, title: string }[]
  51. type VideoAttributeHookFilter =
  52. 'filter:api.video.user-import.video-attribute.result' |
  53. 'filter:api.video.upload.video-attribute.result' |
  54. 'filter:api.video.live.video-attribute.result'
  55. export class LocalVideoCreator {
  56. private readonly lTags: LoggerTagsFn
  57. private readonly videoFilePath: string | undefined
  58. private readonly videoFileProbe: FfprobeData
  59. private readonly videoAttributes: VideoAttributes
  60. private readonly liveAttributes: LiveAttributes | undefined
  61. private readonly channel: MChannelAccountLight
  62. private readonly videoAttributeResultHook: VideoAttributeHookFilter
  63. private video: MVideoFullLight
  64. private videoFile: MVideoFile
  65. private videoPath: string
  66. constructor (private readonly options: {
  67. lTags: LoggerTagsFn
  68. videoFile: {
  69. path: string
  70. probe: FfprobeData
  71. }
  72. videoAttributes: VideoAttributes
  73. liveAttributes: LiveAttributes
  74. channel: MChannelAccountLight
  75. user: MUser
  76. videoAttributeResultHook: VideoAttributeHookFilter
  77. thumbnails: ThumbnailOptions
  78. chapters: ChaptersOption | undefined
  79. fallbackChapters: {
  80. fromDescription: boolean
  81. finalFallback: ChaptersOption | undefined
  82. }
  83. }) {
  84. this.videoFilePath = options.videoFile?.path
  85. this.videoFileProbe = options.videoFile?.probe
  86. this.videoAttributes = options.videoAttributes
  87. this.liveAttributes = options.liveAttributes
  88. this.channel = options.channel
  89. this.videoAttributeResultHook = options.videoAttributeResultHook
  90. }
  91. async create () {
  92. this.video = new VideoModel(
  93. await Hooks.wrapObject(this.buildVideo(this.videoAttributes, this.channel), this.videoAttributeResultHook)
  94. ) as MVideoFullLight
  95. this.video.VideoChannel = this.channel
  96. this.video.url = getLocalVideoActivityPubUrl(this.video)
  97. if (this.videoFilePath) {
  98. this.videoFile = await buildNewFile({ path: this.videoFilePath, mode: 'web-video', ffprobe: this.videoFileProbe })
  99. this.videoPath = VideoPathManager.Instance.getFSVideoFileOutputPath(this.video, this.videoFile)
  100. await move(this.videoFilePath, this.videoPath)
  101. this.video.aspectRatio = buildAspectRatio({ width: this.videoFile.width, height: this.videoFile.height })
  102. }
  103. const thumbnails = await this.createThumbnails()
  104. await retryTransactionWrapper(() => {
  105. return sequelizeTypescript.transaction(async transaction => {
  106. await this.video.save({ transaction })
  107. for (const thumbnail of thumbnails) {
  108. await this.video.addAndSaveThumbnail(thumbnail, transaction)
  109. }
  110. if (this.videoFile) {
  111. this.videoFile.videoId = this.video.id
  112. await this.videoFile.save({ transaction })
  113. this.video.VideoFiles = [ this.videoFile ]
  114. }
  115. await setVideoTags({ video: this.video, tags: this.videoAttributes.tags, transaction })
  116. // Schedule an update in the future?
  117. if (this.videoAttributes.scheduleUpdate) {
  118. await ScheduleVideoUpdateModel.create({
  119. videoId: this.video.id,
  120. updateAt: new Date(this.videoAttributes.scheduleUpdate.updateAt),
  121. privacy: this.videoAttributes.scheduleUpdate.privacy || null
  122. }, { transaction })
  123. }
  124. if (this.options.chapters) {
  125. await replaceChapters({ video: this.video, chapters: this.options.chapters, transaction })
  126. } else if (this.options.fallbackChapters.fromDescription) {
  127. if (!await replaceChaptersFromDescriptionIfNeeded({ newDescription: this.video.description, video: this.video, transaction })) {
  128. await replaceChapters({ video: this.video, chapters: this.options.fallbackChapters.finalFallback, transaction })
  129. }
  130. }
  131. await autoBlacklistVideoIfNeeded({
  132. video: this.video,
  133. user: this.options.user,
  134. isRemote: false,
  135. isNew: true,
  136. isNewFile: true,
  137. transaction
  138. })
  139. if (this.videoAttributes.privacy === VideoPrivacy.PASSWORD_PROTECTED) {
  140. await VideoPasswordModel.addPasswords(this.videoAttributes.videoPasswords, this.video.id, transaction)
  141. }
  142. if (this.videoAttributes.isLive) {
  143. const videoLive = new VideoLiveModel({
  144. saveReplay: this.liveAttributes.saveReplay || false,
  145. permanentLive: this.liveAttributes.permanentLive || false,
  146. latencyMode: this.liveAttributes.latencyMode || LiveVideoLatencyMode.DEFAULT,
  147. streamKey: this.liveAttributes.streamKey || buildUUID()
  148. })
  149. if (videoLive.saveReplay) {
  150. const replaySettings = new VideoLiveReplaySettingModel({
  151. privacy: this.liveAttributes.replaySettings?.privacy ?? this.video.privacy
  152. })
  153. await replaySettings.save({ transaction })
  154. videoLive.replaySettingId = replaySettings.id
  155. }
  156. videoLive.videoId = this.video.id
  157. this.video.VideoLive = await videoLive.save({ transaction })
  158. }
  159. if (this.videoFile) {
  160. transaction.afterCommit(() => {
  161. addVideoJobsAfterCreation({ video: this.video, videoFile: this.videoFile })
  162. .catch(err => logger.error('Cannot build new video jobs of %s.', this.video.uuid, { err, ...this.lTags(this.video.uuid) }))
  163. })
  164. } else {
  165. await federateVideoIfNeeded(this.video, true, transaction)
  166. }
  167. }).catch(err => {
  168. // Reset elements to reinsert them in the database
  169. this.video.isNewRecord = true
  170. if (this.videoFile) this.videoFile.isNewRecord = true
  171. for (const t of thumbnails) {
  172. t.isNewRecord = true
  173. }
  174. throw err
  175. })
  176. })
  177. if (this.videoAttributes.inputFilename) {
  178. await createVideoSource({
  179. inputFilename: this.videoAttributes.inputFilename,
  180. inputPath: this.videoPath,
  181. inputProbe: this.videoFileProbe,
  182. video: this.video
  183. })
  184. }
  185. // Channel has a new content, set as updated
  186. await this.channel.setAsUpdated()
  187. return { video: this.video, videoFile: this.videoFile }
  188. }
  189. private async createThumbnails () {
  190. const promises: Promise<MThumbnail>[] = []
  191. let toGenerate = [ ThumbnailType.MINIATURE, ThumbnailType.PREVIEW ]
  192. for (const type of [ ThumbnailType.MINIATURE, ThumbnailType.PREVIEW ]) {
  193. const thumbnail = this.options.thumbnails.find(t => t.type === type)
  194. if (!thumbnail) continue
  195. promises.push(
  196. updateLocalVideoMiniatureFromExisting({
  197. inputPath: thumbnail.path,
  198. video: this.video,
  199. type,
  200. automaticallyGenerated: thumbnail.automaticallyGenerated || false,
  201. keepOriginal: thumbnail.keepOriginal
  202. })
  203. )
  204. toGenerate = toGenerate.filter(t => t !== thumbnail.type)
  205. }
  206. return [
  207. ...await Promise.all(promises),
  208. ...await generateLocalVideoMiniature({
  209. video: this.video,
  210. videoFile: this.videoFile,
  211. types: toGenerate,
  212. ffprobe: this.videoFileProbe
  213. })
  214. ]
  215. }
  216. private buildVideo (videoInfo: VideoAttributes, channel: MChannel): FilteredModelAttributes<VideoModel> {
  217. return {
  218. name: videoInfo.name,
  219. state: videoInfo.state,
  220. remote: false,
  221. category: videoInfo.category,
  222. licence: videoInfo.licence ?? CONFIG.DEFAULTS.PUBLISH.LICENCE,
  223. language: videoInfo.language,
  224. commentsEnabled: videoInfo.commentsEnabled ?? CONFIG.DEFAULTS.PUBLISH.COMMENTS_ENABLED,
  225. downloadEnabled: videoInfo.downloadEnabled ?? CONFIG.DEFAULTS.PUBLISH.DOWNLOAD_ENABLED,
  226. waitTranscoding: videoInfo.waitTranscoding || false,
  227. nsfw: videoInfo.nsfw || false,
  228. description: videoInfo.description,
  229. support: videoInfo.support,
  230. privacy: videoInfo.privacy || VideoPrivacy.PRIVATE,
  231. isLive: videoInfo.isLive,
  232. channelId: channel.id,
  233. originallyPublishedAt: videoInfo.originallyPublishedAt
  234. ? new Date(videoInfo.originallyPublishedAt)
  235. : null,
  236. uuid: buildUUID(),
  237. duration: videoInfo.duration
  238. }
  239. }
  240. }