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 @@
⚠️ Never share your stream key with anyone.
+
+ + + This is a permanent live + + + + 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 + + +
+
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 { schedulePublicationAt: VIDEO_SCHEDULE_PUBLICATION_AT_VALIDATOR, originallyPublishedAt: VIDEO_ORIGINALLY_PUBLISHED_AT_VALIDATOR, liveStreamKey: null, + permanentLive: null, saveReplay: null } @@ -158,6 +159,7 @@ export class VideoEditComponent implements OnInit, OnDestroy { this.trackChannelChange() this.trackPrivacyChange() + this.trackLivePermanentFieldChange() } ngOnInit () { @@ -254,6 +256,10 @@ export class VideoEditComponent implements OnInit, OnDestroy { return this.serverConfig.live.allowReplay } + isPermanentLiveEnabled () { + return this.form.value['permanentLive'] === true + } + private sortVideoCaptions () { this.videoCaptions.sort((v1, v2) => { if (v1.language.label < v2.language.label) return -1 @@ -362,6 +368,24 @@ export class VideoEditComponent implements OnInit, OnDestroy { ) } + private trackLivePermanentFieldChange () { + // We will update the "support" field depending on the channel + this.form.controls['permanentLive'] + .valueChanges + .subscribe( + permanentLive => { + const saveReplayControl = this.form.controls['saveReplay'] + + if (permanentLive === true) { + saveReplayControl.setValue(false) + saveReplayControl.disable() + } else { + saveReplayControl.enable() + } + } + ) + } + private updateSupportField (support: string) { return this.form.patchValue({ support: support || '' }) } 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 video.uuid = this.videoUUID const liveVideoUpdate: LiveVideoUpdate = { - saveReplay: this.form.value.saveReplay + saveReplay: this.form.value.saveReplay, + permanentLive: this.form.value.permanentLive } // 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 { if (this.liveVideo) { this.form.patchValue({ - saveReplay: this.liveVideo.saveReplay + saveReplay: this.liveVideo.saveReplay, + permanentLive: this.liveVideo.permanentLive }) } }) @@ -114,7 +115,8 @@ export class VideoUpdateComponent extends FormReactive implements OnInit { this.video.patch(this.form.value) const liveVideoUpdate: LiveVideoUpdate = { - saveReplay: this.form.value.saveReplay + saveReplay: this.form.value.saveReplay, + permanentLive: this.form.value.permanentLive } 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) { } async function addVideo (req: express.Request, res: express.Response) { - // Transferring the video could be long + // Uploading the video could be long // Set timeout to 10 minutes, as Express's default is 2 minutes req.setTimeout(1000 * 60 * 10, () => { 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) { const video = res.locals.videoAll const videoLive = res.locals.videoLive + videoLive.saveReplay = body.saveReplay || false + videoLive.permanentLive = body.permanentLive || false video.VideoLive = await videoLive.save() @@ -90,6 +92,7 @@ async function addLiveVideo (req: express.Request, res: express.Response) { const videoLive = new VideoLiveModel() videoLive.saveReplay = videoInfo.saveReplay || false + videoLive.permanentLive = videoInfo.permanentLive || false videoLive.streamKey = uuidv4() 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) { sensitive: 'as:sensitive', language: 'sc:inLanguage', + isLiveBroadcast: 'sc:isLiveBroadcast', + liveSaveReplay: { + '@type': 'sc:Boolean', + '@id': 'pt:liveSaveReplay' + }, + permanentLive: { + '@type': 'sc:Boolean', + '@id': 'pt:permanentLive' + }, + Infohash: 'pt:Infohash', Playlist: 'pt:Playlist', 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) { if (!isBooleanValid(video.commentsEnabled)) video.commentsEnabled = false if (!isBooleanValid(video.isLiveBroadcast)) video.isLiveBroadcast = false if (!isBooleanValid(video.liveSaveReplay)) video.liveSaveReplay = false + if (!isBooleanValid(video.permanentLive)) video.permanentLive = false return isActivityPubUrlValid(video.id) && isVideoNameValid(video.name) && @@ -74,8 +75,6 @@ function sanitizeAndCheckVideoTorrentObject (video: any) { (!video.language || isRemoteStringIdentifierValid(video.language)) && isVideoViewsValid(video.views) && isBooleanValid(video.sensitive) && - isBooleanValid(video.commentsEnabled) && - isBooleanValid(video.downloadEnabled) && isDateValid(video.published) && isDateValid(video.updated) && (!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' // --------------------------------------------------------------------------- -const LAST_MIGRATION_VERSION = 565 +const LAST_MIGRATION_VERSION = 570 // --------------------------------------------------------------------------- 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 @@ +import * as Sequelize from 'sequelize' + +async function up (utils: { + transaction: Sequelize.Transaction + queryInterface: Sequelize.QueryInterface + sequelize: Sequelize.Sequelize + db: any +}): Promise { + { + const data = { + type: Sequelize.BOOLEAN, + defaultValue: false, + allowNull: false + } + + await utils.queryInterface.addColumn('videoLive', 'permanentLive', data) + } +} + +function down (options) { + throw new Error('Not implemented.') +} + +export { + up, + down +} 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: { if (video.isLive) { const [ videoLive ] = await VideoLiveModel.upsert({ saveReplay: videoObject.liveSaveReplay, + permanentLive: videoObject.permanentLive, videoId: video.id }, { transaction: t, returning: true }) @@ -631,6 +632,7 @@ async function createVideo (videoObject: VideoObject, channel: MChannelAccountLi const videoLive = new VideoLiveModel({ streamKey: null, saveReplay: videoObject.liveSaveReplay, + permanentLive: videoObject.permanentLive, videoId: videoCreated.id }) 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 @@ import * as Bull from 'bull' -import { copy, readdir, remove } from 'fs-extra' +import { copy, pathExists, readdir, remove } from 'fs-extra' import { join } from 'path' import { getDurationFromVideoFile, getVideoFileResolution } from '@server/helpers/ffprobe-utils' import { VIDEO_LIVE } from '@server/initializers/constants' @@ -14,6 +14,7 @@ import { VideoStreamingPlaylistModel } from '@server/models/video/video-streamin import { MStreamingPlaylist, MVideo, MVideoLive } from '@server/types/models' import { ThumbnailType, VideoLiveEndingPayload, VideoState } from '@shared/models' import { logger } from '../../../helpers/logger' +import { LiveManager } from '@server/lib/live-manager' async function processVideoLiveEnding (job: Bull.Job) { const payload = job.data as VideoLiveEndingPayload @@ -36,6 +37,8 @@ async function processVideoLiveEnding (job: Bull.Job) { return } + LiveManager.Instance.cleanupShaSegments(video.uuid) + if (live.saveReplay !== true) { return cleanupLive(video, streamingPlaylist) } @@ -43,10 +46,19 @@ async function processVideoLiveEnding (job: Bull.Job) { return saveLive(video, live) } +async function cleanupLive (video: MVideo, streamingPlaylist: MStreamingPlaylist) { + const hlsDirectory = getHLSDirectory(video) + + await remove(hlsDirectory) + + await streamingPlaylist.destroy() +} + // --------------------------------------------------------------------------- export { - processVideoLiveEnding + processVideoLiveEnding, + cleanupLive } // --------------------------------------------------------------------------- @@ -131,16 +143,9 @@ async function saveLive (video: MVideo, live: MVideoLive) { await publishAndFederateIfNeeded(videoWithFiles, true) } -async function cleanupLive (video: MVideo, streamingPlaylist: MStreamingPlaylist) { - const hlsDirectory = getHLSDirectory(video) - - await remove(hlsDirectory) - - streamingPlaylist.destroy() - .catch(err => logger.error('Cannot remove live streaming playlist.', { err })) -} - async function cleanupLiveFiles (hlsDirectory: string) { + if (!await pathExists(hlsDirectory)) return + const files = await readdir(hlsDirectory) 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' import { federateVideoIfNeeded } from './activitypub/videos' import { buildSha256Segment } from './hls' import { JobQueue } from './job-queue' +import { cleanupLive } from './job-queue/handlers/video-live-ending' import { PeerTubeSocket } from './peertube-socket' import { isAbleToUploadVideo } from './user' import { getHLSDirectory } from './video-paths' @@ -153,6 +154,10 @@ class LiveManager { watchers.push(new Date().getTime()) } + cleanupShaSegments (videoUUID: string) { + this.segmentsSha256.delete(videoUUID) + } + private getContext () { return context } @@ -184,6 +189,14 @@ class LiveManager { return this.abortSession(sessionId) } + // Cleanup old potential live files (could happen with a permanent live) + this.cleanupShaSegments(video.uuid) + + const oldStreamingPlaylist = await VideoStreamingPlaylistModel.loadHLSPlaylistByVideo(video.id) + if (oldStreamingPlaylist) { + await cleanupLive(video, oldStreamingPlaylist) + } + this.videoSessions.set(video.id, sessionId) const playlistUrl = WEBSERVER.URL + VideoStreamingPlaylistModel.getHlsMasterPlaylistStaticPath(video.uuid) @@ -372,7 +385,13 @@ class LiveManager { logger.info('RTMP transmuxing for video %s ended. Scheduling cleanup', rtmpUrl) this.transSessions.delete(sessionId) + this.watchersPerVideo.delete(videoLive.videoId) + this.videoSessions.delete(videoLive.videoId) + + const newLivesPerUser = this.livesPerUser.get(user.id) + .filter(o => o.liveId !== videoLive.id) + this.livesPerUser.set(user.id, newLivesPerUser) setTimeout(() => { // Wait latest segments generation, and close watchers @@ -412,14 +431,21 @@ class LiveManager { const fullVideo = await VideoModel.loadAndPopulateAccountAndServerAndTags(videoId) if (!fullVideo) return - JobQueue.Instance.createJob({ - type: 'video-live-ending', - payload: { - videoId: fullVideo.id - } - }, { delay: cleanupNow ? 0 : VIDEO_LIVE.CLEANUP_DELAY }) + const live = await VideoLiveModel.loadByVideoId(videoId) + + if (!live.permanentLive) { + JobQueue.Instance.createJob({ + type: 'video-live-ending', + payload: { + videoId: fullVideo.id + } + }, { delay: cleanupNow ? 0 : VIDEO_LIVE.CLEANUP_DELAY }) + + fullVideo.state = VideoState.LIVE_ENDED + } else { + fullVideo.state = VideoState.WAITING_FOR_LIVE + } - fullVideo.state = VideoState.LIVE_ENDED await fullVideo.save() 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([ .customSanitizer(toBooleanOrNull) .custom(isBooleanValid).withMessage('Should have a valid saveReplay attribute'), + body('permanentLive') + .optional() + .customSanitizer(toBooleanOrNull) + .custom(isBooleanValid).withMessage('Should have a valid permanentLive attribute'), + async (req: express.Request, res: express.Response, next: express.NextFunction) => { logger.debug('Checking videoLiveAddValidator parameters', { parameters: req.body }) + if (areValidationErrors(req, res)) return cleanUpReqFiles(req) + if (CONFIG.LIVE.ENABLED !== true) { cleanUpReqFiles(req) @@ -66,7 +73,12 @@ const videoLiveAddValidator = getCommonVideoEditAttributes().concat([ .json({ error: 'Saving live replay is not allowed instance' }) } - if (areValidationErrors(req, res)) return cleanUpReqFiles(req) + if (req.body.permanentLive && req.body.saveReplay) { + cleanUpReqFiles(req) + + return res.status(400) + .json({ error: 'Cannot set this live as permanent while saving its replay' }) + } const user = res.locals.oauth.token.User if (!await doesVideoChannelOfAccountExist(req.body.channelId, user, res)) return cleanUpReqFiles(req) @@ -116,6 +128,11 @@ const videoLiveUpdateValidator = [ if (areValidationErrors(req, res)) return + if (req.body.permanentLive && req.body.saveReplay) { + return res.status(400) + .json({ error: 'Cannot set this live as permanent while saving its replay' }) + } + if (CONFIG.LIVE.ALLOW_REPLAY !== true && req.body.saveReplay === true) { return res.status(403) .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 { ? video.VideoLive.saveReplay : null, + permanentLive: video.isLive + ? video.VideoLive.permanentLive + : null, + state: video.state, commentsEnabled: video.commentsEnabled, 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 { @Column saveReplay: boolean + @AllowNull(false) + @Column + permanentLive: boolean + @CreatedAt createdAt: Date @@ -99,6 +103,7 @@ export class VideoLiveModel extends Model { : null, streamKey: this.streamKey, + permanentLive: this.permanentLive, saveReplay: this.saveReplay } } 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 () { tags: [ 'tag1', 'tag2' ], privacy: VideoPrivacy.PUBLIC, channelId, - saveReplay: false + saveReplay: false, + permanentLive: false } }) @@ -211,6 +212,12 @@ describe('Test video lives API validator', function () { await makeUploadRequest({ url: server.url, path, token: server.accessToken, fields, attaches }) }) + it('Should fail with save replay and permanent live set to true', async function () { + const fields = immutableAssign(baseCorrectParams, { saveReplay: true, permanentLive: true }) + + await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) + }) + it('Should succeed with the correct parameters', async function () { this.timeout(30000) @@ -372,6 +379,12 @@ describe('Test video lives API validator', function () { await updateLive(server.url, server.accessToken, videoIdNotLive, {}, 404) }) + it('Should fail with save replay and permanent live set to true', async function () { + const fields = { saveReplay: true, permanentLive: true } + + await updateLive(server.url, server.accessToken, videoId, fields, 400) + }) + it('Should succeed with the correct params', async function () { await updateLive(server.url, server.accessToken, videoId, { saveReplay: false }) }) 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 @@ import './live-constraints' +import './live-permanent' import './live-save-replay' 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 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import 'mocha' +import * as chai from 'chai' +import { LiveVideoCreate, VideoDetails, VideoPrivacy, VideoState } from '@shared/models' +import { + checkLiveCleanup, + cleanupTests, + createLive, + doubleFollow, + flushAndRunMultipleServers, + getLive, + getPlaylistsCount, + getVideo, + sendRTMPStreamInVideo, + ServerInfo, + setAccessTokensToServers, + setDefaultVideoChannel, + stopFfmpeg, + updateCustomSubConfig, + updateLive, + wait, + waitJobs, + waitUntilLiveStarts +} from '../../../../shared/extra-utils' + +const expect = chai.expect + +describe('Permenant live', function () { + let servers: ServerInfo[] = [] + let videoUUID: string + + async function createLiveWrapper (permanentLive: boolean) { + const attributes: LiveVideoCreate = { + channelId: servers[0].videoChannel.id, + privacy: VideoPrivacy.PUBLIC, + name: 'my super live', + saveReplay: false, + permanentLive + } + + const res = await createLive(servers[0].url, servers[0].accessToken, attributes) + return res.body.video.uuid + } + + async function checkVideoState (videoId: string, state: VideoState) { + for (const server of servers) { + const res = await getVideo(server.url, videoId) + expect((res.body as VideoDetails).state.id).to.equal(state) + } + } + + before(async function () { + this.timeout(120000) + + servers = await flushAndRunMultipleServers(2) + + // Get the access tokens + await setAccessTokensToServers(servers) + await setDefaultVideoChannel(servers) + + // Server 1 and server 2 follow each other + await doubleFollow(servers[0], servers[1]) + + await updateCustomSubConfig(servers[0].url, servers[0].accessToken, { + live: { + enabled: true, + allowReplay: true, + maxDuration: null, + transcoding: { + enabled: true, + resolutions: { + '240p': true, + '360p': true, + '480p': true, + '720p': true, + '1080p': true, + '2160p': true + } + } + } + }) + }) + + it('Should create a non permanent live and update it to be a permanent live', async function () { + this.timeout(20000) + + const videoUUID = await createLiveWrapper(false) + + { + const res = await getLive(servers[0].url, servers[0].accessToken, videoUUID) + expect(res.body.permanentLive).to.be.false + } + + await updateLive(servers[0].url, servers[0].accessToken, videoUUID, { permanentLive: true }) + + { + const res = await getLive(servers[0].url, servers[0].accessToken, videoUUID) + expect(res.body.permanentLive).to.be.true + } + }) + + it('Should create a permanent live', async function () { + this.timeout(20000) + + videoUUID = await createLiveWrapper(true) + + const res = await getLive(servers[0].url, servers[0].accessToken, videoUUID) + expect(res.body.permanentLive).to.be.true + + await waitJobs(servers) + }) + + it('Should stream into this permanent live', async function () { + this.timeout(40000) + + const command = await sendRTMPStreamInVideo(servers[0].url, servers[0].accessToken, videoUUID) + + for (const server of servers) { + await waitUntilLiveStarts(server.url, server.accessToken, videoUUID) + } + + await checkVideoState(videoUUID, VideoState.PUBLISHED) + + await stopFfmpeg(command) + + await waitJobs(servers) + }) + + it('Should not have cleaned up this live', async function () { + this.timeout(40000) + + await wait(5000) + await waitJobs(servers) + + for (const server of servers) { + const res = await getVideo(server.url, videoUUID) + + const videoDetails = res.body as VideoDetails + expect(videoDetails.streamingPlaylists).to.have.lengthOf(1) + } + }) + + it('Should have set this live to waiting for live state', async function () { + this.timeout(20000) + + await checkVideoState(videoUUID, VideoState.WAITING_FOR_LIVE) + }) + + it('Should be able to stream again in the permanent live', async function () { + this.timeout(20000) + + await updateCustomSubConfig(servers[0].url, servers[0].accessToken, { + live: { + enabled: true, + allowReplay: true, + maxDuration: null, + transcoding: { + enabled: true, + resolutions: { + '240p': false, + '360p': false, + '480p': false, + '720p': false, + '1080p': false, + '2160p': false + } + } + } + }) + + const command = await sendRTMPStreamInVideo(servers[0].url, servers[0].accessToken, videoUUID) + + for (const server of servers) { + await waitUntilLiveStarts(server.url, server.accessToken, videoUUID) + } + + await checkVideoState(videoUUID, VideoState.PUBLISHED) + + const count = await getPlaylistsCount(servers[0], videoUUID) + // master playlist and 720p playlist + expect(count).to.equal(2) + + await stopFfmpeg(command) + }) + + after(async function () { + await cleanupTests(servers) + }) +}) 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 expect(files).to.contain('segments-sha256.json') } +async function getPlaylistsCount (server: ServerInfo, videoUUID: string) { + const basePath = buildServerDirectory(server, 'streaming-playlists') + const hlsPath = join(basePath, 'hls', videoUUID) + + const files = await readdir(hlsPath) + + return files.filter(f => f.endsWith('.m3u8')).length +} + // --------------------------------------------------------------------------- export { getLive, + getPlaylistsCount, waitUntilLivePublished, updateLive, 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 { isLiveBroadcast: boolean liveSaveReplay: boolean + permanentLive: boolean commentsEnabled: boolean 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' export interface LiveVideoCreate extends VideoCreate { saveReplay?: boolean + permanentLive?: boolean } 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 @@ export interface LiveVideoUpdate { + permanentLive?: boolean saveReplay?: boolean } 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 { rtmpUrl: string streamKey: string saveReplay: boolean + permanentLive: boolean } 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: new videos, and how to keep up to date with their latest publications! - name: My History description: > - Operations related to your watch history. + Operations related to your watch history. - name: My Notifications description: > Notifications following new videos, follows or reports. They allow you @@ -1567,6 +1567,9 @@ paths: type: integer saveReplay: type: boolean + permanentLive: + description: User can stream multiple times in a permanent live + type: boolean thumbnailfile: description: Live video/replay thumbnail file type: string @@ -5614,6 +5617,9 @@ components: properties: saveReplay: type: boolean + permanentLive: + description: User can stream multiple times in a permanent live + type: boolean LiveVideoResponse: properties: @@ -5624,6 +5630,9 @@ components: description: RTMP stream key to use to stream into this live video saveReplay: type: boolean + permanentLive: + description: User can stream multiple times in a permanent live + type: boolean callbacks: searchIndex: