video-live-ending.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319
  1. import { Job } from 'bullmq'
  2. import { remove } from 'fs-extra/esm'
  3. import { readdir } from 'fs/promises'
  4. import { join } from 'path'
  5. import { ThumbnailType, VideoLiveEndingPayload, VideoState } from '@peertube/peertube-models'
  6. import { peertubeTruncate } from '@server/helpers/core-utils.js'
  7. import { CONSTRAINTS_FIELDS } from '@server/initializers/constants.js'
  8. import { getLocalVideoActivityPubUrl } from '@server/lib/activitypub/url.js'
  9. import { federateVideoIfNeeded } from '@server/lib/activitypub/videos/index.js'
  10. import { cleanupAndDestroyPermanentLive, cleanupTMPLiveFiles, cleanupUnsavedNormalLive } from '@server/lib/live/index.js'
  11. import {
  12. generateHLSMasterPlaylistFilename,
  13. generateHlsSha256SegmentsFilename,
  14. getHLSDirectory,
  15. getLiveReplayBaseDirectory
  16. } from '@server/lib/paths.js'
  17. import { generateLocalVideoMiniature, regenerateMiniaturesIfNeeded } from '@server/lib/thumbnail.js'
  18. import { generateHlsPlaylistResolutionFromTS } from '@server/lib/transcoding/hls-transcoding.js'
  19. import { VideoPathManager } from '@server/lib/video-path-manager.js'
  20. import { moveToNextState } from '@server/lib/video-state.js'
  21. import { VideoBlacklistModel } from '@server/models/video/video-blacklist.js'
  22. import { VideoFileModel } from '@server/models/video/video-file.js'
  23. import { VideoLiveReplaySettingModel } from '@server/models/video/video-live-replay-setting.js'
  24. import { VideoLiveSessionModel } from '@server/models/video/video-live-session.js'
  25. import { VideoLiveModel } from '@server/models/video/video-live.js'
  26. import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist.js'
  27. import { VideoModel } from '@server/models/video/video.js'
  28. import { MVideo, MVideoLive, MVideoLiveSession, MVideoWithAllFiles } from '@server/types/models/index.js'
  29. import { ffprobePromise, getAudioStream, getVideoStreamDimensionsInfo, getVideoStreamFPS } from '@peertube/peertube-ffmpeg'
  30. import { logger, loggerTagsFactory } from '../../../helpers/logger.js'
  31. import { JobQueue } from '../job-queue.js'
  32. import { isVideoInPublicDirectory } from '@server/lib/video-privacy.js'
  33. import { buildStoryboardJobIfNeeded } from '@server/lib/video-jobs.js'
  34. const lTags = loggerTagsFactory('live', 'job')
  35. async function processVideoLiveEnding (job: Job) {
  36. const payload = job.data as VideoLiveEndingPayload
  37. logger.info('Processing video live ending for %s.', payload.videoId, { payload, ...lTags() })
  38. function logError () {
  39. logger.warn('Video live %d does not exist anymore. Cannot process live ending.', payload.videoId, lTags())
  40. }
  41. const video = await VideoModel.load(payload.videoId)
  42. const live = await VideoLiveModel.loadByVideoId(payload.videoId)
  43. const liveSession = await VideoLiveSessionModel.load(payload.liveSessionId)
  44. if (!video || !live || !liveSession) {
  45. logError()
  46. return
  47. }
  48. const permanentLive = live.permanentLive
  49. liveSession.endingProcessed = true
  50. await liveSession.save()
  51. if (liveSession.saveReplay !== true) {
  52. return cleanupLiveAndFederate({ permanentLive, video, streamingPlaylistId: payload.streamingPlaylistId })
  53. }
  54. if (await hasReplayFiles(payload.replayDirectory) !== true) {
  55. logger.info(`No replay files found for live ${video.uuid}, skipping video replay creation.`, { ...lTags(video.uuid) })
  56. return cleanupLiveAndFederate({ permanentLive, video, streamingPlaylistId: payload.streamingPlaylistId })
  57. }
  58. if (permanentLive) {
  59. await saveReplayToExternalVideo({
  60. liveVideo: video,
  61. liveSession,
  62. publishedAt: payload.publishedAt,
  63. replayDirectory: payload.replayDirectory
  64. })
  65. return cleanupLiveAndFederate({ permanentLive, video, streamingPlaylistId: payload.streamingPlaylistId })
  66. }
  67. return replaceLiveByReplay({
  68. video,
  69. liveSession,
  70. live,
  71. permanentLive,
  72. replayDirectory: payload.replayDirectory
  73. })
  74. }
  75. // ---------------------------------------------------------------------------
  76. export {
  77. processVideoLiveEnding
  78. }
  79. // ---------------------------------------------------------------------------
  80. async function saveReplayToExternalVideo (options: {
  81. liveVideo: MVideo
  82. liveSession: MVideoLiveSession
  83. publishedAt: string
  84. replayDirectory: string
  85. }) {
  86. const { liveVideo, liveSession, publishedAt, replayDirectory } = options
  87. const replaySettings = await VideoLiveReplaySettingModel.load(liveSession.replaySettingId)
  88. const videoNameSuffix = ` - ${new Date(publishedAt).toLocaleString()}`
  89. const truncatedVideoName = peertubeTruncate(liveVideo.name, {
  90. length: CONSTRAINTS_FIELDS.VIDEOS.NAME.max - videoNameSuffix.length
  91. })
  92. const replayVideo = new VideoModel({
  93. name: truncatedVideoName + videoNameSuffix,
  94. isLive: false,
  95. state: VideoState.TO_TRANSCODE,
  96. duration: 0,
  97. remote: liveVideo.remote,
  98. category: liveVideo.category,
  99. licence: liveVideo.licence,
  100. language: liveVideo.language,
  101. commentsEnabled: liveVideo.commentsEnabled,
  102. downloadEnabled: liveVideo.downloadEnabled,
  103. waitTranscoding: true,
  104. nsfw: liveVideo.nsfw,
  105. description: liveVideo.description,
  106. aspectRatio: liveVideo.aspectRatio,
  107. support: liveVideo.support,
  108. privacy: replaySettings.privacy,
  109. channelId: liveVideo.channelId
  110. }) as MVideoWithAllFiles
  111. replayVideo.Thumbnails = []
  112. replayVideo.VideoFiles = []
  113. replayVideo.VideoStreamingPlaylists = []
  114. replayVideo.url = getLocalVideoActivityPubUrl(replayVideo)
  115. await replayVideo.save()
  116. liveSession.replayVideoId = replayVideo.id
  117. await liveSession.save()
  118. // If live is blacklisted, also blacklist the replay
  119. const blacklist = await VideoBlacklistModel.loadByVideoId(liveVideo.id)
  120. if (blacklist) {
  121. await VideoBlacklistModel.create({
  122. videoId: replayVideo.id,
  123. unfederated: blacklist.unfederated,
  124. reason: blacklist.reason,
  125. type: blacklist.type
  126. })
  127. }
  128. const inputFileMutexReleaser = await VideoPathManager.Instance.lockFiles(liveVideo.uuid)
  129. try {
  130. await assignReplayFilesToVideo({ video: replayVideo, replayDirectory })
  131. await remove(replayDirectory)
  132. } finally {
  133. inputFileMutexReleaser()
  134. }
  135. const thumbnails = await generateLocalVideoMiniature({
  136. video: replayVideo,
  137. videoFile: replayVideo.getMaxQualityFile(),
  138. types: [ ThumbnailType.MINIATURE, ThumbnailType.PREVIEW ],
  139. ffprobe: undefined
  140. })
  141. for (const thumbnail of thumbnails) {
  142. await replayVideo.addAndSaveThumbnail(thumbnail)
  143. }
  144. await moveToNextState({ video: replayVideo, isNewVideo: true })
  145. await createStoryboardJob(replayVideo)
  146. }
  147. async function replaceLiveByReplay (options: {
  148. video: MVideo
  149. liveSession: MVideoLiveSession
  150. live: MVideoLive
  151. permanentLive: boolean
  152. replayDirectory: string
  153. }) {
  154. const { video: liveVideo, liveSession, live, permanentLive, replayDirectory } = options
  155. const replaySettings = await VideoLiveReplaySettingModel.load(liveSession.replaySettingId)
  156. const videoWithFiles = await VideoModel.loadFull(liveVideo.id)
  157. const hlsPlaylist = videoWithFiles.getHLSPlaylist()
  158. const replayInAnotherDirectory = isVideoInPublicDirectory(liveVideo.privacy) !== isVideoInPublicDirectory(replaySettings.privacy)
  159. logger.info(`Replacing live ${liveVideo.uuid} by replay ${replayDirectory}.`, { replayInAnotherDirectory, ...lTags(liveVideo.uuid) })
  160. await cleanupTMPLiveFiles(videoWithFiles, hlsPlaylist)
  161. await live.destroy()
  162. videoWithFiles.isLive = false
  163. videoWithFiles.privacy = replaySettings.privacy
  164. videoWithFiles.waitTranscoding = true
  165. videoWithFiles.state = VideoState.TO_TRANSCODE
  166. await videoWithFiles.save()
  167. liveSession.replayVideoId = videoWithFiles.id
  168. await liveSession.save()
  169. await VideoFileModel.removeHLSFilesOfVideoId(hlsPlaylist.id)
  170. // Reset playlist
  171. hlsPlaylist.VideoFiles = []
  172. hlsPlaylist.playlistFilename = generateHLSMasterPlaylistFilename()
  173. hlsPlaylist.segmentsSha256Filename = generateHlsSha256SegmentsFilename()
  174. await hlsPlaylist.save()
  175. const inputFileMutexReleaser = await VideoPathManager.Instance.lockFiles(videoWithFiles.uuid)
  176. try {
  177. await assignReplayFilesToVideo({ video: videoWithFiles, replayDirectory })
  178. // Should not happen in this function, but we keep the code if in the future we can replace the permanent live by a replay
  179. if (permanentLive) { // Remove session replay
  180. await remove(replayDirectory)
  181. } else {
  182. // We won't stream again in this live, we can delete the base replay directory
  183. await remove(getLiveReplayBaseDirectory(liveVideo))
  184. // If the live was in another base directory, also delete it
  185. if (replayInAnotherDirectory) {
  186. await remove(getHLSDirectory(liveVideo))
  187. }
  188. }
  189. } finally {
  190. inputFileMutexReleaser()
  191. }
  192. // Regenerate the thumbnail & preview?
  193. await regenerateMiniaturesIfNeeded(videoWithFiles, undefined)
  194. // We consider this is a new video
  195. await moveToNextState({ video: videoWithFiles, isNewVideo: true })
  196. await createStoryboardJob(videoWithFiles)
  197. }
  198. async function assignReplayFilesToVideo (options: {
  199. video: MVideo
  200. replayDirectory: string
  201. }) {
  202. const { video, replayDirectory } = options
  203. const concatenatedTsFiles = await readdir(replayDirectory)
  204. logger.info(`Assigning replays ${replayDirectory} to video ${video.uuid}.`, { concatenatedTsFiles, ...lTags(video.uuid) })
  205. for (const concatenatedTsFile of concatenatedTsFiles) {
  206. // Generating hls playlist can be long, reload the video in this case
  207. await video.reload()
  208. const concatenatedTsFilePath = join(replayDirectory, concatenatedTsFile)
  209. const probe = await ffprobePromise(concatenatedTsFilePath)
  210. const { audioStream } = await getAudioStream(concatenatedTsFilePath, probe)
  211. const { resolution } = await getVideoStreamDimensionsInfo(concatenatedTsFilePath, probe)
  212. const fps = await getVideoStreamFPS(concatenatedTsFilePath, probe)
  213. try {
  214. await generateHlsPlaylistResolutionFromTS({
  215. video,
  216. inputFileMutexReleaser: null, // Already locked in parent
  217. concatenatedTsFilePath,
  218. resolution,
  219. fps,
  220. isAAC: audioStream?.codec_name === 'aac'
  221. })
  222. } catch (err) {
  223. logger.error('Cannot generate HLS playlist resolution from TS files.', { err })
  224. }
  225. }
  226. return video
  227. }
  228. async function cleanupLiveAndFederate (options: {
  229. video: MVideo
  230. permanentLive: boolean
  231. streamingPlaylistId: number
  232. }) {
  233. const { permanentLive, video, streamingPlaylistId } = options
  234. const streamingPlaylist = await VideoStreamingPlaylistModel.loadWithVideo(streamingPlaylistId)
  235. if (streamingPlaylist) {
  236. if (permanentLive) {
  237. await cleanupAndDestroyPermanentLive(video, streamingPlaylist)
  238. } else {
  239. await cleanupUnsavedNormalLive(video, streamingPlaylist)
  240. }
  241. }
  242. try {
  243. const fullVideo = await VideoModel.loadFull(video.id)
  244. return federateVideoIfNeeded(fullVideo, false, undefined)
  245. } catch (err) {
  246. logger.warn('Cannot federate live after cleanup', { videoId: video.id, err })
  247. }
  248. }
  249. function createStoryboardJob (video: MVideo) {
  250. return JobQueue.Instance.createJob(buildStoryboardJobIfNeeded({ video, federate: true }))
  251. }
  252. async function hasReplayFiles (replayDirectory: string) {
  253. return (await readdir(replayDirectory)).length !== 0
  254. }