aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--server/helpers/ffmpeg-utils.ts15
-rw-r--r--server/helpers/video.ts10
-rw-r--r--server/initializers/constants.ts5
-rw-r--r--server/lib/activitypub/videos.ts6
-rw-r--r--server/lib/job-queue/handlers/video-live-ending.ts33
-rw-r--r--server/lib/live-manager.ts42
-rw-r--r--server/models/video/video.ts8
-rw-r--r--server/tests/api/check-params/live.ts7
-rw-r--r--server/tests/api/live/index.ts2
-rw-r--r--server/tests/api/live/live-constraints.ts199
-rw-r--r--server/tests/api/live/live-save-replay.ts307
-rw-r--r--server/tests/api/live/live.ts213
-rw-r--r--shared/extra-utils/videos/live.ts59
-rw-r--r--shared/extra-utils/videos/videos.ts9
14 files changed, 685 insertions, 230 deletions
diff --git a/server/helpers/ffmpeg-utils.ts b/server/helpers/ffmpeg-utils.ts
index 268ed7624..3b794b8a2 100644
--- a/server/helpers/ffmpeg-utils.ts
+++ b/server/helpers/ffmpeg-utils.ts
@@ -353,7 +353,7 @@ function convertWebPToJPG (path: string, destination: string): Promise<void> {
353 }) 353 })
354} 354}
355 355
356function runLiveTranscoding (rtmpUrl: string, outPath: string, resolutions: number[], deleteSegments: boolean) { 356function runLiveTranscoding (rtmpUrl: string, outPath: string, resolutions: number[], fps, deleteSegments: boolean) {
357 const command = getFFmpeg(rtmpUrl) 357 const command = getFFmpeg(rtmpUrl)
358 command.inputOption('-fflags nobuffer') 358 command.inputOption('-fflags nobuffer')
359 359
@@ -375,10 +375,6 @@ function runLiveTranscoding (rtmpUrl: string, outPath: string, resolutions: numb
375 })) 375 }))
376 ]) 376 ])
377 377
378 const liveFPS = VIDEO_TRANSCODING_FPS.AVERAGE
379
380 command.withFps(liveFPS)
381
382 command.outputOption('-b_strategy 1') 378 command.outputOption('-b_strategy 1')
383 command.outputOption('-bf 16') 379 command.outputOption('-bf 16')
384 command.outputOption('-preset superfast') 380 command.outputOption('-preset superfast')
@@ -386,13 +382,14 @@ function runLiveTranscoding (rtmpUrl: string, outPath: string, resolutions: numb
386 command.outputOption('-map_metadata -1') 382 command.outputOption('-map_metadata -1')
387 command.outputOption('-pix_fmt yuv420p') 383 command.outputOption('-pix_fmt yuv420p')
388 command.outputOption('-max_muxing_queue_size 1024') 384 command.outputOption('-max_muxing_queue_size 1024')
385 command.outputOption('-g ' + (fps * 2))
389 386
390 for (let i = 0; i < resolutions.length; i++) { 387 for (let i = 0; i < resolutions.length; i++) {
391 const resolution = resolutions[i] 388 const resolution = resolutions[i]
392 389
393 command.outputOption(`-map [vout${resolution}]`) 390 command.outputOption(`-map [vout${resolution}]`)
394 command.outputOption(`-c:v:${i} libx264`) 391 command.outputOption(`-c:v:${i} libx264`)
395 command.outputOption(`-b:v:${i} ${getTargetBitrate(resolution, liveFPS, VIDEO_TRANSCODING_FPS)}`) 392 command.outputOption(`-b:v:${i} ${getTargetBitrate(resolution, fps, VIDEO_TRANSCODING_FPS)}`)
396 393
397 command.outputOption(`-map a:0`) 394 command.outputOption(`-map a:0`)
398 command.outputOption(`-c:a:${i} aac`) 395 command.outputOption(`-c:a:${i} aac`)
@@ -443,8 +440,8 @@ async function hlsPlaylistToFragmentedMP4 (hlsDirectory: string, segmentFiles: s
443 command.run() 440 command.run()
444 441
445 function cleaner () { 442 function cleaner () {
446 remove(concatFile) 443 remove(concatFilePath)
447 .catch(err => logger.error('Cannot remove concat file in %s.', hlsDirectory, { err })) 444 .catch(err => logger.error('Cannot remove concat file in %s.', hlsDirectory, { err }))
448 } 445 }
449 446
450 return new Promise<string>((res, rej) => { 447 return new Promise<string>((res, rej) => {
@@ -497,7 +494,7 @@ function addDefaultX264Params (command: ffmpeg.FfmpegCommand) {
497} 494}
498 495
499function addDefaultLiveHLSParams (command: ffmpeg.FfmpegCommand, outPath: string, deleteSegments: boolean) { 496function addDefaultLiveHLSParams (command: ffmpeg.FfmpegCommand, outPath: string, deleteSegments: boolean) {
500 command.outputOption('-hls_time ' + VIDEO_LIVE.SEGMENT_TIME) 497 command.outputOption('-hls_time ' + VIDEO_LIVE.SEGMENT_TIME_SECONDS)
501 command.outputOption('-hls_list_size ' + VIDEO_LIVE.SEGMENTS_LIST_SIZE) 498 command.outputOption('-hls_list_size ' + VIDEO_LIVE.SEGMENTS_LIST_SIZE)
502 499
503 if (deleteSegments === true) { 500 if (deleteSegments === true) {
diff --git a/server/helpers/video.ts b/server/helpers/video.ts
index 488b4da17..999137c6d 100644
--- a/server/helpers/video.ts
+++ b/server/helpers/video.ts
@@ -15,7 +15,7 @@ import {
15 MVideoThumbnail, 15 MVideoThumbnail,
16 MVideoWithRights 16 MVideoWithRights
17} from '@server/types/models' 17} from '@server/types/models'
18import { VideoPrivacy, VideoTranscodingPayload } from '@shared/models' 18import { VideoPrivacy, VideoState, VideoTranscodingPayload } from '@shared/models'
19import { VideoModel } from '../models/video/video' 19import { VideoModel } from '../models/video/video'
20 20
21type VideoFetchType = 'all' | 'only-video' | 'only-video-with-rights' | 'id' | 'none' | 'only-immutable-attributes' 21type VideoFetchType = 'all' | 'only-video' | 'only-video-with-rights' | 'id' | 'none' | 'only-immutable-attributes'
@@ -104,6 +104,13 @@ function isPrivacyForFederation (privacy: VideoPrivacy) {
104 (CONFIG.FEDERATION.VIDEOS.FEDERATE_UNLISTED === true && castedPrivacy === VideoPrivacy.UNLISTED) 104 (CONFIG.FEDERATION.VIDEOS.FEDERATE_UNLISTED === true && castedPrivacy === VideoPrivacy.UNLISTED)
105} 105}
106 106
107function isStateForFederation (state: VideoState) {
108 const castedState = parseInt(state + '', 10)
109
110 return castedState === VideoState.PUBLISHED || castedState === VideoState.WAITING_FOR_LIVE || castedState === VideoState.LIVE_ENDED
111
112}
113
107function getPrivaciesForFederation () { 114function getPrivaciesForFederation () {
108 return (CONFIG.FEDERATION.VIDEOS.FEDERATE_UNLISTED === true) 115 return (CONFIG.FEDERATION.VIDEOS.FEDERATE_UNLISTED === true)
109 ? [ { privacy: VideoPrivacy.PUBLIC }, { privacy: VideoPrivacy.UNLISTED } ] 116 ? [ { privacy: VideoPrivacy.PUBLIC }, { privacy: VideoPrivacy.UNLISTED } ]
@@ -127,6 +134,7 @@ export {
127 addOptimizeOrMergeAudioJob, 134 addOptimizeOrMergeAudioJob,
128 extractVideo, 135 extractVideo,
129 getExtFromMimetype, 136 getExtFromMimetype,
137 isStateForFederation,
130 isPrivacyForFederation, 138 isPrivacyForFederation,
131 getPrivaciesForFederation 139 getPrivaciesForFederation
132} 140}
diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts
index f8380eaa0..d1f94e6e6 100644
--- a/server/initializers/constants.ts
+++ b/server/initializers/constants.ts
@@ -609,7 +609,7 @@ const HLS_REDUNDANCY_DIRECTORY = join(CONFIG.STORAGE.REDUNDANCY_DIR, 'hls')
609const VIDEO_LIVE = { 609const VIDEO_LIVE = {
610 EXTENSION: '.ts', 610 EXTENSION: '.ts',
611 CLEANUP_DELAY: 1000 * 60 * 5, // 5 minutes 611 CLEANUP_DELAY: 1000 * 60 * 5, // 5 minutes
612 SEGMENT_TIME: 4, // 4 seconds 612 SEGMENT_TIME_SECONDS: 4, // 4 seconds
613 SEGMENTS_LIST_SIZE: 15, // 15 maximum segments in live playlist 613 SEGMENTS_LIST_SIZE: 15, // 15 maximum segments in live playlist
614 RTMP: { 614 RTMP: {
615 CHUNK_SIZE: 60000, 615 CHUNK_SIZE: 60000,
@@ -738,7 +738,8 @@ if (isTestInstance() === true) {
738 738
739 PLUGIN_EXTERNAL_AUTH_TOKEN_LIFETIME = 5000 739 PLUGIN_EXTERNAL_AUTH_TOKEN_LIFETIME = 5000
740 740
741 VIDEO_LIVE.CLEANUP_DELAY = 10000 741 VIDEO_LIVE.CLEANUP_DELAY = 5000
742 VIDEO_LIVE.SEGMENT_TIME_SECONDS = 2
742} 743}
743 744
744updateWebserverUrls() 745updateWebserverUrls()
diff --git a/server/lib/activitypub/videos.ts b/server/lib/activitypub/videos.ts
index ea1e6a38f..ab4aac0a1 100644
--- a/server/lib/activitypub/videos.ts
+++ b/server/lib/activitypub/videos.ts
@@ -85,7 +85,7 @@ async function federateVideoIfNeeded (videoArg: MVideoAPWithoutCaption, isNewVid
85 // Check this is not a blacklisted video, or unfederated blacklisted video 85 // Check this is not a blacklisted video, or unfederated blacklisted video
86 (video.isBlacklisted() === false || (isNewVideo === false && video.VideoBlacklist.unfederated === false)) && 86 (video.isBlacklisted() === false || (isNewVideo === false && video.VideoBlacklist.unfederated === false)) &&
87 // Check the video is public/unlisted and published 87 // Check the video is public/unlisted and published
88 video.hasPrivacyForFederation() && (video.state === VideoState.PUBLISHED || video.state === VideoState.WAITING_FOR_LIVE) 88 video.hasPrivacyForFederation() && video.hasStateForFederation()
89 ) { 89 ) {
90 // Fetch more attributes that we will need to serialize in AP object 90 // Fetch more attributes that we will need to serialize in AP object
91 if (isArray(video.VideoCaptions) === false) { 91 if (isArray(video.VideoCaptions) === false) {
@@ -302,7 +302,7 @@ async function updateVideoFromAP (options: {
302}) { 302}) {
303 const { video, videoObject, account, channel, overrideTo } = options 303 const { video, videoObject, account, channel, overrideTo } = options
304 304
305 logger.debug('Updating remote video "%s".', options.videoObject.uuid, { account, channel }) 305 logger.debug('Updating remote video "%s".', options.videoObject.uuid, { videoObject: options.videoObject, account, channel })
306 306
307 let videoFieldsSave: any 307 let videoFieldsSave: any
308 const wasPrivateVideo = video.privacy === VideoPrivacy.PRIVATE 308 const wasPrivateVideo = video.privacy === VideoPrivacy.PRIVATE
@@ -562,6 +562,8 @@ function isAPHashTagObject (url: any): url is ActivityHashTagObject {
562 return url && url.type === 'Hashtag' 562 return url && url.type === 'Hashtag'
563} 563}
564 564
565
566
565async function createVideo (videoObject: VideoObject, channel: MChannelAccountLight, waitThumbnail = false) { 567async function createVideo (videoObject: VideoObject, channel: MChannelAccountLight, waitThumbnail = false) {
566 logger.debug('Adding remote video %s.', videoObject.id) 568 logger.debug('Adding remote video %s.', videoObject.id)
567 569
diff --git a/server/lib/job-queue/handlers/video-live-ending.ts b/server/lib/job-queue/handlers/video-live-ending.ts
index 1e964726e..2b900998a 100644
--- a/server/lib/job-queue/handlers/video-live-ending.ts
+++ b/server/lib/job-queue/handlers/video-live-ending.ts
@@ -6,22 +6,31 @@ import { publishAndFederateIfNeeded } from '@server/lib/video'
6import { getHLSDirectory } from '@server/lib/video-paths' 6import { getHLSDirectory } from '@server/lib/video-paths'
7import { generateHlsPlaylist } from '@server/lib/video-transcoding' 7import { generateHlsPlaylist } from '@server/lib/video-transcoding'
8import { VideoModel } from '@server/models/video/video' 8import { VideoModel } from '@server/models/video/video'
9import { VideoFileModel } from '@server/models/video/video-file'
9import { VideoLiveModel } from '@server/models/video/video-live' 10import { VideoLiveModel } from '@server/models/video/video-live'
10import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist' 11import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist'
11import { MStreamingPlaylist, MVideo, MVideoLive, MVideoWithFile } from '@server/types/models' 12import { MStreamingPlaylist, MVideo, MVideoLive } from '@server/types/models'
12import { VideoLiveEndingPayload, VideoState } from '@shared/models' 13import { VideoLiveEndingPayload, VideoState } from '@shared/models'
13import { logger } from '../../../helpers/logger' 14import { logger } from '../../../helpers/logger'
14import { VideoFileModel } from '@server/models/video/video-file'
15 15
16async function processVideoLiveEnding (job: Bull.Job) { 16async function processVideoLiveEnding (job: Bull.Job) {
17 const payload = job.data as VideoLiveEndingPayload 17 const payload = job.data as VideoLiveEndingPayload
18 18
19 function logError () {
20 logger.warn('Video live %d does not exist anymore. Cannot process live ending.', payload.videoId)
21 }
22
19 const video = await VideoModel.load(payload.videoId) 23 const video = await VideoModel.load(payload.videoId)
20 const live = await VideoLiveModel.loadByVideoId(payload.videoId) 24 const live = await VideoLiveModel.loadByVideoId(payload.videoId)
21 25
26 if (!video || !live) {
27 logError()
28 return
29 }
30
22 const streamingPlaylist = await VideoStreamingPlaylistModel.loadHLSPlaylistByVideo(video.id) 31 const streamingPlaylist = await VideoStreamingPlaylistModel.loadHLSPlaylistByVideo(video.id)
23 if (!video || !streamingPlaylist || !live) { 32 if (!streamingPlaylist) {
24 logger.warn('Video live %d does not exist anymore. Cannot process live ending.', payload.videoId) 33 logError()
25 return 34 return
26 } 35 }
27 36
@@ -52,21 +61,21 @@ async function saveLive (video: MVideo, live: MVideoLive) {
52 const playlistPath = join(hlsDirectory, playlistFile) 61 const playlistPath = join(hlsDirectory, playlistFile)
53 const { videoFileResolution } = await getVideoFileResolution(playlistPath) 62 const { videoFileResolution } = await getVideoFileResolution(playlistPath)
54 63
55 const mp4TmpName = buildMP4TmpName(videoFileResolution) 64 const mp4TmpPath = buildMP4TmpPath(hlsDirectory, videoFileResolution)
56 65
57 // Playlist name is for example 3.m3u8 66 // Playlist name is for example 3.m3u8
58 // Segments names are 3-0.ts 3-1.ts etc 67 // Segments names are 3-0.ts 3-1.ts etc
59 const shouldStartWith = playlistFile.replace(/\.m3u8$/, '') + '-' 68 const shouldStartWith = playlistFile.replace(/\.m3u8$/, '') + '-'
60 69
61 const segmentFiles = files.filter(f => f.startsWith(shouldStartWith) && f.endsWith('.ts')) 70 const segmentFiles = files.filter(f => f.startsWith(shouldStartWith) && f.endsWith('.ts'))
62 await hlsPlaylistToFragmentedMP4(hlsDirectory, segmentFiles, mp4TmpName) 71 await hlsPlaylistToFragmentedMP4(hlsDirectory, segmentFiles, mp4TmpPath)
63 72
64 for (const file of segmentFiles) { 73 for (const file of segmentFiles) {
65 await remove(join(hlsDirectory, file)) 74 await remove(join(hlsDirectory, file))
66 } 75 }
67 76
68 if (!duration) { 77 if (!duration) {
69 duration = await getDurationFromVideoFile(mp4TmpName) 78 duration = await getDurationFromVideoFile(mp4TmpPath)
70 } 79 }
71 80
72 resolutions.push(videoFileResolution) 81 resolutions.push(videoFileResolution)
@@ -90,7 +99,7 @@ async function saveLive (video: MVideo, live: MVideoLive) {
90 hlsPlaylist.VideoFiles = [] 99 hlsPlaylist.VideoFiles = []
91 100
92 for (const resolution of resolutions) { 101 for (const resolution of resolutions) {
93 const videoInputPath = buildMP4TmpName(resolution) 102 const videoInputPath = buildMP4TmpPath(hlsDirectory, resolution)
94 const { isPortraitMode } = await getVideoFileResolution(videoInputPath) 103 const { isPortraitMode } = await getVideoFileResolution(videoInputPath)
95 104
96 await generateHlsPlaylist({ 105 await generateHlsPlaylist({
@@ -101,7 +110,7 @@ async function saveLive (video: MVideo, live: MVideoLive) {
101 isPortraitMode 110 isPortraitMode
102 }) 111 })
103 112
104 await remove(join(hlsDirectory, videoInputPath)) 113 await remove(videoInputPath)
105 } 114 }
106 115
107 await publishAndFederateIfNeeded(video, true) 116 await publishAndFederateIfNeeded(video, true)
@@ -110,7 +119,7 @@ async function saveLive (video: MVideo, live: MVideoLive) {
110async function cleanupLive (video: MVideo, streamingPlaylist: MStreamingPlaylist) { 119async function cleanupLive (video: MVideo, streamingPlaylist: MStreamingPlaylist) {
111 const hlsDirectory = getHLSDirectory(video, false) 120 const hlsDirectory = getHLSDirectory(video, false)
112 121
113 await cleanupLiveFiles(hlsDirectory) 122 await remove(hlsDirectory)
114 123
115 streamingPlaylist.destroy() 124 streamingPlaylist.destroy()
116 .catch(err => logger.error('Cannot remove live streaming playlist.', { err })) 125 .catch(err => logger.error('Cannot remove live streaming playlist.', { err }))
@@ -135,6 +144,6 @@ async function cleanupLiveFiles (hlsDirectory: string) {
135 } 144 }
136} 145}
137 146
138function buildMP4TmpName (resolution: number) { 147function buildMP4TmpPath (basePath: string, resolution: number) {
139 return resolution + '-tmp.mp4' 148 return join(basePath, resolution + '-tmp.mp4')
140} 149}
diff --git a/server/lib/live-manager.ts b/server/lib/live-manager.ts
index 2d8f906e9..6eb05c9d6 100644
--- a/server/lib/live-manager.ts
+++ b/server/lib/live-manager.ts
@@ -4,7 +4,7 @@ import * as chokidar from 'chokidar'
4import { FfmpegCommand } from 'fluent-ffmpeg' 4import { FfmpegCommand } from 'fluent-ffmpeg'
5import { ensureDir, stat } from 'fs-extra' 5import { ensureDir, stat } from 'fs-extra'
6import { basename } from 'path' 6import { basename } from 'path'
7import { computeResolutionsToTranscode, runLiveMuxing, runLiveTranscoding } from '@server/helpers/ffmpeg-utils' 7import { computeResolutionsToTranscode, getVideoFileFPS, getVideoFileResolution, getVideoStreamCodec, getVideoStreamSize, runLiveMuxing, runLiveTranscoding } from '@server/helpers/ffmpeg-utils'
8import { logger } from '@server/helpers/logger' 8import { logger } from '@server/helpers/logger'
9import { CONFIG, registerConfigChangedHandler } from '@server/initializers/config' 9import { CONFIG, registerConfigChangedHandler } from '@server/initializers/config'
10import { MEMOIZE_TTL, P2P_MEDIA_LOADER_PEER_VERSION, VIDEO_LIVE, WEBSERVER } from '@server/initializers/constants' 10import { MEMOIZE_TTL, P2P_MEDIA_LOADER_PEER_VERSION, VIDEO_LIVE, WEBSERVER } from '@server/initializers/constants'
@@ -137,6 +137,13 @@ class LiveManager {
137 this.abortSession(sessionId) 137 this.abortSession(sessionId)
138 } 138 }
139 139
140 getLiveQuotaUsedByUser (userId: number) {
141 const currentLives = this.livesPerUser.get(userId)
142 if (!currentLives) return 0
143
144 return currentLives.reduce((sum, obj) => sum + obj.size, 0)
145 }
146
140 private getContext () { 147 private getContext () {
141 return context 148 return context
142 } 149 }
@@ -173,8 +180,15 @@ class LiveManager {
173 const playlistUrl = WEBSERVER.URL + VideoStreamingPlaylistModel.getHlsMasterPlaylistStaticPath(video.uuid) 180 const playlistUrl = WEBSERVER.URL + VideoStreamingPlaylistModel.getHlsMasterPlaylistStaticPath(video.uuid)
174 181
175 const session = this.getContext().sessions.get(sessionId) 182 const session = this.getContext().sessions.get(sessionId)
183 const rtmpUrl = 'rtmp://127.0.0.1:' + config.rtmp.port + streamPath
184
185 const [ resolutionResult, fps ] = await Promise.all([
186 getVideoFileResolution(rtmpUrl),
187 getVideoFileFPS(rtmpUrl)
188 ])
189
176 const resolutionsEnabled = CONFIG.LIVE.TRANSCODING.ENABLED 190 const resolutionsEnabled = CONFIG.LIVE.TRANSCODING.ENABLED
177 ? computeResolutionsToTranscode(session.videoHeight, 'live') 191 ? computeResolutionsToTranscode(resolutionResult.videoFileResolution, 'live')
178 : [] 192 : []
179 193
180 logger.info('Will mux/transcode live video of original resolution %d.', session.videoHeight, { resolutionsEnabled }) 194 logger.info('Will mux/transcode live video of original resolution %d.', session.videoHeight, { resolutionsEnabled })
@@ -193,8 +207,9 @@ class LiveManager {
193 sessionId, 207 sessionId,
194 videoLive, 208 videoLive,
195 playlist: videoStreamingPlaylist, 209 playlist: videoStreamingPlaylist,
196 streamPath,
197 originalResolution: session.videoHeight, 210 originalResolution: session.videoHeight,
211 rtmpUrl,
212 fps,
198 resolutionsEnabled 213 resolutionsEnabled
199 }) 214 })
200 } 215 }
@@ -203,11 +218,12 @@ class LiveManager {
203 sessionId: string 218 sessionId: string
204 videoLive: MVideoLiveVideo 219 videoLive: MVideoLiveVideo
205 playlist: MStreamingPlaylist 220 playlist: MStreamingPlaylist
206 streamPath: string 221 rtmpUrl: string
222 fps: number
207 resolutionsEnabled: number[] 223 resolutionsEnabled: number[]
208 originalResolution: number 224 originalResolution: number
209 }) { 225 }) {
210 const { sessionId, videoLive, playlist, streamPath, resolutionsEnabled, originalResolution } = options 226 const { sessionId, videoLive, playlist, resolutionsEnabled, originalResolution, fps, rtmpUrl } = options
211 const startStreamDateTime = new Date().getTime() 227 const startStreamDateTime = new Date().getTime()
212 const allResolutions = resolutionsEnabled.concat([ originalResolution ]) 228 const allResolutions = resolutionsEnabled.concat([ originalResolution ])
213 229
@@ -238,17 +254,16 @@ class LiveManager {
238 const outPath = getHLSDirectory(videoLive.Video) 254 const outPath = getHLSDirectory(videoLive.Video)
239 await ensureDir(outPath) 255 await ensureDir(outPath)
240 256
257 const videoUUID = videoLive.Video.uuid
241 const deleteSegments = videoLive.saveReplay === false 258 const deleteSegments = videoLive.saveReplay === false
242 259
243 const rtmpUrl = 'rtmp://127.0.0.1:' + config.rtmp.port + streamPath
244 const ffmpegExec = CONFIG.LIVE.TRANSCODING.ENABLED 260 const ffmpegExec = CONFIG.LIVE.TRANSCODING.ENABLED
245 ? runLiveTranscoding(rtmpUrl, outPath, allResolutions, deleteSegments) 261 ? runLiveTranscoding(rtmpUrl, outPath, allResolutions, fps, deleteSegments)
246 : runLiveMuxing(rtmpUrl, outPath, deleteSegments) 262 : runLiveMuxing(rtmpUrl, outPath, deleteSegments)
247 263
248 logger.info('Running live muxing/transcoding.') 264 logger.info('Running live muxing/transcoding for %s.', videoUUID)
249 this.transSessions.set(sessionId, ffmpegExec) 265 this.transSessions.set(sessionId, ffmpegExec)
250 266
251 const videoUUID = videoLive.Video.uuid
252 const tsWatcher = chokidar.watch(outPath + '/*.ts') 267 const tsWatcher = chokidar.watch(outPath + '/*.ts')
253 268
254 const updateSegment = segmentPath => this.segmentsSha256Queue.push({ operation: 'update', segmentPath, videoUUID }) 269 const updateSegment = segmentPath => this.segmentsSha256Queue.push({ operation: 'update', segmentPath, videoUUID })
@@ -307,7 +322,7 @@ class LiveManager {
307 }) 322 })
308 323
309 const onFFmpegEnded = () => { 324 const onFFmpegEnded = () => {
310 logger.info('RTMP transmuxing for video %s ended. Scheduling cleanup', streamPath) 325 logger.info('RTMP transmuxing for video %s ended. Scheduling cleanup', rtmpUrl)
311 326
312 this.transSessions.delete(sessionId) 327 this.transSessions.delete(sessionId)
313 328
@@ -332,13 +347,6 @@ class LiveManager {
332 ffmpegExec.on('end', () => onFFmpegEnded()) 347 ffmpegExec.on('end', () => onFFmpegEnded())
333 } 348 }
334 349
335 getLiveQuotaUsedByUser (userId: number) {
336 const currentLives = this.livesPerUser.get(userId)
337 if (!currentLives) return 0
338
339 return currentLives.reduce((sum, obj) => sum + obj.size, 0)
340 }
341
342 private async onEndTransmuxing (videoId: number, cleanupNow = false) { 350 private async onEndTransmuxing (videoId: number, cleanupNow = false) {
343 try { 351 try {
344 const fullVideo = await VideoModel.loadAndPopulateAccountAndServerAndTags(videoId) 352 const fullVideo = await VideoModel.loadAndPopulateAccountAndServerAndTags(videoId)
diff --git a/server/models/video/video.ts b/server/models/video/video.ts
index 7e008f7ea..8e71f8c32 100644
--- a/server/models/video/video.ts
+++ b/server/models/video/video.ts
@@ -25,7 +25,7 @@ import {
25 UpdatedAt 25 UpdatedAt
26} from 'sequelize-typescript' 26} from 'sequelize-typescript'
27import { buildNSFWFilter } from '@server/helpers/express-utils' 27import { buildNSFWFilter } from '@server/helpers/express-utils'
28import { getPrivaciesForFederation, isPrivacyForFederation } from '@server/helpers/video' 28import { getPrivaciesForFederation, isPrivacyForFederation, isStateForFederation } from '@server/helpers/video'
29import { LiveManager } from '@server/lib/live-manager' 29import { LiveManager } from '@server/lib/live-manager'
30import { getHLSDirectory, getTorrentFileName, getTorrentFilePath, getVideoFilename, getVideoFilePath } from '@server/lib/video-paths' 30import { getHLSDirectory, getTorrentFileName, getTorrentFilePath, getVideoFilename, getVideoFilePath } from '@server/lib/video-paths'
31import { getServerActor } from '@server/models/application/application' 31import { getServerActor } from '@server/models/application/application'
@@ -823,6 +823,8 @@ export class VideoModel extends Model<VideoModel> {
823 static stopLiveIfNeeded (instance: VideoModel) { 823 static stopLiveIfNeeded (instance: VideoModel) {
824 if (!instance.isLive) return 824 if (!instance.isLive) return
825 825
826 logger.info('Stopping live of video %s after video deletion.', instance.uuid)
827
826 return LiveManager.Instance.stopSessionOf(instance.id) 828 return LiveManager.Instance.stopSessionOf(instance.id)
827 } 829 }
828 830
@@ -1921,6 +1923,10 @@ export class VideoModel extends Model<VideoModel> {
1921 return isPrivacyForFederation(this.privacy) 1923 return isPrivacyForFederation(this.privacy)
1922 } 1924 }
1923 1925
1926 hasStateForFederation () {
1927 return isStateForFederation(this.state)
1928 }
1929
1924 isNewVideo (newPrivacy: VideoPrivacy) { 1930 isNewVideo (newPrivacy: VideoPrivacy) {
1925 return this.hasPrivacyForFederation() === false && isPrivacyForFederation(newPrivacy) === true 1931 return this.hasPrivacyForFederation() === false && isPrivacyForFederation(newPrivacy) === true
1926 } 1932 }
diff --git a/server/tests/api/check-params/live.ts b/server/tests/api/check-params/live.ts
index 3e97dffdc..2b2d1beec 100644
--- a/server/tests/api/check-params/live.ts
+++ b/server/tests/api/check-params/live.ts
@@ -1,7 +1,6 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ 1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2 2
3import 'mocha' 3import 'mocha'
4import * as chai from 'chai'
5import { omit } from 'lodash' 4import { omit } from 'lodash'
6import { join } from 'path' 5import { join } from 'path'
7import { LiveVideo, VideoPrivacy } from '@shared/models' 6import { LiveVideo, VideoPrivacy } from '@shared/models'
@@ -14,11 +13,11 @@ import {
14 immutableAssign, 13 immutableAssign,
15 makePostBodyRequest, 14 makePostBodyRequest,
16 makeUploadRequest, 15 makeUploadRequest,
16 runAndTestFfmpegStreamError,
17 sendRTMPStream, 17 sendRTMPStream,
18 ServerInfo, 18 ServerInfo,
19 setAccessTokensToServers, 19 setAccessTokensToServers,
20 stopFfmpeg, 20 stopFfmpeg,
21 testFfmpegStreamError,
22 updateCustomSubConfig, 21 updateCustomSubConfig,
23 updateLive, 22 updateLive,
24 uploadVideoAndGetId, 23 uploadVideoAndGetId,
@@ -30,9 +29,7 @@ describe('Test video lives API validator', function () {
30 const path = '/api/v1/videos/live' 29 const path = '/api/v1/videos/live'
31 let server: ServerInfo 30 let server: ServerInfo
32 let userAccessToken = '' 31 let userAccessToken = ''
33 let accountName: string
34 let channelId: number 32 let channelId: number
35 let channelName: string
36 let videoId: number 33 let videoId: number
37 let videoIdNotLive: number 34 let videoIdNotLive: number
38 35
@@ -414,7 +411,7 @@ describe('Test video lives API validator', function () {
414 411
415 await waitUntilLiveStarts(server.url, server.accessToken, videoId) 412 await waitUntilLiveStarts(server.url, server.accessToken, videoId)
416 413
417 await testFfmpegStreamError(server.url, server.accessToken, videoId, true) 414 await runAndTestFfmpegStreamError(server.url, server.accessToken, videoId, true)
418 415
419 await stopFfmpeg(command) 416 await stopFfmpeg(command)
420 }) 417 })
diff --git a/server/tests/api/live/index.ts b/server/tests/api/live/index.ts
index 280daf423..ee77af286 100644
--- a/server/tests/api/live/index.ts
+++ b/server/tests/api/live/index.ts
@@ -1 +1,3 @@
1export * from './live-constraints'
2export * from './live-save-replay'
1export * from './live' 3export * from './live'
diff --git a/server/tests/api/live/live-constraints.ts b/server/tests/api/live/live-constraints.ts
new file mode 100644
index 000000000..23c8e3b0a
--- /dev/null
+++ b/server/tests/api/live/live-constraints.ts
@@ -0,0 +1,199 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2
3import 'mocha'
4import * as chai from 'chai'
5import { User, VideoDetails, VideoPrivacy } from '@shared/models'
6import {
7 checkLiveCleanup,
8 cleanupTests,
9 createLive,
10 createUser,
11 doubleFollow,
12 flushAndRunMultipleServers,
13 getMyUserInformation,
14 getVideo,
15 runAndTestFfmpegStreamError,
16 ServerInfo,
17 setAccessTokensToServers,
18 setDefaultVideoChannel,
19 updateCustomSubConfig,
20 updateUser,
21 userLogin,
22 wait,
23 waitJobs
24} from '../../../../shared/extra-utils'
25
26const expect = chai.expect
27
28describe('Test live constraints', function () {
29 let servers: ServerInfo[] = []
30 let userId: number
31 let userAccessToken: string
32 let userChannelId: number
33
34 async function createLiveWrapper (saveReplay: boolean) {
35 const liveAttributes = {
36 name: 'user live',
37 channelId: userChannelId,
38 privacy: VideoPrivacy.PUBLIC,
39 saveReplay
40 }
41
42 const res = await createLive(servers[0].url, userAccessToken, liveAttributes)
43 return res.body.video.uuid as string
44 }
45
46 async function checkSaveReplay (videoId: string, resolutions = [ 720 ]) {
47 for (const server of servers) {
48 const res = await getVideo(server.url, videoId)
49
50 const video: VideoDetails = res.body
51 expect(video.isLive).to.be.false
52 expect(video.duration).to.be.greaterThan(0)
53 }
54
55 await checkLiveCleanup(servers[0], videoId, resolutions)
56 }
57
58 before(async function () {
59 this.timeout(120000)
60
61 servers = await flushAndRunMultipleServers(2)
62
63 // Get the access tokens
64 await setAccessTokensToServers(servers)
65 await setDefaultVideoChannel(servers)
66
67 await updateCustomSubConfig(servers[0].url, servers[0].accessToken, {
68 live: {
69 enabled: true,
70 allowReplay: true,
71 transcoding: {
72 enabled: false
73 }
74 }
75 })
76
77 {
78 const user = { username: 'user1', password: 'superpassword' }
79 const res = await createUser({
80 url: servers[0].url,
81 accessToken: servers[0].accessToken,
82 username: user.username,
83 password: user.password
84 })
85 userId = res.body.user.id
86
87 userAccessToken = await userLogin(servers[0], user)
88
89 const resMe = await getMyUserInformation(servers[0].url, userAccessToken)
90 userChannelId = (resMe.body as User).videoChannels[0].id
91
92 await updateUser({
93 url: servers[0].url,
94 userId,
95 accessToken: servers[0].accessToken,
96 videoQuota: 1,
97 videoQuotaDaily: -1
98 })
99 }
100
101 // Server 1 and server 2 follow each other
102 await doubleFollow(servers[0], servers[1])
103 })
104
105 it('Should not have size limit if save replay is disabled', async function () {
106 this.timeout(60000)
107
108 const userVideoLiveoId = await createLiveWrapper(false)
109 await runAndTestFfmpegStreamError(servers[0].url, userAccessToken, userVideoLiveoId, false)
110 })
111
112 it('Should have size limit depending on user global quota if save replay is enabled', async function () {
113 this.timeout(60000)
114
115 // Wait for user quota memoize cache invalidation
116 await wait(5000)
117
118 const userVideoLiveoId = await createLiveWrapper(true)
119 await runAndTestFfmpegStreamError(servers[0].url, userAccessToken, userVideoLiveoId, true)
120
121 await waitJobs(servers)
122
123 await checkSaveReplay(userVideoLiveoId)
124 })
125
126 it('Should have size limit depending on user daily quota if save replay is enabled', async function () {
127 this.timeout(60000)
128
129 // Wait for user quota memoize cache invalidation
130 await wait(5000)
131
132 await updateUser({
133 url: servers[0].url,
134 userId,
135 accessToken: servers[0].accessToken,
136 videoQuota: -1,
137 videoQuotaDaily: 1
138 })
139
140 const userVideoLiveoId = await createLiveWrapper(true)
141 await runAndTestFfmpegStreamError(servers[0].url, userAccessToken, userVideoLiveoId, true)
142
143 await waitJobs(servers)
144
145 await checkSaveReplay(userVideoLiveoId)
146 })
147
148 it('Should succeed without quota limit', async function () {
149 this.timeout(60000)
150
151 // Wait for user quota memoize cache invalidation
152 await wait(5000)
153
154 await updateUser({
155 url: servers[0].url,
156 userId,
157 accessToken: servers[0].accessToken,
158 videoQuota: 10 * 1000 * 1000,
159 videoQuotaDaily: -1
160 })
161
162 const userVideoLiveoId = await createLiveWrapper(true)
163 await runAndTestFfmpegStreamError(servers[0].url, userAccessToken, userVideoLiveoId, false)
164 })
165
166 it('Should have max duration limit', async function () {
167 this.timeout(30000)
168
169 await updateCustomSubConfig(servers[0].url, servers[0].accessToken, {
170 live: {
171 enabled: true,
172 allowReplay: true,
173 maxDuration: 1,
174 transcoding: {
175 enabled: true,
176 resolutions: {
177 '240p': true,
178 '360p': true,
179 '480p': true,
180 '720p': true,
181 '1080p': true,
182 '2160p': true
183 }
184 }
185 }
186 })
187
188 const userVideoLiveoId = await createLiveWrapper(true)
189 await runAndTestFfmpegStreamError(servers[0].url, userAccessToken, userVideoLiveoId, true)
190
191 await waitJobs(servers)
192
193 await checkSaveReplay(userVideoLiveoId, [ 720, 480, 360, 240 ])
194 })
195
196 after(async function () {
197 await cleanupTests(servers)
198 })
199})
diff --git a/server/tests/api/live/live-save-replay.ts b/server/tests/api/live/live-save-replay.ts
new file mode 100644
index 000000000..3ffa0c093
--- /dev/null
+++ b/server/tests/api/live/live-save-replay.ts
@@ -0,0 +1,307 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2
3import 'mocha'
4import * as chai from 'chai'
5import { FfmpegCommand } from 'fluent-ffmpeg'
6import { LiveVideoCreate, VideoDetails, VideoPrivacy, VideoState } from '@shared/models'
7import {
8 addVideoToBlacklist,
9 checkLiveCleanup,
10 cleanupTests,
11 createLive,
12 doubleFollow,
13 flushAndRunMultipleServers,
14 getVideo,
15 getVideosList,
16 removeVideo,
17 sendRTMPStreamInVideo,
18 ServerInfo,
19 setAccessTokensToServers,
20 setDefaultVideoChannel,
21 stopFfmpeg,
22 testFfmpegStreamError,
23 updateCustomSubConfig,
24 updateVideo,
25 waitJobs,
26 waitUntilLiveStarts
27} from '../../../../shared/extra-utils'
28
29const expect = chai.expect
30
31describe('Save replay setting', function () {
32 let servers: ServerInfo[] = []
33 let liveVideoUUID: string
34 let ffmpegCommand: FfmpegCommand
35
36 async function createLiveWrapper (saveReplay: boolean) {
37 if (liveVideoUUID) {
38 try {
39 await removeVideo(servers[0].url, servers[0].accessToken, liveVideoUUID)
40 await waitJobs(servers)
41 } catch {}
42 }
43
44 const attributes: LiveVideoCreate = {
45 channelId: servers[0].videoChannel.id,
46 privacy: VideoPrivacy.PUBLIC,
47 name: 'my super live',
48 saveReplay
49 }
50
51 const res = await createLive(servers[0].url, servers[0].accessToken, attributes)
52 return res.body.video.uuid
53 }
54
55 async function checkVideosExist (videoId: string, existsInList: boolean, getStatus?: number) {
56 for (const server of servers) {
57 const length = existsInList ? 1 : 0
58
59 const resVideos = await getVideosList(server.url)
60 expect(resVideos.body.data).to.have.lengthOf(length)
61 expect(resVideos.body.total).to.equal(length)
62
63 if (getStatus) {
64 await getVideo(server.url, videoId, getStatus)
65 }
66 }
67 }
68
69 async function checkVideoState (videoId: string, state: VideoState) {
70 for (const server of servers) {
71 const res = await getVideo(server.url, videoId)
72 expect((res.body as VideoDetails).state.id).to.equal(state)
73 }
74 }
75
76 before(async function () {
77 this.timeout(120000)
78
79 servers = await flushAndRunMultipleServers(2)
80
81 // Get the access tokens
82 await setAccessTokensToServers(servers)
83 await setDefaultVideoChannel(servers)
84
85 // Server 1 and server 2 follow each other
86 await doubleFollow(servers[0], servers[1])
87
88 await updateCustomSubConfig(servers[0].url, servers[0].accessToken, {
89 live: {
90 enabled: true,
91 allowReplay: true,
92 maxDuration: null,
93 transcoding: {
94 enabled: false,
95 resolutions: {
96 '240p': true,
97 '360p': true,
98 '480p': true,
99 '720p': true,
100 '1080p': true,
101 '2160p': true
102 }
103 }
104 }
105 })
106 })
107
108 describe('With save replay disabled', function () {
109
110 before(async function () {
111 this.timeout(10000)
112 })
113
114 it('Should correctly create and federate the "waiting for stream" live', async function () {
115 this.timeout(20000)
116
117 liveVideoUUID = await createLiveWrapper(false)
118
119 await waitJobs(servers)
120
121 await checkVideosExist(liveVideoUUID, false, 200)
122 await checkVideoState(liveVideoUUID, VideoState.WAITING_FOR_LIVE)
123 })
124
125 it('Should correctly have updated the live and federated it when streaming in the live', async function () {
126 this.timeout(20000)
127
128 ffmpegCommand = await sendRTMPStreamInVideo(servers[0].url, servers[0].accessToken, liveVideoUUID)
129 await waitUntilLiveStarts(servers[0].url, servers[0].accessToken, liveVideoUUID)
130
131 await waitJobs(servers)
132
133 await checkVideosExist(liveVideoUUID, true, 200)
134 await checkVideoState(liveVideoUUID, VideoState.PUBLISHED)
135 })
136
137 it('Should correctly delete the video files after the stream ended', async function () {
138 this.timeout(30000)
139
140 await stopFfmpeg(ffmpegCommand)
141
142 await waitJobs(servers)
143
144 // Live still exist, but cannot be played anymore
145 await checkVideosExist(liveVideoUUID, false, 200)
146 await checkVideoState(liveVideoUUID, VideoState.LIVE_ENDED)
147
148 // No resolutions saved since we did not save replay
149 await checkLiveCleanup(servers[0], liveVideoUUID, [])
150 })
151
152 it('Should correctly terminate the stream on blacklist and delete the live', async function () {
153 this.timeout(40000)
154
155 liveVideoUUID = await createLiveWrapper(false)
156
157 ffmpegCommand = await sendRTMPStreamInVideo(servers[0].url, servers[0].accessToken, liveVideoUUID)
158 await waitUntilLiveStarts(servers[0].url, servers[0].accessToken, liveVideoUUID)
159
160 await waitJobs(servers)
161 await checkVideosExist(liveVideoUUID, true, 200)
162
163 await Promise.all([
164 addVideoToBlacklist(servers[0].url, servers[0].accessToken, liveVideoUUID, 'bad live', true),
165 testFfmpegStreamError(ffmpegCommand, true)
166 ])
167
168 await waitJobs(servers)
169
170 await checkVideosExist(liveVideoUUID, false)
171
172 await getVideo(servers[0].url, liveVideoUUID, 401)
173 await getVideo(servers[1].url, liveVideoUUID, 404)
174
175 await checkLiveCleanup(servers[0], liveVideoUUID, [])
176 })
177
178 it('Should correctly terminate the stream on delete and delete the video', async function () {
179 this.timeout(40000)
180
181 liveVideoUUID = await createLiveWrapper(false)
182
183 ffmpegCommand = await sendRTMPStreamInVideo(servers[0].url, servers[0].accessToken, liveVideoUUID)
184 await waitUntilLiveStarts(servers[0].url, servers[0].accessToken, liveVideoUUID)
185
186 await waitJobs(servers)
187 await checkVideosExist(liveVideoUUID, true, 200)
188
189 await Promise.all([
190 testFfmpegStreamError(ffmpegCommand, true),
191 removeVideo(servers[0].url, servers[0].accessToken, liveVideoUUID)
192 ])
193
194 await waitJobs(servers)
195
196 await checkVideosExist(liveVideoUUID, false, 404)
197 await checkLiveCleanup(servers[0], liveVideoUUID, [])
198 })
199 })
200
201 describe('With save replay enabled', function () {
202
203 it('Should correctly create and federate the "waiting for stream" live', async function () {
204 this.timeout(20000)
205
206 liveVideoUUID = await createLiveWrapper(true)
207
208 await waitJobs(servers)
209
210 await checkVideosExist(liveVideoUUID, false, 200)
211 await checkVideoState(liveVideoUUID, VideoState.WAITING_FOR_LIVE)
212 })
213
214 it('Should correctly have updated the live and federated it when streaming in the live', async function () {
215 this.timeout(20000)
216
217 ffmpegCommand = await sendRTMPStreamInVideo(servers[0].url, servers[0].accessToken, liveVideoUUID)
218 await waitUntilLiveStarts(servers[0].url, servers[0].accessToken, liveVideoUUID)
219
220 await waitJobs(servers)
221
222 await checkVideosExist(liveVideoUUID, true, 200)
223 await checkVideoState(liveVideoUUID, VideoState.PUBLISHED)
224 })
225
226 it('Should correctly have saved the live and federated it after the streaming', async function () {
227 this.timeout(30000)
228
229 await stopFfmpeg(ffmpegCommand)
230
231 await waitJobs(servers)
232
233 // Live has been transcoded
234 await checkVideosExist(liveVideoUUID, true, 200)
235 await checkVideoState(liveVideoUUID, VideoState.PUBLISHED)
236 })
237
238 it('Should update the saved live and correctly federate the updated attributes', async function () {
239 this.timeout(30000)
240
241 await updateVideo(servers[0].url, servers[0].accessToken, liveVideoUUID, { name: 'video updated' })
242 await waitJobs(servers)
243
244 for (const server of servers) {
245 const res = await getVideo(server.url, liveVideoUUID)
246 expect(res.body.name).to.equal('video updated')
247 expect(res.body.isLive).to.be.false
248 }
249 })
250
251 it('Should have cleaned up the live files', async function () {
252 await checkLiveCleanup(servers[0], liveVideoUUID, [ 720 ])
253 })
254
255 it('Should correctly terminate the stream on blacklist and blacklist the saved replay video', async function () {
256 this.timeout(40000)
257
258 liveVideoUUID = await createLiveWrapper(true)
259
260 ffmpegCommand = await sendRTMPStreamInVideo(servers[0].url, servers[0].accessToken, liveVideoUUID)
261 await waitUntilLiveStarts(servers[0].url, servers[0].accessToken, liveVideoUUID)
262
263 await waitJobs(servers)
264 await checkVideosExist(liveVideoUUID, true, 200)
265
266 await Promise.all([
267 addVideoToBlacklist(servers[0].url, servers[0].accessToken, liveVideoUUID, 'bad live', true),
268 testFfmpegStreamError(ffmpegCommand, true)
269 ])
270
271 await waitJobs(servers)
272
273 await checkVideosExist(liveVideoUUID, false)
274
275 await getVideo(servers[0].url, liveVideoUUID, 401)
276 await getVideo(servers[1].url, liveVideoUUID, 404)
277
278 await checkLiveCleanup(servers[0], liveVideoUUID, [ 720 ])
279 })
280
281 it('Should correctly terminate the stream on delete and delete the video', async function () {
282 this.timeout(40000)
283
284 liveVideoUUID = await createLiveWrapper(true)
285
286 ffmpegCommand = await sendRTMPStreamInVideo(servers[0].url, servers[0].accessToken, liveVideoUUID)
287 await waitUntilLiveStarts(servers[0].url, servers[0].accessToken, liveVideoUUID)
288
289 await waitJobs(servers)
290 await checkVideosExist(liveVideoUUID, true, 200)
291
292 await Promise.all([
293 removeVideo(servers[0].url, servers[0].accessToken, liveVideoUUID),
294 testFfmpegStreamError(ffmpegCommand, true)
295 ])
296
297 await waitJobs(servers)
298
299 await checkVideosExist(liveVideoUUID, false, 404)
300 await checkLiveCleanup(servers[0], liveVideoUUID, [])
301 })
302 })
303
304 after(async function () {
305 await cleanupTests(servers)
306 })
307})
diff --git a/server/tests/api/live/live.ts b/server/tests/api/live/live.ts
index f351e9650..f7ccb453d 100644
--- a/server/tests/api/live/live.ts
+++ b/server/tests/api/live/live.ts
@@ -4,6 +4,7 @@ import 'mocha'
4import * as chai from 'chai' 4import * as chai from 'chai'
5import { LiveVideo, LiveVideoCreate, User, VideoDetails, VideoPrivacy } from '@shared/models' 5import { LiveVideo, LiveVideoCreate, User, VideoDetails, VideoPrivacy } from '@shared/models'
6import { 6import {
7 addVideoToBlacklist,
7 cleanupTests, 8 cleanupTests,
8 createLive, 9 createLive,
9 createUser, 10 createUser,
@@ -15,6 +16,7 @@ import {
15 getVideosList, 16 getVideosList,
16 makeRawRequest, 17 makeRawRequest,
17 removeVideo, 18 removeVideo,
19 sendRTMPStream,
18 ServerInfo, 20 ServerInfo,
19 setAccessTokensToServers, 21 setAccessTokensToServers,
20 setDefaultVideoChannel, 22 setDefaultVideoChannel,
@@ -22,9 +24,7 @@ import {
22 testImage, 24 testImage,
23 updateCustomSubConfig, 25 updateCustomSubConfig,
24 updateLive, 26 updateLive,
25 updateUser,
26 userLogin, 27 userLogin,
27 wait,
28 waitJobs 28 waitJobs
29} from '../../../../shared/extra-utils' 29} from '../../../../shared/extra-utils'
30 30
@@ -32,7 +32,6 @@ const expect = chai.expect
32 32
33describe('Test live', function () { 33describe('Test live', function () {
34 let servers: ServerInfo[] = [] 34 let servers: ServerInfo[] = []
35 let liveVideoUUID: string
36 let userId: number 35 let userId: number
37 let userAccessToken: string 36 let userAccessToken: string
38 let userChannelId: number 37 let userChannelId: number
@@ -49,7 +48,10 @@ describe('Test live', function () {
49 await updateCustomSubConfig(servers[0].url, servers[0].accessToken, { 48 await updateCustomSubConfig(servers[0].url, servers[0].accessToken, {
50 live: { 49 live: {
51 enabled: true, 50 enabled: true,
52 allowReplay: true 51 allowReplay: true,
52 transcoding: {
53 enabled: false
54 }
53 } 55 }
54 }) 56 })
55 57
@@ -74,6 +76,7 @@ describe('Test live', function () {
74 }) 76 })
75 77
76 describe('Live creation, update and delete', function () { 78 describe('Live creation, update and delete', function () {
79 let liveVideoUUID: string
77 80
78 it('Should create a live with the appropriate parameters', async function () { 81 it('Should create a live with the appropriate parameters', async function () {
79 this.timeout(20000) 82 this.timeout(20000)
@@ -220,206 +223,74 @@ describe('Test live', function () {
220 }) 223 })
221 }) 224 })
222 225
223 describe('Test live constraints', function () { 226 describe('Stream checks', function () {
227 let liveVideo: LiveVideo & VideoDetails
228 let rtmpUrl: string
229
230 before(function () {
231 rtmpUrl = 'rtmp://' + servers[0].hostname + ':1936'
232 })
224 233
225 async function createLiveWrapper (saveReplay: boolean) { 234 async function createLiveWrapper () {
226 const liveAttributes = { 235 const liveAttributes = {
227 name: 'user live', 236 name: 'user live',
228 channelId: userChannelId, 237 channelId: userChannelId,
229 privacy: VideoPrivacy.PUBLIC, 238 privacy: VideoPrivacy.PUBLIC,
230 saveReplay 239 saveReplay: false
231 } 240 }
232 241
233 const res = await createLive(servers[0].url, userAccessToken, liveAttributes) 242 const res = await createLive(servers[0].url, userAccessToken, liveAttributes)
234 return res.body.video.uuid as string 243 const uuid = res.body.video.uuid
235 }
236
237 before(async function () {
238 await updateCustomSubConfig(servers[0].url, servers[0].accessToken, {
239 live: {
240 enabled: true,
241 allowReplay: true
242 }
243 })
244
245 await updateUser({
246 url: servers[0].url,
247 userId,
248 accessToken: servers[0].accessToken,
249 videoQuota: 1,
250 videoQuotaDaily: -1
251 })
252 })
253 244
254 it('Should not have size limit if save replay is disabled', async function () { 245 const resLive = await getLive(servers[0].url, servers[0].accessToken, uuid)
255 this.timeout(30000) 246 const resVideo = await getVideo(servers[0].url, uuid)
256 247
257 const userVideoLiveoId = await createLiveWrapper(false) 248 return Object.assign(resVideo.body, resLive.body) as LiveVideo & VideoDetails
258 await testFfmpegStreamError(servers[0].url, userAccessToken, userVideoLiveoId, false) 249 }
259 })
260 250
261 it('Should have size limit depending on user global quota if save replay is enabled', async function () { 251 it('Should not allow a stream without the appropriate path', async function () {
262 this.timeout(30000) 252 this.timeout(30000)
263 253
264 const userVideoLiveoId = await createLiveWrapper(true) 254 liveVideo = await createLiveWrapper()
265 await testFfmpegStreamError(servers[0].url, userAccessToken, userVideoLiveoId, true)
266
267 await waitJobs(servers)
268
269 for (const server of servers) {
270 const res = await getVideo(server.url, userVideoLiveoId)
271 255
272 const video: VideoDetails = res.body 256 const command = sendRTMPStream(rtmpUrl + '/bad-live', liveVideo.streamKey)
273 expect(video.isLive).to.be.false 257 await testFfmpegStreamError(command, true)
274 expect(video.duration).to.be.greaterThan(0)
275 }
276
277 // TODO: check stream correctly saved + cleaned
278 }) 258 })
279 259
280 it('Should have size limit depending on user daily quota if save replay is enabled', async function () { 260 it('Should not allow a stream without the appropriate stream key', async function () {
281 this.timeout(30000) 261 this.timeout(30000)
282 262
283 await updateUser({ 263 const command = sendRTMPStream(rtmpUrl + '/live', 'bad-stream-key')
284 url: servers[0].url, 264 await testFfmpegStreamError(command, true)
285 userId,
286 accessToken: servers[0].accessToken,
287 videoQuota: -1,
288 videoQuotaDaily: 1
289 })
290
291 const userVideoLiveoId = await createLiveWrapper(true)
292 await testFfmpegStreamError(servers[0].url, userAccessToken, userVideoLiveoId, true)
293
294 // TODO: check stream correctly saved + cleaned
295 }) 265 })
296 266
297 it('Should succeed without quota limit', async function () { 267 it('Should succeed with the correct params', async function () {
298 this.timeout(30000) 268 this.timeout(30000)
299 269
300 // Wait for user quota memoize cache invalidation 270 const command = sendRTMPStream(rtmpUrl + '/live', liveVideo.streamKey)
301 await wait(5000) 271 await testFfmpegStreamError(command, false)
302
303 await updateUser({
304 url: servers[0].url,
305 userId,
306 accessToken: servers[0].accessToken,
307 videoQuota: 10 * 1000 * 1000,
308 videoQuotaDaily: -1
309 })
310
311 const userVideoLiveoId = await createLiveWrapper(true)
312 await testFfmpegStreamError(servers[0].url, userAccessToken, userVideoLiveoId, false)
313 }) 272 })
314 273
315 it('Should have max duration limit', async function () { 274 it('Should not allow a stream on a live that was blacklisted', async function () {
316 this.timeout(30000) 275 this.timeout(30000)
317 276
318 await updateCustomSubConfig(servers[0].url, servers[0].accessToken, { 277 liveVideo = await createLiveWrapper()
319 live: {
320 enabled: true,
321 allowReplay: true,
322 maxDuration: 1
323 }
324 })
325
326 const userVideoLiveoId = await createLiveWrapper(true)
327 await testFfmpegStreamError(servers[0].url, userAccessToken, userVideoLiveoId, true)
328
329 // TODO: check stream correctly saved + cleaned
330 })
331 })
332
333 describe('With save replay disabled', function () {
334 278
335 it('Should correctly create and federate the "waiting for stream" live', async function () { 279 await addVideoToBlacklist(servers[0].url, servers[0].accessToken, liveVideo.uuid)
336 280
281 const command = sendRTMPStream(rtmpUrl + '/live', liveVideo.streamKey)
282 await testFfmpegStreamError(command, true)
337 }) 283 })
338 284
339 it('Should correctly have updated the live and federated it when streaming in the live', async function () { 285 it('Should not allow a stream on a live that was deleted', async function () {
340 286 this.timeout(30000)
341 })
342
343 it('Should correctly delete the video and the live after the stream ended', async function () {
344 // Wait 10 seconds
345 // get video 404
346 // get video federation 404
347
348 // check cleanup
349 })
350
351 it('Should correctly terminate the stream on blacklist and delete the live', async function () {
352 // Wait 10 seconds
353 // get video 404
354 // get video federation 404
355
356 // check cleanup
357 })
358
359 it('Should correctly terminate the stream on delete and delete the video', async function () {
360 // Wait 10 seconds
361 // get video 404
362 // get video federation 404
363
364 // check cleanup
365 })
366 })
367
368 describe('With save replay enabled', function () {
369
370 it('Should correctly create and federate the "waiting for stream" live', async function () {
371
372 })
373
374 it('Should correctly have updated the live and federated it when streaming in the live', async function () {
375
376 })
377
378 it('Should correctly have saved the live and federated it after the streaming', async function () {
379
380 })
381
382 it('Should update the saved live and correctly federate the updated attributes', async function () {
383
384 })
385
386 it('Should have cleaned up the live files', async function () {
387
388 })
389
390 it('Should correctly terminate the stream on blacklist and blacklist the saved replay video', async function () {
391 // Wait 10 seconds
392 // get video -> blacklisted
393 // get video federation -> blacklisted
394
395 // check cleanup live files quand meme
396 })
397
398 it('Should correctly terminate the stream on delete and delete the video', async function () {
399 // Wait 10 seconds
400 // get video 404
401 // get video federation 404
402
403 // check cleanup
404 })
405 })
406
407 describe('Stream checks', function () {
408
409 it('Should not allow a stream without the appropriate path', async function () {
410
411 })
412
413 it('Should not allow a stream without the appropriate stream key', async function () {
414
415 })
416
417 it('Should not allow a stream on a live that was blacklisted', async function () {
418 287
419 }) 288 liveVideo = await createLiveWrapper()
420 289
421 it('Should not allow a stream on a live that was deleted', async function () { 290 await removeVideo(servers[0].url, servers[0].accessToken, liveVideo.uuid)
422 291
292 const command = sendRTMPStream(rtmpUrl + '/live', liveVideo.streamKey)
293 await testFfmpegStreamError(command, true)
423 }) 294 })
424 }) 295 })
425 296
diff --git a/shared/extra-utils/videos/live.ts b/shared/extra-utils/videos/live.ts
index a391565a4..f90dd420d 100644
--- a/shared/extra-utils/videos/live.ts
+++ b/shared/extra-utils/videos/live.ts
@@ -1,8 +1,14 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2
3import { expect } from 'chai'
1import * as ffmpeg from 'fluent-ffmpeg' 4import * as ffmpeg from 'fluent-ffmpeg'
5import { pathExists, readdir } from 'fs-extra'
2import { omit } from 'lodash' 6import { omit } from 'lodash'
7import { join } from 'path'
3import { LiveVideo, LiveVideoCreate, LiveVideoUpdate, VideoDetails, VideoState } from '@shared/models' 8import { LiveVideo, LiveVideoCreate, LiveVideoUpdate, VideoDetails, VideoState } from '@shared/models'
4import { buildAbsoluteFixturePath, wait } from '../miscs/miscs' 9import { buildAbsoluteFixturePath, buildServerDirectory, wait } from '../miscs/miscs'
5import { makeGetRequest, makePutBodyRequest, makeUploadRequest } from '../requests/requests' 10import { makeGetRequest, makePutBodyRequest, makeUploadRequest } from '../requests/requests'
11import { ServerInfo } from '../server/servers'
6import { getVideoWithToken } from './videos' 12import { getVideoWithToken } from './videos'
7 13
8function getLive (url: string, token: string, videoId: number | string, statusCodeExpected = 200) { 14function getLive (url: string, token: string, videoId: number | string, statusCodeExpected = 200) {
@@ -47,21 +53,22 @@ function createLive (url: string, token: string, fields: LiveVideoCreate, status
47 }) 53 })
48} 54}
49 55
50async function sendRTMPStreamInVideo (url: string, token: string, videoId: number | string, onErrorCb?: Function) { 56async function sendRTMPStreamInVideo (url: string, token: string, videoId: number | string) {
51 const res = await getLive(url, token, videoId) 57 const res = await getLive(url, token, videoId)
52 const videoLive = res.body as LiveVideo 58 const videoLive = res.body as LiveVideo
53 59
54 return sendRTMPStream(videoLive.rtmpUrl, videoLive.streamKey, onErrorCb) 60 return sendRTMPStream(videoLive.rtmpUrl, videoLive.streamKey)
55} 61}
56 62
57function sendRTMPStream (rtmpBaseUrl: string, streamKey: string, onErrorCb?: Function) { 63function sendRTMPStream (rtmpBaseUrl: string, streamKey: string) {
58 const fixture = buildAbsoluteFixturePath('video_short.mp4') 64 const fixture = buildAbsoluteFixturePath('video_short.mp4')
59 65
60 const command = ffmpeg(fixture) 66 const command = ffmpeg(fixture)
61 command.inputOption('-stream_loop -1') 67 command.inputOption('-stream_loop -1')
62 command.inputOption('-re') 68 command.inputOption('-re')
63 69 command.outputOption('-c:v libx264')
64 command.outputOption('-c copy') 70 command.outputOption('-g 50')
71 command.outputOption('-keyint_min 2')
65 command.outputOption('-f flv') 72 command.outputOption('-f flv')
66 73
67 const rtmpUrl = rtmpBaseUrl + '/' + streamKey 74 const rtmpUrl = rtmpBaseUrl + '/' + streamKey
@@ -70,7 +77,7 @@ function sendRTMPStream (rtmpBaseUrl: string, streamKey: string, onErrorCb?: Fun
70 command.on('error', err => { 77 command.on('error', err => {
71 if (err?.message?.includes('Exiting normally')) return 78 if (err?.message?.includes('Exiting normally')) return
72 79
73 if (onErrorCb) onErrorCb(err) 80 if (process.env.DEBUG) console.error(err)
74 }) 81 })
75 82
76 if (process.env.DEBUG) { 83 if (process.env.DEBUG) {
@@ -94,8 +101,13 @@ function waitFfmpegUntilError (command: ffmpeg.FfmpegCommand, successAfterMS = 1
94 }) 101 })
95} 102}
96 103
97async function testFfmpegStreamError (url: string, token: string, videoId: number | string, shouldHaveError: boolean) { 104async function runAndTestFfmpegStreamError (url: string, token: string, videoId: number | string, shouldHaveError: boolean) {
98 const command = await sendRTMPStreamInVideo(url, token, videoId) 105 const command = await sendRTMPStreamInVideo(url, token, videoId)
106
107 return testFfmpegStreamError(command, shouldHaveError)
108}
109
110async function testFfmpegStreamError (command: ffmpeg.FfmpegCommand, shouldHaveError: boolean) {
99 let error: Error 111 let error: Error
100 112
101 try { 113 try {
@@ -127,6 +139,31 @@ async function waitUntilLiveStarts (url: string, token: string, videoId: number
127 } while (video.state.id === VideoState.WAITING_FOR_LIVE) 139 } while (video.state.id === VideoState.WAITING_FOR_LIVE)
128} 140}
129 141
142async function checkLiveCleanup (server: ServerInfo, videoUUID: string, resolutions: number[] = []) {
143 const basePath = buildServerDirectory(server.internalServerNumber, 'streaming-playlists')
144 const hlsPath = join(basePath, 'hls', videoUUID)
145
146 if (resolutions.length === 0) {
147 const result = await pathExists(hlsPath)
148 expect(result).to.be.false
149
150 return
151 }
152
153 const files = await readdir(hlsPath)
154
155 // fragmented file and playlist per resolution + master playlist + segments sha256 json file
156 expect(files).to.have.lengthOf(resolutions.length * 2 + 2)
157
158 for (const resolution of resolutions) {
159 expect(files).to.contain(`${videoUUID}-${resolution}-fragmented.mp4`)
160 expect(files).to.contain(`${resolution}.m3u8`)
161 }
162
163 expect(files).to.contain('master.m3u8')
164 expect(files).to.contain('segments-sha256.json')
165}
166
130// --------------------------------------------------------------------------- 167// ---------------------------------------------------------------------------
131 168
132export { 169export {
@@ -134,9 +171,11 @@ export {
134 updateLive, 171 updateLive,
135 waitUntilLiveStarts, 172 waitUntilLiveStarts,
136 createLive, 173 createLive,
137 testFfmpegStreamError, 174 runAndTestFfmpegStreamError,
175 checkLiveCleanup,
138 stopFfmpeg, 176 stopFfmpeg,
139 sendRTMPStreamInVideo, 177 sendRTMPStreamInVideo,
140 waitFfmpegUntilError, 178 waitFfmpegUntilError,
141 sendRTMPStream 179 sendRTMPStream,
180 testFfmpegStreamError
142} 181}
diff --git a/shared/extra-utils/videos/videos.ts b/shared/extra-utils/videos/videos.ts
index 2f7f2182c..29a646541 100644
--- a/shared/extra-utils/videos/videos.ts
+++ b/shared/extra-utils/videos/videos.ts
@@ -312,6 +312,14 @@ function removeVideo (url: string, token: string, id: number | string, expectedS
312 .expect(expectedStatus) 312 .expect(expectedStatus)
313} 313}
314 314
315async function removeAllVideos (server: ServerInfo) {
316 const resVideos = await getVideosList(server.url)
317
318 for (const v of resVideos.body.data) {
319 await removeVideo(server.url, server.accessToken, v.id)
320 }
321}
322
315async function checkVideoFilesWereRemoved ( 323async function checkVideoFilesWereRemoved (
316 videoUUID: string, 324 videoUUID: string,
317 serverNumber: number, 325 serverNumber: number,
@@ -685,6 +693,7 @@ export {
685 getVideoFileMetadataUrl, 693 getVideoFileMetadataUrl,
686 getVideoWithToken, 694 getVideoWithToken,
687 getVideosList, 695 getVideosList,
696 removeAllVideos,
688 getVideosListPagination, 697 getVideosListPagination,
689 getVideosListSort, 698 getVideosListSort,
690 removeVideo, 699 removeVideo,