aboutsummaryrefslogtreecommitdiffhomepage
path: root/server
diff options
context:
space:
mode:
Diffstat (limited to 'server')
-rw-r--r--server/controllers/api/videos/live.ts36
-rw-r--r--server/initializers/constants.ts2
-rw-r--r--server/initializers/database.ts2
-rw-r--r--server/initializers/migrations/0710-live-sessions.ts34
-rw-r--r--server/lib/job-queue/handlers/video-live-ending.ts87
-rw-r--r--server/lib/live/live-manager.ts64
-rw-r--r--server/lib/live/shared/muxing-session.ts10
-rw-r--r--server/lib/video-blacklist.ts4
-rw-r--r--server/lib/video-state.ts4
-rw-r--r--server/middlewares/validators/videos/video-live.ts38
-rw-r--r--server/models/video/video-live-session.ts142
-rw-r--r--server/models/video/video.ts2
-rw-r--r--server/tests/api/check-params/live.ts46
-rw-r--r--server/tests/api/live/live-constraints.ts52
-rw-r--r--server/tests/api/live/live-permanent.ts17
-rw-r--r--server/tests/api/live/live-save-replay.ts72
-rw-r--r--server/tests/api/live/live.ts8
-rw-r--r--server/tests/api/notifications/user-notifications.ts76
-rw-r--r--server/tests/shared/notifications.ts9
-rw-r--r--server/types/express.d.ts1
-rw-r--r--server/types/models/video/index.ts2
-rw-r--r--server/types/models/video/video-live-session.ts15
22 files changed, 658 insertions, 65 deletions
diff --git a/server/controllers/api/videos/live.ts b/server/controllers/api/videos/live.ts
index e51658927..ec4c073b5 100644
--- a/server/controllers/api/videos/live.ts
+++ b/server/controllers/api/videos/live.ts
@@ -1,13 +1,21 @@
1import express from 'express' 1import express from 'express'
2import { exists } from '@server/helpers/custom-validators/misc' 2import { exists } from '@server/helpers/custom-validators/misc'
3import { createReqFiles } from '@server/helpers/express-utils' 3import { createReqFiles } from '@server/helpers/express-utils'
4import { getFormattedObjects } from '@server/helpers/utils'
4import { ASSETS_PATH, MIMETYPES } from '@server/initializers/constants' 5import { ASSETS_PATH, MIMETYPES } from '@server/initializers/constants'
5import { getLocalVideoActivityPubUrl } from '@server/lib/activitypub/url' 6import { getLocalVideoActivityPubUrl } from '@server/lib/activitypub/url'
6import { federateVideoIfNeeded } from '@server/lib/activitypub/videos' 7import { federateVideoIfNeeded } from '@server/lib/activitypub/videos'
7import { Hooks } from '@server/lib/plugins/hooks' 8import { Hooks } from '@server/lib/plugins/hooks'
8import { buildLocalVideoFromReq, buildVideoThumbnailsFromReq, setVideoTags } from '@server/lib/video' 9import { buildLocalVideoFromReq, buildVideoThumbnailsFromReq, setVideoTags } from '@server/lib/video'
9import { videoLiveAddValidator, videoLiveGetValidator, videoLiveUpdateValidator } from '@server/middlewares/validators/videos/video-live' 10import {
11 videoLiveAddValidator,
12 videoLiveFindReplaySessionValidator,
13 videoLiveGetValidator,
14 videoLiveListSessionsValidator,
15 videoLiveUpdateValidator
16} from '@server/middlewares/validators/videos/video-live'
10import { VideoLiveModel } from '@server/models/video/video-live' 17import { VideoLiveModel } from '@server/models/video/video-live'
18import { VideoLiveSessionModel } from '@server/models/video/video-live-session'
11import { MVideoDetails, MVideoFullLight } from '@server/types/models' 19import { MVideoDetails, MVideoFullLight } from '@server/types/models'
12import { buildUUID, uuidToShort } from '@shared/extra-utils' 20import { buildUUID, uuidToShort } from '@shared/extra-utils'
13import { HttpStatusCode, LiveVideoCreate, LiveVideoLatencyMode, LiveVideoUpdate, UserRight, VideoState } from '@shared/models' 21import { HttpStatusCode, LiveVideoCreate, LiveVideoLatencyMode, LiveVideoUpdate, UserRight, VideoState } from '@shared/models'
@@ -28,6 +36,13 @@ liveRouter.post('/live',
28 asyncRetryTransactionMiddleware(addLiveVideo) 36 asyncRetryTransactionMiddleware(addLiveVideo)
29) 37)
30 38
39liveRouter.get('/live/:videoId/sessions',
40 authenticate,
41 asyncMiddleware(videoLiveGetValidator),
42 videoLiveListSessionsValidator,
43 asyncMiddleware(getLiveVideoSessions)
44)
45
31liveRouter.get('/live/:videoId', 46liveRouter.get('/live/:videoId',
32 optionalAuthenticate, 47 optionalAuthenticate,
33 asyncMiddleware(videoLiveGetValidator), 48 asyncMiddleware(videoLiveGetValidator),
@@ -41,6 +56,11 @@ liveRouter.put('/live/:videoId',
41 asyncRetryTransactionMiddleware(updateLiveVideo) 56 asyncRetryTransactionMiddleware(updateLiveVideo)
42) 57)
43 58
59liveRouter.get('/:videoId/live-session',
60 asyncMiddleware(videoLiveFindReplaySessionValidator),
61 getLiveReplaySession
62)
63
44// --------------------------------------------------------------------------- 64// ---------------------------------------------------------------------------
45 65
46export { 66export {
@@ -55,6 +75,20 @@ function getLiveVideo (req: express.Request, res: express.Response) {
55 return res.json(videoLive.toFormattedJSON(canSeePrivateLiveInformation(res))) 75 return res.json(videoLive.toFormattedJSON(canSeePrivateLiveInformation(res)))
56} 76}
57 77
78function getLiveReplaySession (req: express.Request, res: express.Response) {
79 const session = res.locals.videoLiveSession
80
81 return res.json(session.toFormattedJSON())
82}
83
84async function getLiveVideoSessions (req: express.Request, res: express.Response) {
85 const videoLive = res.locals.videoLive
86
87 const data = await VideoLiveSessionModel.listSessionsOfLiveForAPI({ videoId: videoLive.videoId })
88
89 return res.json(getFormattedObjects(data, data.length))
90}
91
58function canSeePrivateLiveInformation (res: express.Response) { 92function canSeePrivateLiveInformation (res: express.Response) {
59 const user = res.locals.oauth?.token.User 93 const user = res.locals.oauth?.token.User
60 if (!user) return false 94 if (!user) return false
diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts
index 9986dbf89..fa0fbc19d 100644
--- a/server/initializers/constants.ts
+++ b/server/initializers/constants.ts
@@ -24,7 +24,7 @@ import { CONFIG, registerConfigChangedHandler } from './config'
24 24
25// --------------------------------------------------------------------------- 25// ---------------------------------------------------------------------------
26 26
27const LAST_MIGRATION_VERSION = 705 27const LAST_MIGRATION_VERSION = 710
28 28
29// --------------------------------------------------------------------------- 29// ---------------------------------------------------------------------------
30 30
diff --git a/server/initializers/database.ts b/server/initializers/database.ts
index 7a7ba61f4..3576f444c 100644
--- a/server/initializers/database.ts
+++ b/server/initializers/database.ts
@@ -7,6 +7,7 @@ import { UserModel } from '@server/models/user/user'
7import { UserNotificationModel } from '@server/models/user/user-notification' 7import { UserNotificationModel } from '@server/models/user/user-notification'
8import { UserVideoHistoryModel } from '@server/models/user/user-video-history' 8import { UserVideoHistoryModel } from '@server/models/user/user-video-history'
9import { VideoJobInfoModel } from '@server/models/video/video-job-info' 9import { VideoJobInfoModel } from '@server/models/video/video-job-info'
10import { VideoLiveSessionModel } from '@server/models/video/video-live-session'
10import { LocalVideoViewerModel } from '@server/models/view/local-video-viewer' 11import { LocalVideoViewerModel } from '@server/models/view/local-video-viewer'
11import { LocalVideoViewerWatchSectionModel } from '@server/models/view/local-video-viewer-watch-section' 12import { LocalVideoViewerWatchSectionModel } from '@server/models/view/local-video-viewer-watch-section'
12import { isTestInstance } from '../helpers/core-utils' 13import { isTestInstance } from '../helpers/core-utils'
@@ -135,6 +136,7 @@ async function initDatabaseModels (silent: boolean) {
135 VideoRedundancyModel, 136 VideoRedundancyModel,
136 UserVideoHistoryModel, 137 UserVideoHistoryModel,
137 VideoLiveModel, 138 VideoLiveModel,
139 VideoLiveSessionModel,
138 AccountBlocklistModel, 140 AccountBlocklistModel,
139 ServerBlocklistModel, 141 ServerBlocklistModel,
140 UserNotificationModel, 142 UserNotificationModel,
diff --git a/server/initializers/migrations/0710-live-sessions.ts b/server/initializers/migrations/0710-live-sessions.ts
new file mode 100644
index 000000000..aaac8d9ce
--- /dev/null
+++ b/server/initializers/migrations/0710-live-sessions.ts
@@ -0,0 +1,34 @@
1import * as Sequelize from 'sequelize'
2
3async function up (utils: {
4 transaction: Sequelize.Transaction
5 queryInterface: Sequelize.QueryInterface
6 sequelize: Sequelize.Sequelize
7 db: any
8}): Promise<void> {
9 const { transaction } = utils
10
11 const query = `
12 CREATE TABLE IF NOT EXISTS "videoLiveSession" (
13 "id" serial,
14 "startDate" timestamp with time zone NOT NULL,
15 "endDate" timestamp with time zone,
16 "error" integer,
17 "replayVideoId" integer REFERENCES "video" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
18 "liveVideoId" integer REFERENCES "video" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
19 "createdAt" timestamp with time zone NOT NULL,
20 "updatedAt" timestamp with time zone NOT NULL,
21 PRIMARY KEY ("id")
22 );
23 `
24 await utils.sequelize.query(query, { transaction })
25}
26
27function down () {
28 throw new Error('Not implemented.')
29}
30
31export {
32 up,
33 down
34}
diff --git a/server/lib/job-queue/handlers/video-live-ending.ts b/server/lib/job-queue/handlers/video-live-ending.ts
index 1e290338c..55fd09344 100644
--- a/server/lib/job-queue/handlers/video-live-ending.ts
+++ b/server/lib/job-queue/handlers/video-live-ending.ts
@@ -15,13 +15,14 @@ import { generateVideoMiniature } from '@server/lib/thumbnail'
15import { generateHlsPlaylistResolutionFromTS } from '@server/lib/transcoding/transcoding' 15import { generateHlsPlaylistResolutionFromTS } from '@server/lib/transcoding/transcoding'
16import { moveToNextState } from '@server/lib/video-state' 16import { moveToNextState } from '@server/lib/video-state'
17import { VideoModel } from '@server/models/video/video' 17import { VideoModel } from '@server/models/video/video'
18import { VideoBlacklistModel } from '@server/models/video/video-blacklist'
18import { VideoFileModel } from '@server/models/video/video-file' 19import { VideoFileModel } from '@server/models/video/video-file'
19import { VideoLiveModel } from '@server/models/video/video-live' 20import { VideoLiveModel } from '@server/models/video/video-live'
21import { VideoLiveSessionModel } from '@server/models/video/video-live-session'
20import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist' 22import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist'
21import { MVideo, MVideoLive, MVideoWithAllFiles } from '@server/types/models' 23import { MVideo, MVideoLive, MVideoLiveSession, MVideoWithAllFiles } from '@server/types/models'
22import { ThumbnailType, VideoLiveEndingPayload, VideoState } from '@shared/models' 24import { ThumbnailType, VideoLiveEndingPayload, VideoState } from '@shared/models'
23import { logger } from '../../../helpers/logger' 25import { logger } from '../../../helpers/logger'
24import { VideoBlacklistModel } from '@server/models/video/video-blacklist'
25 26
26async function processVideoLiveEnding (job: Job) { 27async function processVideoLiveEnding (job: Job) {
27 const payload = job.data as VideoLiveEndingPayload 28 const payload = job.data as VideoLiveEndingPayload
@@ -32,27 +33,28 @@ async function processVideoLiveEnding (job: Job) {
32 logger.warn('Video live %d does not exist anymore. Cannot process live ending.', payload.videoId) 33 logger.warn('Video live %d does not exist anymore. Cannot process live ending.', payload.videoId)
33 } 34 }
34 35
35 const video = await VideoModel.load(payload.videoId) 36 const liveVideo = await VideoModel.load(payload.videoId)
36 const live = await VideoLiveModel.loadByVideoId(payload.videoId) 37 const live = await VideoLiveModel.loadByVideoId(payload.videoId)
38 const liveSession = await VideoLiveSessionModel.load(payload.liveSessionId)
37 39
38 if (!video || !live) { 40 if (!liveVideo || !live || !liveSession) {
39 logError() 41 logError()
40 return 42 return
41 } 43 }
42 44
43 LiveSegmentShaStore.Instance.cleanupShaSegments(video.uuid) 45 LiveSegmentShaStore.Instance.cleanupShaSegments(liveVideo.uuid)
44 46
45 if (live.saveReplay !== true) { 47 if (live.saveReplay !== true) {
46 return cleanupLiveAndFederate(video) 48 return cleanupLiveAndFederate({ liveVideo })
47 } 49 }
48 50
49 if (live.permanentLive) { 51 if (live.permanentLive) {
50 await saveReplayToExternalVideo(video, payload.publishedAt, payload.replayDirectory) 52 await saveReplayToExternalVideo({ liveVideo, liveSession, publishedAt: payload.publishedAt, replayDirectory: payload.replayDirectory })
51 53
52 return cleanupLiveAndFederate(video) 54 return cleanupLiveAndFederate({ liveVideo })
53 } 55 }
54 56
55 return replaceLiveByReplay(video, live, payload.replayDirectory) 57 return replaceLiveByReplay({ liveVideo, live, liveSession, replayDirectory: payload.replayDirectory })
56} 58}
57 59
58// --------------------------------------------------------------------------- 60// ---------------------------------------------------------------------------
@@ -63,7 +65,14 @@ export {
63 65
64// --------------------------------------------------------------------------- 66// ---------------------------------------------------------------------------
65 67
66async function saveReplayToExternalVideo (liveVideo: MVideo, publishedAt: string, replayDirectory: string) { 68async function saveReplayToExternalVideo (options: {
69 liveVideo: MVideo
70 liveSession: MVideoLiveSession
71 publishedAt: string
72 replayDirectory: string
73}) {
74 const { liveVideo, liveSession, publishedAt, replayDirectory } = options
75
67 await cleanupTMPLiveFiles(getLiveDirectory(liveVideo)) 76 await cleanupTMPLiveFiles(getLiveDirectory(liveVideo))
68 77
69 const video = new VideoModel({ 78 const video = new VideoModel({
@@ -78,7 +87,7 @@ async function saveReplayToExternalVideo (liveVideo: MVideo, publishedAt: string
78 language: liveVideo.language, 87 language: liveVideo.language,
79 commentsEnabled: liveVideo.commentsEnabled, 88 commentsEnabled: liveVideo.commentsEnabled,
80 downloadEnabled: liveVideo.downloadEnabled, 89 downloadEnabled: liveVideo.downloadEnabled,
81 waitTranscoding: liveVideo.waitTranscoding, 90 waitTranscoding: true,
82 nsfw: liveVideo.nsfw, 91 nsfw: liveVideo.nsfw,
83 description: liveVideo.description, 92 description: liveVideo.description,
84 support: liveVideo.support, 93 support: liveVideo.support,
@@ -94,6 +103,9 @@ async function saveReplayToExternalVideo (liveVideo: MVideo, publishedAt: string
94 103
95 await video.save() 104 await video.save()
96 105
106 liveSession.replayVideoId = video.id
107 await liveSession.save()
108
97 // If live is blacklisted, also blacklist the replay 109 // If live is blacklisted, also blacklist the replay
98 const blacklist = await VideoBlacklistModel.loadByVideoId(liveVideo.id) 110 const blacklist = await VideoBlacklistModel.loadByVideoId(liveVideo.id)
99 if (blacklist) { 111 if (blacklist) {
@@ -105,7 +117,7 @@ async function saveReplayToExternalVideo (liveVideo: MVideo, publishedAt: string
105 }) 117 })
106 } 118 }
107 119
108 await assignReplaysToVideo(video, replayDirectory) 120 await assignReplayFilesToVideo({ video, replayDirectory })
109 121
110 await remove(replayDirectory) 122 await remove(replayDirectory)
111 123
@@ -117,18 +129,29 @@ async function saveReplayToExternalVideo (liveVideo: MVideo, publishedAt: string
117 await moveToNextState({ video, isNewVideo: true }) 129 await moveToNextState({ video, isNewVideo: true })
118} 130}
119 131
120async function replaceLiveByReplay (video: MVideo, live: MVideoLive, replayDirectory: string) { 132async function replaceLiveByReplay (options: {
121 await cleanupTMPLiveFiles(getLiveDirectory(video)) 133 liveVideo: MVideo
134 liveSession: MVideoLiveSession
135 live: MVideoLive
136 replayDirectory: string
137}) {
138 const { liveVideo, liveSession, live, replayDirectory } = options
139
140 await cleanupTMPLiveFiles(getLiveDirectory(liveVideo))
122 141
123 await live.destroy() 142 await live.destroy()
124 143
125 video.isLive = false 144 liveVideo.isLive = false
126 video.state = VideoState.TO_TRANSCODE 145 liveVideo.waitTranscoding = true
146 liveVideo.state = VideoState.TO_TRANSCODE
127 147
128 await video.save() 148 await liveVideo.save()
149
150 liveSession.replayVideoId = liveVideo.id
151 await liveSession.save()
129 152
130 // Remove old HLS playlist video files 153 // Remove old HLS playlist video files
131 const videoWithFiles = await VideoModel.loadAndPopulateAccountAndServerAndTags(video.id) 154 const videoWithFiles = await VideoModel.loadAndPopulateAccountAndServerAndTags(liveVideo.id)
132 155
133 const hlsPlaylist = videoWithFiles.getHLSPlaylist() 156 const hlsPlaylist = videoWithFiles.getHLSPlaylist()
134 await VideoFileModel.removeHLSFilesOfVideoId(hlsPlaylist.id) 157 await VideoFileModel.removeHLSFilesOfVideoId(hlsPlaylist.id)
@@ -139,7 +162,7 @@ async function replaceLiveByReplay (video: MVideo, live: MVideoLive, replayDirec
139 hlsPlaylist.segmentsSha256Filename = generateHlsSha256SegmentsFilename() 162 hlsPlaylist.segmentsSha256Filename = generateHlsSha256SegmentsFilename()
140 await hlsPlaylist.save() 163 await hlsPlaylist.save()
141 164
142 await assignReplaysToVideo(videoWithFiles, replayDirectory) 165 await assignReplayFilesToVideo({ video: videoWithFiles, replayDirectory })
143 166
144 await remove(getLiveReplayBaseDirectory(videoWithFiles)) 167 await remove(getLiveReplayBaseDirectory(videoWithFiles))
145 168
@@ -150,7 +173,7 @@ async function replaceLiveByReplay (video: MVideo, live: MVideoLive, replayDirec
150 videoFile: videoWithFiles.getMaxQualityFile(), 173 videoFile: videoWithFiles.getMaxQualityFile(),
151 type: ThumbnailType.MINIATURE 174 type: ThumbnailType.MINIATURE
152 }) 175 })
153 await video.addAndSaveThumbnail(miniature) 176 await videoWithFiles.addAndSaveThumbnail(miniature)
154 } 177 }
155 178
156 if (videoWithFiles.getPreview().automaticallyGenerated === true) { 179 if (videoWithFiles.getPreview().automaticallyGenerated === true) {
@@ -159,13 +182,19 @@ async function replaceLiveByReplay (video: MVideo, live: MVideoLive, replayDirec
159 videoFile: videoWithFiles.getMaxQualityFile(), 182 videoFile: videoWithFiles.getMaxQualityFile(),
160 type: ThumbnailType.PREVIEW 183 type: ThumbnailType.PREVIEW
161 }) 184 })
162 await video.addAndSaveThumbnail(preview) 185 await videoWithFiles.addAndSaveThumbnail(preview)
163 } 186 }
164 187
165 await moveToNextState({ video: videoWithFiles, isNewVideo: false }) 188 // We consider this is a new video
189 await moveToNextState({ video: videoWithFiles, isNewVideo: true })
166} 190}
167 191
168async function assignReplaysToVideo (video: MVideo, replayDirectory: string) { 192async function assignReplayFilesToVideo (options: {
193 video: MVideo
194 replayDirectory: string
195}) {
196 const { video, replayDirectory } = options
197
169 let durationDone = false 198 let durationDone = false
170 199
171 const concatenatedTsFiles = await readdir(replayDirectory) 200 const concatenatedTsFiles = await readdir(replayDirectory)
@@ -197,11 +226,15 @@ async function assignReplaysToVideo (video: MVideo, replayDirectory: string) {
197 return video 226 return video
198} 227}
199 228
200async function cleanupLiveAndFederate (video: MVideo) { 229async function cleanupLiveAndFederate (options: {
201 const streamingPlaylist = await VideoStreamingPlaylistModel.loadHLSPlaylistByVideo(video.id) 230 liveVideo: MVideo
202 await cleanupLive(video, streamingPlaylist) 231}) {
232 const { liveVideo } = options
233
234 const streamingPlaylist = await VideoStreamingPlaylistModel.loadHLSPlaylistByVideo(liveVideo.id)
235 await cleanupLive(liveVideo, streamingPlaylist)
203 236
204 const fullVideo = await VideoModel.loadAndPopulateAccountAndServerAndTags(video.id) 237 const fullVideo = await VideoModel.loadAndPopulateAccountAndServerAndTags(liveVideo.id)
205 return federateVideoIfNeeded(fullVideo, false, undefined) 238 return federateVideoIfNeeded(fullVideo, false, undefined)
206} 239}
207 240
diff --git a/server/lib/live/live-manager.ts b/server/lib/live/live-manager.ts
index da09aa05c..df2804a0e 100644
--- a/server/lib/live/live-manager.ts
+++ b/server/lib/live/live-manager.ts
@@ -17,10 +17,11 @@ import { P2P_MEDIA_LOADER_PEER_VERSION, VIDEO_LIVE } from '@server/initializers/
17import { UserModel } from '@server/models/user/user' 17import { UserModel } from '@server/models/user/user'
18import { VideoModel } from '@server/models/video/video' 18import { VideoModel } from '@server/models/video/video'
19import { VideoLiveModel } from '@server/models/video/video-live' 19import { VideoLiveModel } from '@server/models/video/video-live'
20import { VideoLiveSessionModel } from '@server/models/video/video-live-session'
20import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist' 21import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist'
21import { MStreamingPlaylistVideo, MVideo, MVideoLiveVideo } from '@server/types/models' 22import { MStreamingPlaylistVideo, MVideo, MVideoLiveSession, MVideoLiveVideo } from '@server/types/models'
22import { wait } from '@shared/core-utils' 23import { wait } from '@shared/core-utils'
23import { VideoState, VideoStreamingPlaylistType } from '@shared/models' 24import { LiveVideoError, VideoState, VideoStreamingPlaylistType } from '@shared/models'
24import { federateVideoIfNeeded } from '../activitypub/videos' 25import { federateVideoIfNeeded } from '../activitypub/videos'
25import { JobQueue } from '../job-queue' 26import { JobQueue } from '../job-queue'
26import { generateHLSMasterPlaylistFilename, generateHlsSha256SegmentsFilename, getLiveReplayBaseDirectory } from '../paths' 27import { generateHLSMasterPlaylistFilename, generateHlsSha256SegmentsFilename, getLiveReplayBaseDirectory } from '../paths'
@@ -174,10 +175,13 @@ class LiveManager {
174 return !!this.rtmpServer 175 return !!this.rtmpServer
175 } 176 }
176 177
177 stopSessionOf (videoId: number) { 178 stopSessionOf (videoId: number, error: LiveVideoError | null) {
178 const sessionId = this.videoSessions.get(videoId) 179 const sessionId = this.videoSessions.get(videoId)
179 if (!sessionId) return 180 if (!sessionId) return
180 181
182 this.saveEndingSession(videoId, error)
183 .catch(err => logger.error('Cannot save ending session.', { err, ...lTags(sessionId) }))
184
181 this.videoSessions.delete(videoId) 185 this.videoSessions.delete(videoId)
182 this.abortSession(sessionId) 186 this.abortSession(sessionId)
183 } 187 }
@@ -274,6 +278,8 @@ class LiveManager {
274 const videoUUID = videoLive.Video.uuid 278 const videoUUID = videoLive.Video.uuid
275 const localLTags = lTags(sessionId, videoUUID) 279 const localLTags = lTags(sessionId, videoUUID)
276 280
281 const liveSession = await this.saveStartingSession(videoLive)
282
277 const user = await UserModel.loadByLiveId(videoLive.id) 283 const user = await UserModel.loadByLiveId(videoLive.id)
278 LiveQuotaStore.Instance.addNewLive(user.id, videoLive.id) 284 LiveQuotaStore.Instance.addNewLive(user.id, videoLive.id)
279 285
@@ -299,24 +305,27 @@ class LiveManager {
299 localLTags 305 localLTags
300 ) 306 )
301 307
302 this.stopSessionOf(videoId) 308 this.stopSessionOf(videoId, LiveVideoError.BAD_SOCKET_HEALTH)
303 }) 309 })
304 310
305 muxingSession.on('duration-exceeded', ({ videoId }) => { 311 muxingSession.on('duration-exceeded', ({ videoId }) => {
306 logger.info('Stopping session of %s: max duration exceeded.', videoUUID, localLTags) 312 logger.info('Stopping session of %s: max duration exceeded.', videoUUID, localLTags)
307 313
308 this.stopSessionOf(videoId) 314 this.stopSessionOf(videoId, LiveVideoError.DURATION_EXCEEDED)
309 }) 315 })
310 316
311 muxingSession.on('quota-exceeded', ({ videoId }) => { 317 muxingSession.on('quota-exceeded', ({ videoId }) => {
312 logger.info('Stopping session of %s: user quota exceeded.', videoUUID, localLTags) 318 logger.info('Stopping session of %s: user quota exceeded.', videoUUID, localLTags)
313 319
314 this.stopSessionOf(videoId) 320 this.stopSessionOf(videoId, LiveVideoError.QUOTA_EXCEEDED)
321 })
322
323 muxingSession.on('ffmpeg-error', ({ videoId }) => {
324 this.stopSessionOf(videoId, LiveVideoError.FFMPEG_ERROR)
315 }) 325 })
316 326
317 muxingSession.on('ffmpeg-error', ({ sessionId }) => this.abortSession(sessionId))
318 muxingSession.on('ffmpeg-end', ({ videoId }) => { 327 muxingSession.on('ffmpeg-end', ({ videoId }) => {
319 this.onMuxingFFmpegEnd(videoId) 328 this.onMuxingFFmpegEnd(videoId, sessionId)
320 }) 329 })
321 330
322 muxingSession.on('after-cleanup', ({ videoId }) => { 331 muxingSession.on('after-cleanup', ({ videoId }) => {
@@ -324,7 +333,7 @@ class LiveManager {
324 333
325 muxingSession.destroy() 334 muxingSession.destroy()
326 335
327 return this.onAfterMuxingCleanup({ videoId }) 336 return this.onAfterMuxingCleanup({ videoId, liveSession })
328 .catch(err => logger.error('Error in end transmuxing.', { err, ...localLTags })) 337 .catch(err => logger.error('Error in end transmuxing.', { err, ...localLTags }))
329 }) 338 })
330 339
@@ -365,15 +374,19 @@ class LiveManager {
365 } 374 }
366 } 375 }
367 376
368 private onMuxingFFmpegEnd (videoId: number) { 377 private onMuxingFFmpegEnd (videoId: number, sessionId: string) {
369 this.videoSessions.delete(videoId) 378 this.videoSessions.delete(videoId)
379
380 this.saveEndingSession(videoId, null)
381 .catch(err => logger.error('Cannot save ending session.', { err, ...lTags(sessionId) }))
370 } 382 }
371 383
372 private async onAfterMuxingCleanup (options: { 384 private async onAfterMuxingCleanup (options: {
373 videoId: number | string 385 videoId: number | string
386 liveSession?: MVideoLiveSession
374 cleanupNow?: boolean // Default false 387 cleanupNow?: boolean // Default false
375 }) { 388 }) {
376 const { videoId, cleanupNow = false } = options 389 const { videoId, liveSession: liveSessionArg, cleanupNow = false } = options
377 390
378 try { 391 try {
379 const fullVideo = await VideoModel.loadAndPopulateAccountAndServerAndTags(videoId) 392 const fullVideo = await VideoModel.loadAndPopulateAccountAndServerAndTags(videoId)
@@ -381,13 +394,25 @@ class LiveManager {
381 394
382 const live = await VideoLiveModel.loadByVideoId(fullVideo.id) 395 const live = await VideoLiveModel.loadByVideoId(fullVideo.id)
383 396
397 const liveSession = liveSessionArg ?? await VideoLiveSessionModel.findCurrentSessionOf(fullVideo.id)
398
399 // On server restart during a live
400 if (!liveSession.endDate) {
401 liveSession.endDate = new Date()
402 await liveSession.save()
403 }
404
384 JobQueue.Instance.createJob({ 405 JobQueue.Instance.createJob({
385 type: 'video-live-ending', 406 type: 'video-live-ending',
386 payload: { 407 payload: {
387 videoId: fullVideo.id, 408 videoId: fullVideo.id,
409
388 replayDirectory: live.saveReplay 410 replayDirectory: live.saveReplay
389 ? await this.findReplayDirectory(fullVideo) 411 ? await this.findReplayDirectory(fullVideo)
390 : undefined, 412 : undefined,
413
414 liveSessionId: liveSession.id,
415
391 publishedAt: fullVideo.publishedAt.toISOString() 416 publishedAt: fullVideo.publishedAt.toISOString()
392 } 417 }
393 }, { delay: cleanupNow ? 0 : VIDEO_LIVE.CLEANUP_DELAY }) 418 }, { delay: cleanupNow ? 0 : VIDEO_LIVE.CLEANUP_DELAY })
@@ -445,6 +470,23 @@ class LiveManager {
445 return playlist.save() 470 return playlist.save()
446 } 471 }
447 472
473 private saveStartingSession (videoLive: MVideoLiveVideo) {
474 const liveSession = new VideoLiveSessionModel({
475 startDate: new Date(),
476 liveVideoId: videoLive.videoId
477 })
478
479 return liveSession.save()
480 }
481
482 private async saveEndingSession (videoId: number, error: LiveVideoError | null) {
483 const liveSession = await VideoLiveSessionModel.findCurrentSessionOf(videoId)
484 liveSession.endDate = new Date()
485 liveSession.error = error
486
487 return liveSession.save()
488 }
489
448 static get Instance () { 490 static get Instance () {
449 return this.instance || (this.instance = new this()) 491 return this.instance || (this.instance = new this())
450 } 492 }
diff --git a/server/lib/live/shared/muxing-session.ts b/server/lib/live/shared/muxing-session.ts
index 588ee8749..1ee9b430f 100644
--- a/server/lib/live/shared/muxing-session.ts
+++ b/server/lib/live/shared/muxing-session.ts
@@ -28,7 +28,7 @@ interface MuxingSessionEvents {
28 'quota-exceeded': ({ videoId: number }) => void 28 'quota-exceeded': ({ videoId: number }) => void
29 29
30 'ffmpeg-end': ({ videoId: number }) => void 30 'ffmpeg-end': ({ videoId: number }) => void
31 'ffmpeg-error': ({ sessionId: string }) => void 31 'ffmpeg-error': ({ videoId: string }) => void
32 32
33 'after-cleanup': ({ videoId: number }) => void 33 'after-cleanup': ({ videoId: number }) => void
34} 34}
@@ -164,7 +164,11 @@ class MuxingSession extends EventEmitter {
164 this.onFFmpegError({ err, stdout, stderr, outPath: this.outDirectory, ffmpegShellCommand }) 164 this.onFFmpegError({ err, stdout, stderr, outPath: this.outDirectory, ffmpegShellCommand })
165 }) 165 })
166 166
167 this.ffmpegCommand.on('end', () => this.onFFmpegEnded(this.outDirectory)) 167 this.ffmpegCommand.on('end', () => {
168 this.emit('ffmpeg-end', ({ videoId: this.videoId }))
169
170 this.onFFmpegEnded(this.outDirectory)
171 })
168 172
169 this.ffmpegCommand.run() 173 this.ffmpegCommand.run()
170 } 174 }
@@ -197,7 +201,7 @@ class MuxingSession extends EventEmitter {
197 201
198 logger.error('Live transcoding error.', { err, stdout, stderr, ffmpegShellCommand, ...this.lTags() }) 202 logger.error('Live transcoding error.', { err, stdout, stderr, ffmpegShellCommand, ...this.lTags() })
199 203
200 this.emit('ffmpeg-error', ({ sessionId: this.sessionId })) 204 this.emit('ffmpeg-error', ({ videoId: this.videoId }))
201 } 205 }
202 206
203 private onFFmpegEnded (outPath: string) { 207 private onFFmpegEnded (outPath: string) {
diff --git a/server/lib/video-blacklist.ts b/server/lib/video-blacklist.ts
index 91f44cb11..fd5837a3a 100644
--- a/server/lib/video-blacklist.ts
+++ b/server/lib/video-blacklist.ts
@@ -9,7 +9,7 @@ import {
9 MVideoFullLight, 9 MVideoFullLight,
10 MVideoWithBlacklistLight 10 MVideoWithBlacklistLight
11} from '@server/types/models' 11} from '@server/types/models'
12import { UserRight, VideoBlacklistCreate, VideoBlacklistType } from '../../shared/models' 12import { LiveVideoError, UserRight, VideoBlacklistCreate, VideoBlacklistType } from '../../shared/models'
13import { UserAdminFlag } from '../../shared/models/users/user-flag.model' 13import { UserAdminFlag } from '../../shared/models/users/user-flag.model'
14import { logger, loggerTagsFactory } from '../helpers/logger' 14import { logger, loggerTagsFactory } from '../helpers/logger'
15import { CONFIG } from '../initializers/config' 15import { CONFIG } from '../initializers/config'
@@ -81,7 +81,7 @@ async function blacklistVideo (videoInstance: MVideoAccountLight, options: Video
81 } 81 }
82 82
83 if (videoInstance.isLive) { 83 if (videoInstance.isLive) {
84 LiveManager.Instance.stopSessionOf(videoInstance.id) 84 LiveManager.Instance.stopSessionOf(videoInstance.id, LiveVideoError.BLACKLISTED)
85 } 85 }
86 86
87 Notifier.Instance.notifyOnVideoBlacklist(blacklist) 87 Notifier.Instance.notifyOnVideoBlacklist(blacklist)
diff --git a/server/lib/video-state.ts b/server/lib/video-state.ts
index 7b207eb87..ae2725d65 100644
--- a/server/lib/video-state.ts
+++ b/server/lib/video-state.ts
@@ -126,12 +126,10 @@ async function moveToPublishedState (options: {
126 const { video, isNewVideo, transaction, previousVideoState } = options 126 const { video, isNewVideo, transaction, previousVideoState } = options
127 const previousState = previousVideoState ?? video.state 127 const previousState = previousVideoState ?? video.state
128 128
129 logger.info('Publishing video %s.', video.uuid, { previousState, tags: [ video.uuid ] }) 129 logger.info('Publishing video %s.', video.uuid, { isNewVideo, previousState, tags: [ video.uuid ] })
130 130
131 await video.setNewState(VideoState.PUBLISHED, isNewVideo, transaction) 131 await video.setNewState(VideoState.PUBLISHED, isNewVideo, transaction)
132 132
133 // If the video was not published, we consider it is a new one for other instances
134 // Live videos are always federated, so it's not a new video
135 await federateVideoIfNeeded(video, isNewVideo, transaction) 133 await federateVideoIfNeeded(video, isNewVideo, transaction)
136 134
137 if (previousState === VideoState.TO_EDIT) { 135 if (previousState === VideoState.TO_EDIT) {
diff --git a/server/middlewares/validators/videos/video-live.ts b/server/middlewares/validators/videos/video-live.ts
index ff492da0f..59638d5e0 100644
--- a/server/middlewares/validators/videos/video-live.ts
+++ b/server/middlewares/validators/videos/video-live.ts
@@ -28,6 +28,7 @@ import {
28 isValidVideoIdParam 28 isValidVideoIdParam
29} from '../shared' 29} from '../shared'
30import { getCommonVideoEditAttributes } from './videos' 30import { getCommonVideoEditAttributes } from './videos'
31import { VideoLiveSessionModel } from '@server/models/video/video-live-session'
31 32
32const videoLiveGetValidator = [ 33const videoLiveGetValidator = [
33 isValidVideoIdParam('videoId'), 34 isValidVideoIdParam('videoId'),
@@ -196,11 +197,48 @@ const videoLiveUpdateValidator = [
196 } 197 }
197] 198]
198 199
200const videoLiveListSessionsValidator = [
201 (req: express.Request, res: express.Response, next: express.NextFunction) => {
202 logger.debug('Checking videoLiveListSessionsValidator parameters', { parameters: req.params })
203
204 // Check the user can manage the live
205 const user = res.locals.oauth.token.User
206 if (!checkUserCanManageVideo(user, res.locals.videoAll, UserRight.GET_ANY_LIVE, res)) return
207
208 return next()
209 }
210]
211
212const videoLiveFindReplaySessionValidator = [
213 isValidVideoIdParam('videoId'),
214
215 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
216 logger.debug('Checking videoLiveFindReplaySessionValidator parameters', { parameters: req.params })
217
218 if (areValidationErrors(req, res)) return
219 if (!await doesVideoExist(req.params.videoId, res, 'id')) return
220
221 const session = await VideoLiveSessionModel.findSessionOfReplay(res.locals.videoId.id)
222 if (!session) {
223 return res.fail({
224 status: HttpStatusCode.NOT_FOUND_404,
225 message: 'No live replay found'
226 })
227 }
228
229 res.locals.videoLiveSession = session
230
231 return next()
232 }
233]
234
199// --------------------------------------------------------------------------- 235// ---------------------------------------------------------------------------
200 236
201export { 237export {
202 videoLiveAddValidator, 238 videoLiveAddValidator,
203 videoLiveUpdateValidator, 239 videoLiveUpdateValidator,
240 videoLiveListSessionsValidator,
241 videoLiveFindReplaySessionValidator,
204 videoLiveGetValidator 242 videoLiveGetValidator
205} 243}
206 244
diff --git a/server/models/video/video-live-session.ts b/server/models/video/video-live-session.ts
new file mode 100644
index 000000000..2b4cde9f8
--- /dev/null
+++ b/server/models/video/video-live-session.ts
@@ -0,0 +1,142 @@
1import { FindOptions } from 'sequelize'
2import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript'
3import { MVideoLiveSession, MVideoLiveSessionReplay } from '@server/types/models'
4import { uuidToShort } from '@shared/extra-utils'
5import { LiveVideoError, LiveVideoSession } from '@shared/models'
6import { AttributesOnly } from '@shared/typescript-utils'
7import { VideoModel } from './video'
8
9export enum ScopeNames {
10 WITH_REPLAY = 'WITH_REPLAY'
11}
12
13@Scopes(() => ({
14 [ScopeNames.WITH_REPLAY]: {
15 include: [
16 {
17 model: VideoModel.unscoped(),
18 as: 'ReplayVideo',
19 required: false
20 }
21 ]
22 }
23}))
24@Table({
25 tableName: 'videoLiveSession',
26 indexes: [
27 {
28 fields: [ 'replayVideoId' ],
29 unique: true
30 },
31 {
32 fields: [ 'liveVideoId' ]
33 }
34 ]
35})
36export class VideoLiveSessionModel extends Model<Partial<AttributesOnly<VideoLiveSessionModel>>> {
37
38 @CreatedAt
39 createdAt: Date
40
41 @UpdatedAt
42 updatedAt: Date
43
44 @AllowNull(false)
45 @Column(DataType.DATE)
46 startDate: Date
47
48 @AllowNull(true)
49 @Column(DataType.DATE)
50 endDate: Date
51
52 @AllowNull(true)
53 @Column
54 error: LiveVideoError
55
56 @ForeignKey(() => VideoModel)
57 @Column
58 replayVideoId: number
59
60 @BelongsTo(() => VideoModel, {
61 foreignKey: {
62 allowNull: true,
63 name: 'replayVideoId'
64 },
65 as: 'ReplayVideo',
66 onDelete: 'set null'
67 })
68 ReplayVideo: VideoModel
69
70 @ForeignKey(() => VideoModel)
71 @Column
72 liveVideoId: number
73
74 @BelongsTo(() => VideoModel, {
75 foreignKey: {
76 allowNull: true,
77 name: 'liveVideoId'
78 },
79 as: 'LiveVideo',
80 onDelete: 'set null'
81 })
82 LiveVideo: VideoModel
83
84 static load (id: number): Promise<MVideoLiveSession> {
85 return VideoLiveSessionModel.findOne({
86 where: { id }
87 })
88 }
89
90 static findSessionOfReplay (replayVideoId: number) {
91 const query = {
92 where: {
93 replayVideoId
94 }
95 }
96
97 return VideoLiveSessionModel.scope(ScopeNames.WITH_REPLAY).findOne(query)
98 }
99
100 static findCurrentSessionOf (videoId: number) {
101 return VideoLiveSessionModel.findOne({
102 where: {
103 liveVideoId: videoId,
104 endDate: null
105 },
106 order: [ [ 'startDate', 'DESC' ] ]
107 })
108 }
109
110 static listSessionsOfLiveForAPI (options: { videoId: number }) {
111 const { videoId } = options
112
113 const query: FindOptions<VideoLiveSessionModel> = {
114 where: {
115 liveVideoId: videoId
116 },
117 order: [ [ 'startDate', 'ASC' ] ]
118 }
119
120 return VideoLiveSessionModel.scope(ScopeNames.WITH_REPLAY).findAll(query)
121 }
122
123 toFormattedJSON (this: MVideoLiveSessionReplay): LiveVideoSession {
124 const replayVideo = this.ReplayVideo
125 ? {
126 id: this.ReplayVideo.id,
127 uuid: this.ReplayVideo.uuid,
128 shortUUID: uuidToShort(this.ReplayVideo.uuid)
129 }
130 : undefined
131
132 return {
133 id: this.id,
134 startDate: this.startDate.toISOString(),
135 endDate: this.endDate
136 ? this.endDate.toISOString()
137 : null,
138 replayVideo,
139 error: this.error
140 }
141 }
142}
diff --git a/server/models/video/video.ts b/server/models/video/video.ts
index 13d81561a..d216ed47d 100644
--- a/server/models/video/video.ts
+++ b/server/models/video/video.ts
@@ -787,7 +787,7 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
787 787
788 logger.info('Stopping live of video %s after video deletion.', instance.uuid) 788 logger.info('Stopping live of video %s after video deletion.', instance.uuid)
789 789
790 LiveManager.Instance.stopSessionOf(instance.id) 790 LiveManager.Instance.stopSessionOf(instance.id, null)
791 } 791 }
792 792
793 @BeforeDestroy 793 @BeforeDestroy
diff --git a/server/tests/api/check-params/live.ts b/server/tests/api/check-params/live.ts
index bbd331657..29f847e51 100644
--- a/server/tests/api/check-params/live.ts
+++ b/server/tests/api/check-params/live.ts
@@ -388,6 +388,52 @@ describe('Test video lives API validator', function () {
388 }) 388 })
389 }) 389 })
390 390
391 describe('When getting live sessions', function () {
392
393 it('Should fail with a bad access token', async function () {
394 await command.listSessions({ token: 'toto', videoId: video.id, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
395 })
396
397 it('Should fail without token', async function () {
398 await command.listSessions({ token: null, videoId: video.id, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
399 })
400
401 it('Should fail with the token of another user', async function () {
402 await command.listSessions({ token: userAccessToken, videoId: video.id, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
403 })
404
405 it('Should fail with a bad video id', async function () {
406 await command.listSessions({ videoId: 'toto', expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
407 })
408
409 it('Should fail with an unknown video id', async function () {
410 await command.listSessions({ videoId: 454555, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
411 })
412
413 it('Should fail with a non live video', async function () {
414 await command.listSessions({ videoId: videoIdNotLive, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
415 })
416
417 it('Should succeed with the correct params', async function () {
418 await command.listSessions({ videoId: video.id })
419 })
420 })
421
422 describe('When getting live session of a replay', function () {
423
424 it('Should fail with a bad video id', async function () {
425 await command.getReplaySession({ videoId: 'toto', expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
426 })
427
428 it('Should fail with an unknown video id', async function () {
429 await command.getReplaySession({ videoId: 454555, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
430 })
431
432 it('Should fail with a non replay video', async function () {
433 await command.getReplaySession({ videoId: videoIdNotLive, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
434 })
435 })
436
391 describe('When updating live information', async function () { 437 describe('When updating live information', async function () {
392 438
393 it('Should fail without access token', async function () { 439 it('Should fail without access token', async function () {
diff --git a/server/tests/api/live/live-constraints.ts b/server/tests/api/live/live-constraints.ts
index b92dc7b89..b81973395 100644
--- a/server/tests/api/live/live-constraints.ts
+++ b/server/tests/api/live/live-constraints.ts
@@ -3,7 +3,7 @@
3import 'mocha' 3import 'mocha'
4import * as chai from 'chai' 4import * as chai from 'chai'
5import { wait } from '@shared/core-utils' 5import { wait } from '@shared/core-utils'
6import { VideoPrivacy } from '@shared/models' 6import { LiveVideoError, VideoPrivacy } from '@shared/models'
7import { 7import {
8 cleanupTests, 8 cleanupTests,
9 ConfigCommand, 9 ConfigCommand,
@@ -12,7 +12,8 @@ import {
12 PeerTubeServer, 12 PeerTubeServer,
13 setAccessTokensToServers, 13 setAccessTokensToServers,
14 setDefaultVideoChannel, 14 setDefaultVideoChannel,
15 waitJobs 15 waitJobs,
16 waitUntilLiveWaitingOnAllServers
16} from '@shared/server-commands' 17} from '@shared/server-commands'
17import { checkLiveCleanup } from '../../shared' 18import { checkLiveCleanup } from '../../shared'
18 19
@@ -24,12 +25,18 @@ describe('Test live constraints', function () {
24 let userAccessToken: string 25 let userAccessToken: string
25 let userChannelId: number 26 let userChannelId: number
26 27
27 async function createLiveWrapper (saveReplay: boolean) { 28 async function createLiveWrapper (options: {
29 replay: boolean
30 permanent: boolean
31 }) {
32 const { replay, permanent } = options
33
28 const liveAttributes = { 34 const liveAttributes = {
29 name: 'user live', 35 name: 'user live',
30 channelId: userChannelId, 36 channelId: userChannelId,
31 privacy: VideoPrivacy.PUBLIC, 37 privacy: VideoPrivacy.PUBLIC,
32 saveReplay 38 saveReplay: replay,
39 permanentLive: permanent
33 } 40 }
34 41
35 const { uuid } = await servers[0].live.create({ token: userAccessToken, fields: liveAttributes }) 42 const { uuid } = await servers[0].live.create({ token: userAccessToken, fields: liveAttributes })
@@ -97,23 +104,42 @@ describe('Test live constraints', function () {
97 it('Should not have size limit if save replay is disabled', async function () { 104 it('Should not have size limit if save replay is disabled', async function () {
98 this.timeout(60000) 105 this.timeout(60000)
99 106
100 const userVideoLiveoId = await createLiveWrapper(false) 107 const userVideoLiveoId = await createLiveWrapper({ replay: false, permanent: false })
101 await servers[0].live.runAndTestStreamError({ token: userAccessToken, videoId: userVideoLiveoId, shouldHaveError: false }) 108 await servers[0].live.runAndTestStreamError({ token: userAccessToken, videoId: userVideoLiveoId, shouldHaveError: false })
102 }) 109 })
103 110
104 it('Should have size limit depending on user global quota if save replay is enabled', async function () { 111 it('Should have size limit depending on user global quota if save replay is enabled on non permanent live', async function () {
105 this.timeout(60000) 112 this.timeout(60000)
106 113
107 // Wait for user quota memoize cache invalidation 114 // Wait for user quota memoize cache invalidation
108 await wait(5000) 115 await wait(5000)
109 116
110 const userVideoLiveoId = await createLiveWrapper(true) 117 const userVideoLiveoId = await createLiveWrapper({ replay: true, permanent: false })
111 await servers[0].live.runAndTestStreamError({ token: userAccessToken, videoId: userVideoLiveoId, shouldHaveError: true }) 118 await servers[0].live.runAndTestStreamError({ token: userAccessToken, videoId: userVideoLiveoId, shouldHaveError: true })
112 119
113 await waitUntilLivePublishedOnAllServers(userVideoLiveoId) 120 await waitUntilLivePublishedOnAllServers(userVideoLiveoId)
114 await waitJobs(servers) 121 await waitJobs(servers)
115 122
116 await checkSaveReplay(userVideoLiveoId) 123 await checkSaveReplay(userVideoLiveoId)
124
125 const session = await servers[0].live.getReplaySession({ videoId: userVideoLiveoId })
126 expect(session.error).to.equal(LiveVideoError.QUOTA_EXCEEDED)
127 })
128
129 it('Should have size limit depending on user global quota if save replay is enabled on a permanent live', async function () {
130 this.timeout(60000)
131
132 // Wait for user quota memoize cache invalidation
133 await wait(5000)
134
135 const userVideoLiveoId = await createLiveWrapper({ replay: true, permanent: true })
136 await servers[0].live.runAndTestStreamError({ token: userAccessToken, videoId: userVideoLiveoId, shouldHaveError: true })
137
138 await waitJobs(servers)
139 await waitUntilLiveWaitingOnAllServers(servers, userVideoLiveoId)
140
141 const session = await servers[0].live.findLatestSession({ videoId: userVideoLiveoId })
142 expect(session.error).to.equal(LiveVideoError.QUOTA_EXCEEDED)
117 }) 143 })
118 144
119 it('Should have size limit depending on user daily quota if save replay is enabled', async function () { 145 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 () {
124 150
125 await updateQuota({ total: -1, daily: 1 }) 151 await updateQuota({ total: -1, daily: 1 })
126 152
127 const userVideoLiveoId = await createLiveWrapper(true) 153 const userVideoLiveoId = await createLiveWrapper({ replay: true, permanent: false })
128 await servers[0].live.runAndTestStreamError({ token: userAccessToken, videoId: userVideoLiveoId, shouldHaveError: true }) 154 await servers[0].live.runAndTestStreamError({ token: userAccessToken, videoId: userVideoLiveoId, shouldHaveError: true })
129 155
130 await waitUntilLivePublishedOnAllServers(userVideoLiveoId) 156 await waitUntilLivePublishedOnAllServers(userVideoLiveoId)
131 await waitJobs(servers) 157 await waitJobs(servers)
132 158
133 await checkSaveReplay(userVideoLiveoId) 159 await checkSaveReplay(userVideoLiveoId)
160
161 const session = await servers[0].live.getReplaySession({ videoId: userVideoLiveoId })
162 expect(session.error).to.equal(LiveVideoError.QUOTA_EXCEEDED)
134 }) 163 })
135 164
136 it('Should succeed without quota limit', async function () { 165 it('Should succeed without quota limit', async function () {
@@ -141,7 +170,7 @@ describe('Test live constraints', function () {
141 170
142 await updateQuota({ total: 10 * 1000 * 1000, daily: -1 }) 171 await updateQuota({ total: 10 * 1000 * 1000, daily: -1 })
143 172
144 const userVideoLiveoId = await createLiveWrapper(true) 173 const userVideoLiveoId = await createLiveWrapper({ replay: true, permanent: false })
145 await servers[0].live.runAndTestStreamError({ token: userAccessToken, videoId: userVideoLiveoId, shouldHaveError: false }) 174 await servers[0].live.runAndTestStreamError({ token: userAccessToken, videoId: userVideoLiveoId, shouldHaveError: false })
146 }) 175 })
147 176
@@ -162,13 +191,16 @@ describe('Test live constraints', function () {
162 } 191 }
163 }) 192 })
164 193
165 const userVideoLiveoId = await createLiveWrapper(true) 194 const userVideoLiveoId = await createLiveWrapper({ replay: true, permanent: false })
166 await servers[0].live.runAndTestStreamError({ token: userAccessToken, videoId: userVideoLiveoId, shouldHaveError: true }) 195 await servers[0].live.runAndTestStreamError({ token: userAccessToken, videoId: userVideoLiveoId, shouldHaveError: true })
167 196
168 await waitUntilLivePublishedOnAllServers(userVideoLiveoId) 197 await waitUntilLivePublishedOnAllServers(userVideoLiveoId)
169 await waitJobs(servers) 198 await waitJobs(servers)
170 199
171 await checkSaveReplay(userVideoLiveoId, [ 720, 480, 360, 240, 144 ]) 200 await checkSaveReplay(userVideoLiveoId, [ 720, 480, 360, 240, 144 ])
201
202 const session = await servers[0].live.getReplaySession({ videoId: userVideoLiveoId })
203 expect(session.error).to.equal(LiveVideoError.DURATION_EXCEEDED)
172 }) 204 })
173 205
174 after(async function () { 206 after(async function () {
diff --git a/server/tests/api/live/live-permanent.ts b/server/tests/api/live/live-permanent.ts
index a88d71dd9..92eac9e5f 100644
--- a/server/tests/api/live/live-permanent.ts
+++ b/server/tests/api/live/live-permanent.ts
@@ -172,6 +172,23 @@ describe('Permanent live', function () {
172 await stopFfmpeg(ffmpegCommand) 172 await stopFfmpeg(ffmpegCommand)
173 }) 173 })
174 174
175 it('Should have appropriate sessions', async function () {
176 this.timeout(60000)
177
178 await servers[0].live.waitUntilWaiting({ videoId: videoUUID })
179
180 const { data, total } = await servers[0].live.listSessions({ videoId: videoUUID })
181 expect(total).to.equal(2)
182 expect(data).to.have.lengthOf(2)
183
184 for (const session of data) {
185 expect(session.startDate).to.exist
186 expect(session.endDate).to.exist
187
188 expect(session.error).to.not.exist
189 }
190 })
191
175 after(async function () { 192 after(async function () {
176 await cleanupTests(servers) 193 await cleanupTests(servers)
177 }) 194 })
diff --git a/server/tests/api/live/live-save-replay.ts b/server/tests/api/live/live-save-replay.ts
index fc6acc624..7ddcb04ef 100644
--- a/server/tests/api/live/live-save-replay.ts
+++ b/server/tests/api/live/live-save-replay.ts
@@ -5,7 +5,7 @@ import * as chai from 'chai'
5import { FfmpegCommand } from 'fluent-ffmpeg' 5import { FfmpegCommand } from 'fluent-ffmpeg'
6import { checkLiveCleanup } from '@server/tests/shared' 6import { checkLiveCleanup } from '@server/tests/shared'
7import { wait } from '@shared/core-utils' 7import { wait } from '@shared/core-utils'
8import { HttpStatusCode, LiveVideoCreate, VideoPrivacy, VideoState } from '@shared/models' 8import { HttpStatusCode, LiveVideoCreate, LiveVideoError, VideoPrivacy, VideoState } from '@shared/models'
9import { 9import {
10 cleanupTests, 10 cleanupTests,
11 ConfigCommand, 11 ConfigCommand,
@@ -143,6 +143,9 @@ describe('Save replay setting', function () {
143 }) 143 })
144 144
145 describe('With save replay disabled', function () { 145 describe('With save replay disabled', function () {
146 let sessionStartDateMin: Date
147 let sessionStartDateMax: Date
148 let sessionEndDateMin: Date
146 149
147 it('Should correctly create and federate the "waiting for stream" live', async function () { 150 it('Should correctly create and federate the "waiting for stream" live', async function () {
148 this.timeout(20000) 151 this.timeout(20000)
@@ -160,7 +163,9 @@ describe('Save replay setting', function () {
160 163
161 ffmpegCommand = await servers[0].live.sendRTMPStreamInVideo({ videoId: liveVideoUUID }) 164 ffmpegCommand = await servers[0].live.sendRTMPStreamInVideo({ videoId: liveVideoUUID })
162 165
166 sessionStartDateMin = new Date()
163 await waitUntilLivePublishedOnAllServers(servers, liveVideoUUID) 167 await waitUntilLivePublishedOnAllServers(servers, liveVideoUUID)
168 sessionStartDateMax = new Date()
164 169
165 await waitJobs(servers) 170 await waitJobs(servers)
166 171
@@ -171,6 +176,7 @@ describe('Save replay setting', function () {
171 it('Should correctly delete the video files after the stream ended', async function () { 176 it('Should correctly delete the video files after the stream ended', async function () {
172 this.timeout(40000) 177 this.timeout(40000)
173 178
179 sessionEndDateMin = new Date()
174 await stopFfmpeg(ffmpegCommand) 180 await stopFfmpeg(ffmpegCommand)
175 181
176 for (const server of servers) { 182 for (const server of servers) {
@@ -186,6 +192,24 @@ describe('Save replay setting', function () {
186 await checkLiveCleanup(servers[0], liveVideoUUID, []) 192 await checkLiveCleanup(servers[0], liveVideoUUID, [])
187 }) 193 })
188 194
195 it('Should have appropriate ended session', async function () {
196 const { data, total } = await servers[0].live.listSessions({ videoId: liveVideoUUID })
197 expect(total).to.equal(1)
198 expect(data).to.have.lengthOf(1)
199
200 const session = data[0]
201
202 const startDate = new Date(session.startDate)
203 expect(startDate).to.be.above(sessionStartDateMin)
204 expect(startDate).to.be.below(sessionStartDateMax)
205
206 expect(session.endDate).to.exist
207 expect(new Date(session.endDate)).to.be.above(sessionEndDateMin)
208
209 expect(session.error).to.not.exist
210 expect(session.replayVideo).to.not.exist
211 })
212
189 it('Should correctly terminate the stream on blacklist and delete the live', async function () { 213 it('Should correctly terminate the stream on blacklist and delete the live', async function () {
190 this.timeout(40000) 214 this.timeout(40000)
191 215
@@ -201,6 +225,15 @@ describe('Save replay setting', function () {
201 await checkLiveCleanup(servers[0], liveVideoUUID, []) 225 await checkLiveCleanup(servers[0], liveVideoUUID, [])
202 }) 226 })
203 227
228 it('Should have blacklisted session error', async function () {
229 const session = await servers[0].live.findLatestSession({ videoId: liveVideoUUID })
230 expect(session.startDate).to.exist
231 expect(session.endDate).to.exist
232
233 expect(session.error).to.equal(LiveVideoError.BLACKLISTED)
234 expect(session.replayVideo).to.not.exist
235 })
236
204 it('Should correctly terminate the stream on delete and delete the video', async function () { 237 it('Should correctly terminate the stream on delete and delete the video', async function () {
205 this.timeout(40000) 238 this.timeout(40000)
206 239
@@ -249,6 +282,22 @@ describe('Save replay setting', function () {
249 await checkVideoState(liveVideoUUID, VideoState.PUBLISHED) 282 await checkVideoState(liveVideoUUID, VideoState.PUBLISHED)
250 }) 283 })
251 284
285 it('Should find the replay live session', async function () {
286 const session = await servers[0].live.getReplaySession({ videoId: liveVideoUUID })
287
288 expect(session).to.exist
289
290 expect(session.startDate).to.exist
291 expect(session.endDate).to.exist
292
293 expect(session.error).to.not.exist
294
295 expect(session.replayVideo).to.exist
296 expect(session.replayVideo.id).to.exist
297 expect(session.replayVideo.shortUUID).to.exist
298 expect(session.replayVideo.uuid).to.equal(liveVideoUUID)
299 })
300
252 it('Should update the saved live and correctly federate the updated attributes', async function () { 301 it('Should update the saved live and correctly federate the updated attributes', async function () {
253 this.timeout(30000) 302 this.timeout(30000)
254 303
@@ -337,6 +386,27 @@ describe('Save replay setting', function () {
337 lastReplayUUID = video.uuid 386 lastReplayUUID = video.uuid
338 }) 387 })
339 388
389 it('Should have appropriate ended session and replay live session', async function () {
390 const { data, total } = await servers[0].live.listSessions({ videoId: liveVideoUUID })
391 expect(total).to.equal(1)
392 expect(data).to.have.lengthOf(1)
393
394 const sessionFromLive = data[0]
395 const sessionFromReplay = await servers[0].live.getReplaySession({ videoId: lastReplayUUID })
396
397 for (const session of [ sessionFromLive, sessionFromReplay ]) {
398 expect(session.startDate).to.exist
399 expect(session.endDate).to.exist
400
401 expect(session.error).to.not.exist
402
403 expect(session.replayVideo).to.exist
404 expect(session.replayVideo.id).to.exist
405 expect(session.replayVideo.shortUUID).to.exist
406 expect(session.replayVideo.uuid).to.equal(lastReplayUUID)
407 }
408 })
409
340 it('Should have cleaned up the live files', async function () { 410 it('Should have cleaned up the live files', async function () {
341 await checkLiveCleanup(servers[0], liveVideoUUID, []) 411 await checkLiveCleanup(servers[0], liveVideoUUID, [])
342 }) 412 })
diff --git a/server/tests/api/live/live.ts b/server/tests/api/live/live.ts
index ab7251e31..9b8fbe3e2 100644
--- a/server/tests/api/live/live.ts
+++ b/server/tests/api/live/live.ts
@@ -594,6 +594,8 @@ describe('Test live', function () {
594 594
595 let permanentLiveReplayName: string 595 let permanentLiveReplayName: string
596 596
597 let beforeServerRestart: Date
598
597 async function createLiveWrapper (options: { saveReplay: boolean, permanent: boolean }) { 599 async function createLiveWrapper (options: { saveReplay: boolean, permanent: boolean }) {
598 const liveAttributes: LiveVideoCreate = { 600 const liveAttributes: LiveVideoCreate = {
599 name: 'live video', 601 name: 'live video',
@@ -636,6 +638,8 @@ describe('Test live', function () {
636 } 638 }
637 639
638 await killallServers([ servers[0] ]) 640 await killallServers([ servers[0] ])
641
642 beforeServerRestart = new Date()
639 await servers[0].run() 643 await servers[0].run()
640 644
641 await wait(5000) 645 await wait(5000)
@@ -653,6 +657,10 @@ describe('Test live', function () {
653 this.timeout(120000) 657 this.timeout(120000)
654 658
655 await commands[0].waitUntilPublished({ videoId: liveVideoReplayId }) 659 await commands[0].waitUntilPublished({ videoId: liveVideoReplayId })
660
661 const session = await commands[0].getReplaySession({ videoId: liveVideoReplayId })
662 expect(session.endDate).to.exist
663 expect(new Date(session.endDate)).to.be.above(beforeServerRestart)
656 }) 664 })
657 665
658 it('Should have saved a permanent live replay', async function () { 666 it('Should have saved a permanent live replay', async function () {
diff --git a/server/tests/api/notifications/user-notifications.ts b/server/tests/api/notifications/user-notifications.ts
index 47e85a30c..1705fda55 100644
--- a/server/tests/api/notifications/user-notifications.ts
+++ b/server/tests/api/notifications/user-notifications.ts
@@ -7,8 +7,8 @@ import {
7 checkMyVideoImportIsFinished, 7 checkMyVideoImportIsFinished,
8 checkNewActorFollow, 8 checkNewActorFollow,
9 checkNewVideoFromSubscription, 9 checkNewVideoFromSubscription,
10 checkVideoStudioEditionIsFinished,
11 checkVideoIsPublished, 10 checkVideoIsPublished,
11 checkVideoStudioEditionIsFinished,
12 FIXTURE_URLS, 12 FIXTURE_URLS,
13 MockSmtpServer, 13 MockSmtpServer,
14 prepareNotificationsTest, 14 prepareNotificationsTest,
@@ -16,8 +16,8 @@ import {
16} from '@server/tests/shared' 16} from '@server/tests/shared'
17import { wait } from '@shared/core-utils' 17import { wait } from '@shared/core-utils'
18import { buildUUID } from '@shared/extra-utils' 18import { buildUUID } from '@shared/extra-utils'
19import { UserNotification, UserNotificationType, VideoStudioTask, VideoPrivacy } from '@shared/models' 19import { UserNotification, UserNotificationType, VideoPrivacy, VideoStudioTask } from '@shared/models'
20import { cleanupTests, PeerTubeServer, waitJobs } from '@shared/server-commands' 20import { cleanupTests, findExternalSavedVideo, PeerTubeServer, stopFfmpeg, waitJobs } from '@shared/server-commands'
21 21
22const expect = chai.expect 22const expect = chai.expect
23 23
@@ -323,6 +323,76 @@ describe('Test user notifications', function () {
323 }) 323 })
324 }) 324 })
325 325
326 describe('My live replay is published', function () {
327
328 let baseParams: CheckerBaseParams
329
330 before(() => {
331 baseParams = {
332 server: servers[1],
333 emails,
334 socketNotifications: adminNotificationsServer2,
335 token: servers[1].accessToken
336 }
337 })
338
339 it('Should send a notification is a live replay of a non permanent live is published', async function () {
340 this.timeout(120000)
341
342 const { shortUUID } = await servers[1].live.create({
343 fields: {
344 name: 'non permanent live',
345 privacy: VideoPrivacy.PUBLIC,
346 channelId: servers[1].store.channel.id,
347 saveReplay: true,
348 permanentLive: false
349 }
350 })
351
352 const ffmpegCommand = await servers[1].live.sendRTMPStreamInVideo({ videoId: shortUUID })
353
354 await waitJobs(servers)
355 await servers[1].live.waitUntilPublished({ videoId: shortUUID })
356
357 await stopFfmpeg(ffmpegCommand)
358 await servers[1].live.waitUntilReplacedByReplay({ videoId: shortUUID })
359
360 await waitJobs(servers)
361 await checkVideoIsPublished({ ...baseParams, videoName: 'non permanent live', shortUUID, checkType: 'presence' })
362 })
363
364 it('Should send a notification is a live replay of a permanent live is published', async function () {
365 this.timeout(120000)
366
367 const { shortUUID } = await servers[1].live.create({
368 fields: {
369 name: 'permanent live',
370 privacy: VideoPrivacy.PUBLIC,
371 channelId: servers[1].store.channel.id,
372 saveReplay: true,
373 permanentLive: true
374 }
375 })
376
377 const ffmpegCommand = await servers[1].live.sendRTMPStreamInVideo({ videoId: shortUUID })
378
379 await waitJobs(servers)
380 await servers[1].live.waitUntilPublished({ videoId: shortUUID })
381
382 const liveDetails = await servers[1].videos.get({ id: shortUUID })
383
384 await stopFfmpeg(ffmpegCommand)
385
386 await servers[1].live.waitUntilWaiting({ videoId: shortUUID })
387 await waitJobs(servers)
388
389 const video = await findExternalSavedVideo(servers[1], liveDetails)
390 expect(video).to.exist
391
392 await checkVideoIsPublished({ ...baseParams, videoName: video.name, shortUUID: video.shortUUID, checkType: 'presence' })
393 })
394 })
395
326 describe('Video studio', function () { 396 describe('Video studio', function () {
327 let baseParams: CheckerBaseParams 397 let baseParams: CheckerBaseParams
328 398
diff --git a/server/tests/shared/notifications.ts b/server/tests/shared/notifications.ts
index 58d79d3aa..a62410880 100644
--- a/server/tests/shared/notifications.ts
+++ b/server/tests/shared/notifications.ts
@@ -16,7 +16,8 @@ import {
16 PeerTubeServer, 16 PeerTubeServer,
17 setAccessTokensToServers, 17 setAccessTokensToServers,
18 setDefaultAccountAvatar, 18 setDefaultAccountAvatar,
19 setDefaultChannelAvatar 19 setDefaultChannelAvatar,
20 setDefaultVideoChannel
20} from '@shared/server-commands' 21} from '@shared/server-commands'
21import { MockSmtpServer } from './mock-servers' 22import { MockSmtpServer } from './mock-servers'
22 23
@@ -682,10 +683,14 @@ async function prepareNotificationsTest (serversCount = 3, overrideConfigArg: an
682 const servers = await createMultipleServers(serversCount, Object.assign(overrideConfig, overrideConfigArg)) 683 const servers = await createMultipleServers(serversCount, Object.assign(overrideConfig, overrideConfigArg))
683 684
684 await setAccessTokensToServers(servers) 685 await setAccessTokensToServers(servers)
686 await setDefaultVideoChannel(servers)
685 await setDefaultChannelAvatar(servers) 687 await setDefaultChannelAvatar(servers)
686 await setDefaultAccountAvatar(servers) 688 await setDefaultAccountAvatar(servers)
687 689
688 if (servers[1]) await servers[1].config.enableStudio() 690 if (servers[1]) {
691 await servers[1].config.enableStudio()
692 await servers[1].config.enableLive({ allowReplay: true, transcoding: false })
693 }
689 694
690 if (serversCount > 1) { 695 if (serversCount > 1) {
691 await doubleFollow(servers[0], servers[1]) 696 await doubleFollow(servers[0], servers[1])
diff --git a/server/types/express.d.ts b/server/types/express.d.ts
index 4537c57c6..7cc13f21d 100644
--- a/server/types/express.d.ts
+++ b/server/types/express.d.ts
@@ -119,6 +119,7 @@ declare module 'express' {
119 videoId?: MVideoId 119 videoId?: MVideoId
120 120
121 videoLive?: MVideoLive 121 videoLive?: MVideoLive
122 videoLiveSession?: MVideoLiveSession
122 123
123 videoShare?: MVideoShareActor 124 videoShare?: MVideoShareActor
124 125
diff --git a/server/types/models/video/index.ts b/server/types/models/video/index.ts
index 5ddffcab5..fdf8e1ddb 100644
--- a/server/types/models/video/index.ts
+++ b/server/types/models/video/index.ts
@@ -1,4 +1,5 @@
1export * from './local-video-viewer-watch-section' 1export * from './local-video-viewer-watch-section'
2export * from './local-video-viewer-watch-section'
2export * from './local-video-viewer' 3export * from './local-video-viewer'
3export * from './schedule-video-update' 4export * from './schedule-video-update'
4export * from './tag' 5export * from './tag'
@@ -11,6 +12,7 @@ export * from './video-channels'
11export * from './video-comment' 12export * from './video-comment'
12export * from './video-file' 13export * from './video-file'
13export * from './video-import' 14export * from './video-import'
15export * from './video-live-session'
14export * from './video-live' 16export * from './video-live'
15export * from './video-playlist' 17export * from './video-playlist'
16export * from './video-playlist-element' 18export * from './video-playlist-element'
diff --git a/server/types/models/video/video-live-session.ts b/server/types/models/video/video-live-session.ts
new file mode 100644
index 000000000..2e5e4b684
--- /dev/null
+++ b/server/types/models/video/video-live-session.ts
@@ -0,0 +1,15 @@
1import { VideoLiveSessionModel } from '@server/models/video/video-live-session'
2import { PickWith } from '@shared/typescript-utils'
3import { MVideo } from './video'
4
5type Use<K extends keyof VideoLiveSessionModel, M> = PickWith<VideoLiveSessionModel, K, M>
6
7// ############################################################################
8
9export type MVideoLiveSession = Omit<VideoLiveSessionModel, 'Video' | 'VideoLive'>
10
11// ############################################################################
12
13export type MVideoLiveSessionReplay =
14 MVideoLiveSession &
15 Use<'ReplayVideo', MVideo>