ニジカ投稿局 https://tv.nizika.tv
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

local-video-creator.ts 11 KiB

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