Browse Source

Support live session in server

Chocobozzz 2 years ago
parent
commit
26e3e98ff0

+ 1 - 0
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;
 }

+ 35 - 1
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

+ 1 - 1
server/initializers/constants.ts

@@ -24,7 +24,7 @@ import { CONFIG, registerConfigChangedHandler } from './config'
 
 // ---------------------------------------------------------------------------
 
-const LAST_MIGRATION_VERSION = 705
+const LAST_MIGRATION_VERSION = 710
 
 // ---------------------------------------------------------------------------
 

+ 2 - 0
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,

+ 34 - 0
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<void> {
+  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
+}

+ 60 - 27
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)
 }
 

+ 53 - 11
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())
   }

+ 7 - 3
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) {

+ 2 - 2
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)

+ 1 - 3
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) {

+ 38 - 0
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
 }
 

+ 142 - 0
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<Partial<AttributesOnly<VideoLiveSessionModel>>> {
+
+  @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<MVideoLiveSession> {
+    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<VideoLiveSessionModel> = {
+      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
+    }
+  }
+}

+ 1 - 1
server/models/video/video.ts

@@ -787,7 +787,7 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
 
     logger.info('Stopping live of video %s after video deletion.', instance.uuid)
 
-    LiveManager.Instance.stopSessionOf(instance.id)
+    LiveManager.Instance.stopSessionOf(instance.id, null)
   }
 
   @BeforeDestroy

+ 46 - 0
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 () {

+ 42 - 10
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 () {

+ 17 - 0
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)
   })

+ 71 - 1
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, [])
     })

+ 8 - 0
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 () {

+ 73 - 3
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
 

+ 7 - 2
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])

+ 1 - 0
server/types/express.d.ts

@@ -119,6 +119,7 @@ declare module 'express' {
       videoId?: MVideoId
 
       videoLive?: MVideoLive
+      videoLiveSession?: MVideoLiveSession
 
       videoShare?: MVideoShareActor
 

+ 2 - 0
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'

+ 15 - 0
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<K extends keyof VideoLiveSessionModel, M> = PickWith<VideoLiveSessionModel, K, M>
+
+// ############################################################################
+
+export type MVideoLiveSession = Omit<VideoLiveSessionModel, 'Video' | 'VideoLive'>
+
+// ############################################################################
+
+export type MVideoLiveSessionReplay =
+  MVideoLiveSession &
+  Use<'ReplayVideo', MVideo>

+ 1 - 0
shared/models/server/job.model.ts

@@ -160,6 +160,7 @@ export type VideoTranscodingPayload =
 export interface VideoLiveEndingPayload {
   videoId: number
   publishedAt: string
+  liveSessionId: number
 
   replayDirectory?: string
 }

+ 2 - 0
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'

+ 7 - 0
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
+}

+ 16 - 0
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
+  }
+}

+ 47 - 1
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<ResultList<LiveVideoSession>>({
+      ...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<LiveVideoSession>({
+      ...options,
+
+      path,
+      implicitToken: true,
+      defaultExpectedStatus: HttpStatusCode.OK_200
+    })
+  }
+
   update (options: OverrideCommandOptions & {
     videoId: number | string
     fields: LiveVideoUpdate

+ 82 - 0
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':