.badge {
font-size: 13px;
+ margin-right: 5px;
}
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'
asyncRetryTransactionMiddleware(addLiveVideo)
)
+liveRouter.get('/live/:videoId/sessions',
+ authenticate,
+ asyncMiddleware(videoLiveGetValidator),
+ videoLiveListSessionsValidator,
+ asyncMiddleware(getLiveVideoSessions)
+)
+
liveRouter.get('/live/:videoId',
optionalAuthenticate,
asyncMiddleware(videoLiveGetValidator),
asyncRetryTransactionMiddleware(updateLiveVideo)
)
+liveRouter.get('/:videoId/live-session',
+ asyncMiddleware(videoLiveFindReplaySessionValidator),
+ getLiveReplaySession
+)
+
// ---------------------------------------------------------------------------
export {
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
// ---------------------------------------------------------------------------
-const LAST_MIGRATION_VERSION = 705
+const LAST_MIGRATION_VERSION = 710
// ---------------------------------------------------------------------------
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'
VideoRedundancyModel,
UserVideoHistoryModel,
VideoLiveModel,
+ VideoLiveSessionModel,
AccountBlocklistModel,
ServerBlocklistModel,
UserNotificationModel,
--- /dev/null
+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
+}
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
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 })
}
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
-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({
language: liveVideo.language,
commentsEnabled: liveVideo.commentsEnabled,
downloadEnabled: liveVideo.downloadEnabled,
- waitTranscoding: liveVideo.waitTranscoding,
+ waitTranscoding: true,
nsfw: liveVideo.nsfw,
description: liveVideo.description,
support: liveVideo.support,
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) {
})
}
- await assignReplaysToVideo(video, replayDirectory)
+ await assignReplayFilesToVideo({ video, replayDirectory })
await remove(replayDirectory)
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)
hlsPlaylist.segmentsSha256Filename = generateHlsSha256SegmentsFilename()
await hlsPlaylist.save()
- await assignReplaysToVideo(videoWithFiles, replayDirectory)
+ await assignReplayFilesToVideo({ video: videoWithFiles, replayDirectory })
await remove(getLiveReplayBaseDirectory(videoWithFiles))
videoFile: videoWithFiles.getMaxQualityFile(),
type: ThumbnailType.MINIATURE
})
- await video.addAndSaveThumbnail(miniature)
+ await videoWithFiles.addAndSaveThumbnail(miniature)
}
if (videoWithFiles.getPreview().automaticallyGenerated === true) {
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)
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)
}
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'
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)
}
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)
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 }) => {
muxingSession.destroy()
- return this.onAfterMuxingCleanup({ videoId })
+ return this.onAfterMuxingCleanup({ videoId, liveSession })
.catch(err => logger.error('Error in end transmuxing.', { err, ...localLTags }))
})
}
}
- 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)
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 })
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())
}
'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
}
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()
}
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) {
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'
}
if (videoInstance.isLive) {
- LiveManager.Instance.stopSessionOf(videoInstance.id)
+ LiveManager.Instance.stopSessionOf(videoInstance.id, LiveVideoError.BLACKLISTED)
}
Notifier.Instance.notifyOnVideoBlacklist(blacklist)
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) {
isValidVideoIdParam
} from '../shared'
import { getCommonVideoEditAttributes } from './videos'
+import { VideoLiveSessionModel } from '@server/models/video/video-live-session'
const videoLiveGetValidator = [
isValidVideoIdParam('videoId'),
}
]
+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
}
--- /dev/null
+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
+ }
+ }
+}
logger.info('Stopping live of video %s after video deletion.', instance.uuid)
- LiveManager.Instance.stopSessionOf(instance.id)
+ LiveManager.Instance.stopSessionOf(instance.id, null)
}
@BeforeDestroy
})
})
+ 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 () {
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,
PeerTubeServer,
setAccessTokensToServers,
setDefaultVideoChannel,
- waitJobs
+ waitJobs,
+ waitUntilLiveWaitingOnAllServers
} from '@shared/server-commands'
import { checkLiveCleanup } from '../../shared'
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 })
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 () {
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 () {
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 })
})
}
})
- 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 () {
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)
})
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,
})
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)
ffmpegCommand = await servers[0].live.sendRTMPStreamInVideo({ videoId: liveVideoUUID })
+ sessionStartDateMin = new Date()
await waitUntilLivePublishedOnAllServers(servers, liveVideoUUID)
+ sessionStartDateMax = new Date()
await waitJobs(servers)
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) {
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)
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)
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)
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, [])
})
let permanentLiveReplayName: string
+ let beforeServerRestart: Date
+
async function createLiveWrapper (options: { saveReplay: boolean, permanent: boolean }) {
const liveAttributes: LiveVideoCreate = {
name: 'live video',
}
await killallServers([ servers[0] ])
+
+ beforeServerRestart = new Date()
await servers[0].run()
await wait(5000)
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 () {
checkMyVideoImportIsFinished,
checkNewActorFollow,
checkNewVideoFromSubscription,
- checkVideoStudioEditionIsFinished,
checkVideoIsPublished,
+ checkVideoStudioEditionIsFinished,
FIXTURE_URLS,
MockSmtpServer,
prepareNotificationsTest,
} 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
})
})
+ 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
PeerTubeServer,
setAccessTokensToServers,
setDefaultAccountAvatar,
- setDefaultChannelAvatar
+ setDefaultChannelAvatar,
+ setDefaultVideoChannel
} from '@shared/server-commands'
import { MockSmtpServer } from './mock-servers'
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])
videoId?: MVideoId
videoLive?: MVideoLive
+ videoLiveSession?: MVideoLiveSession
videoShare?: MVideoShareActor
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'
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'
--- /dev/null
+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>
export interface VideoLiveEndingPayload {
videoId: number
publishedAt: string
+ liveSessionId: number
replayDirectory?: string
}
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'
--- /dev/null
+export const enum LiveVideoError {
+ BAD_SOCKET_HEALTH = 1,
+ DURATION_EXCEEDED = 2,
+ QUOTA_EXCEEDED = 3,
+ FFMPEG_ERROR = 4,
+ BLACKLISTED = 5
+}
--- /dev/null
+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
+ }
+}
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'
})
}
+ 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
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:
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':