X-Git-Url: https://git.immae.eu/?a=blobdiff_plain;f=server%2Flib%2Fjob-queue%2Fhandlers%2Fvideo-live-ending.ts;h=814f313a3bb8d946905bbbce3fb1ffba571e0d28;hb=a687879e94fa5d3ecdd76bec3d94d0e1698ee913;hp=517b90abca145e0dd7fd95a229bb6f455e6f601e;hpb=1e4d2cb5aef11898585fae4053da4ebd0a69b480;p=github%2FChocobozzz%2FPeerTube.git diff --git a/server/lib/job-queue/handlers/video-live-ending.ts b/server/lib/job-queue/handlers/video-live-ending.ts index 517b90abc..814f313a3 100644 --- a/server/lib/job-queue/handlers/video-live-ending.ts +++ b/server/lib/job-queue/handlers/video-live-ending.ts @@ -1,162 +1,272 @@ -import * as Bull from 'bull' -import { pathExists, readdir, remove } from 'fs-extra' +import { Job } from 'bullmq' +import { readdir, remove } from 'fs-extra' import { join } from 'path' -import { ffprobePromise, getAudioStream, getDurationFromVideoFile, getVideoFileResolution } from '@server/helpers/ffprobe-utils' -import { VIDEO_LIVE } from '@server/initializers/constants' -import { LiveManager } from '@server/lib/live-manager' +import { getLocalVideoActivityPubUrl } from '@server/lib/activitypub/url' +import { federateVideoIfNeeded } from '@server/lib/activitypub/videos' +import { cleanupAndDestroyPermanentLive, cleanupTMPLiveFiles, cleanupUnsavedNormalLive } from '@server/lib/live' +import { generateHLSMasterPlaylistFilename, generateHlsSha256SegmentsFilename, getLiveReplayBaseDirectory } from '@server/lib/paths' import { generateVideoMiniature } from '@server/lib/thumbnail' -import { generateHlsPlaylistResolutionFromTS } from '@server/lib/transcoding/video-transcoding' -import { publishAndFederateIfNeeded } from '@server/lib/video' -import { getHLSDirectory } from '@server/lib/video-paths' +import { generateHlsPlaylistResolutionFromTS } from '@server/lib/transcoding/hls-transcoding' +import { VideoPathManager } from '@server/lib/video-path-manager' +import { moveToNextState } from '@server/lib/video-state' import { VideoModel } from '@server/models/video/video' +import { VideoBlacklistModel } from '@server/models/video/video-blacklist' import { VideoFileModel } from '@server/models/video/video-file' import { VideoLiveModel } from '@server/models/video/video-live' +import { VideoLiveReplaySettingModel } from '@server/models/video/video-live-replay-setting' +import { VideoLiveSessionModel } from '@server/models/video/video-live-session' import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist' -import { MStreamingPlaylist, MVideo, MVideoLive } from '@server/types/models' +import { MVideo, MVideoLive, MVideoLiveSession, MVideoWithAllFiles } from '@server/types/models' +import { ffprobePromise, getAudioStream, getVideoStreamDimensionsInfo, getVideoStreamFPS } from '@shared/ffmpeg' import { ThumbnailType, VideoLiveEndingPayload, VideoState } from '@shared/models' -import { logger } from '../../../helpers/logger' +import { logger, loggerTagsFactory } from '../../../helpers/logger' -async function processVideoLiveEnding (job: Bull.Job) { +const lTags = loggerTagsFactory('live', 'job') + +async function processVideoLiveEnding (job: Job) { const payload = job.data as VideoLiveEndingPayload + logger.info('Processing video live ending for %s.', payload.videoId, { payload, ...lTags() }) + function logError () { - logger.warn('Video live %d does not exist anymore. Cannot process live ending.', payload.videoId) + logger.warn('Video live %d does not exist anymore. Cannot process live ending.', payload.videoId, lTags()) } const video = await VideoModel.load(payload.videoId) const live = await VideoLiveModel.loadByVideoId(payload.videoId) + const liveSession = await VideoLiveSessionModel.load(payload.liveSessionId) - if (!video || !live) { + if (!video || !live || !liveSession) { logError() return } - const streamingPlaylist = await VideoStreamingPlaylistModel.loadHLSPlaylistByVideo(video.id) - if (!streamingPlaylist) { - logError() - return - } + const permanentLive = live.permanentLive - LiveManager.Instance.cleanupShaSegments(video.uuid) + liveSession.endingProcessed = true + await liveSession.save() - if (live.saveReplay !== true) { - return cleanupLive(video, streamingPlaylist) + if (liveSession.saveReplay !== true) { + return cleanupLiveAndFederate({ permanentLive, video, streamingPlaylistId: payload.streamingPlaylistId }) } - return saveLive(video, live) -} - -async function cleanupLive (video: MVideo, streamingPlaylist: MStreamingPlaylist) { - const hlsDirectory = getHLSDirectory(video) + if (permanentLive) { + await saveReplayToExternalVideo({ + liveVideo: video, + liveSession, + publishedAt: payload.publishedAt, + replayDirectory: payload.replayDirectory + }) - await remove(hlsDirectory) + return cleanupLiveAndFederate({ permanentLive, video, streamingPlaylistId: payload.streamingPlaylistId }) + } - await streamingPlaylist.destroy() + return replaceLiveByReplay({ + video, + liveSession, + live, + permanentLive, + replayDirectory: payload.replayDirectory + }) } // --------------------------------------------------------------------------- export { - processVideoLiveEnding, - cleanupLive + processVideoLiveEnding } // --------------------------------------------------------------------------- -async function saveLive (video: MVideo, live: MVideoLive) { - const hlsDirectory = getHLSDirectory(video, false) - const replayDirectory = join(hlsDirectory, VIDEO_LIVE.REPLAY_DIRECTORY) +async function saveReplayToExternalVideo (options: { + liveVideo: MVideo + liveSession: MVideoLiveSession + publishedAt: string + replayDirectory: string +}) { + const { liveVideo, liveSession, publishedAt, replayDirectory } = options + + const replaySettings = await VideoLiveReplaySettingModel.load(liveSession.replaySettingId) + + const replayVideo = new VideoModel({ + name: `${liveVideo.name} - ${new Date(publishedAt).toLocaleString()}`, + isLive: false, + state: VideoState.TO_TRANSCODE, + duration: 0, + + remote: liveVideo.remote, + category: liveVideo.category, + licence: liveVideo.licence, + language: liveVideo.language, + commentsEnabled: liveVideo.commentsEnabled, + downloadEnabled: liveVideo.downloadEnabled, + waitTranscoding: true, + nsfw: liveVideo.nsfw, + description: liveVideo.description, + support: liveVideo.support, + privacy: replaySettings.privacy, + channelId: liveVideo.channelId + }) as MVideoWithAllFiles + + replayVideo.Thumbnails = [] + replayVideo.VideoFiles = [] + replayVideo.VideoStreamingPlaylists = [] + + replayVideo.url = getLocalVideoActivityPubUrl(replayVideo) + + await replayVideo.save() + + liveSession.replayVideoId = replayVideo.id + await liveSession.save() + + // If live is blacklisted, also blacklist the replay + const blacklist = await VideoBlacklistModel.loadByVideoId(liveVideo.id) + if (blacklist) { + await VideoBlacklistModel.create({ + videoId: replayVideo.id, + unfederated: blacklist.unfederated, + reason: blacklist.reason, + type: blacklist.type + }) + } - const rootFiles = await readdir(hlsDirectory) + await assignReplayFilesToVideo({ video: replayVideo, replayDirectory }) - const playlistFiles = rootFiles.filter(file => { - return file.endsWith('.m3u8') && file !== 'master.m3u8' - }) + await remove(replayDirectory) - await cleanupLiveFiles(hlsDirectory) + for (const type of [ ThumbnailType.MINIATURE, ThumbnailType.PREVIEW ]) { + const image = await generateVideoMiniature({ video: replayVideo, videoFile: replayVideo.getMaxQualityFile(), type }) + await replayVideo.addAndSaveThumbnail(image) + } - await live.destroy() + await moveToNextState({ video: replayVideo, isNewVideo: true }) +} - video.isLive = false - // Reinit views - video.views = 0 - video.state = VideoState.TO_TRANSCODE +async function replaceLiveByReplay (options: { + video: MVideo + liveSession: MVideoLiveSession + live: MVideoLive + permanentLive: boolean + replayDirectory: string +}) { + const { video, liveSession, live, permanentLive, replayDirectory } = options + + const replaySettings = await VideoLiveReplaySettingModel.load(liveSession.replaySettingId) + const videoWithFiles = await VideoModel.loadFull(video.id) + const hlsPlaylist = videoWithFiles.getHLSPlaylist() - await video.save() + await cleanupTMPLiveFiles(videoWithFiles, hlsPlaylist) - // Remove old HLS playlist video files - const videoWithFiles = await VideoModel.loadAndPopulateAccountAndServerAndTags(video.id) + await live.destroy() - const hlsPlaylist = videoWithFiles.getHLSPlaylist() - await VideoFileModel.removeHLSFilesOfVideoId(hlsPlaylist.id) - hlsPlaylist.VideoFiles = [] + videoWithFiles.isLive = false + videoWithFiles.privacy = replaySettings.privacy + videoWithFiles.waitTranscoding = true + videoWithFiles.state = VideoState.TO_TRANSCODE - let durationDone = false + await videoWithFiles.save() - for (const playlistFile of playlistFiles) { - const concatenatedTsFile = LiveManager.Instance.buildConcatenatedName(playlistFile) - const concatenatedTsFilePath = join(replayDirectory, concatenatedTsFile) + liveSession.replayVideoId = videoWithFiles.id + await liveSession.save() - const probe = await ffprobePromise(concatenatedTsFilePath) - const { audioStream } = await getAudioStream(concatenatedTsFilePath, probe) - - const { videoFileResolution, isPortraitMode } = await getVideoFileResolution(concatenatedTsFilePath, probe) + await VideoFileModel.removeHLSFilesOfVideoId(hlsPlaylist.id) - const outputPath = await generateHlsPlaylistResolutionFromTS({ - video: videoWithFiles, - concatenatedTsFilePath, - resolution: videoFileResolution, - isPortraitMode, - isAAC: audioStream?.codec_name === 'aac' - }) + // Reset playlist + hlsPlaylist.VideoFiles = [] + hlsPlaylist.playlistFilename = generateHLSMasterPlaylistFilename() + hlsPlaylist.segmentsSha256Filename = generateHlsSha256SegmentsFilename() + await hlsPlaylist.save() - if (!durationDone) { - videoWithFiles.duration = await getDurationFromVideoFile(outputPath) - await videoWithFiles.save() + await assignReplayFilesToVideo({ video: videoWithFiles, replayDirectory }) - durationDone = true - } + if (permanentLive) { // Remove session replay + await remove(replayDirectory) + } else { // We won't stream again in this live, we can delete the base replay directory + await remove(getLiveReplayBaseDirectory(videoWithFiles)) } - await remove(replayDirectory) - // Regenerate the thumbnail & preview? if (videoWithFiles.getMiniature().automaticallyGenerated === true) { - await generateVideoMiniature({ + const miniature = await generateVideoMiniature({ video: videoWithFiles, videoFile: videoWithFiles.getMaxQualityFile(), type: ThumbnailType.MINIATURE }) + await videoWithFiles.addAndSaveThumbnail(miniature) } if (videoWithFiles.getPreview().automaticallyGenerated === true) { - await generateVideoMiniature({ + const preview = await generateVideoMiniature({ video: videoWithFiles, videoFile: videoWithFiles.getMaxQualityFile(), type: ThumbnailType.PREVIEW }) + await videoWithFiles.addAndSaveThumbnail(preview) } - await publishAndFederateIfNeeded(videoWithFiles, true) + // We consider this is a new video + await moveToNextState({ video: videoWithFiles, isNewVideo: true }) } -async function cleanupLiveFiles (hlsDirectory: string) { - if (!await pathExists(hlsDirectory)) return +async function assignReplayFilesToVideo (options: { + video: MVideo + replayDirectory: string +}) { + const { video, replayDirectory } = options - const files = await readdir(hlsDirectory) + const concatenatedTsFiles = await readdir(replayDirectory) - for (const filename of files) { - if ( - filename.endsWith('.ts') || - filename.endsWith('.m3u8') || - filename.endsWith('.mpd') || - filename.endsWith('.m4s') || - filename.endsWith('.tmp') - ) { - const p = join(hlsDirectory, filename) + for (const concatenatedTsFile of concatenatedTsFiles) { + const inputFileMutexReleaser = await VideoPathManager.Instance.lockFiles(video.uuid) + await video.reload() - remove(p) - .catch(err => logger.error('Cannot remove %s.', p, { err })) + const concatenatedTsFilePath = join(replayDirectory, concatenatedTsFile) + + const probe = await ffprobePromise(concatenatedTsFilePath) + const { audioStream } = await getAudioStream(concatenatedTsFilePath, probe) + const { resolution } = await getVideoStreamDimensionsInfo(concatenatedTsFilePath, probe) + const fps = await getVideoStreamFPS(concatenatedTsFilePath, probe) + + try { + await generateHlsPlaylistResolutionFromTS({ + video, + inputFileMutexReleaser, + concatenatedTsFilePath, + resolution, + fps, + isAAC: audioStream?.codec_name === 'aac' + }) + } catch (err) { + logger.error('Cannot generate HLS playlist resolution from TS files.', { err }) } + + inputFileMutexReleaser() + } + + return video +} + +async function cleanupLiveAndFederate (options: { + video: MVideo + permanentLive: boolean + streamingPlaylistId: number +}) { + const { permanentLive, video, streamingPlaylistId } = options + + const streamingPlaylist = await VideoStreamingPlaylistModel.loadWithVideo(streamingPlaylistId) + + if (streamingPlaylist) { + if (permanentLive) { + await cleanupAndDestroyPermanentLive(video, streamingPlaylist) + } else { + await cleanupUnsavedNormalLive(video, streamingPlaylist) + } + } + + try { + const fullVideo = await VideoModel.loadFull(video.id) + return federateVideoIfNeeded(fullVideo, false, undefined) + } catch (err) { + logger.warn('Cannot federate live after cleanup', { videoId: video.id, err }) } }