diff options
author | Chocobozzz <me@florianbigard.com> | 2020-12-03 14:10:54 +0100 |
---|---|---|
committer | Chocobozzz <me@florianbigard.com> | 2020-12-03 15:21:16 +0100 |
commit | bb4ba6d94c5051fdd665ebe63fffcc105778b8be (patch) | |
tree | d39302608c53e31395683bb5dd551eac6ced89f8 /server | |
parent | 19b7ebfaa822b12f6da25ad2ba10398b3ef25ec6 (diff) | |
download | PeerTube-bb4ba6d94c5051fdd665ebe63fffcc105778b8be.tar.gz PeerTube-bb4ba6d94c5051fdd665ebe63fffcc105778b8be.tar.zst PeerTube-bb4ba6d94c5051fdd665ebe63fffcc105778b8be.zip |
Add permanent live support
Diffstat (limited to 'server')
-rw-r--r-- | server/controllers/api/videos/index.ts | 2 | ||||
-rw-r--r-- | server/controllers/api/videos/live.ts | 3 | ||||
-rw-r--r-- | server/helpers/activitypub.ts | 10 | ||||
-rw-r--r-- | server/helpers/custom-validators/activitypub/videos.ts | 3 | ||||
-rw-r--r-- | server/initializers/constants.ts | 2 | ||||
-rw-r--r-- | server/initializers/migrations/0570-permanent-live.ts | 27 | ||||
-rw-r--r-- | server/lib/activitypub/videos.ts | 2 | ||||
-rw-r--r-- | server/lib/job-queue/handlers/video-live-ending.ts | 27 | ||||
-rw-r--r-- | server/lib/live-manager.ts | 40 | ||||
-rw-r--r-- | server/middlewares/validators/videos/video-live.ts | 19 | ||||
-rw-r--r-- | server/models/video/video-format-utils.ts | 4 | ||||
-rw-r--r-- | server/models/video/video-live.ts | 5 | ||||
-rw-r--r-- | server/tests/api/check-params/live.ts | 15 | ||||
-rw-r--r-- | server/tests/api/live/index.ts | 1 | ||||
-rw-r--r-- | server/tests/api/live/live-permanent.ts | 190 |
15 files changed, 326 insertions, 24 deletions
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 | }) | ||