diff options
25 files changed, 392 insertions, 28 deletions
diff --git a/client/src/app/+videos/+video-edit/shared/video-edit.component.html b/client/src/app/+videos/+video-edit/shared/video-edit.component.html index bf8b0b267..f62464d35 100644 --- a/client/src/app/+videos/+video-edit/shared/video-edit.component.html +++ b/client/src/app/+videos/+video-edit/shared/video-edit.component.html | |||
@@ -223,6 +223,18 @@ | |||
223 | <div class="form-group-description" i18n>⚠️ Never share your stream key with anyone.</div> | 223 | <div class="form-group-description" i18n>⚠️ Never share your stream key with anyone.</div> |
224 | </div> | 224 | </div> |
225 | 225 | ||
226 | <div class="form-group"> | ||
227 | <my-peertube-checkbox inputName="liveVideoPermanentLive" formControlName="permanentLive"> | ||
228 | <ng-template ptTemplate="label"> | ||
229 | <ng-container i18n>This is a permanent live</ng-container> | ||
230 | </ng-template> | ||
231 | |||
232 | <ng-container ngProjectAs="description"> | ||
233 | <span i18n>You can stream multiple times in a permanent live. The URL for your viewers won't change but you cannot save replays of your lives</span> | ||
234 | </ng-container> | ||
235 | </my-peertube-checkbox> | ||
236 | </div> | ||
237 | |||
226 | <div class="form-group" *ngIf="isSaveReplayEnabled()"> | 238 | <div class="form-group" *ngIf="isSaveReplayEnabled()"> |
227 | <my-peertube-checkbox inputName="liveVideoSaveReplay" formControlName="saveReplay"> | 239 | <my-peertube-checkbox inputName="liveVideoSaveReplay" formControlName="saveReplay"> |
228 | <ng-template ptTemplate="label"> | 240 | <ng-template ptTemplate="label"> |
diff --git a/client/src/app/+videos/+video-edit/shared/video-edit.component.ts b/client/src/app/+videos/+video-edit/shared/video-edit.component.ts index de78a18cc..5294a57a1 100644 --- a/client/src/app/+videos/+video-edit/shared/video-edit.component.ts +++ b/client/src/app/+videos/+video-edit/shared/video-edit.component.ts | |||
@@ -138,6 +138,7 @@ export class VideoEditComponent implements OnInit, OnDestroy { | |||
138 | schedulePublicationAt: VIDEO_SCHEDULE_PUBLICATION_AT_VALIDATOR, | 138 | schedulePublicationAt: VIDEO_SCHEDULE_PUBLICATION_AT_VALIDATOR, |
139 | originallyPublishedAt: VIDEO_ORIGINALLY_PUBLISHED_AT_VALIDATOR, | 139 | originallyPublishedAt: VIDEO_ORIGINALLY_PUBLISHED_AT_VALIDATOR, |
140 | liveStreamKey: null, | 140 | liveStreamKey: null, |
141 | permanentLive: null, | ||
141 | saveReplay: null | 142 | saveReplay: null |
142 | } | 143 | } |
143 | 144 | ||
@@ -158,6 +159,7 @@ export class VideoEditComponent implements OnInit, OnDestroy { | |||
158 | 159 | ||
159 | this.trackChannelChange() | 160 | this.trackChannelChange() |
160 | this.trackPrivacyChange() | 161 | this.trackPrivacyChange() |
162 | this.trackLivePermanentFieldChange() | ||
161 | } | 163 | } |
162 | 164 | ||
163 | ngOnInit () { | 165 | ngOnInit () { |
@@ -254,6 +256,10 @@ export class VideoEditComponent implements OnInit, OnDestroy { | |||
254 | return this.serverConfig.live.allowReplay | 256 | return this.serverConfig.live.allowReplay |
255 | } | 257 | } |
256 | 258 | ||
259 | isPermanentLiveEnabled () { | ||
260 | return this.form.value['permanentLive'] === true | ||
261 | } | ||
262 | |||
257 | private sortVideoCaptions () { | 263 | private sortVideoCaptions () { |
258 | this.videoCaptions.sort((v1, v2) => { | 264 | this.videoCaptions.sort((v1, v2) => { |
259 | if (v1.language.label < v2.language.label) return -1 | 265 | if (v1.language.label < v2.language.label) return -1 |
@@ -362,6 +368,24 @@ export class VideoEditComponent implements OnInit, OnDestroy { | |||
362 | ) | 368 | ) |
363 | } | 369 | } |
364 | 370 | ||
371 | private trackLivePermanentFieldChange () { | ||
372 | // We will update the "support" field depending on the channel | ||
373 | this.form.controls['permanentLive'] | ||
374 | .valueChanges | ||
375 | .subscribe( | ||
376 | permanentLive => { | ||
377 | const saveReplayControl = this.form.controls['saveReplay'] | ||
378 | |||
379 | if (permanentLive === true) { | ||
380 | saveReplayControl.setValue(false) | ||
381 | saveReplayControl.disable() | ||
382 | } else { | ||
383 | saveReplayControl.enable() | ||
384 | } | ||
385 | } | ||
386 | ) | ||
387 | } | ||
388 | |||
365 | private updateSupportField (support: string) { | 389 | private updateSupportField (support: string) { |
366 | return this.form.patchValue({ support: support || '' }) | 390 | return this.form.patchValue({ support: support || '' }) |
367 | } | 391 | } |
diff --git a/client/src/app/+videos/+video-edit/video-add-components/video-go-live.component.ts b/client/src/app/+videos/+video-edit/video-add-components/video-go-live.component.ts index d29b2da97..a87d84d48 100644 --- a/client/src/app/+videos/+video-edit/video-add-components/video-go-live.component.ts +++ b/client/src/app/+videos/+video-edit/video-add-components/video-go-live.component.ts | |||
@@ -107,7 +107,8 @@ export class VideoGoLiveComponent extends VideoSend implements OnInit, CanCompon | |||
107 | video.uuid = this.videoUUID | 107 | video.uuid = this.videoUUID |
108 | 108 | ||
109 | const liveVideoUpdate: LiveVideoUpdate = { | 109 | const liveVideoUpdate: LiveVideoUpdate = { |
110 | saveReplay: this.form.value.saveReplay | 110 | saveReplay: this.form.value.saveReplay, |
111 | permanentLive: this.form.value.permanentLive | ||
111 | } | 112 | } |
112 | 113 | ||
113 | // Update the video | 114 | // Update the video |
diff --git a/client/src/app/+videos/+video-edit/video-update.component.ts b/client/src/app/+videos/+video-edit/video-update.component.ts index e37163ca9..30c82343b 100644 --- a/client/src/app/+videos/+video-edit/video-update.component.ts +++ b/client/src/app/+videos/+video-edit/video-update.component.ts | |||
@@ -64,7 +64,8 @@ export class VideoUpdateComponent extends FormReactive implements OnInit { | |||
64 | 64 | ||
65 | if (this.liveVideo) { | 65 | if (this.liveVideo) { |
66 | this.form.patchValue({ | 66 | this.form.patchValue({ |
67 | saveReplay: this.liveVideo.saveReplay | 67 | saveReplay: this.liveVideo.saveReplay, |
68 | permanentLive: this.liveVideo.permanentLive | ||
68 | }) | 69 | }) |
69 | } | 70 | } |
70 | }) | 71 | }) |
@@ -114,7 +115,8 @@ export class VideoUpdateComponent extends FormReactive implements OnInit { | |||
114 | this.video.patch(this.form.value) | 115 | this.video.patch(this.form.value) |
115 | 116 | ||
116 | const liveVideoUpdate: LiveVideoUpdate = { | 117 | const liveVideoUpdate: LiveVideoUpdate = { |
117 | saveReplay: this.form.value.saveReplay | 118 | saveReplay: this.form.value.saveReplay, |
119 | permanentLive: this.form.value.permanentLive | ||
118 | } | 120 | } |
119 | 121 | ||
120 | this.loadingBar.useRef().start() | 122 | this.loadingBar.useRef().start() |
diff --git a/server/controllers/api/videos/index.ts b/server/controllers/api/videos/index.ts index 0dcd38ad2..71a0f97e2 100644 --- a/server/controllers/api/videos/index.ts +++ b/server/controllers/api/videos/index.ts | |||
@@ -174,7 +174,7 @@ function listVideoPrivacies (req: express.Request, res: express.Response) { | |||
174 | } | 174 | } |
175 | 175 | ||
176 | async function addVideo (req: express.Request, res: express.Response) { | 176 | async function addVideo (req: express.Request, res: express.Response) { |
177 | // Transferring the video could be long | 177 | // Uploading the video could be long |
178 | // Set timeout to 10 minutes, as Express's default is 2 minutes | 178 | // Set timeout to 10 minutes, as Express's default is 2 minutes |
179 | req.setTimeout(1000 * 60 * 10, () => { | 179 | req.setTimeout(1000 * 60 * 10, () => { |
180 | logger.error('Upload video has timed out.') | 180 | logger.error('Upload video has timed out.') |
diff --git a/server/controllers/api/videos/live.ts b/server/controllers/api/videos/live.ts index a6f00c1bd..e67d89612 100644 --- a/server/controllers/api/videos/live.ts +++ b/server/controllers/api/videos/live.ts | |||
@@ -67,7 +67,9 @@ async function updateLiveVideo (req: express.Request, res: express.Response) { | |||
67 | 67 | ||
68 | const video = res.locals.videoAll | 68 | const video = res.locals.videoAll |
69 | const videoLive = res.locals.videoLive | 69 | const videoLive = res.locals.videoLive |
70 | |||
70 | videoLive.saveReplay = body.saveReplay || false | 71 | videoLive.saveReplay = body.saveReplay || false |
72 | videoLive.permanentLive = body.permanentLive || false | ||
71 | 73 | ||
72 | video.VideoLive = await videoLive.save() | 74 | video.VideoLive = await videoLive.save() |
73 | 75 | ||
@@ -90,6 +92,7 @@ async function addLiveVideo (req: express.Request, res: express.Response) { | |||
90 | 92 | ||
91 | const videoLive = new VideoLiveModel() | 93 | const videoLive = new VideoLiveModel() |
92 | videoLive.saveReplay = videoInfo.saveReplay || false | 94 | videoLive.saveReplay = videoInfo.saveReplay || false |
95 | videoLive.permanentLive = videoInfo.permanentLive || false | ||
93 | videoLive.streamKey = uuidv4() | 96 | videoLive.streamKey = uuidv4() |
94 | 97 | ||
95 | const [ thumbnailModel, previewModel ] = await buildVideoThumbnailsFromReq({ | 98 | const [ thumbnailModel, previewModel ] = await buildVideoThumbnailsFromReq({ |
diff --git a/server/helpers/activitypub.ts b/server/helpers/activitypub.ts index d28453d79..1188d6cf9 100644 --- a/server/helpers/activitypub.ts +++ b/server/helpers/activitypub.ts | |||
@@ -39,6 +39,16 @@ function getContextData (type: ContextType) { | |||
39 | sensitive: 'as:sensitive', | 39 | sensitive: 'as:sensitive', |
40 | language: 'sc:inLanguage', | 40 | language: 'sc:inLanguage', |
41 | 41 | ||
42 | isLiveBroadcast: 'sc:isLiveBroadcast', | ||
43 | liveSaveReplay: { | ||
44 | '@type': 'sc:Boolean', | ||
45 | '@id': 'pt:liveSaveReplay' | ||
46 | }, | ||
47 | permanentLive: { | ||
48 | '@type': 'sc:Boolean', | ||
49 | '@id': 'pt:permanentLive' | ||
50 | }, | ||
51 | |||
42 | Infohash: 'pt:Infohash', | 52 | Infohash: 'pt:Infohash', |
43 | Playlist: 'pt:Playlist', | 53 | Playlist: 'pt:Playlist', |
44 | PlaylistElement: 'pt:PlaylistElement', | 54 | PlaylistElement: 'pt:PlaylistElement', |
diff --git a/server/helpers/custom-validators/activitypub/videos.ts b/server/helpers/custom-validators/activitypub/videos.ts index cb385b07d..a01429c83 100644 --- a/server/helpers/custom-validators/activitypub/videos.ts +++ b/server/helpers/custom-validators/activitypub/videos.ts | |||
@@ -64,6 +64,7 @@ function sanitizeAndCheckVideoTorrentObject (video: any) { | |||
64 | if (!isBooleanValid(video.commentsEnabled)) video.commentsEnabled = false | 64 | if (!isBooleanValid(video.commentsEnabled)) video.commentsEnabled = false |
65 | if (!isBooleanValid(video.isLiveBroadcast)) video.isLiveBroadcast = false | 65 | if (!isBooleanValid(video.isLiveBroadcast)) video.isLiveBroadcast = false |
66 | if (!isBooleanValid(video.liveSaveReplay)) video.liveSaveReplay = false | 66 | if (!isBooleanValid(video.liveSaveReplay)) video.liveSaveReplay = false |
67 | if (!isBooleanValid(video.permanentLive)) video.permanentLive = false | ||
67 | 68 | ||
68 | return isActivityPubUrlValid(video.id) && | 69 | return isActivityPubUrlValid(video.id) && |
69 | isVideoNameValid(video.name) && | 70 | isVideoNameValid(video.name) && |
@@ -74,8 +75,6 @@ function sanitizeAndCheckVideoTorrentObject (video: any) { | |||
74 | (!video.language || isRemoteStringIdentifierValid(video.language)) && | 75 | (!video.language || isRemoteStringIdentifierValid(video.language)) && |
75 | isVideoViewsValid(video.views) && | 76 | isVideoViewsValid(video.views) && |
76 | isBooleanValid(video.sensitive) && | 77 | isBooleanValid(video.sensitive) && |
77 | isBooleanValid(video.commentsEnabled) && | ||
78 | isBooleanValid(video.downloadEnabled) && | ||
79 | isDateValid(video.published) && | 78 | isDateValid(video.published) && |
80 | isDateValid(video.updated) && | 79 | isDateValid(video.updated) && |
81 | (!video.originallyPublishedAt || isDateValid(video.originallyPublishedAt)) && | 80 | (!video.originallyPublishedAt || isDateValid(video.originallyPublishedAt)) && |
diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts index 2c7acd757..9e642af95 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 = 565 | 27 | const LAST_MIGRATION_VERSION = 570 |
28 | 28 | ||
29 | // --------------------------------------------------------------------------- | 29 | // --------------------------------------------------------------------------- |
30 | 30 | ||
diff --git a/server/initializers/migrations/0570-permanent-live.ts b/server/initializers/migrations/0570-permanent-live.ts new file mode 100644 index 000000000..9572a9b2d --- /dev/null +++ b/server/initializers/migrations/0570-permanent-live.ts | |||
@@ -0,0 +1,27 @@ | |||
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 | { | ||
10 | const data = { | ||
11 | type: Sequelize.BOOLEAN, | ||
12 | defaultValue: false, | ||
13 | allowNull: false | ||
14 | } | ||
15 | |||
16 | await utils.queryInterface.addColumn('videoLive', 'permanentLive', data) | ||
17 | } | ||
18 | } | ||
19 | |||
20 | function down (options) { | ||
21 | throw new Error('Not implemented.') | ||
22 | } | ||
23 | |||
24 | export { | ||
25 | up, | ||
26 | down | ||
27 | } | ||
diff --git a/server/lib/activitypub/videos.ts b/server/lib/activitypub/videos.ts index 4053f487c..04f0bfc23 100644 --- a/server/lib/activitypub/videos.ts +++ b/server/lib/activitypub/videos.ts | |||
@@ -429,6 +429,7 @@ async function updateVideoFromAP (options: { | |||
429 | if (video.isLive) { | 429 | if (video.isLive) { |
430 | const [ videoLive ] = await VideoLiveModel.upsert({ | 430 | const [ videoLive ] = await VideoLiveModel.upsert({ |
431 | saveReplay: videoObject.liveSaveReplay, | 431 | saveReplay: videoObject.liveSaveReplay, |
432 | permanentLive: videoObject.permanentLive, | ||
432 | videoId: video.id | 433 | videoId: video.id |
433 | }, { transaction: t, returning: true }) | 434 | }, { transaction: t, returning: true }) |
434 | 435 | ||
@@ -631,6 +632,7 @@ async function createVideo (videoObject: VideoObject, channel: MChannelAccountLi | |||
631 | const videoLive = new VideoLiveModel({ | 632 | const videoLive = new VideoLiveModel({ |
632 | streamKey: null, | 633 | streamKey: null, |
633 | saveReplay: videoObject.liveSaveReplay, | 634 | saveReplay: videoObject.liveSaveReplay, |
635 | permanentLive: videoObject.permanentLive, | ||
634 | videoId: videoCreated.id | 636 | videoId: videoCreated.id |
635 | }) | 637 | }) |
636 | 638 | ||
diff --git a/server/lib/job-queue/handlers/video-live-ending.ts b/server/lib/job-queue/handlers/video-live-ending.ts index d3c84ce75..e3c11caa2 100644 --- a/server/lib/job-queue/handlers/video-live-ending.ts +++ b/server/lib/job-queue/handlers/video-live-ending.ts | |||
@@ -1,5 +1,5 @@ | |||
1 | import * as Bull from 'bull' | 1 | import * as Bull from 'bull' |
2 | import { copy, readdir, remove } from 'fs-extra' | 2 | import { copy, pathExists, readdir, remove } from 'fs-extra' |
3 | import { join } from 'path' | 3 | import { join } from 'path' |
4 | import { getDurationFromVideoFile, getVideoFileResolution } from '@server/helpers/ffprobe-utils' | 4 | import { getDurationFromVideoFile, getVideoFileResolution } from '@server/helpers/ffprobe-utils' |
5 | import { VIDEO_LIVE } from '@server/initializers/constants' | 5 | import { VIDEO_LIVE } from '@server/initializers/constants' |
@@ -14,6 +14,7 @@ import { VideoStreamingPlaylistModel } from '@server/models/video/video-streamin | |||
14 | import { MStreamingPlaylist, MVideo, MVideoLive } from '@server/types/models' | 14 | import { MStreamingPlaylist, MVideo, MVideoLive } from '@server/types/models' |
15 | import { ThumbnailType, VideoLiveEndingPayload, VideoState } from '@shared/models' | 15 | import { ThumbnailType, VideoLiveEndingPayload, VideoState } from '@shared/models' |
16 | import { logger } from '../../../helpers/logger' | 16 | import { logger } from '../../../helpers/logger' |
17 | import { LiveManager } from '@server/lib/live-manager' | ||
17 | 18 | ||
18 | async function processVideoLiveEnding (job: Bull.Job) { | 19 | async function processVideoLiveEnding (job: Bull.Job) { |
19 | const payload = job.data as VideoLiveEndingPayload | 20 | const payload = job.data as VideoLiveEndingPayload |
@@ -36,6 +37,8 @@ async function processVideoLiveEnding (job: Bull.Job) { | |||
36 | return | 37 | return |
37 | } | 38 | } |
38 | 39 | ||
40 | LiveManager.Instance.cleanupShaSegments(video.uuid) | ||
41 | |||
39 | if (live.saveReplay !== true) { | 42 | if (live.saveReplay !== true) { |
40 | return cleanupLive(video, streamingPlaylist) | 43 | return cleanupLive(video, streamingPlaylist) |
41 | } | 44 | } |
@@ -43,10 +46,19 @@ async function processVideoLiveEnding (job: Bull.Job) { | |||
43 | return saveLive(video, live) | 46 | return saveLive(video, live) |
44 | } | 47 | } |
45 | 48 | ||
49 | async function cleanupLive (video: MVideo, streamingPlaylist: MStreamingPlaylist) { | ||
50 | const hlsDirectory = getHLSDirectory(video) | ||
51 | |||
52 | await remove(hlsDirectory) | ||
53 | |||
54 | await streamingPlaylist.destroy() | ||
55 | } | ||
56 | |||
46 | // --------------------------------------------------------------------------- | 57 | // --------------------------------------------------------------------------- |
47 | 58 | ||
48 | export { | 59 | export { |
49 | processVideoLiveEnding | 60 | processVideoLiveEnding, |
61 | cleanupLive | ||
50 | } | 62 | } |
51 | 63 | ||
52 | // --------------------------------------------------------------------------- | 64 | // --------------------------------------------------------------------------- |
@@ -131,16 +143,9 @@ async function saveLive (video: MVideo, live: MVideoLive) { | |||
131 | await publishAndFederateIfNeeded(videoWithFiles, true) | 143 | await publishAndFederateIfNeeded(videoWithFiles, true) |
132 | } | 144 | } |
133 | 145 | ||
134 | async function cleanupLive (video: MVideo, streamingPlaylist: MStreamingPlaylist) { | ||
135 | const hlsDirectory = getHLSDirectory(video) | ||
136 | |||
137 | await remove(hlsDirectory) | ||
138 | |||
139 | streamingPlaylist.destroy() | ||
140 | .catch(err => logger.error('Cannot remove live streaming playlist.', { err })) | ||
141 | } | ||
142 | |||
143 | async function cleanupLiveFiles (hlsDirectory: string) { | 146 | async function cleanupLiveFiles (hlsDirectory: string) { |
147 | if (!await pathExists(hlsDirectory)) return | ||
148 | |||
144 | const files = await readdir(hlsDirectory) | 149 | const files = await readdir(hlsDirectory) |
145 | 150 | ||
146 | for (const filename of files) { | 151 | for (const filename of files) { |
diff --git a/server/lib/live-manager.ts b/server/lib/live-manager.ts index 4f45ce530..dcf016169 100644 --- a/server/lib/live-manager.ts +++ b/server/lib/live-manager.ts | |||
@@ -19,6 +19,7 @@ import { VideoState, VideoStreamingPlaylistType } from '@shared/models' | |||
19 | import { federateVideoIfNeeded } from './activitypub/videos' | 19 | import { federateVideoIfNeeded } from './activitypub/videos' |
20 | import { buildSha256Segment } from './hls' | 20 | import { buildSha256Segment } from './hls' |
21 | import { JobQueue } from './job-queue' | 21 | import { JobQueue } from './job-queue' |
22 | import { cleanupLive } from './job-queue/handlers/video-live-ending' | ||
22 | import { PeerTubeSocket } from './peertube-socket' | 23 | import { PeerTubeSocket } from './peertube-socket' |
23 | import { isAbleToUploadVideo } from './user' | 24 | import { isAbleToUploadVideo } from './user' |
24 | import { getHLSDirectory } from './video-paths' | 25 | import { getHLSDirectory } from './video-paths' |
@@ -153,6 +154,10 @@ class LiveManager { | |||
153 | watchers.push(new Date().getTime()) | 154 | watchers.push(new Date().getTime()) |
154 | } | 155 | } |
155 | 156 | ||
157 | cleanupShaSegments (videoUUID: string) { | ||
158 | this.segmentsSha256.delete(videoUUID) | ||
159 | } | ||
160 | |||
156 | private getContext () { | 161 | private getContext () { |
157 | return context | 162 | return context |
158 | } | 163 | } |
@@ -184,6 +189,14 @@ class LiveManager { | |||
184 | return this.abortSession(sessionId) | 189 | return this.abortSession(sessionId) |
185 | } | 190 | } |
186 | 191 | ||
192 | // Cleanup old potential live files (could happen with a permanent live) | ||
193 | this.cleanupShaSegments(video.uuid) | ||
194 | |||
195 | const oldStreamingPlaylist = await VideoStreamingPlaylistModel.loadHLSPlaylistByVideo(video.id) | ||
196 | if (oldStreamingPlaylist) { | ||
197 | await cleanupLive(video, oldStreamingPlaylist) | ||
198 | } | ||
199 | |||
187 | this.videoSessions.set(video.id, sessionId) | 200 | this.videoSessions.set(video.id, sessionId) |
188 | 201 | ||
189 | const playlistUrl = WEBSERVER.URL + VideoStreamingPlaylistModel.getHlsMasterPlaylistStaticPath(video.uuid) | 202 | const playlistUrl = WEBSERVER.URL + VideoStreamingPlaylistModel.getHlsMasterPlaylistStaticPath(video.uuid) |
@@ -372,7 +385,13 @@ class LiveManager { | |||
372 | logger.info('RTMP transmuxing for video %s ended. Scheduling cleanup', rtmpUrl) | 385 | logger.info('RTMP transmuxing for video %s ended. Scheduling cleanup', rtmpUrl) |
373 | 386 | ||
374 | this.transSessions.delete(sessionId) | 387 | this.transSessions.delete(sessionId) |
388 | |||
375 | this.watchersPerVideo.delete(videoLive.videoId) | 389 | this.watchersPerVideo.delete(videoLive.videoId) |
390 | this.videoSessions.delete(videoLive.videoId) | ||
391 | |||
392 | const newLivesPerUser = this.livesPerUser.get(user.id) | ||
393 | .filter(o => o.liveId !== videoLive.id) | ||
394 | this.livesPerUser.set(user.id, newLivesPerUser) | ||
376 | 395 | ||
377 | setTimeout(() => { | 396 | setTimeout(() => { |
378 | // Wait latest segments generation, and close watchers | 397 | // Wait latest segments generation, and close watchers |
@@ -412,14 +431,21 @@ class LiveManager { | |||
412 | const fullVideo = await VideoModel.loadAndPopulateAccountAndServerAndTags(videoId) | 431 | const fullVideo = await VideoModel.loadAndPopulateAccountAndServerAndTags(videoId) |
413 | if (!fullVideo) return | 432 | if (!fullVideo) return |
414 | 433 | ||
415 | JobQueue.Instance.createJob({ | 434 | const live = await VideoLiveModel.loadByVideoId(videoId) |
416 | type: 'video-live-ending', | 435 | |
417 | payload: { | 436 | if (!live.permanentLive) { |
418 | videoId: fullVideo.id | 437 | JobQueue.Instance.createJob({ |
419 | } | 438 | type: 'video-live-ending', |
420 | }, { delay: cleanupNow ? 0 : VIDEO_LIVE.CLEANUP_DELAY }) | 439 | payload: { |
440 | videoId: fullVideo.id | ||
441 | } | ||
442 | }, { delay: cleanupNow ? 0 : VIDEO_LIVE.CLEANUP_DELAY }) | ||
443 | |||
444 | fullVideo.state = VideoState.LIVE_ENDED | ||
445 | } else { | ||
446 | fullVideo.state = VideoState.WAITING_FOR_LIVE | ||
447 | } | ||
421 | 448 | ||
422 | fullVideo.state = VideoState.LIVE_ENDED | ||
423 | await fullVideo.save() | 449 | await fullVideo.save() |
424 | 450 | ||
425 | PeerTubeSocket.Instance.sendVideoLiveNewState(fullVideo) | 451 | PeerTubeSocket.Instance.sendVideoLiveNewState(fullVideo) |
diff --git a/server/middlewares/validators/videos/video-live.ts b/server/middlewares/validators/videos/video-live.ts index ff92db910..69a14ccb1 100644 --- a/server/middlewares/validators/videos/video-live.ts +++ b/server/middlewares/validators/videos/video-live.ts | |||
@@ -49,9 +49,16 @@ const videoLiveAddValidator = getCommonVideoEditAttributes().concat([ | |||
49 | .customSanitizer(toBooleanOrNull) | 49 | .customSanitizer(toBooleanOrNull) |
50 | .custom(isBooleanValid).withMessage('Should have a valid saveReplay attribute'), | 50 | .custom(isBooleanValid).withMessage('Should have a valid saveReplay attribute'), |
51 | 51 | ||
52 | body('permanentLive') | ||
53 | .optional() | ||
54 | .customSanitizer(toBooleanOrNull) | ||
55 | .custom(isBooleanValid).withMessage('Should have a valid permanentLive attribute'), | ||
56 | |||
52 | async (req: express.Request, res: express.Response, next: express.NextFunction) => { | 57 | async (req: express.Request, res: express.Response, next: express.NextFunction) => { |
53 | logger.debug('Checking videoLiveAddValidator parameters', { parameters: req.body }) | 58 | logger.debug('Checking videoLiveAddValidator parameters', { parameters: req.body }) |
54 | 59 | ||
60 | if (areValidationErrors(req, res)) return cleanUpReqFiles(req) | ||
61 | |||
55 | if (CONFIG.LIVE.ENABLED !== true) { | 62 | if (CONFIG.LIVE.ENABLED !== true) { |
56 | cleanUpReqFiles(req) | 63 | cleanUpReqFiles(req) |
57 | 64 | ||
@@ -66,7 +73,12 @@ const videoLiveAddValidator = getCommonVideoEditAttributes().concat([ | |||
66 | .json({ error: 'Saving live replay is not allowed instance' }) | 73 | .json({ error: 'Saving live replay is not allowed instance' }) |
67 | } | 74 | } |
68 | 75 | ||
69 | if (areValidationErrors(req, res)) return cleanUpReqFiles(req) | 76 | if (req.body.permanentLive && req.body.saveReplay) { |
77 | cleanUpReqFiles(req) | ||
78 | |||
79 | return res.status(400) | ||
80 | .json({ error: 'Cannot set this live as permanent while saving its replay' }) | ||
81 | } | ||
70 | 82 | ||
71 | const user = res.locals.oauth.token.User | 83 | const user = res.locals.oauth.token.User |
72 | if (!await doesVideoChannelOfAccountExist(req.body.channelId, user, res)) return cleanUpReqFiles(req) | 84 | if (!await doesVideoChannelOfAccountExist(req.body.channelId, user, res)) return cleanUpReqFiles(req) |
@@ -116,6 +128,11 @@ const videoLiveUpdateValidator = [ | |||
116 | 128 | ||
117 | if (areValidationErrors(req, res)) return | 129 | if (areValidationErrors(req, res)) return |
118 | 130 | ||
131 | if (req.body.permanentLive && req.body.saveReplay) { | ||
132 | return res.status(400) | ||
133 | .json({ error: 'Cannot set this live as permanent while saving its replay' }) | ||
134 | } | ||
135 | |||
119 | if (CONFIG.LIVE.ALLOW_REPLAY !== true && req.body.saveReplay === true) { | 136 | if (CONFIG.LIVE.ALLOW_REPLAY !== true && req.body.saveReplay === true) { |
120 | return res.status(403) | 137 | return res.status(403) |
121 | .json({ error: 'Saving live replay is not allowed instance' }) | 138 | .json({ error: 'Saving live replay is not allowed instance' }) |
diff --git a/server/models/video/video-format-utils.ts b/server/models/video/video-format-utils.ts index b1adbcb86..a1f022fb4 100644 --- a/server/models/video/video-format-utils.ts +++ b/server/models/video/video-format-utils.ts | |||
@@ -360,6 +360,10 @@ function videoModelToActivityPubObject (video: MVideoAP): VideoObject { | |||
360 | ? video.VideoLive.saveReplay | 360 | ? video.VideoLive.saveReplay |
361 | : null, | 361 | : null, |
362 | 362 | ||
363 | permanentLive: video.isLive | ||
364 | ? video.VideoLive.permanentLive | ||
365 | : null, | ||
366 | |||
363 | state: video.state, | 367 | state: video.state, |
364 | commentsEnabled: video.commentsEnabled, | 368 | commentsEnabled: video.commentsEnabled, |
365 | downloadEnabled: video.downloadEnabled, | 369 | downloadEnabled: video.downloadEnabled, |
diff --git a/server/models/video/video-live.ts b/server/models/video/video-live.ts index f3bff74ea..875ba9b31 100644 --- a/server/models/video/video-live.ts +++ b/server/models/video/video-live.ts | |||
@@ -38,6 +38,10 @@ export class VideoLiveModel extends Model<VideoLiveModel> { | |||
38 | @Column | 38 | @Column |
39 | saveReplay: boolean | 39 | saveReplay: boolean |
40 | 40 | ||
41 | @AllowNull(false) | ||
42 | @Column | ||
43 | permanentLive: boolean | ||
44 | |||
41 | @CreatedAt | 45 | @CreatedAt |
42 | createdAt: Date | 46 | createdAt: Date |
43 | 47 | ||
@@ -99,6 +103,7 @@ export class VideoLiveModel extends Model<VideoLiveModel> { | |||
99 | : null, | 103 | : null, |
100 | 104 | ||
101 | streamKey: this.streamKey, | 105 | streamKey: this.streamKey, |
106 | permanentLive: this.permanentLive, | ||
102 | saveReplay: this.saveReplay | 107 | saveReplay: this.saveReplay |
103 | } | 108 | } |
104 | } | 109 | } |
diff --git a/server/tests/api/check-params/live.ts b/server/tests/api/check-params/live.ts index 2b2d1beec..055f2f295 100644 --- a/server/tests/api/check-params/live.ts +++ b/server/tests/api/check-params/live.ts | |||
@@ -84,7 +84,8 @@ describe('Test video lives API validator', function () { | |||
84 | tags: [ 'tag1', 'tag2' ], | 84 | tags: [ 'tag1', 'tag2' ], |
85 | privacy: VideoPrivacy.PUBLIC, | 85 | privacy: VideoPrivacy.PUBLIC, |
86 | channelId, | 86 | channelId, |
87 | saveReplay: false | 87 | saveReplay: false, |
88 | permanentLive: false | ||
88 | } | 89 | } |
89 | }) | 90 | }) |
90 | 91 | ||
@@ -211,6 +212,12 @@ describe('Test video lives API validator', function () { | |||
211 | await makeUploadRequest({ url: server.url, path, token: server.accessToken, fields, attaches }) | 212 | await makeUploadRequest({ url: server.url, path, token: server.accessToken, fields, attaches }) |
212 | }) | 213 | }) |
213 | 214 | ||
215 | it('Should fail with save replay and permanent live set to true', async function () { | ||
216 | const fields = immutableAssign(baseCorrectParams, { saveReplay: true, permanentLive: true }) | ||
217 | |||
218 | await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) | ||
219 | }) | ||
220 | |||
214 | it('Should succeed with the correct parameters', async function () { | 221 | it('Should succeed with the correct parameters', async function () { |
215 | this.timeout(30000) | 222 | this.timeout(30000) |
216 | 223 | ||
@@ -372,6 +379,12 @@ describe('Test video lives API validator', function () { | |||
372 | await updateLive(server.url, server.accessToken, videoIdNotLive, {}, 404) | 379 | await updateLive(server.url, server.accessToken, videoIdNotLive, {}, 404) |
373 | }) | 380 | }) |
374 | 381 | ||
382 | it('Should fail with save replay and permanent live set to true', async function () { | ||
383 | const fields = { saveReplay: true, permanentLive: true } | ||
384 | |||
385 | await updateLive(server.url, server.accessToken, videoId, fields, 400) | ||
386 | }) | ||
387 | |||
375 | it('Should succeed with the correct params', async function () { | 388 | it('Should succeed with the correct params', async function () { |
376 | await updateLive(server.url, server.accessToken, videoId, { saveReplay: false }) | 389 | await updateLive(server.url, server.accessToken, videoId, { saveReplay: false }) |
377 | }) | 390 | }) |
diff --git a/server/tests/api/live/index.ts b/server/tests/api/live/index.ts index 32219969a..c733f564e 100644 --- a/server/tests/api/live/index.ts +++ b/server/tests/api/live/index.ts | |||
@@ -1,3 +1,4 @@ | |||
1 | import './live-constraints' | 1 | import './live-constraints' |
2 | import './live-permanent' | ||
2 | import './live-save-replay' | 3 | import './live-save-replay' |
3 | import './live' | 4 | import './live' |
diff --git a/server/tests/api/live/live-permanent.ts b/server/tests/api/live/live-permanent.ts new file mode 100644 index 000000000..a64588ed7 --- /dev/null +++ b/server/tests/api/live/live-permanent.ts | |||
@@ -0,0 +1,190 @@ | |||
1 | /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ | ||
2 | |||
3 | import 'mocha' | ||
4 | import * as chai from 'chai' | ||
5 | import { LiveVideoCreate, VideoDetails, VideoPrivacy, VideoState } from '@shared/models' | ||
6 | import { | ||
7 | checkLiveCleanup, | ||
8 | cleanupTests, | ||
9 | createLive, | ||
10 | doubleFollow, | ||
11 | flushAndRunMultipleServers, | ||
12 | getLive, | ||
13 | getPlaylistsCount, | ||
14 | getVideo, | ||
15 | sendRTMPStreamInVideo, | ||
16 | ServerInfo, | ||
17 | setAccessTokensToServers, | ||
18 | setDefaultVideoChannel, | ||
19 | stopFfmpeg, | ||
20 | updateCustomSubConfig, | ||
21 | updateLive, | ||
22 | wait, | ||
23 | waitJobs, | ||
24 | waitUntilLiveStarts | ||
25 | } from '../../../../shared/extra-utils' | ||
26 | |||
27 | const expect = chai.expect | ||
28 | |||
29 | describe('Permenant live', function () { | ||
30 | let servers: ServerInfo[] = [] | ||
31 | let videoUUID: string | ||
32 | |||
33 | async function createLiveWrapper (permanentLive: boolean) { | ||
34 | const attributes: LiveVideoCreate = { | ||
35 | channelId: servers[0].videoChannel.id, | ||
36 | privacy: VideoPrivacy.PUBLIC, | ||
37 | name: 'my super live', | ||
38 | saveReplay: false, | ||
39 | permanentLive | ||
40 | } | ||
41 | |||
42 | const res = await createLive(servers[0].url, servers[0].accessToken, attributes) | ||
43 | return res.body.video.uuid | ||
44 | } | ||
45 | |||
46 | async function checkVideoState (videoId: string, state: VideoState) { | ||
47 | for (const server of servers) { | ||
48 | const res = await getVideo(server.url, videoId) | ||
49 | expect((res.body as VideoDetails).state.id).to.equal(state) | ||
50 | } | ||
51 | } | ||
52 | |||
53 | before(async function () { | ||
54 | this.timeout(120000) | ||
55 | |||
56 | servers = await flushAndRunMultipleServers(2) | ||
57 | |||
58 | // Get the access tokens | ||
59 | await setAccessTokensToServers(servers) | ||
60 | await setDefaultVideoChannel(servers) | ||
61 | |||
62 | // Server 1 and server 2 follow each other | ||
63 | await doubleFollow(servers[0], servers[1]) | ||
64 | |||
65 | await updateCustomSubConfig(servers[0].url, servers[0].accessToken, { | ||
66 | live: { | ||
67 | enabled: true, | ||
68 | allowReplay: true, | ||
69 | maxDuration: null, | ||
70 | transcoding: { | ||
71 | enabled: true, | ||
72 | resolutions: { | ||
73 | '240p': true, | ||
74 | '360p': true, | ||
75 | '480p': true, | ||
76 | '720p': true, | ||
77 | '1080p': true, | ||
78 | '2160p': true | ||
79 | } | ||
80 | } | ||
81 | } | ||
82 | }) | ||
83 | }) | ||
84 | |||
85 | it('Should create a non permanent live and update it to be a permanent live', async function () { | ||
86 | this.timeout(20000) | ||
87 | |||
88 | const videoUUID = await createLiveWrapper(false) | ||
89 | |||
90 | { | ||
91 | const res = await getLive(servers[0].url, servers[0].accessToken, videoUUID) | ||
92 | expect(res.body.permanentLive).to.be.false | ||
93 | } | ||
94 | |||
95 | await updateLive(servers[0].url, servers[0].accessToken, videoUUID, { permanentLive: true }) | ||
96 | |||
97 | { | ||
98 | const res = await getLive(servers[0].url, servers[0].accessToken, videoUUID) | ||
99 | expect(res.body.permanentLive).to.be.true | ||
100 | } | ||
101 | }) | ||
102 | |||
103 | it('Should create a permanent live', async function () { | ||
104 | this.timeout(20000) | ||
105 | |||
106 | videoUUID = await createLiveWrapper(true) | ||
107 | |||
108 | const res = await getLive(servers[0].url, servers[0].accessToken, videoUUID) | ||
109 | expect(res.body.permanentLive).to.be.true | ||
110 | |||
111 | await waitJobs(servers) | ||
112 | }) | ||
113 | |||
114 | it('Should stream into this permanent live', async function () { | ||
115 | this.timeout(40000) | ||
116 | |||
117 | const command = await sendRTMPStreamInVideo(servers[0].url, servers[0].accessToken, videoUUID) | ||
118 | |||
119 | for (const server of servers) { | ||
120 | await waitUntilLiveStarts(server.url, server.accessToken, videoUUID) | ||
121 | } | ||
122 | |||
123 | await checkVideoState(videoUUID, VideoState.PUBLISHED) | ||
124 | |||
125 | await stopFfmpeg(command) | ||
126 | |||
127 | await waitJobs(servers) | ||
128 | }) | ||
129 | |||
130 | it('Should not have cleaned up this live', async function () { | ||
131 | this.timeout(40000) | ||
132 | |||
133 | await wait(5000) | ||
134 | await waitJobs(servers) | ||
135 | |||
136 | for (const server of servers) { | ||
137 | const res = await getVideo(server.url, videoUUID) | ||
138 | |||
139 | const videoDetails = res.body as VideoDetails | ||
140 | expect(videoDetails.streamingPlaylists).to.have.lengthOf(1) | ||
141 | } | ||
142 | }) | ||
143 | |||
144 | it('Should have set this live to waiting for live state', async function () { | ||
145 | this.timeout(20000) | ||
146 | |||
147 | await checkVideoState(videoUUID, VideoState.WAITING_FOR_LIVE) | ||
148 | }) | ||
149 | |||
150 | it('Should be able to stream again in the permanent live', async function () { | ||
151 | this.timeout(20000) | ||
152 | |||
153 | await updateCustomSubConfig(servers[0].url, servers[0].accessToken, { | ||
154 | live: { | ||
155 | enabled: true, | ||
156 | allowReplay: true, | ||
157 | maxDuration: null, | ||
158 | transcoding: { | ||
159 | enabled: true, | ||
160 | resolutions: { | ||
161 | '240p': false, | ||
162 | '360p': false, | ||
163 | '480p': false, | ||
164 | '720p': false, | ||
165 | '1080p': false, | ||
166 | '2160p': false | ||
167 | } | ||
168 | } | ||
169 | } | ||
170 | }) | ||
171 | |||
172 | const command = await sendRTMPStreamInVideo(servers[0].url, servers[0].accessToken, videoUUID) | ||
173 | |||
174 | for (const server of servers) { | ||
175 | await waitUntilLiveStarts(server.url, server.accessToken, videoUUID) | ||
176 | } | ||
177 | |||
178 | await checkVideoState(videoUUID, VideoState.PUBLISHED) | ||
179 | |||
180 | const count = await getPlaylistsCount(servers[0], videoUUID) | ||
181 | // master playlist and 720p playlist | ||
182 | expect(count).to.equal(2) | ||
183 | |||
184 | await stopFfmpeg(command) | ||
185 | }) | ||
186 | |||
187 | after(async function () { | ||
188 | await cleanupTests(servers) | ||
189 | }) | ||
190 | }) | ||
diff --git a/shared/extra-utils/videos/live.ts b/shared/extra-utils/videos/live.ts index 346134969..522beb8bc 100644 --- a/shared/extra-utils/videos/live.ts +++ b/shared/extra-utils/videos/live.ts | |||
@@ -177,10 +177,20 @@ async function checkLiveCleanup (server: ServerInfo, videoUUID: string, resoluti | |||
177 | expect(files).to.contain('segments-sha256.json') | 177 | expect(files).to.contain('segments-sha256.json') |
178 | } | 178 | } |
179 | 179 | ||
180 | async function getPlaylistsCount (server: ServerInfo, videoUUID: string) { | ||
181 | const basePath = buildServerDirectory(server, 'streaming-playlists') | ||
182 | const hlsPath = join(basePath, 'hls', videoUUID) | ||
183 | |||
184 | const files = await readdir(hlsPath) | ||
185 | |||
186 | return files.filter(f => f.endsWith('.m3u8')).length | ||
187 | } | ||
188 | |||
180 | // --------------------------------------------------------------------------- | 189 | // --------------------------------------------------------------------------- |
181 | 190 | ||
182 | export { | 191 | export { |
183 | getLive, | 192 | getLive, |
193 | getPlaylistsCount, | ||
184 | waitUntilLivePublished, | 194 | waitUntilLivePublished, |
185 | updateLive, | 195 | updateLive, |
186 | waitUntilLiveStarts, | 196 | waitUntilLiveStarts, |
diff --git a/shared/models/activitypub/objects/video-torrent-object.ts b/shared/models/activitypub/objects/video-torrent-object.ts index d99d273c3..6d18e93d5 100644 --- a/shared/models/activitypub/objects/video-torrent-object.ts +++ b/shared/models/activitypub/objects/video-torrent-object.ts | |||
@@ -24,6 +24,7 @@ export interface VideoObject { | |||
24 | 24 | ||
25 | isLiveBroadcast: boolean | 25 | isLiveBroadcast: boolean |
26 | liveSaveReplay: boolean | 26 | liveSaveReplay: boolean |
27 | permanentLive: boolean | ||
27 | 28 | ||
28 | commentsEnabled: boolean | 29 | commentsEnabled: boolean |
29 | downloadEnabled: boolean | 30 | downloadEnabled: boolean |
diff --git a/shared/models/videos/live/live-video-create.model.ts b/shared/models/videos/live/live-video-create.model.ts index 1ef4b70dd..caa7acc17 100644 --- a/shared/models/videos/live/live-video-create.model.ts +++ b/shared/models/videos/live/live-video-create.model.ts | |||
@@ -2,4 +2,5 @@ import { VideoCreate } from '../video-create.model' | |||
2 | 2 | ||
3 | export interface LiveVideoCreate extends VideoCreate { | 3 | export interface LiveVideoCreate extends VideoCreate { |
4 | saveReplay?: boolean | 4 | saveReplay?: boolean |
5 | permanentLive?: boolean | ||
5 | } | 6 | } |
diff --git a/shared/models/videos/live/live-video-update.model.ts b/shared/models/videos/live/live-video-update.model.ts index 0f0f67d06..a39c44797 100644 --- a/shared/models/videos/live/live-video-update.model.ts +++ b/shared/models/videos/live/live-video-update.model.ts | |||
@@ -1,3 +1,4 @@ | |||
1 | export interface LiveVideoUpdate { | 1 | export interface LiveVideoUpdate { |
2 | permanentLive?: boolean | ||
2 | saveReplay?: boolean | 3 | saveReplay?: boolean |
3 | } | 4 | } |
diff --git a/shared/models/videos/live/live-video.model.ts b/shared/models/videos/live/live-video.model.ts index a3f8275e3..d6e9a50d1 100644 --- a/shared/models/videos/live/live-video.model.ts +++ b/shared/models/videos/live/live-video.model.ts | |||
@@ -2,4 +2,5 @@ export interface LiveVideo { | |||
2 | rtmpUrl: string | 2 | rtmpUrl: string |
3 | streamKey: string | 3 | streamKey: string |
4 | saveReplay: boolean | 4 | saveReplay: boolean |
5 | permanentLive: boolean | ||
5 | } | 6 | } |
diff --git a/support/doc/api/openapi.yaml b/support/doc/api/openapi.yaml index 4f9bca729..2d6b4df27 100644 --- a/support/doc/api/openapi.yaml +++ b/support/doc/api/openapi.yaml | |||
@@ -72,7 +72,7 @@ tags: | |||
72 | new videos, and how to keep up to date with their latest publications! | 72 | new videos, and how to keep up to date with their latest publications! |
73 | - name: My History | 73 | - name: My History |
74 | description: > | 74 | description: > |
75 | Operations related to your watch history. | 75 | Operations related to your watch history. |
76 | - name: My Notifications | 76 | - name: My Notifications |
77 | description: > | 77 | description: > |
78 | Notifications following new videos, follows or reports. They allow you | 78 | Notifications following new videos, follows or reports. They allow you |
@@ -1567,6 +1567,9 @@ paths: | |||
1567 | type: integer | 1567 | type: integer |
1568 | saveReplay: | 1568 | saveReplay: |
1569 | type: boolean | 1569 | type: boolean |
1570 | permanentLive: | ||
1571 | description: User can stream multiple times in a permanent live | ||
1572 | type: boolean | ||
1570 | thumbnailfile: | 1573 | thumbnailfile: |
1571 | description: Live video/replay thumbnail file | 1574 | description: Live video/replay thumbnail file |
1572 | type: string | 1575 | type: string |
@@ -5614,6 +5617,9 @@ components: | |||
5614 | properties: | 5617 | properties: |
5615 | saveReplay: | 5618 | saveReplay: |
5616 | type: boolean | 5619 | type: boolean |
5620 | permanentLive: | ||
5621 | description: User can stream multiple times in a permanent live | ||
5622 | type: boolean | ||
5617 | 5623 | ||
5618 | LiveVideoResponse: | 5624 | LiveVideoResponse: |
5619 | properties: | 5625 | properties: |
@@ -5624,6 +5630,9 @@ components: | |||
5624 | description: RTMP stream key to use to stream into this live video | 5630 | description: RTMP stream key to use to stream into this live video |
5625 | saveReplay: | 5631 | saveReplay: |
5626 | type: boolean | 5632 | type: boolean |
5633 | permanentLive: | ||
5634 | description: User can stream multiple times in a permanent live | ||
5635 | type: boolean | ||
5627 | 5636 | ||
5628 | callbacks: | 5637 | callbacks: |
5629 | searchIndex: | 5638 | searchIndex: |