From 26e3e98ff0e222a9fb9226938ac6902af77921bd Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Tue, 3 May 2022 11:38:07 +0200 Subject: [PATCH] Support live session in server --- .../live-stream-information.component.scss | 1 + server/controllers/api/videos/live.ts | 36 ++++- server/initializers/constants.ts | 2 +- server/initializers/database.ts | 2 + .../migrations/0710-live-sessions.ts | 34 +++++ .../job-queue/handlers/video-live-ending.ts | 87 +++++++---- server/lib/live/live-manager.ts | 64 ++++++-- server/lib/live/shared/muxing-session.ts | 10 +- server/lib/video-blacklist.ts | 4 +- server/lib/video-state.ts | 4 +- .../validators/videos/video-live.ts | 38 +++++ server/models/video/video-live-session.ts | 142 ++++++++++++++++++ server/models/video/video.ts | 2 +- server/tests/api/check-params/live.ts | 46 ++++++ server/tests/api/live/live-constraints.ts | 52 +++++-- server/tests/api/live/live-permanent.ts | 17 +++ server/tests/api/live/live-save-replay.ts | 72 ++++++++- server/tests/api/live/live.ts | 8 + .../api/notifications/user-notifications.ts | 76 +++++++++- server/tests/shared/notifications.ts | 9 +- server/types/express.d.ts | 1 + server/types/models/video/index.ts | 2 + .../types/models/video/video-live-session.ts | 15 ++ shared/models/server/job.model.ts | 1 + shared/models/videos/live/index.ts | 2 + .../videos/live/live-video-error.enum.ts | 7 + .../videos/live/live-video-session.model.ts | 16 ++ shared/server-commands/videos/live-command.ts | 48 +++++- support/doc/api/openapi.yaml | 82 ++++++++++ 29 files changed, 814 insertions(+), 66 deletions(-) create mode 100644 server/initializers/migrations/0710-live-sessions.ts create mode 100644 server/models/video/video-live-session.ts create mode 100644 server/types/models/video/video-live-session.ts create mode 100644 shared/models/videos/live/live-video-error.enum.ts create mode 100644 shared/models/videos/live/live-video-session.model.ts diff --git a/client/src/app/shared/shared-video-live/live-stream-information.component.scss b/client/src/app/shared/shared-video-live/live-stream-information.component.scss index b9008ba59..7cd53478f 100644 --- a/client/src/app/shared/shared-video-live/live-stream-information.component.scss +++ b/client/src/app/shared/shared-video-live/live-stream-information.component.scss @@ -15,4 +15,5 @@ p-autocomplete { .badge { font-size: 13px; + margin-right: 5px; } diff --git a/server/controllers/api/videos/live.ts b/server/controllers/api/videos/live.ts index e51658927..ec4c073b5 100644 --- a/server/controllers/api/videos/live.ts +++ b/server/controllers/api/videos/live.ts @@ -1,13 +1,21 @@ import express from 'express' import { exists } from '@server/helpers/custom-validators/misc' import { createReqFiles } from '@server/helpers/express-utils' +import { getFormattedObjects } from '@server/helpers/utils' import { ASSETS_PATH, MIMETYPES } from '@server/initializers/constants' import { getLocalVideoActivityPubUrl } from '@server/lib/activitypub/url' import { federateVideoIfNeeded } from '@server/lib/activitypub/videos' import { Hooks } from '@server/lib/plugins/hooks' import { buildLocalVideoFromReq, buildVideoThumbnailsFromReq, setVideoTags } from '@server/lib/video' -import { videoLiveAddValidator, videoLiveGetValidator, videoLiveUpdateValidator } from '@server/middlewares/validators/videos/video-live' +import { + videoLiveAddValidator, + videoLiveFindReplaySessionValidator, + videoLiveGetValidator, + videoLiveListSessionsValidator, + videoLiveUpdateValidator +} from '@server/middlewares/validators/videos/video-live' import { VideoLiveModel } from '@server/models/video/video-live' +import { VideoLiveSessionModel } from '@server/models/video/video-live-session' import { MVideoDetails, MVideoFullLight } from '@server/types/models' import { buildUUID, uuidToShort } from '@shared/extra-utils' import { HttpStatusCode, LiveVideoCreate, LiveVideoLatencyMode, LiveVideoUpdate, UserRight, VideoState } from '@shared/models' @@ -28,6 +36,13 @@ liveRouter.post('/live', asyncRetryTransactionMiddleware(addLiveVideo) ) +liveRouter.get('/live/:videoId/sessions', + authenticate, + asyncMiddleware(videoLiveGetValidator), + videoLiveListSessionsValidator, + asyncMiddleware(getLiveVideoSessions) +) + liveRouter.get('/live/:videoId', optionalAuthenticate, asyncMiddleware(videoLiveGetValidator), @@ -41,6 +56,11 @@ liveRouter.put('/live/:videoId', asyncRetryTransactionMiddleware(updateLiveVideo) ) +liveRouter.get('/:videoId/live-session', + asyncMiddleware(videoLiveFindReplaySessionValidator), + getLiveReplaySession +) + // --------------------------------------------------------------------------- export { @@ -55,6 +75,20 @@ function getLiveVideo (req: express.Request, res: express.Response) { return res.json(videoLive.toFormattedJSON(canSeePrivateLiveInformation(res))) } +function getLiveReplaySession (req: express.Request, res: express.Response) { + const session = res.locals.videoLiveSession + + return res.json(session.toFormattedJSON()) +} + +async function getLiveVideoSessions (req: express.Request, res: express.Response) { + const videoLive = res.locals.videoLive + + const data = await VideoLiveSessionModel.listSessionsOfLiveForAPI({ videoId: videoLive.videoId }) + + return res.json(getFormattedObjects(data, data.length)) +} + function canSeePrivateLiveInformation (res: express.Response) { const user = res.locals.oauth?.token.User if (!user) return false diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts index 9986dbf89..fa0fbc19d 100644 --- a/server/initializers/constants.ts +++ b/server/initializers/constants.ts @@ -24,7 +24,7 @@ import { CONFIG, registerConfigChangedHandler } from './config' // --------------------------------------------------------------------------- -const LAST_MIGRATION_VERSION = 705 +const LAST_MIGRATION_VERSION = 710 // --------------------------------------------------------------------------- diff --git a/server/initializers/database.ts b/server/initializers/database.ts index 7a7ba61f4..3576f444c 100644 --- a/server/initializers/database.ts +++ b/server/initializers/database.ts @@ -7,6 +7,7 @@ import { UserModel } from '@server/models/user/user' import { UserNotificationModel } from '@server/models/user/user-notification' import { UserVideoHistoryModel } from '@server/models/user/user-video-history' import { VideoJobInfoModel } from '@server/models/video/video-job-info' +import { VideoLiveSessionModel } from '@server/models/video/video-live-session' import { LocalVideoViewerModel } from '@server/models/view/local-video-viewer' import { LocalVideoViewerWatchSectionModel } from '@server/models/view/local-video-viewer-watch-section' import { isTestInstance } from '../helpers/core-utils' @@ -135,6 +136,7 @@ async function initDatabaseModels (silent: boolean) { VideoRedundancyModel, UserVideoHistoryModel, VideoLiveModel, + VideoLiveSessionModel, AccountBlocklistModel, ServerBlocklistModel, UserNotificationModel, diff --git a/server/initializers/migrations/0710-live-sessions.ts b/server/initializers/migrations/0710-live-sessions.ts new file mode 100644 index 000000000..aaac8d9ce --- /dev/null +++ b/server/initializers/migrations/0710-live-sessions.ts @@ -0,0 +1,34 @@ +import * as Sequelize from 'sequelize' + +async function up (utils: { + transaction: Sequelize.Transaction + queryInterface: Sequelize.QueryInterface + sequelize: Sequelize.Sequelize + db: any +}): Promise { + const { transaction } = utils + + const query = ` + CREATE TABLE IF NOT EXISTS "videoLiveSession" ( + "id" serial, + "startDate" timestamp with time zone NOT NULL, + "endDate" timestamp with time zone, + "error" integer, + "replayVideoId" integer REFERENCES "video" ("id") ON DELETE SET NULL ON UPDATE CASCADE, + "liveVideoId" integer REFERENCES "video" ("id") ON DELETE SET NULL ON UPDATE CASCADE, + "createdAt" timestamp with time zone NOT NULL, + "updatedAt" timestamp with time zone NOT NULL, + PRIMARY KEY ("id") + ); + ` + await utils.sequelize.query(query, { transaction }) +} + +function down () { + throw new Error('Not implemented.') +} + +export { + up, + down +} diff --git a/server/lib/job-queue/handlers/video-live-ending.ts b/server/lib/job-queue/handlers/video-live-ending.ts index 1e290338c..55fd09344 100644 --- a/server/lib/job-queue/handlers/video-live-ending.ts +++ b/server/lib/job-queue/handlers/video-live-ending.ts @@ -15,13 +15,14 @@ import { generateVideoMiniature } from '@server/lib/thumbnail' import { generateHlsPlaylistResolutionFromTS } from '@server/lib/transcoding/transcoding' 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 { VideoLiveSessionModel } from '@server/models/video/video-live-session' import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist' -import { MVideo, MVideoLive, MVideoWithAllFiles } from '@server/types/models' +import { MVideo, MVideoLive, MVideoLiveSession, MVideoWithAllFiles } from '@server/types/models' import { ThumbnailType, VideoLiveEndingPayload, VideoState } from '@shared/models' import { logger } from '../../../helpers/logger' -import { VideoBlacklistModel } from '@server/models/video/video-blacklist' async function processVideoLiveEnding (job: Job) { const payload = job.data as VideoLiveEndingPayload @@ -32,27 +33,28 @@ async function processVideoLiveEnding (job: Job) { logger.warn('Video live %d does not exist anymore. Cannot process live ending.', payload.videoId) } - const video = await VideoModel.load(payload.videoId) + const liveVideo = await VideoModel.load(payload.videoId) const live = await VideoLiveModel.loadByVideoId(payload.videoId) + const liveSession = await VideoLiveSessionModel.load(payload.liveSessionId) - if (!video || !live) { + if (!liveVideo || !live || !liveSession) { logError() return } - LiveSegmentShaStore.Instance.cleanupShaSegments(video.uuid) + LiveSegmentShaStore.Instance.cleanupShaSegments(liveVideo.uuid) if (live.saveReplay !== true) { - return cleanupLiveAndFederate(video) + return cleanupLiveAndFederate({ liveVideo }) } if (live.permanentLive) { - await saveReplayToExternalVideo(video, payload.publishedAt, payload.replayDirectory) + await saveReplayToExternalVideo({ liveVideo, liveSession, publishedAt: payload.publishedAt, replayDirectory: payload.replayDirectory }) - return cleanupLiveAndFederate(video) + return cleanupLiveAndFederate({ liveVideo }) } - return replaceLiveByReplay(video, live, payload.replayDirectory) + return replaceLiveByReplay({ liveVideo, live, liveSession, replayDirectory: payload.replayDirectory }) } // --------------------------------------------------------------------------- @@ -63,7 +65,14 @@ export { // --------------------------------------------------------------------------- -async function saveReplayToExternalVideo (liveVideo: MVideo, publishedAt: string, replayDirectory: string) { +async function saveReplayToExternalVideo (options: { + liveVideo: MVideo + liveSession: MVideoLiveSession + publishedAt: string + replayDirectory: string +}) { + const { liveVideo, liveSession, publishedAt, replayDirectory } = options + await cleanupTMPLiveFiles(getLiveDirectory(liveVideo)) const video = new VideoModel({ @@ -78,7 +87,7 @@ async function saveReplayToExternalVideo (liveVideo: MVideo, publishedAt: string language: liveVideo.language, commentsEnabled: liveVideo.commentsEnabled, downloadEnabled: liveVideo.downloadEnabled, - waitTranscoding: liveVideo.waitTranscoding, + waitTranscoding: true, nsfw: liveVideo.nsfw, description: liveVideo.description, support: liveVideo.support, @@ -94,6 +103,9 @@ async function saveReplayToExternalVideo (liveVideo: MVideo, publishedAt: string await video.save() + liveSession.replayVideoId = video.id + await liveSession.save() + // If live is blacklisted, also blacklist the replay const blacklist = await VideoBlacklistModel.loadByVideoId(liveVideo.id) if (blacklist) { @@ -105,7 +117,7 @@ async function saveReplayToExternalVideo (liveVideo: MVideo, publishedAt: string }) } - await assignReplaysToVideo(video, replayDirectory) + await assignReplayFilesToVideo({ video, replayDirectory }) await remove(replayDirectory) @@ -117,18 +129,29 @@ async function saveReplayToExternalVideo (liveVideo: MVideo, publishedAt: string await moveToNextState({ video, isNewVideo: true }) } -async function replaceLiveByReplay (video: MVideo, live: MVideoLive, replayDirectory: string) { - await cleanupTMPLiveFiles(getLiveDirectory(video)) +async function replaceLiveByReplay (options: { + liveVideo: MVideo + liveSession: MVideoLiveSession + live: MVideoLive + replayDirectory: string +}) { + const { liveVideo, liveSession, live, replayDirectory } = options + + await cleanupTMPLiveFiles(getLiveDirectory(liveVideo)) await live.destroy() - video.isLive = false - video.state = VideoState.TO_TRANSCODE + liveVideo.isLive = false + liveVideo.waitTranscoding = true + liveVideo.state = VideoState.TO_TRANSCODE - await video.save() + await liveVideo.save() + + liveSession.replayVideoId = liveVideo.id + await liveSession.save() // Remove old HLS playlist video files - const videoWithFiles = await VideoModel.loadAndPopulateAccountAndServerAndTags(video.id) + const videoWithFiles = await VideoModel.loadAndPopulateAccountAndServerAndTags(liveVideo.id) const hlsPlaylist = videoWithFiles.getHLSPlaylist() await VideoFileModel.removeHLSFilesOfVideoId(hlsPlaylist.id) @@ -139,7 +162,7 @@ async function replaceLiveByReplay (video: MVideo, live: MVideoLive, replayDirec hlsPlaylist.segmentsSha256Filename = generateHlsSha256SegmentsFilename() await hlsPlaylist.save() - await assignReplaysToVideo(videoWithFiles, replayDirectory) + await assignReplayFilesToVideo({ video: videoWithFiles, replayDirectory }) await remove(getLiveReplayBaseDirectory(videoWithFiles)) @@ -150,7 +173,7 @@ async function replaceLiveByReplay (video: MVideo, live: MVideoLive, replayDirec videoFile: videoWithFiles.getMaxQualityFile(), type: ThumbnailType.MINIATURE }) - await video.addAndSaveThumbnail(miniature) + await videoWithFiles.addAndSaveThumbnail(miniature) } if (videoWithFiles.getPreview().automaticallyGenerated === true) { @@ -159,13 +182,19 @@ async function replaceLiveByReplay (video: MVideo, live: MVideoLive, replayDirec videoFile: videoWithFiles.getMaxQualityFile(), type: ThumbnailType.PREVIEW }) - await video.addAndSaveThumbnail(preview) + await videoWithFiles.addAndSaveThumbnail(preview) } - await moveToNextState({ video: videoWithFiles, isNewVideo: false }) + // We consider this is a new video + await moveToNextState({ video: videoWithFiles, isNewVideo: true }) } -async function assignReplaysToVideo (video: MVideo, replayDirectory: string) { +async function assignReplayFilesToVideo (options: { + video: MVideo + replayDirectory: string +}) { + const { video, replayDirectory } = options + let durationDone = false const concatenatedTsFiles = await readdir(replayDirectory) @@ -197,11 +226,15 @@ async function assignReplaysToVideo (video: MVideo, replayDirectory: string) { return video } -async function cleanupLiveAndFederate (video: MVideo) { - const streamingPlaylist = await VideoStreamingPlaylistModel.loadHLSPlaylistByVideo(video.id) - await cleanupLive(video, streamingPlaylist) +async function cleanupLiveAndFederate (options: { + liveVideo: MVideo +}) { + const { liveVideo } = options + + const streamingPlaylist = await VideoStreamingPlaylistModel.loadHLSPlaylistByVideo(liveVideo.id) + await cleanupLive(liveVideo, streamingPlaylist) - const fullVideo = await VideoModel.loadAndPopulateAccountAndServerAndTags(video.id) + const fullVideo = await VideoModel.loadAndPopulateAccountAndServerAndTags(liveVideo.id) return federateVideoIfNeeded(fullVideo, false, undefined) } diff --git a/server/lib/live/live-manager.ts b/server/lib/live/live-manager.ts index da09aa05c..df2804a0e 100644 --- a/server/lib/live/live-manager.ts +++ b/server/lib/live/live-manager.ts @@ -17,10 +17,11 @@ import { P2P_MEDIA_LOADER_PEER_VERSION, VIDEO_LIVE } from '@server/initializers/ import { UserModel } from '@server/models/user/user' import { VideoModel } from '@server/models/video/video' import { VideoLiveModel } from '@server/models/video/video-live' +import { VideoLiveSessionModel } from '@server/models/video/video-live-session' import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist' -import { MStreamingPlaylistVideo, MVideo, MVideoLiveVideo } from '@server/types/models' +import { MStreamingPlaylistVideo, MVideo, MVideoLiveSession, MVideoLiveVideo } from '@server/types/models' import { wait } from '@shared/core-utils' -import { VideoState, VideoStreamingPlaylistType } from '@shared/models' +import { LiveVideoError, VideoState, VideoStreamingPlaylistType } from '@shared/models' import { federateVideoIfNeeded } from '../activitypub/videos' import { JobQueue } from '../job-queue' import { generateHLSMasterPlaylistFilename, generateHlsSha256SegmentsFilename, getLiveReplayBaseDirectory } from '../paths' @@ -174,10 +175,13 @@ class LiveManager { return !!this.rtmpServer } - stopSessionOf (videoId: number) { + stopSessionOf (videoId: number, error: LiveVideoError | null) { const sessionId = this.videoSessions.get(videoId) if (!sessionId) return + this.saveEndingSession(videoId, error) + .catch(err => logger.error('Cannot save ending session.', { err, ...lTags(sessionId) })) + this.videoSessions.delete(videoId) this.abortSession(sessionId) } @@ -274,6 +278,8 @@ class LiveManager { const videoUUID = videoLive.Video.uuid const localLTags = lTags(sessionId, videoUUID) + const liveSession = await this.saveStartingSession(videoLive) + const user = await UserModel.loadByLiveId(videoLive.id) LiveQuotaStore.Instance.addNewLive(user.id, videoLive.id) @@ -299,24 +305,27 @@ class LiveManager { localLTags ) - this.stopSessionOf(videoId) + this.stopSessionOf(videoId, LiveVideoError.BAD_SOCKET_HEALTH) }) muxingSession.on('duration-exceeded', ({ videoId }) => { logger.info('Stopping session of %s: max duration exceeded.', videoUUID, localLTags) - this.stopSessionOf(videoId) + this.stopSessionOf(videoId, LiveVideoError.DURATION_EXCEEDED) }) muxingSession.on('quota-exceeded', ({ videoId }) => { logger.info('Stopping session of %s: user quota exceeded.', videoUUID, localLTags) - this.stopSessionOf(videoId) + this.stopSessionOf(videoId, LiveVideoError.QUOTA_EXCEEDED) + }) + + muxingSession.on('ffmpeg-error', ({ videoId }) => { + this.stopSessionOf(videoId, LiveVideoError.FFMPEG_ERROR) }) - muxingSession.on('ffmpeg-error', ({ sessionId }) => this.abortSession(sessionId)) muxingSession.on('ffmpeg-end', ({ videoId }) => { - this.onMuxingFFmpegEnd(videoId) + this.onMuxingFFmpegEnd(videoId, sessionId) }) muxingSession.on('after-cleanup', ({ videoId }) => { @@ -324,7 +333,7 @@ class LiveManager { muxingSession.destroy() - return this.onAfterMuxingCleanup({ videoId }) + return this.onAfterMuxingCleanup({ videoId, liveSession }) .catch(err => logger.error('Error in end transmuxing.', { err, ...localLTags })) }) @@ -365,15 +374,19 @@ class LiveManager { } } - private onMuxingFFmpegEnd (videoId: number) { + private onMuxingFFmpegEnd (videoId: number, sessionId: string) { this.videoSessions.delete(videoId) + + this.saveEndingSession(videoId, null) + .catch(err => logger.error('Cannot save ending session.', { err, ...lTags(sessionId) })) } private async onAfterMuxingCleanup (options: { videoId: number | string + liveSession?: MVideoLiveSession cleanupNow?: boolean // Default false }) { - const { videoId, cleanupNow = false } = options + const { videoId, liveSession: liveSessionArg, cleanupNow = false } = options try { const fullVideo = await VideoModel.loadAndPopulateAccountAndServerAndTags(videoId) @@ -381,13 +394,25 @@ class LiveManager { const live = await VideoLiveModel.loadByVideoId(fullVideo.id) + const liveSession = liveSessionArg ?? await VideoLiveSessionModel.findCurrentSessionOf(fullVideo.id) + + // On server restart during a live + if (!liveSession.endDate) { + liveSession.endDate = new Date() + await liveSession.save() + } + JobQueue.Instance.createJob({ type: 'video-live-ending', payload: { videoId: fullVideo.id, + replayDirectory: live.saveReplay ? await this.findReplayDirectory(fullVideo) : undefined, + + liveSessionId: liveSession.id, + publishedAt: fullVideo.publishedAt.toISOString() } }, { delay: cleanupNow ? 0 : VIDEO_LIVE.CLEANUP_DELAY }) @@ -445,6 +470,23 @@ class LiveManager { return playlist.save() } + private saveStartingSession (videoLive: MVideoLiveVideo) { + const liveSession = new VideoLiveSessionModel({ + startDate: new Date(), + liveVideoId: videoLive.videoId + }) + + return liveSession.save() + } + + private async saveEndingSession (videoId: number, error: LiveVideoError | null) { + const liveSession = await VideoLiveSessionModel.findCurrentSessionOf(videoId) + liveSession.endDate = new Date() + liveSession.error = error + + return liveSession.save() + } + static get Instance () { return this.instance || (this.instance = new this()) } diff --git a/server/lib/live/shared/muxing-session.ts b/server/lib/live/shared/muxing-session.ts index 588ee8749..1ee9b430f 100644 --- a/server/lib/live/shared/muxing-session.ts +++ b/server/lib/live/shared/muxing-session.ts @@ -28,7 +28,7 @@ interface MuxingSessionEvents { 'quota-exceeded': ({ videoId: number }) => void 'ffmpeg-end': ({ videoId: number }) => void - 'ffmpeg-error': ({ sessionId: string }) => void + 'ffmpeg-error': ({ videoId: string }) => void 'after-cleanup': ({ videoId: number }) => void } @@ -164,7 +164,11 @@ class MuxingSession extends EventEmitter { this.onFFmpegError({ err, stdout, stderr, outPath: this.outDirectory, ffmpegShellCommand }) }) - this.ffmpegCommand.on('end', () => this.onFFmpegEnded(this.outDirectory)) + this.ffmpegCommand.on('end', () => { + this.emit('ffmpeg-end', ({ videoId: this.videoId })) + + this.onFFmpegEnded(this.outDirectory) + }) this.ffmpegCommand.run() } @@ -197,7 +201,7 @@ class MuxingSession extends EventEmitter { logger.error('Live transcoding error.', { err, stdout, stderr, ffmpegShellCommand, ...this.lTags() }) - this.emit('ffmpeg-error', ({ sessionId: this.sessionId })) + this.emit('ffmpeg-error', ({ videoId: this.videoId })) } private onFFmpegEnded (outPath: string) { diff --git a/server/lib/video-blacklist.ts b/server/lib/video-blacklist.ts index 91f44cb11..fd5837a3a 100644 --- a/server/lib/video-blacklist.ts +++ b/server/lib/video-blacklist.ts @@ -9,7 +9,7 @@ import { MVideoFullLight, MVideoWithBlacklistLight } from '@server/types/models' -import { UserRight, VideoBlacklistCreate, VideoBlacklistType } from '../../shared/models' +import { LiveVideoError, UserRight, VideoBlacklistCreate, VideoBlacklistType } from '../../shared/models' import { UserAdminFlag } from '../../shared/models/users/user-flag.model' import { logger, loggerTagsFactory } from '../helpers/logger' import { CONFIG } from '../initializers/config' @@ -81,7 +81,7 @@ async function blacklistVideo (videoInstance: MVideoAccountLight, options: Video } if (videoInstance.isLive) { - LiveManager.Instance.stopSessionOf(videoInstance.id) + LiveManager.Instance.stopSessionOf(videoInstance.id, LiveVideoError.BLACKLISTED) } Notifier.Instance.notifyOnVideoBlacklist(blacklist) diff --git a/server/lib/video-state.ts b/server/lib/video-state.ts index 7b207eb87..ae2725d65 100644 --- a/server/lib/video-state.ts +++ b/server/lib/video-state.ts @@ -126,12 +126,10 @@ async function moveToPublishedState (options: { const { video, isNewVideo, transaction, previousVideoState } = options const previousState = previousVideoState ?? video.state - logger.info('Publishing video %s.', video.uuid, { previousState, tags: [ video.uuid ] }) + logger.info('Publishing video %s.', video.uuid, { isNewVideo, previousState, tags: [ video.uuid ] }) await video.setNewState(VideoState.PUBLISHED, isNewVideo, transaction) - // If the video was not published, we consider it is a new one for other instances - // Live videos are always federated, so it's not a new video await federateVideoIfNeeded(video, isNewVideo, transaction) if (previousState === VideoState.TO_EDIT) { diff --git a/server/middlewares/validators/videos/video-live.ts b/server/middlewares/validators/videos/video-live.ts index ff492da0f..59638d5e0 100644 --- a/server/middlewares/validators/videos/video-live.ts +++ b/server/middlewares/validators/videos/video-live.ts @@ -28,6 +28,7 @@ import { isValidVideoIdParam } from '../shared' import { getCommonVideoEditAttributes } from './videos' +import { VideoLiveSessionModel } from '@server/models/video/video-live-session' const videoLiveGetValidator = [ isValidVideoIdParam('videoId'), @@ -196,11 +197,48 @@ const videoLiveUpdateValidator = [ } ] +const videoLiveListSessionsValidator = [ + (req: express.Request, res: express.Response, next: express.NextFunction) => { + logger.debug('Checking videoLiveListSessionsValidator parameters', { parameters: req.params }) + + // Check the user can manage the live + const user = res.locals.oauth.token.User + if (!checkUserCanManageVideo(user, res.locals.videoAll, UserRight.GET_ANY_LIVE, res)) return + + return next() + } +] + +const videoLiveFindReplaySessionValidator = [ + isValidVideoIdParam('videoId'), + + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + logger.debug('Checking videoLiveFindReplaySessionValidator parameters', { parameters: req.params }) + + if (areValidationErrors(req, res)) return + if (!await doesVideoExist(req.params.videoId, res, 'id')) return + + const session = await VideoLiveSessionModel.findSessionOfReplay(res.locals.videoId.id) + if (!session) { + return res.fail({ + status: HttpStatusCode.NOT_FOUND_404, + message: 'No live replay found' + }) + } + + res.locals.videoLiveSession = session + + return next() + } +] + // --------------------------------------------------------------------------- export { videoLiveAddValidator, videoLiveUpdateValidator, + videoLiveListSessionsValidator, + videoLiveFindReplaySessionValidator, videoLiveGetValidator } diff --git a/server/models/video/video-live-session.ts b/server/models/video/video-live-session.ts new file mode 100644 index 000000000..2b4cde9f8 --- /dev/null +++ b/server/models/video/video-live-session.ts @@ -0,0 +1,142 @@ +import { FindOptions } from 'sequelize' +import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript' +import { MVideoLiveSession, MVideoLiveSessionReplay } from '@server/types/models' +import { uuidToShort } from '@shared/extra-utils' +import { LiveVideoError, LiveVideoSession } from '@shared/models' +import { AttributesOnly } from '@shared/typescript-utils' +import { VideoModel } from './video' + +export enum ScopeNames { + WITH_REPLAY = 'WITH_REPLAY' +} + +@Scopes(() => ({ + [ScopeNames.WITH_REPLAY]: { + include: [ + { + model: VideoModel.unscoped(), + as: 'ReplayVideo', + required: false + } + ] + } +})) +@Table({ + tableName: 'videoLiveSession', + indexes: [ + { + fields: [ 'replayVideoId' ], + unique: true + }, + { + fields: [ 'liveVideoId' ] + } + ] +}) +export class VideoLiveSessionModel extends Model>> { + + @CreatedAt + createdAt: Date + + @UpdatedAt + updatedAt: Date + + @AllowNull(false) + @Column(DataType.DATE) + startDate: Date + + @AllowNull(true) + @Column(DataType.DATE) + endDate: Date + + @AllowNull(true) + @Column + error: LiveVideoError + + @ForeignKey(() => VideoModel) + @Column + replayVideoId: number + + @BelongsTo(() => VideoModel, { + foreignKey: { + allowNull: true, + name: 'replayVideoId' + }, + as: 'ReplayVideo', + onDelete: 'set null' + }) + ReplayVideo: VideoModel + + @ForeignKey(() => VideoModel) + @Column + liveVideoId: number + + @BelongsTo(() => VideoModel, { + foreignKey: { + allowNull: true, + name: 'liveVideoId' + }, + as: 'LiveVideo', + onDelete: 'set null' + }) + LiveVideo: VideoModel + + static load (id: number): Promise { + return VideoLiveSessionModel.findOne({ + where: { id } + }) + } + + static findSessionOfReplay (replayVideoId: number) { + const query = { + where: { + replayVideoId + } + } + + return VideoLiveSessionModel.scope(ScopeNames.WITH_REPLAY).findOne(query) + } + + static findCurrentSessionOf (videoId: number) { + return VideoLiveSessionModel.findOne({ + where: { + liveVideoId: videoId, + endDate: null + }, + order: [ [ 'startDate', 'DESC' ] ] + }) + } + + static listSessionsOfLiveForAPI (options: { videoId: number }) { + const { videoId } = options + + const query: FindOptions = { + where: { + liveVideoId: videoId + }, + order: [ [ 'startDate', 'ASC' ] ] + } + + return VideoLiveSessionModel.scope(ScopeNames.WITH_REPLAY).findAll(query) + } + + toFormattedJSON (this: MVideoLiveSessionReplay): LiveVideoSession { + const replayVideo = this.ReplayVideo + ? { + id: this.ReplayVideo.id, + uuid: this.ReplayVideo.uuid, + shortUUID: uuidToShort(this.ReplayVideo.uuid) + } + : undefined + + return { + id: this.id, + startDate: this.startDate.toISOString(), + endDate: this.endDate + ? this.endDate.toISOString() + : null, + replayVideo, + error: this.error + } + } +} diff --git a/server/models/video/video.ts b/server/models/video/video.ts index 13d81561a..d216ed47d 100644 --- a/server/models/video/video.ts +++ b/server/models/video/video.ts @@ -787,7 +787,7 @@ export class VideoModel extends Model>> { logger.info('Stopping live of video %s after video deletion.', instance.uuid) - LiveManager.Instance.stopSessionOf(instance.id) + LiveManager.Instance.stopSessionOf(instance.id, null) } @BeforeDestroy diff --git a/server/tests/api/check-params/live.ts b/server/tests/api/check-params/live.ts index bbd331657..29f847e51 100644 --- a/server/tests/api/check-params/live.ts +++ b/server/tests/api/check-params/live.ts @@ -388,6 +388,52 @@ describe('Test video lives API validator', function () { }) }) + describe('When getting live sessions', function () { + + it('Should fail with a bad access token', async function () { + await command.listSessions({ token: 'toto', videoId: video.id, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + }) + + it('Should fail without token', async function () { + await command.listSessions({ token: null, videoId: video.id, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + }) + + it('Should fail with the token of another user', async function () { + await command.listSessions({ token: userAccessToken, videoId: video.id, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + }) + + it('Should fail with a bad video id', async function () { + await command.listSessions({ videoId: 'toto', expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should fail with an unknown video id', async function () { + await command.listSessions({ videoId: 454555, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + }) + + it('Should fail with a non live video', async function () { + await command.listSessions({ videoId: videoIdNotLive, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + }) + + it('Should succeed with the correct params', async function () { + await command.listSessions({ videoId: video.id }) + }) + }) + + describe('When getting live session of a replay', function () { + + it('Should fail with a bad video id', async function () { + await command.getReplaySession({ videoId: 'toto', expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should fail with an unknown video id', async function () { + await command.getReplaySession({ videoId: 454555, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + }) + + it('Should fail with a non replay video', async function () { + await command.getReplaySession({ videoId: videoIdNotLive, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + }) + }) + describe('When updating live information', async function () { it('Should fail without access token', async function () { diff --git a/server/tests/api/live/live-constraints.ts b/server/tests/api/live/live-constraints.ts index b92dc7b89..b81973395 100644 --- a/server/tests/api/live/live-constraints.ts +++ b/server/tests/api/live/live-constraints.ts @@ -3,7 +3,7 @@ import 'mocha' import * as chai from 'chai' import { wait } from '@shared/core-utils' -import { VideoPrivacy } from '@shared/models' +import { LiveVideoError, VideoPrivacy } from '@shared/models' import { cleanupTests, ConfigCommand, @@ -12,7 +12,8 @@ import { PeerTubeServer, setAccessTokensToServers, setDefaultVideoChannel, - waitJobs + waitJobs, + waitUntilLiveWaitingOnAllServers } from '@shared/server-commands' import { checkLiveCleanup } from '../../shared' @@ -24,12 +25,18 @@ describe('Test live constraints', function () { let userAccessToken: string let userChannelId: number - async function createLiveWrapper (saveReplay: boolean) { + async function createLiveWrapper (options: { + replay: boolean + permanent: boolean + }) { + const { replay, permanent } = options + const liveAttributes = { name: 'user live', channelId: userChannelId, privacy: VideoPrivacy.PUBLIC, - saveReplay + saveReplay: replay, + permanentLive: permanent } const { uuid } = await servers[0].live.create({ token: userAccessToken, fields: liveAttributes }) @@ -97,23 +104,42 @@ describe('Test live constraints', function () { it('Should not have size limit if save replay is disabled', async function () { this.timeout(60000) - const userVideoLiveoId = await createLiveWrapper(false) + const userVideoLiveoId = await createLiveWrapper({ replay: false, permanent: false }) await servers[0].live.runAndTestStreamError({ token: userAccessToken, videoId: userVideoLiveoId, shouldHaveError: false }) }) - it('Should have size limit depending on user global quota if save replay is enabled', async function () { + it('Should have size limit depending on user global quota if save replay is enabled on non permanent live', async function () { this.timeout(60000) // Wait for user quota memoize cache invalidation await wait(5000) - const userVideoLiveoId = await createLiveWrapper(true) + const userVideoLiveoId = await createLiveWrapper({ replay: true, permanent: false }) await servers[0].live.runAndTestStreamError({ token: userAccessToken, videoId: userVideoLiveoId, shouldHaveError: true }) await waitUntilLivePublishedOnAllServers(userVideoLiveoId) await waitJobs(servers) await checkSaveReplay(userVideoLiveoId) + + const session = await servers[0].live.getReplaySession({ videoId: userVideoLiveoId }) + expect(session.error).to.equal(LiveVideoError.QUOTA_EXCEEDED) + }) + + it('Should have size limit depending on user global quota if save replay is enabled on a permanent live', async function () { + this.timeout(60000) + + // Wait for user quota memoize cache invalidation + await wait(5000) + + const userVideoLiveoId = await createLiveWrapper({ replay: true, permanent: true }) + await servers[0].live.runAndTestStreamError({ token: userAccessToken, videoId: userVideoLiveoId, shouldHaveError: true }) + + await waitJobs(servers) + await waitUntilLiveWaitingOnAllServers(servers, userVideoLiveoId) + + const session = await servers[0].live.findLatestSession({ videoId: userVideoLiveoId }) + expect(session.error).to.equal(LiveVideoError.QUOTA_EXCEEDED) }) it('Should have size limit depending on user daily quota if save replay is enabled', async function () { @@ -124,13 +150,16 @@ describe('Test live constraints', function () { await updateQuota({ total: -1, daily: 1 }) - const userVideoLiveoId = await createLiveWrapper(true) + const userVideoLiveoId = await createLiveWrapper({ replay: true, permanent: false }) await servers[0].live.runAndTestStreamError({ token: userAccessToken, videoId: userVideoLiveoId, shouldHaveError: true }) await waitUntilLivePublishedOnAllServers(userVideoLiveoId) await waitJobs(servers) await checkSaveReplay(userVideoLiveoId) + + const session = await servers[0].live.getReplaySession({ videoId: userVideoLiveoId }) + expect(session.error).to.equal(LiveVideoError.QUOTA_EXCEEDED) }) it('Should succeed without quota limit', async function () { @@ -141,7 +170,7 @@ describe('Test live constraints', function () { await updateQuota({ total: 10 * 1000 * 1000, daily: -1 }) - const userVideoLiveoId = await createLiveWrapper(true) + const userVideoLiveoId = await createLiveWrapper({ replay: true, permanent: false }) await servers[0].live.runAndTestStreamError({ token: userAccessToken, videoId: userVideoLiveoId, shouldHaveError: false }) }) @@ -162,13 +191,16 @@ describe('Test live constraints', function () { } }) - const userVideoLiveoId = await createLiveWrapper(true) + const userVideoLiveoId = await createLiveWrapper({ replay: true, permanent: false }) await servers[0].live.runAndTestStreamError({ token: userAccessToken, videoId: userVideoLiveoId, shouldHaveError: true }) await waitUntilLivePublishedOnAllServers(userVideoLiveoId) await waitJobs(servers) await checkSaveReplay(userVideoLiveoId, [ 720, 480, 360, 240, 144 ]) + + const session = await servers[0].live.getReplaySession({ videoId: userVideoLiveoId }) + expect(session.error).to.equal(LiveVideoError.DURATION_EXCEEDED) }) after(async function () { diff --git a/server/tests/api/live/live-permanent.ts b/server/tests/api/live/live-permanent.ts index a88d71dd9..92eac9e5f 100644 --- a/server/tests/api/live/live-permanent.ts +++ b/server/tests/api/live/live-permanent.ts @@ -172,6 +172,23 @@ describe('Permanent live', function () { await stopFfmpeg(ffmpegCommand) }) + it('Should have appropriate sessions', async function () { + this.timeout(60000) + + await servers[0].live.waitUntilWaiting({ videoId: videoUUID }) + + const { data, total } = await servers[0].live.listSessions({ videoId: videoUUID }) + expect(total).to.equal(2) + expect(data).to.have.lengthOf(2) + + for (const session of data) { + expect(session.startDate).to.exist + expect(session.endDate).to.exist + + expect(session.error).to.not.exist + } + }) + after(async function () { await cleanupTests(servers) }) diff --git a/server/tests/api/live/live-save-replay.ts b/server/tests/api/live/live-save-replay.ts index fc6acc624..7ddcb04ef 100644 --- a/server/tests/api/live/live-save-replay.ts +++ b/server/tests/api/live/live-save-replay.ts @@ -5,7 +5,7 @@ import * as chai from 'chai' import { FfmpegCommand } from 'fluent-ffmpeg' import { checkLiveCleanup } from '@server/tests/shared' import { wait } from '@shared/core-utils' -import { HttpStatusCode, LiveVideoCreate, VideoPrivacy, VideoState } from '@shared/models' +import { HttpStatusCode, LiveVideoCreate, LiveVideoError, VideoPrivacy, VideoState } from '@shared/models' import { cleanupTests, ConfigCommand, @@ -143,6 +143,9 @@ describe('Save replay setting', function () { }) describe('With save replay disabled', function () { + let sessionStartDateMin: Date + let sessionStartDateMax: Date + let sessionEndDateMin: Date it('Should correctly create and federate the "waiting for stream" live', async function () { this.timeout(20000) @@ -160,7 +163,9 @@ describe('Save replay setting', function () { ffmpegCommand = await servers[0].live.sendRTMPStreamInVideo({ videoId: liveVideoUUID }) + sessionStartDateMin = new Date() await waitUntilLivePublishedOnAllServers(servers, liveVideoUUID) + sessionStartDateMax = new Date() await waitJobs(servers) @@ -171,6 +176,7 @@ describe('Save replay setting', function () { it('Should correctly delete the video files after the stream ended', async function () { this.timeout(40000) + sessionEndDateMin = new Date() await stopFfmpeg(ffmpegCommand) for (const server of servers) { @@ -186,6 +192,24 @@ describe('Save replay setting', function () { await checkLiveCleanup(servers[0], liveVideoUUID, []) }) + it('Should have appropriate ended session', async function () { + const { data, total } = await servers[0].live.listSessions({ videoId: liveVideoUUID }) + expect(total).to.equal(1) + expect(data).to.have.lengthOf(1) + + const session = data[0] + + const startDate = new Date(session.startDate) + expect(startDate).to.be.above(sessionStartDateMin) + expect(startDate).to.be.below(sessionStartDateMax) + + expect(session.endDate).to.exist + expect(new Date(session.endDate)).to.be.above(sessionEndDateMin) + + expect(session.error).to.not.exist + expect(session.replayVideo).to.not.exist + }) + it('Should correctly terminate the stream on blacklist and delete the live', async function () { this.timeout(40000) @@ -201,6 +225,15 @@ describe('Save replay setting', function () { await checkLiveCleanup(servers[0], liveVideoUUID, []) }) + it('Should have blacklisted session error', async function () { + const session = await servers[0].live.findLatestSession({ videoId: liveVideoUUID }) + expect(session.startDate).to.exist + expect(session.endDate).to.exist + + expect(session.error).to.equal(LiveVideoError.BLACKLISTED) + expect(session.replayVideo).to.not.exist + }) + it('Should correctly terminate the stream on delete and delete the video', async function () { this.timeout(40000) @@ -249,6 +282,22 @@ describe('Save replay setting', function () { await checkVideoState(liveVideoUUID, VideoState.PUBLISHED) }) + it('Should find the replay live session', async function () { + const session = await servers[0].live.getReplaySession({ videoId: liveVideoUUID }) + + expect(session).to.exist + + expect(session.startDate).to.exist + expect(session.endDate).to.exist + + expect(session.error).to.not.exist + + expect(session.replayVideo).to.exist + expect(session.replayVideo.id).to.exist + expect(session.replayVideo.shortUUID).to.exist + expect(session.replayVideo.uuid).to.equal(liveVideoUUID) + }) + it('Should update the saved live and correctly federate the updated attributes', async function () { this.timeout(30000) @@ -337,6 +386,27 @@ describe('Save replay setting', function () { lastReplayUUID = video.uuid }) + it('Should have appropriate ended session and replay live session', async function () { + const { data, total } = await servers[0].live.listSessions({ videoId: liveVideoUUID }) + expect(total).to.equal(1) + expect(data).to.have.lengthOf(1) + + const sessionFromLive = data[0] + const sessionFromReplay = await servers[0].live.getReplaySession({ videoId: lastReplayUUID }) + + for (const session of [ sessionFromLive, sessionFromReplay ]) { + expect(session.startDate).to.exist + expect(session.endDate).to.exist + + expect(session.error).to.not.exist + + expect(session.replayVideo).to.exist + expect(session.replayVideo.id).to.exist + expect(session.replayVideo.shortUUID).to.exist + expect(session.replayVideo.uuid).to.equal(lastReplayUUID) + } + }) + it('Should have cleaned up the live files', async function () { await checkLiveCleanup(servers[0], liveVideoUUID, []) }) diff --git a/server/tests/api/live/live.ts b/server/tests/api/live/live.ts index ab7251e31..9b8fbe3e2 100644 --- a/server/tests/api/live/live.ts +++ b/server/tests/api/live/live.ts @@ -594,6 +594,8 @@ describe('Test live', function () { let permanentLiveReplayName: string + let beforeServerRestart: Date + async function createLiveWrapper (options: { saveReplay: boolean, permanent: boolean }) { const liveAttributes: LiveVideoCreate = { name: 'live video', @@ -636,6 +638,8 @@ describe('Test live', function () { } await killallServers([ servers[0] ]) + + beforeServerRestart = new Date() await servers[0].run() await wait(5000) @@ -653,6 +657,10 @@ describe('Test live', function () { this.timeout(120000) await commands[0].waitUntilPublished({ videoId: liveVideoReplayId }) + + const session = await commands[0].getReplaySession({ videoId: liveVideoReplayId }) + expect(session.endDate).to.exist + expect(new Date(session.endDate)).to.be.above(beforeServerRestart) }) it('Should have saved a permanent live replay', async function () { diff --git a/server/tests/api/notifications/user-notifications.ts b/server/tests/api/notifications/user-notifications.ts index 47e85a30c..1705fda55 100644 --- a/server/tests/api/notifications/user-notifications.ts +++ b/server/tests/api/notifications/user-notifications.ts @@ -7,8 +7,8 @@ import { checkMyVideoImportIsFinished, checkNewActorFollow, checkNewVideoFromSubscription, - checkVideoStudioEditionIsFinished, checkVideoIsPublished, + checkVideoStudioEditionIsFinished, FIXTURE_URLS, MockSmtpServer, prepareNotificationsTest, @@ -16,8 +16,8 @@ import { } from '@server/tests/shared' import { wait } from '@shared/core-utils' import { buildUUID } from '@shared/extra-utils' -import { UserNotification, UserNotificationType, VideoStudioTask, VideoPrivacy } from '@shared/models' -import { cleanupTests, PeerTubeServer, waitJobs } from '@shared/server-commands' +import { UserNotification, UserNotificationType, VideoPrivacy, VideoStudioTask } from '@shared/models' +import { cleanupTests, findExternalSavedVideo, PeerTubeServer, stopFfmpeg, waitJobs } from '@shared/server-commands' const expect = chai.expect @@ -323,6 +323,76 @@ describe('Test user notifications', function () { }) }) + describe('My live replay is published', function () { + + let baseParams: CheckerBaseParams + + before(() => { + baseParams = { + server: servers[1], + emails, + socketNotifications: adminNotificationsServer2, + token: servers[1].accessToken + } + }) + + it('Should send a notification is a live replay of a non permanent live is published', async function () { + this.timeout(120000) + + const { shortUUID } = await servers[1].live.create({ + fields: { + name: 'non permanent live', + privacy: VideoPrivacy.PUBLIC, + channelId: servers[1].store.channel.id, + saveReplay: true, + permanentLive: false + } + }) + + const ffmpegCommand = await servers[1].live.sendRTMPStreamInVideo({ videoId: shortUUID }) + + await waitJobs(servers) + await servers[1].live.waitUntilPublished({ videoId: shortUUID }) + + await stopFfmpeg(ffmpegCommand) + await servers[1].live.waitUntilReplacedByReplay({ videoId: shortUUID }) + + await waitJobs(servers) + await checkVideoIsPublished({ ...baseParams, videoName: 'non permanent live', shortUUID, checkType: 'presence' }) + }) + + it('Should send a notification is a live replay of a permanent live is published', async function () { + this.timeout(120000) + + const { shortUUID } = await servers[1].live.create({ + fields: { + name: 'permanent live', + privacy: VideoPrivacy.PUBLIC, + channelId: servers[1].store.channel.id, + saveReplay: true, + permanentLive: true + } + }) + + const ffmpegCommand = await servers[1].live.sendRTMPStreamInVideo({ videoId: shortUUID }) + + await waitJobs(servers) + await servers[1].live.waitUntilPublished({ videoId: shortUUID }) + + const liveDetails = await servers[1].videos.get({ id: shortUUID }) + + await stopFfmpeg(ffmpegCommand) + + await servers[1].live.waitUntilWaiting({ videoId: shortUUID }) + await waitJobs(servers) + + const video = await findExternalSavedVideo(servers[1], liveDetails) + expect(video).to.exist + + await checkVideoIsPublished({ ...baseParams, videoName: video.name, shortUUID: video.shortUUID, checkType: 'presence' }) + }) + }) + describe('Video studio', function () { let baseParams: CheckerBaseParams diff --git a/server/tests/shared/notifications.ts b/server/tests/shared/notifications.ts index 58d79d3aa..a62410880 100644 --- a/server/tests/shared/notifications.ts +++ b/server/tests/shared/notifications.ts @@ -16,7 +16,8 @@ import { PeerTubeServer, setAccessTokensToServers, setDefaultAccountAvatar, - setDefaultChannelAvatar + setDefaultChannelAvatar, + setDefaultVideoChannel } from '@shared/server-commands' import { MockSmtpServer } from './mock-servers' @@ -682,10 +683,14 @@ async function prepareNotificationsTest (serversCount = 3, overrideConfigArg: an const servers = await createMultipleServers(serversCount, Object.assign(overrideConfig, overrideConfigArg)) await setAccessTokensToServers(servers) + await setDefaultVideoChannel(servers) await setDefaultChannelAvatar(servers) await setDefaultAccountAvatar(servers) - if (servers[1]) await servers[1].config.enableStudio() + if (servers[1]) { + await servers[1].config.enableStudio() + await servers[1].config.enableLive({ allowReplay: true, transcoding: false }) + } if (serversCount > 1) { await doubleFollow(servers[0], servers[1]) diff --git a/server/types/express.d.ts b/server/types/express.d.ts index 4537c57c6..7cc13f21d 100644 --- a/server/types/express.d.ts +++ b/server/types/express.d.ts @@ -119,6 +119,7 @@ declare module 'express' { videoId?: MVideoId videoLive?: MVideoLive + videoLiveSession?: MVideoLiveSession videoShare?: MVideoShareActor diff --git a/server/types/models/video/index.ts b/server/types/models/video/index.ts index 5ddffcab5..fdf8e1ddb 100644 --- a/server/types/models/video/index.ts +++ b/server/types/models/video/index.ts @@ -1,4 +1,5 @@ export * from './local-video-viewer-watch-section' +export * from './local-video-viewer-watch-section' export * from './local-video-viewer' export * from './schedule-video-update' export * from './tag' @@ -11,6 +12,7 @@ export * from './video-channels' export * from './video-comment' export * from './video-file' export * from './video-import' +export * from './video-live-session' export * from './video-live' export * from './video-playlist' export * from './video-playlist-element' diff --git a/server/types/models/video/video-live-session.ts b/server/types/models/video/video-live-session.ts new file mode 100644 index 000000000..2e5e4b684 --- /dev/null +++ b/server/types/models/video/video-live-session.ts @@ -0,0 +1,15 @@ +import { VideoLiveSessionModel } from '@server/models/video/video-live-session' +import { PickWith } from '@shared/typescript-utils' +import { MVideo } from './video' + +type Use = PickWith + +// ############################################################################ + +export type MVideoLiveSession = Omit + +// ############################################################################ + +export type MVideoLiveSessionReplay = + MVideoLiveSession & + Use<'ReplayVideo', MVideo> diff --git a/shared/models/server/job.model.ts b/shared/models/server/job.model.ts index 9370cf011..bc5ffa570 100644 --- a/shared/models/server/job.model.ts +++ b/shared/models/server/job.model.ts @@ -160,6 +160,7 @@ export type VideoTranscodingPayload = export interface VideoLiveEndingPayload { videoId: number publishedAt: string + liveSessionId: number replayDirectory?: string } diff --git a/shared/models/videos/live/index.ts b/shared/models/videos/live/index.ts index 68f32092a..07b59fe2c 100644 --- a/shared/models/videos/live/index.ts +++ b/shared/models/videos/live/index.ts @@ -1,6 +1,8 @@ export * from './live-video-create.model' +export * from './live-video-error.enum' export * from './live-video-event-payload.model' export * from './live-video-event.type' export * from './live-video-latency-mode.enum' +export * from './live-video-session.model' export * from './live-video-update.model' export * from './live-video.model' diff --git a/shared/models/videos/live/live-video-error.enum.ts b/shared/models/videos/live/live-video-error.enum.ts new file mode 100644 index 000000000..3a8e4afa0 --- /dev/null +++ b/shared/models/videos/live/live-video-error.enum.ts @@ -0,0 +1,7 @@ +export const enum LiveVideoError { + BAD_SOCKET_HEALTH = 1, + DURATION_EXCEEDED = 2, + QUOTA_EXCEEDED = 3, + FFMPEG_ERROR = 4, + BLACKLISTED = 5 +} diff --git a/shared/models/videos/live/live-video-session.model.ts b/shared/models/videos/live/live-video-session.model.ts new file mode 100644 index 000000000..7ff6afbe5 --- /dev/null +++ b/shared/models/videos/live/live-video-session.model.ts @@ -0,0 +1,16 @@ +import { LiveVideoError } from './live-video-error.enum' + +export interface LiveVideoSession { + id: number + + startDate: string + endDate: string + + error: LiveVideoError + + replayVideo: { + id: number + uuid: string + shortUUID: string + } +} diff --git a/shared/server-commands/videos/live-command.ts b/shared/server-commands/videos/live-command.ts index c24c7a5fc..2ff65881b 100644 --- a/shared/server-commands/videos/live-command.ts +++ b/shared/server-commands/videos/live-command.ts @@ -4,7 +4,17 @@ import { readdir } from 'fs-extra' import { omit } from 'lodash' import { join } from 'path' import { wait } from '@shared/core-utils' -import { HttpStatusCode, LiveVideo, LiveVideoCreate, LiveVideoUpdate, VideoCreateResult, VideoDetails, VideoState } from '@shared/models' +import { + HttpStatusCode, + LiveVideo, + LiveVideoCreate, + LiveVideoSession, + LiveVideoUpdate, + ResultList, + VideoCreateResult, + VideoDetails, + VideoState +} from '@shared/models' import { unwrapBody } from '../requests' import { AbstractCommand, OverrideCommandOptions } from '../shared' import { sendRTMPStream, testFfmpegStreamError } from './live' @@ -25,6 +35,42 @@ export class LiveCommand extends AbstractCommand { }) } + listSessions (options: OverrideCommandOptions & { + videoId: number | string + }) { + const path = `/api/v1/videos/live/${options.videoId}/sessions` + + return this.getRequestBody>({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + async findLatestSession (options: OverrideCommandOptions & { + videoId: number | string + }) { + const { data: sessions } = await this.listSessions(options) + + return sessions[sessions.length - 1] + } + + getReplaySession (options: OverrideCommandOptions & { + videoId: number | string + }) { + const path = `/api/v1/videos/${options.videoId}/live-session` + + return this.getRequestBody({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + update (options: OverrideCommandOptions & { videoId: number | string fields: LiveVideoUpdate diff --git a/support/doc/api/openapi.yaml b/support/doc/api/openapi.yaml index 123e54f47..3a8b481d3 100644 --- a/support/doc/api/openapi.yaml +++ b/support/doc/api/openapi.yaml @@ -2462,6 +2462,48 @@ paths: description: bad parameters or trying to update a live that has already started '403': description: trying to save replay of the live but saving replay is not enabled on the instance + /videos/live/{id}/sessions: + get: + summary: List live sessions + description: List all sessions created in a particular live + security: + - OAuth2: [] + tags: + - Live Videos + parameters: + - $ref: '#/components/parameters/idOrUUID' + responses: + '200': + description: successful operation + content: + application/json: + schema: + type: object + properties: + total: + type: integer + example: 1 + data: + type: array + items: + $ref: '#/components/schemas/LiveVideoSessionResponse' + /videos/{id}/live-session: + get: + summary: Get live session of a replay + description: If the video is a replay of a live, you can find the associated live session using this endpoint + security: + - OAuth2: [] + tags: + - Live Videos + parameters: + - $ref: '#/components/parameters/idOrUUID' + responses: + '200': + description: successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/LiveVideoSessionResponse' /users/me/abuses: get: @@ -7673,6 +7715,46 @@ components: description: User can select live latency mode if enabled by the instance $ref: '#/components/schemas/LiveVideoLatencyMode' + LiveVideoSessionResponse: + properties: + id: + type: integer + startDate: + type: string + format: date-time + description: Start date of the live session + endDate: + type: string + format: date-time + nullable: true + description: End date of the live session + error: + type: integer + enum: + - 1 + - 2 + - 3 + - 4 + - 5 + nullable: true + description: > + Error type if an error occured during the live session: + - `1`: Bad socket health (transcoding is too slow) + - `2`: Max duration exceeded + - `3`: Quota exceeded + - `4`: Quota FFmpeg error + - `5`: Video has been blacklisted during the live + replayVideo: + type: object + description: Video replay information + properties: + id: + type: number + uuid: + $ref: '#/components/schemas/UUIDv4' + shortUUID: + $ref: '#/components/schemas/shortUUID' + callbacks: searchIndex: 'https://search.example.org/api/v1/search/videos': -- 2.41.0