aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--client/src/app/+videos/+video-edit/shared/video-edit.component.html12
-rw-r--r--client/src/app/+videos/+video-edit/shared/video-edit.component.ts24
-rw-r--r--client/src/app/+videos/+video-edit/video-add-components/video-go-live.component.ts3
-rw-r--r--client/src/app/+videos/+video-edit/video-update.component.ts6
-rw-r--r--server/controllers/api/videos/index.ts2
-rw-r--r--server/controllers/api/videos/live.ts3
-rw-r--r--server/helpers/activitypub.ts10
-rw-r--r--server/helpers/custom-validators/activitypub/videos.ts3
-rw-r--r--server/initializers/constants.ts2
-rw-r--r--server/initializers/migrations/0570-permanent-live.ts27
-rw-r--r--server/lib/activitypub/videos.ts2
-rw-r--r--server/lib/job-queue/handlers/video-live-ending.ts27
-rw-r--r--server/lib/live-manager.ts40
-rw-r--r--server/middlewares/validators/videos/video-live.ts19
-rw-r--r--server/models/video/video-format-utils.ts4
-rw-r--r--server/models/video/video-live.ts5
-rw-r--r--server/tests/api/check-params/live.ts15
-rw-r--r--server/tests/api/live/index.ts1
-rw-r--r--server/tests/api/live/live-permanent.ts190
-rw-r--r--shared/extra-utils/videos/live.ts10
-rw-r--r--shared/models/activitypub/objects/video-torrent-object.ts1
-rw-r--r--shared/models/videos/live/live-video-create.model.ts1
-rw-r--r--shared/models/videos/live/live-video-update.model.ts1
-rw-r--r--shared/models/videos/live/live-video.model.ts1
-rw-r--r--support/doc/api/openapi.yaml11
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
176async function addVideo (req: express.Request, res: express.Response) { 176async 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
27const LAST_MIGRATION_VERSION = 565 27const 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 @@
1import * as Sequelize from 'sequelize'
2
3async function up (utils: {
4 transaction: Sequelize.Transaction
5 queryInterface: Sequelize.QueryInterface
6 sequelize: Sequelize.Sequelize
7 db: any
8}): Promise<void> {
9 {
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
20function down (options) {
21 throw new Error('Not implemented.')
22}
23
24export {
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 @@
1import * as Bull from 'bull' 1import * as Bull from 'bull'
2import { copy, readdir, remove } from 'fs-extra' 2import { copy, pathExists, readdir, remove } from 'fs-extra'
3import { join } from 'path' 3import { join } from 'path'
4import { getDurationFromVideoFile, getVideoFileResolution } from '@server/helpers/ffprobe-utils' 4import { getDurationFromVideoFile, getVideoFileResolution } from '@server/helpers/ffprobe-utils'
5import { VIDEO_LIVE } from '@server/initializers/constants' 5import { VIDEO_LIVE } from '@server/initializers/constants'
@@ -14,6 +14,7 @@ import { VideoStreamingPlaylistModel } from '@server/models/video/video-streamin
14import { MStreamingPlaylist, MVideo, MVideoLive } from '@server/types/models' 14import { MStreamingPlaylist, MVideo, MVideoLive } from '@server/types/models'
15import { ThumbnailType, VideoLiveEndingPayload, VideoState } from '@shared/models' 15import { ThumbnailType, VideoLiveEndingPayload, VideoState } from '@shared/models'
16import { logger } from '../../../helpers/logger' 16import { logger } from '../../../helpers/logger'
17import { LiveManager } from '@server/lib/live-manager'
17 18
18async function processVideoLiveEnding (job: Bull.Job) { 19async 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
49async 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
48export { 59export {
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
134async 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
143async function cleanupLiveFiles (hlsDirectory: string) { 146async 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'
19import { federateVideoIfNeeded } from './activitypub/videos' 19import { federateVideoIfNeeded } from './activitypub/videos'
20import { buildSha256Segment } from './hls' 20import { buildSha256Segment } from './hls'
21import { JobQueue } from './job-queue' 21import { JobQueue } from './job-queue'
22import { cleanupLive } from './job-queue/handlers/video-live-ending'
22import { PeerTubeSocket } from './peertube-socket' 23import { PeerTubeSocket } from './peertube-socket'
23import { isAbleToUploadVideo } from './user' 24import { isAbleToUploadVideo } from './user'
24import { getHLSDirectory } from './video-paths' 25import { 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 @@
1import './live-constraints' 1import './live-constraints'
2import './live-permanent'
2import './live-save-replay' 3import './live-save-replay'
3import './live' 4import './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
3import 'mocha'
4import * as chai from 'chai'
5import { LiveVideoCreate, VideoDetails, VideoPrivacy, VideoState } from '@shared/models'
6import {
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
27const expect = chai.expect
28
29describe('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
180async 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
182export { 191export {
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
3export interface LiveVideoCreate extends VideoCreate { 3export 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 @@
1export interface LiveVideoUpdate { 1export 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: