diff options
author | Chocobozzz <me@florianbigard.com> | 2022-05-03 11:38:07 +0200 |
---|---|---|
committer | Chocobozzz <me@florianbigard.com> | 2022-05-03 14:49:15 +0200 |
commit | 26e3e98ff0e222a9fb9226938ac6902af77921bd (patch) | |
tree | 73d1c6f2524e380862d3365f12043fc319d40841 | |
parent | 86c5229b4d726202378ef46854383bcafca22310 (diff) | |
download | PeerTube-26e3e98ff0e222a9fb9226938ac6902af77921bd.tar.gz PeerTube-26e3e98ff0e222a9fb9226938ac6902af77921bd.tar.zst PeerTube-26e3e98ff0e222a9fb9226938ac6902af77921bd.zip |
Support live session in server
29 files changed, 814 insertions, 66 deletions
diff --git a/client/src/app/shared/shared-video-live/live-stream-information.component.scss b/client/src/app/shared/shared-video-live/live-stream-information.component.scss index b9008ba59..7cd53478f 100644 --- a/client/src/app/shared/shared-video-live/live-stream-information.component.scss +++ b/client/src/app/shared/shared-video-live/live-stream-information.component.scss | |||
@@ -15,4 +15,5 @@ p-autocomplete { | |||
15 | 15 | ||
16 | .badge { | 16 | .badge { |
17 | font-size: 13px; | 17 | font-size: 13px; |
18 | margin-right: 5px; | ||
18 | } | 19 | } |
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 @@ | |||
1 | import express from 'express' | 1 | import express from 'express' |
2 | import { exists } from '@server/helpers/custom-validators/misc' | 2 | import { exists } from '@server/helpers/custom-validators/misc' |
3 | import { createReqFiles } from '@server/helpers/express-utils' | 3 | import { createReqFiles } from '@server/helpers/express-utils' |
4 | import { getFormattedObjects } from '@server/helpers/utils' | ||
4 | import { ASSETS_PATH, MIMETYPES } from '@server/initializers/constants' | 5 | import { ASSETS_PATH, MIMETYPES } from '@server/initializers/constants' |
5 | import { getLocalVideoActivityPubUrl } from '@server/lib/activitypub/url' | 6 | import { getLocalVideoActivityPubUrl } from '@server/lib/activitypub/url' |
6 | import { federateVideoIfNeeded } from '@server/lib/activitypub/videos' | 7 | import { federateVideoIfNeeded } from '@server/lib/activitypub/videos' |
7 | import { Hooks } from '@server/lib/plugins/hooks' | 8 | import { Hooks } from '@server/lib/plugins/hooks' |
8 | import { buildLocalVideoFromReq, buildVideoThumbnailsFromReq, setVideoTags } from '@server/lib/video' | 9 | import { buildLocalVideoFromReq, buildVideoThumbnailsFromReq, setVideoTags } from '@server/lib/video' |
9 | import { videoLiveAddValidator, videoLiveGetValidator, videoLiveUpdateValidator } from '@server/middlewares/validators/videos/video-live' | 10 | import { |
11 | videoLiveAddValidator, | ||
12 | videoLiveFindReplaySessionValidator, | ||
13 | videoLiveGetValidator, | ||
14 | videoLiveListSessionsValidator, | ||
15 | videoLiveUpdateValidator | ||
16 | } from '@server/middlewares/validators/videos/video-live' | ||
10 | import { VideoLiveModel } from '@server/models/video/video-live' | 17 | import { VideoLiveModel } from '@server/models/video/video-live' |
18 | import { VideoLiveSessionModel } from '@server/models/video/video-live-session' | ||
11 | import { MVideoDetails, MVideoFullLight } from '@server/types/models' | 19 | import { MVideoDetails, MVideoFullLight } from '@server/types/models' |
12 | import { buildUUID, uuidToShort } from '@shared/extra-utils' | 20 | import { buildUUID, uuidToShort } from '@shared/extra-utils' |
13 | import { HttpStatusCode, LiveVideoCreate, LiveVideoLatencyMode, LiveVideoUpdate, UserRight, VideoState } from '@shared/models' | 21 | import { 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 | ||
39 | liveRouter.get('/live/:videoId/sessions', | ||
40 | authenticate, | ||
41 | asyncMiddleware(videoLiveGetValidator), | ||
42 | videoLiveListSessionsValidator, | ||
43 | asyncMiddleware(getLiveVideoSessions) | ||
44 | ) | ||
45 | |||
31 | liveRouter.get('/live/:videoId', | 46 | liveRouter.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 | ||
59 | liveRouter.get('/:videoId/live-session', | ||
60 | asyncMiddleware(videoLiveFindReplaySessionValidator), | ||
61 | getLiveReplaySession | ||
62 | ) | ||
63 | |||
44 | // --------------------------------------------------------------------------- | 64 | // --------------------------------------------------------------------------- |
45 | 65 | ||
46 | export { | 66 | export { |
@@ -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 | ||
78 | function getLiveReplaySession (req: express.Request, res: express.Response) { | ||
79 | const session = res.locals.videoLiveSession | ||
80 | |||
81 | return res.json(session.toFormattedJSON()) | ||
82 | } | ||
83 | |||
84 | async 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 | |||
58 | function canSeePrivateLiveInformation (res: express.Response) { | 92 | function 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 | ||
27 | const LAST_MIGRATION_VERSION = 705 | 27 | const 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' | |||
7 | import { UserNotificationModel } from '@server/models/user/user-notification' | 7 | import { UserNotificationModel } from '@server/models/user/user-notification' |
8 | import { UserVideoHistoryModel } from '@server/models/user/user-video-history' | 8 | import { UserVideoHistoryModel } from '@server/models/user/user-video-history' |
9 | import { VideoJobInfoModel } from '@server/models/video/video-job-info' | 9 | import { VideoJobInfoModel } from '@server/models/video/video-job-info' |
10 | import { VideoLiveSessionModel } from '@server/models/video/video-live-session' | ||
10 | import { LocalVideoViewerModel } from '@server/models/view/local-video-viewer' | 11 | import { LocalVideoViewerModel } from '@server/models/view/local-video-viewer' |
11 | import { LocalVideoViewerWatchSectionModel } from '@server/models/view/local-video-viewer-watch-section' | 12 | import { LocalVideoViewerWatchSectionModel } from '@server/models/view/local-video-viewer-watch-section' |
12 | import { isTestInstance } from '../helpers/core-utils' | 13 | import { 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 @@ | |||
1 | import * as Sequelize from 'sequelize' | ||
2 | |||
3 | async 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 | |||
27 | function down () { | ||
28 | throw new Error('Not implemented.') | ||
29 | } | ||
30 | |||
31 | export { | ||
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' | |||
15 | import { generateHlsPlaylistResolutionFromTS } from '@server/lib/transcoding/transcoding' | 15 | import { generateHlsPlaylistResolutionFromTS } from '@server/lib/transcoding/transcoding' |
16 | import { moveToNextState } from '@server/lib/video-state' | 16 | import { moveToNextState } from '@server/lib/video-state' |
17 | import { VideoModel } from '@server/models/video/video' | 17 | import { VideoModel } from '@server/models/video/video' |
18 | import { VideoBlacklistModel } from '@server/models/video/video-blacklist' | ||
18 | import { VideoFileModel } from '@server/models/video/video-file' | 19 | import { VideoFileModel } from '@server/models/video/video-file' |
19 | import { VideoLiveModel } from '@server/models/video/video-live' | 20 | import { VideoLiveModel } from '@server/models/video/video-live' |
21 | import { VideoLiveSessionModel } from '@server/models/video/video-live-session' | ||
20 | import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist' | 22 | import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist' |
21 | import { MVideo, MVideoLive, MVideoWithAllFiles } from '@server/types/models' | 23 | import { MVideo, MVideoLive, MVideoLiveSession, MVideoWithAllFiles } from '@server/types/models' |
22 | import { ThumbnailType, VideoLiveEndingPayload, VideoState } from '@shared/models' | 24 | import { ThumbnailType, VideoLiveEndingPayload, VideoState } from '@shared/models' |
23 | import { logger } from '../../../helpers/logger' | 25 | import { logger } from '../../../helpers/logger' |
24 | import { VideoBlacklistModel } from '@server/models/video/video-blacklist' | ||
25 | 26 | ||
26 | async function processVideoLiveEnding (job: Job) { | 27 | async 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 | ||
66 | async function saveReplayToExternalVideo (liveVideo: MVideo, publishedAt: string, replayDirectory: string) { | 68 | async 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 | ||
120 | async function replaceLiveByReplay (video: MVideo, live: MVideoLive, replayDirectory: string) { | 132 | async 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 | ||
168 | async function assignReplaysToVideo (video: MVideo, replayDirectory: string) { | 192 | async 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 | ||
200 | async function cleanupLiveAndFederate (video: MVideo) { | 229 | async 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/ | |||
17 | import { UserModel } from '@server/models/user/user' | 17 | import { UserModel } from '@server/models/user/user' |
18 | import { VideoModel } from '@server/models/video/video' | 18 | import { VideoModel } from '@server/models/video/video' |
19 | import { VideoLiveModel } from '@server/models/video/video-live' | 19 | import { VideoLiveModel } from '@server/models/video/video-live' |
20 | import { VideoLiveSessionModel } from '@server/models/video/video-live-session' | ||
20 | import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist' | 21 | import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist' |
21 | import { MStreamingPlaylistVideo, MVideo, MVideoLiveVideo } from '@server/types/models' | 22 | import { MStreamingPlaylistVideo, MVideo, MVideoLiveSession, MVideoLiveVideo } from '@server/types/models' |
22 | import { wait } from '@shared/core-utils' | 23 | import { wait } from '@shared/core-utils' |
23 | import { VideoState, VideoStreamingPlaylistType } from '@shared/models' | 24 | import { LiveVideoError, VideoState, VideoStreamingPlaylistType } from '@shared/models' |
24 | import { federateVideoIfNeeded } from '../activitypub/videos' | 25 | import { federateVideoIfNeeded } from '../activitypub/videos' |
25 | import { JobQueue } from '../job-queue' | 26 | import { JobQueue } from '../job-queue' |
26 | import { generateHLSMasterPlaylistFilename, generateHlsSha256SegmentsFilename, getLiveReplayBaseDirectory } from '../paths' | 27 | import { 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' |
12 | import { UserRight, VideoBlacklistCreate, VideoBlacklistType } from '../../shared/models' | 12 | import { LiveVideoError, UserRight, VideoBlacklistCreate, VideoBlacklistType } from '../../shared/models' |
13 | import { UserAdminFlag } from '../../shared/models/users/user-flag.model' | 13 | import { UserAdminFlag } from '../../shared/models/users/user-flag.model' |
14 | import { logger, loggerTagsFactory } from '../helpers/logger' | 14 | import { logger, loggerTagsFactory } from '../helpers/logger' |
15 | import { CONFIG } from '../initializers/config' | 15 | import { 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' |
30 | import { getCommonVideoEditAttributes } from './videos' | 30 | import { getCommonVideoEditAttributes } from './videos' |
31 | import { VideoLiveSessionModel } from '@server/models/video/video-live-session' | ||
31 | 32 | ||
32 | const videoLiveGetValidator = [ | 33 | const videoLiveGetValidator = [ |
33 | isValidVideoIdParam('videoId'), | 34 | isValidVideoIdParam('videoId'), |
@@ -196,11 +197,48 @@ const videoLiveUpdateValidator = [ | |||
196 | } | 197 | } |
197 | ] | 198 | ] |
198 | 199 | ||
200 | const 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 | |||
212 | const 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 | ||
201 | export { | 237 | export { |
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 @@ | |||
1 | import { FindOptions } from 'sequelize' | ||
2 | import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript' | ||
3 | import { MVideoLiveSession, MVideoLiveSessionReplay } from '@server/types/models' | ||
4 | import { uuidToShort } from '@shared/extra-utils' | ||
5 | import { LiveVideoError, LiveVideoSession } from '@shared/models' | ||
6 | import { AttributesOnly } from '@shared/typescript-utils' | ||
7 | import { VideoModel } from './video' | ||
8 | |||
9 | export 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 | }) | ||
36 | export 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 @@ | |||
3 | import 'mocha' | 3 | import 'mocha' |
4 | import * as chai from 'chai' | 4 | import * as chai from 'chai' |
5 | import { wait } from '@shared/core-utils' | 5 | import { wait } from '@shared/core-utils' |
6 | import { VideoPrivacy } from '@shared/models' | 6 | import { LiveVideoError, VideoPrivacy } from '@shared/models' |
7 | import { | 7 | import { |
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' |
17 | import { checkLiveCleanup } from '../../shared' | 18 | import { 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' | |||
5 | import { FfmpegCommand } from 'fluent-ffmpeg' | 5 | import { FfmpegCommand } from 'fluent-ffmpeg' |
6 | import { checkLiveCleanup } from '@server/tests/shared' | 6 | import { checkLiveCleanup } from '@server/tests/shared' |
7 | import { wait } from '@shared/core-utils' | 7 | import { wait } from '@shared/core-utils' |
8 | import { HttpStatusCode, LiveVideoCreate, VideoPrivacy, VideoState } from '@shared/models' | 8 | import { HttpStatusCode, LiveVideoCreate, LiveVideoError, VideoPrivacy, VideoState } from '@shared/models' |
9 | import { | 9 | import { |
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' |
17 | import { wait } from '@shared/core-utils' | 17 | import { wait } from '@shared/core-utils' |
18 | import { buildUUID } from '@shared/extra-utils' | 18 | import { buildUUID } from '@shared/extra-utils' |
19 | import { UserNotification, UserNotificationType, VideoStudioTask, VideoPrivacy } from '@shared/models' | 19 | import { UserNotification, UserNotificationType, VideoPrivacy, VideoStudioTask } from '@shared/models' |
20 | import { cleanupTests, PeerTubeServer, waitJobs } from '@shared/server-commands' | 20 | import { cleanupTests, findExternalSavedVideo, PeerTubeServer, stopFfmpeg, waitJobs } from '@shared/server-commands' |
21 | 21 | ||
22 | const expect = chai.expect | 22 | const 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' |
21 | import { MockSmtpServer } from './mock-servers' | 22 | import { 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 @@ | |||
1 | export * from './local-video-viewer-watch-section' | 1 | export * from './local-video-viewer-watch-section' |
2 | export * from './local-video-viewer-watch-section' | ||
2 | export * from './local-video-viewer' | 3 | export * from './local-video-viewer' |
3 | export * from './schedule-video-update' | 4 | export * from './schedule-video-update' |
4 | export * from './tag' | 5 | export * from './tag' |
@@ -11,6 +12,7 @@ export * from './video-channels' | |||
11 | export * from './video-comment' | 12 | export * from './video-comment' |
12 | export * from './video-file' | 13 | export * from './video-file' |
13 | export * from './video-import' | 14 | export * from './video-import' |
15 | export * from './video-live-session' | ||
14 | export * from './video-live' | 16 | export * from './video-live' |
15 | export * from './video-playlist' | 17 | export * from './video-playlist' |
16 | export * from './video-playlist-element' | 18 | export * from './video-playlist-element' |
diff --git a/server/types/models/video/video-live-session.ts b/server/types/models/video/video-live-session.ts new file mode 100644 index 000000000..2e5e4b684 --- /dev/null +++ b/server/types/models/video/video-live-session.ts | |||
@@ -0,0 +1,15 @@ | |||
1 | import { VideoLiveSessionModel } from '@server/models/video/video-live-session' | ||
2 | import { PickWith } from '@shared/typescript-utils' | ||
3 | import { MVideo } from './video' | ||
4 | |||
5 | type Use<K extends keyof VideoLiveSessionModel, M> = PickWith<VideoLiveSessionModel, K, M> | ||
6 | |||
7 | // ############################################################################ | ||
8 | |||
9 | export type MVideoLiveSession = Omit<VideoLiveSessionModel, 'Video' | 'VideoLive'> | ||
10 | |||
11 | // ############################################################################ | ||
12 | |||
13 | export type MVideoLiveSessionReplay = | ||
14 | MVideoLiveSession & | ||
15 | Use<'ReplayVideo', MVideo> | ||
diff --git a/shared/models/server/job.model.ts b/shared/models/server/job.model.ts index 9370cf011..bc5ffa570 100644 --- a/shared/models/server/job.model.ts +++ b/shared/models/server/job.model.ts | |||
@@ -160,6 +160,7 @@ export type VideoTranscodingPayload = | |||
160 | export interface VideoLiveEndingPayload { | 160 | export interface VideoLiveEndingPayload { |
161 | videoId: number | 161 | videoId: number |
162 | publishedAt: string | 162 | publishedAt: string |
163 | liveSessionId: number | ||
163 | 164 | ||
164 | replayDirectory?: string | 165 | replayDirectory?: string |
165 | } | 166 | } |
diff --git a/shared/models/videos/live/index.ts b/shared/models/videos/live/index.ts index 68f32092a..07b59fe2c 100644 --- a/shared/models/videos/live/index.ts +++ b/shared/models/videos/live/index.ts | |||
@@ -1,6 +1,8 @@ | |||
1 | export * from './live-video-create.model' | 1 | export * from './live-video-create.model' |
2 | export * from './live-video-error.enum' | ||
2 | export * from './live-video-event-payload.model' | 3 | export * from './live-video-event-payload.model' |
3 | export * from './live-video-event.type' | 4 | export * from './live-video-event.type' |
4 | export * from './live-video-latency-mode.enum' | 5 | export * from './live-video-latency-mode.enum' |
6 | export * from './live-video-session.model' | ||
5 | export * from './live-video-update.model' | 7 | export * from './live-video-update.model' |
6 | export * from './live-video.model' | 8 | export * from './live-video.model' |
diff --git a/shared/models/videos/live/live-video-error.enum.ts b/shared/models/videos/live/live-video-error.enum.ts new file mode 100644 index 000000000..3a8e4afa0 --- /dev/null +++ b/shared/models/videos/live/live-video-error.enum.ts | |||
@@ -0,0 +1,7 @@ | |||
1 | export const enum LiveVideoError { | ||
2 | BAD_SOCKET_HEALTH = 1, | ||
3 | DURATION_EXCEEDED = 2, | ||
4 | QUOTA_EXCEEDED = 3, | ||
5 | FFMPEG_ERROR = 4, | ||
6 | BLACKLISTED = 5 | ||
7 | } | ||
diff --git a/shared/models/videos/live/live-video-session.model.ts b/shared/models/videos/live/live-video-session.model.ts new file mode 100644 index 000000000..7ff6afbe5 --- /dev/null +++ b/shared/models/videos/live/live-video-session.model.ts | |||
@@ -0,0 +1,16 @@ | |||
1 | import { LiveVideoError } from './live-video-error.enum' | ||
2 | |||
3 | export interface LiveVideoSession { | ||
4 | id: number | ||
5 | |||
6 | startDate: string | ||
7 | endDate: string | ||
8 | |||
9 | error: LiveVideoError | ||
10 | |||
11 | replayVideo: { | ||
12 | id: number | ||
13 | uuid: string | ||
14 | shortUUID: string | ||
15 | } | ||
16 | } | ||
diff --git a/shared/server-commands/videos/live-command.ts b/shared/server-commands/videos/live-command.ts index c24c7a5fc..2ff65881b 100644 --- a/shared/server-commands/videos/live-command.ts +++ b/shared/server-commands/videos/live-command.ts | |||
@@ -4,7 +4,17 @@ import { readdir } from 'fs-extra' | |||
4 | import { omit } from 'lodash' | 4 | import { omit } from 'lodash' |
5 | import { join } from 'path' | 5 | import { join } from 'path' |
6 | import { wait } from '@shared/core-utils' | 6 | import { wait } from '@shared/core-utils' |
7 | import { HttpStatusCode, LiveVideo, LiveVideoCreate, LiveVideoUpdate, VideoCreateResult, VideoDetails, VideoState } from '@shared/models' | 7 | import { |
8 | HttpStatusCode, | ||
9 | LiveVideo, | ||
10 | LiveVideoCreate, | ||
11 | LiveVideoSession, | ||
12 | LiveVideoUpdate, | ||
13 | ResultList, | ||
14 | VideoCreateResult, | ||
15 | VideoDetails, | ||
16 | VideoState | ||
17 | } from '@shared/models' | ||
8 | import { unwrapBody } from '../requests' | 18 | import { unwrapBody } from '../requests' |
9 | import { AbstractCommand, OverrideCommandOptions } from '../shared' | 19 | import { AbstractCommand, OverrideCommandOptions } from '../shared' |
10 | import { sendRTMPStream, testFfmpegStreamError } from './live' | 20 | import { sendRTMPStream, testFfmpegStreamError } from './live' |
@@ -25,6 +35,42 @@ export class LiveCommand extends AbstractCommand { | |||
25 | }) | 35 | }) |
26 | } | 36 | } |
27 | 37 | ||
38 | listSessions (options: OverrideCommandOptions & { | ||
39 | videoId: number | string | ||
40 | }) { | ||
41 | const path = `/api/v1/videos/live/${options.videoId}/sessions` | ||
42 | |||
43 | return this.getRequestBody<ResultList<LiveVideoSession>>({ | ||
44 | ...options, | ||
45 | |||
46 | path, | ||
47 | implicitToken: true, | ||
48 | defaultExpectedStatus: HttpStatusCode.OK_200 | ||
49 | }) | ||
50 | } | ||
51 | |||
52 | async findLatestSession (options: OverrideCommandOptions & { | ||
53 | videoId: number | string | ||
54 | }) { | ||
55 | const { data: sessions } = await this.listSessions(options) | ||
56 | |||
57 | return sessions[sessions.length - 1] | ||
58 | } | ||
59 | |||
60 | getReplaySession (options: OverrideCommandOptions & { | ||
61 | videoId: number | string | ||
62 | }) { | ||
63 | const path = `/api/v1/videos/${options.videoId}/live-session` | ||
64 | |||
65 | return this.getRequestBody<LiveVideoSession>({ | ||
66 | ...options, | ||
67 | |||
68 | path, | ||
69 | implicitToken: true, | ||
70 | defaultExpectedStatus: HttpStatusCode.OK_200 | ||
71 | }) | ||
72 | } | ||
73 | |||
28 | update (options: OverrideCommandOptions & { | 74 | update (options: OverrideCommandOptions & { |
29 | videoId: number | string | 75 | videoId: number | string |
30 | fields: LiveVideoUpdate | 76 | fields: LiveVideoUpdate |
diff --git a/support/doc/api/openapi.yaml b/support/doc/api/openapi.yaml index 123e54f47..3a8b481d3 100644 --- a/support/doc/api/openapi.yaml +++ b/support/doc/api/openapi.yaml | |||
@@ -2462,6 +2462,48 @@ paths: | |||
2462 | description: bad parameters or trying to update a live that has already started | 2462 | description: bad parameters or trying to update a live that has already started |
2463 | '403': | 2463 | '403': |
2464 | description: trying to save replay of the live but saving replay is not enabled on the instance | 2464 | description: trying to save replay of the live but saving replay is not enabled on the instance |
2465 | /videos/live/{id}/sessions: | ||
2466 | get: | ||
2467 | summary: List live sessions | ||
2468 | description: List all sessions created in a particular live | ||
2469 | security: | ||
2470 | - OAuth2: [] | ||
2471 | tags: | ||
2472 | - Live Videos | ||
2473 | parameters: | ||
2474 | - $ref: '#/components/parameters/idOrUUID' | ||
2475 | responses: | ||
2476 | '200': | ||
2477 | description: successful operation | ||
2478 | content: | ||
2479 | application/json: | ||
2480 | schema: | ||
2481 | type: object | ||
2482 | properties: | ||
2483 | total: | ||
2484 | type: integer | ||
2485 | example: 1 | ||
2486 | data: | ||
2487 | type: array | ||
2488 | items: | ||
2489 | $ref: '#/components/schemas/LiveVideoSessionResponse' | ||
2490 | /videos/{id}/live-session: | ||
2491 | get: | ||
2492 | summary: Get live session of a replay | ||
2493 | description: If the video is a replay of a live, you can find the associated live session using this endpoint | ||
2494 | security: | ||
2495 | - OAuth2: [] | ||
2496 | tags: | ||
2497 | - Live Videos | ||
2498 | parameters: | ||
2499 | - $ref: '#/components/parameters/idOrUUID' | ||
2500 | responses: | ||
2501 | '200': | ||
2502 | description: successful operation | ||
2503 | content: | ||
2504 | application/json: | ||
2505 | schema: | ||
2506 | $ref: '#/components/schemas/LiveVideoSessionResponse' | ||
2465 | 2507 | ||
2466 | /users/me/abuses: | 2508 | /users/me/abuses: |
2467 | get: | 2509 | get: |
@@ -7673,6 +7715,46 @@ components: | |||
7673 | description: User can select live latency mode if enabled by the instance | 7715 | description: User can select live latency mode if enabled by the instance |
7674 | $ref: '#/components/schemas/LiveVideoLatencyMode' | 7716 | $ref: '#/components/schemas/LiveVideoLatencyMode' |
7675 | 7717 | ||
7718 | LiveVideoSessionResponse: | ||
7719 | properties: | ||
7720 | id: | ||
7721 | type: integer | ||
7722 | startDate: | ||
7723 | type: string | ||
7724 | format: date-time | ||
7725 | description: Start date of the live session | ||
7726 | endDate: | ||
7727 | type: string | ||
7728 | format: date-time | ||
7729 | nullable: true | ||
7730 | description: End date of the live session | ||
7731 | error: | ||
7732 | type: integer | ||
7733 | enum: | ||
7734 | - 1 | ||
7735 | - 2 | ||
7736 | - 3 | ||
7737 | - 4 | ||
7738 | - 5 | ||
7739 | nullable: true | ||
7740 | description: > | ||
7741 | Error type if an error occured during the live session: | ||
7742 | - `1`: Bad socket health (transcoding is too slow) | ||
7743 | - `2`: Max duration exceeded | ||
7744 | - `3`: Quota exceeded | ||
7745 | - `4`: Quota FFmpeg error | ||
7746 | - `5`: Video has been blacklisted during the live | ||
7747 | replayVideo: | ||
7748 | type: object | ||
7749 | description: Video replay information | ||
7750 | properties: | ||
7751 | id: | ||
7752 | type: number | ||
7753 | uuid: | ||
7754 | $ref: '#/components/schemas/UUIDv4' | ||
7755 | shortUUID: | ||
7756 | $ref: '#/components/schemas/shortUUID' | ||
7757 | |||
7676 | callbacks: | 7758 | callbacks: |
7677 | searchIndex: | 7759 | searchIndex: |
7678 | 'https://search.example.org/api/v1/search/videos': | 7760 | 'https://search.example.org/api/v1/search/videos': |