aboutsummaryrefslogtreecommitdiffhomepage
path: root/server
diff options
context:
space:
mode:
Diffstat (limited to 'server')
-rw-r--r--server/assets/default-live-background.jpgbin0 -> 93634 bytes
-rw-r--r--server/controllers/api/config.ts39
-rw-r--r--server/controllers/api/videos/index.ts4
-rw-r--r--server/controllers/api/videos/live.ts116
-rw-r--r--server/controllers/index.ts1
-rw-r--r--server/controllers/live.ts29
-rw-r--r--server/controllers/static.ts9
-rw-r--r--server/helpers/core-utils.ts11
-rw-r--r--server/helpers/custom-validators/videos.ts5
-rw-r--r--server/helpers/ffmpeg-utils.ts138
-rw-r--r--server/initializers/config.ts21
-rw-r--r--server/initializers/constants.ts50
-rw-r--r--server/initializers/database.ts10
-rw-r--r--server/initializers/migrations/0535-video-live.ts39
-rw-r--r--server/initializers/migrations/0540-video-file-infohash.ts26
-rw-r--r--server/lib/hls.ts10
-rw-r--r--server/lib/job-queue/handlers/video-transcoding.ts2
-rw-r--r--server/lib/live-manager.ts310
-rw-r--r--server/lib/video-paths.ts3
-rw-r--r--server/lib/video-transcoding.ts7
-rw-r--r--server/lib/video.ts31
-rw-r--r--server/middlewares/validators/videos/video-live.ts66
-rw-r--r--server/models/video/video-file.ts4
-rw-r--r--server/models/video/video-format-utils.ts2
-rw-r--r--server/models/video/video-live.ts74
-rw-r--r--server/models/video/video-streaming-playlist.ts4
-rw-r--r--server/models/video/video.ts5
-rw-r--r--server/tests/api/check-params/config.ts16
-rw-r--r--server/tests/api/server/config.ts36
-rw-r--r--server/tests/api/videos/video-transcoder.ts2
-rw-r--r--server/types/models/video/index.ts1
-rw-r--r--server/types/models/video/video-live.ts15
-rw-r--r--server/typings/express/index.d.ts5
33 files changed, 1030 insertions, 61 deletions
diff --git a/server/assets/default-live-background.jpg b/server/assets/default-live-background.jpg
new file mode 100644
index 000000000..2743af7fc
--- /dev/null
+++ b/server/assets/default-live-background.jpg
Binary files differ
diff --git a/server/controllers/api/config.ts b/server/controllers/api/config.ts
index b80ea4902..bd100ef9c 100644
--- a/server/controllers/api/config.ts
+++ b/server/controllers/api/config.ts
@@ -113,7 +113,15 @@ async function getConfig (req: express.Request, res: express.Response) {
113 webtorrent: { 113 webtorrent: {
114 enabled: CONFIG.TRANSCODING.WEBTORRENT.ENABLED 114 enabled: CONFIG.TRANSCODING.WEBTORRENT.ENABLED
115 }, 115 },
116 enabledResolutions: getEnabledResolutions() 116 enabledResolutions: getEnabledResolutions('vod')
117 },
118 live: {
119 enabled: CONFIG.LIVE.ENABLED,
120
121 transcoding: {
122 enabled: CONFIG.LIVE.TRANSCODING.ENABLED,
123 enabledResolutions: getEnabledResolutions('live')
124 }
117 }, 125 },
118 import: { 126 import: {
119 videos: { 127 videos: {
@@ -232,7 +240,7 @@ async function deleteCustomConfig (req: express.Request, res: express.Response)
232 240
233 const data = customConfig() 241 const data = customConfig()
234 242
235 return res.json(data).end() 243 return res.json(data)
236} 244}
237 245
238async function updateCustomConfig (req: express.Request, res: express.Response) { 246async function updateCustomConfig (req: express.Request, res: express.Response) {
@@ -254,7 +262,7 @@ async function updateCustomConfig (req: express.Request, res: express.Response)
254 oldCustomConfigAuditKeys 262 oldCustomConfigAuditKeys
255 ) 263 )
256 264
257 return res.json(data).end() 265 return res.json(data)
258} 266}
259 267
260function getRegisteredThemes () { 268function getRegisteredThemes () {
@@ -268,9 +276,13 @@ function getRegisteredThemes () {
268 })) 276 }))
269} 277}
270 278
271function getEnabledResolutions () { 279function getEnabledResolutions (type: 'vod' | 'live') {
272 return Object.keys(CONFIG.TRANSCODING.RESOLUTIONS) 280 const transcoding = type === 'vod'
273 .filter(key => CONFIG.TRANSCODING.ENABLED && CONFIG.TRANSCODING.RESOLUTIONS[key] === true) 281 ? CONFIG.TRANSCODING
282 : CONFIG.LIVE.TRANSCODING
283
284 return Object.keys(transcoding.RESOLUTIONS)
285 .filter(key => transcoding.ENABLED && transcoding.RESOLUTIONS[key] === true)
274 .map(r => parseInt(r, 10)) 286 .map(r => parseInt(r, 10))
275} 287}
276 288
@@ -411,6 +423,21 @@ function customConfig (): CustomConfig {
411 enabled: CONFIG.TRANSCODING.HLS.ENABLED 423 enabled: CONFIG.TRANSCODING.HLS.ENABLED
412 } 424 }
413 }, 425 },
426 live: {
427 enabled: CONFIG.LIVE.ENABLED,
428 transcoding: {
429 enabled: CONFIG.LIVE.TRANSCODING.ENABLED,
430 threads: CONFIG.LIVE.TRANSCODING.THREADS,
431 resolutions: {
432 '240p': CONFIG.LIVE.TRANSCODING.RESOLUTIONS['240p'],
433 '360p': CONFIG.LIVE.TRANSCODING.RESOLUTIONS['360p'],
434 '480p': CONFIG.LIVE.TRANSCODING.RESOLUTIONS['480p'],
435 '720p': CONFIG.LIVE.TRANSCODING.RESOLUTIONS['720p'],
436 '1080p': CONFIG.LIVE.TRANSCODING.RESOLUTIONS['1080p'],
437 '2160p': CONFIG.LIVE.TRANSCODING.RESOLUTIONS['2160p']
438 }
439 }
440 },
414 import: { 441 import: {
415 videos: { 442 videos: {
416 http: { 443 http: {
diff --git a/server/controllers/api/videos/index.ts b/server/controllers/api/videos/index.ts
index 15b6f214f..94f0361ee 100644
--- a/server/controllers/api/videos/index.ts
+++ b/server/controllers/api/videos/index.ts
@@ -63,6 +63,7 @@ import { blacklistRouter } from './blacklist'
63import { videoCaptionsRouter } from './captions' 63import { videoCaptionsRouter } from './captions'
64import { videoCommentRouter } from './comment' 64import { videoCommentRouter } from './comment'
65import { videoImportsRouter } from './import' 65import { videoImportsRouter } from './import'
66import { liveRouter } from './live'
66import { ownershipVideoRouter } from './ownership' 67import { ownershipVideoRouter } from './ownership'
67import { rateVideoRouter } from './rate' 68import { rateVideoRouter } from './rate'
68import { watchingRouter } from './watching' 69import { watchingRouter } from './watching'
@@ -96,6 +97,7 @@ videosRouter.use('/', videoCaptionsRouter)
96videosRouter.use('/', videoImportsRouter) 97videosRouter.use('/', videoImportsRouter)
97videosRouter.use('/', ownershipVideoRouter) 98videosRouter.use('/', ownershipVideoRouter)
98videosRouter.use('/', watchingRouter) 99videosRouter.use('/', watchingRouter)
100videosRouter.use('/', liveRouter)
99 101
100videosRouter.get('/categories', listVideoCategories) 102videosRouter.get('/categories', listVideoCategories)
101videosRouter.get('/licences', listVideoLicences) 103videosRouter.get('/licences', listVideoLicences)
@@ -304,7 +306,7 @@ async function addVideo (req: express.Request, res: express.Response) {
304 id: videoCreated.id, 306 id: videoCreated.id,
305 uuid: videoCreated.uuid 307 uuid: videoCreated.uuid
306 } 308 }
307 }).end() 309 })
308} 310}
309 311
310async function updateVideo (req: express.Request, res: express.Response) { 312async function updateVideo (req: express.Request, res: express.Response) {
diff --git a/server/controllers/api/videos/live.ts b/server/controllers/api/videos/live.ts
new file mode 100644
index 000000000..d08ef9869
--- /dev/null
+++ b/server/controllers/api/videos/live.ts
@@ -0,0 +1,116 @@
1import * as express from 'express'
2import { v4 as uuidv4 } from 'uuid'
3import { createReqFiles } from '@server/helpers/express-utils'
4import { CONFIG } from '@server/initializers/config'
5import { ASSETS_PATH, MIMETYPES } from '@server/initializers/constants'
6import { getVideoActivityPubUrl } from '@server/lib/activitypub/url'
7import { videoLiveAddValidator, videoLiveGetValidator } from '@server/middlewares/validators/videos/video-live'
8import { VideoLiveModel } from '@server/models/video/video-live'
9import { MVideoDetails, MVideoFullLight } from '@server/types/models'
10import { VideoCreate, VideoPrivacy, VideoState } from '../../../../shared'
11import { ThumbnailType } from '../../../../shared/models/videos/thumbnail.type'
12import { logger } from '../../../helpers/logger'
13import { sequelizeTypescript } from '../../../initializers/database'
14import { createVideoMiniatureFromExisting } from '../../../lib/thumbnail'
15import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate } from '../../../middlewares'
16import { TagModel } from '../../../models/video/tag'
17import { VideoModel } from '../../../models/video/video'
18import { buildLocalVideoFromCreate } from '@server/lib/video'
19
20const liveRouter = express.Router()
21
22const reqVideoFileLive = createReqFiles(
23 [ 'thumbnailfile', 'previewfile' ],
24 MIMETYPES.IMAGE.MIMETYPE_EXT,
25 {
26 thumbnailfile: CONFIG.STORAGE.TMP_DIR,
27 previewfile: CONFIG.STORAGE.TMP_DIR
28 }
29)
30
31liveRouter.post('/live',
32 authenticate,
33 reqVideoFileLive,
34 asyncMiddleware(videoLiveAddValidator),
35 asyncRetryTransactionMiddleware(addLiveVideo)
36)
37
38liveRouter.get('/live/:videoId',
39 authenticate,
40 asyncMiddleware(videoLiveGetValidator),
41 asyncRetryTransactionMiddleware(getVideoLive)
42)
43
44// ---------------------------------------------------------------------------
45
46export {
47 liveRouter
48}
49
50// ---------------------------------------------------------------------------
51
52async function getVideoLive (req: express.Request, res: express.Response) {
53 const videoLive = res.locals.videoLive
54
55 return res.json(videoLive.toFormattedJSON())
56}
57
58async function addLiveVideo (req: express.Request, res: express.Response) {
59 const videoInfo: VideoCreate = req.body
60
61 // Prepare data so we don't block the transaction
62 const videoData = buildLocalVideoFromCreate(videoInfo, res.locals.videoChannel.id)
63 videoData.isLive = true
64
65 const videoLive = new VideoLiveModel()
66 videoLive.streamKey = uuidv4()
67
68 const video = new VideoModel(videoData) as MVideoDetails
69 video.url = getVideoActivityPubUrl(video) // We use the UUID, so set the URL after building the object
70
71 // Process thumbnail or create it from the video
72 const thumbnailField = req.files ? req.files['thumbnailfile'] : null
73 const thumbnailModel = thumbnailField
74 ? await createVideoMiniatureFromExisting(thumbnailField[0].path, video, ThumbnailType.MINIATURE, false)
75 : await createVideoMiniatureFromExisting(ASSETS_PATH.DEFAULT_LIVE_BACKGROUND, video, ThumbnailType.MINIATURE, true)
76
77 // Process preview or create it from the video
78 const previewField = req.files ? req.files['previewfile'] : null
79 const previewModel = previewField
80 ? await createVideoMiniatureFromExisting(previewField[0].path, video, ThumbnailType.PREVIEW, false)
81 : await createVideoMiniatureFromExisting(ASSETS_PATH.DEFAULT_LIVE_BACKGROUND, video, ThumbnailType.PREVIEW, true)
82
83 const { videoCreated } = await sequelizeTypescript.transaction(async t => {
84 const sequelizeOptions = { transaction: t }
85
86 const videoCreated = await video.save(sequelizeOptions) as MVideoFullLight
87
88 if (thumbnailModel) await videoCreated.addAndSaveThumbnail(thumbnailModel, t)
89 if (previewModel) await videoCreated.addAndSaveThumbnail(previewModel, t)
90
91 // Do not forget to add video channel information to the created video
92 videoCreated.VideoChannel = res.locals.videoChannel
93
94 videoLive.videoId = videoCreated.id
95 await videoLive.save(sequelizeOptions)
96
97 // Create tags
98 if (videoInfo.tags !== undefined) {
99 const tagInstances = await TagModel.findOrCreateTags(videoInfo.tags, t)
100
101 await video.$set('Tags', tagInstances, sequelizeOptions)
102 video.Tags = tagInstances
103 }
104
105 logger.info('Video live %s with uuid %s created.', videoInfo.name, videoCreated.uuid)
106
107 return { videoCreated }
108 })
109
110 return res.json({
111 video: {
112 id: videoCreated.id,
113 uuid: videoCreated.uuid
114 }
115 })
116}
diff --git a/server/controllers/index.ts b/server/controllers/index.ts
index 0d64b33bb..5a199ae9c 100644
--- a/server/controllers/index.ts
+++ b/server/controllers/index.ts
@@ -5,6 +5,7 @@ export * from './feeds'
5export * from './services' 5export * from './services'
6export * from './static' 6export * from './static'
7export * from './lazy-static' 7export * from './lazy-static'
8export * from './live'
8export * from './webfinger' 9export * from './webfinger'
9export * from './tracker' 10export * from './tracker'
10export * from './bots' 11export * from './bots'
diff --git a/server/controllers/live.ts b/server/controllers/live.ts
new file mode 100644
index 000000000..fa4c2cc1a
--- /dev/null
+++ b/server/controllers/live.ts
@@ -0,0 +1,29 @@
1import * as express from 'express'
2import { mapToJSON } from '@server/helpers/core-utils'
3import { LiveManager } from '@server/lib/live-manager'
4
5const liveRouter = express.Router()
6
7liveRouter.use('/segments-sha256/:videoUUID',
8 getSegmentsSha256
9)
10
11// ---------------------------------------------------------------------------
12
13export {
14 liveRouter
15}
16
17// ---------------------------------------------------------------------------
18
19function getSegmentsSha256 (req: express.Request, res: express.Response) {
20 const videoUUID = req.params.videoUUID
21
22 const result = LiveManager.Instance.getSegmentsSha256(videoUUID)
23
24 if (!result) {
25 return res.sendStatus(404)
26 }
27
28 return res.json(mapToJSON(result))
29}
diff --git a/server/controllers/static.ts b/server/controllers/static.ts
index 3f7bbdbae..e04c27b11 100644
--- a/server/controllers/static.ts
+++ b/server/controllers/static.ts
@@ -260,7 +260,14 @@ async function generateNodeinfo (req: express.Request, res: express.Response) {
260 webtorrent: { 260 webtorrent: {
261 enabled: CONFIG.TRANSCODING.WEBTORRENT.ENABLED 261 enabled: CONFIG.TRANSCODING.WEBTORRENT.ENABLED
262 }, 262 },
263 enabledResolutions: getEnabledResolutions() 263 enabledResolutions: getEnabledResolutions('vod')
264 },
265 live: {
266 enabled: CONFIG.LIVE.ENABLED,
267 transcoding: {
268 enabled: CONFIG.LIVE.TRANSCODING.ENABLED,
269 enabledResolutions: getEnabledResolutions('live')
270 }
264 }, 271 },
265 import: { 272 import: {
266 videos: { 273 videos: {
diff --git a/server/helpers/core-utils.ts b/server/helpers/core-utils.ts
index b1f5d9610..49eee7c59 100644
--- a/server/helpers/core-utils.ts
+++ b/server/helpers/core-utils.ts
@@ -175,6 +175,16 @@ function pageToStartAndCount (page: number, itemsPerPage: number) {
175 return { start, count: itemsPerPage } 175 return { start, count: itemsPerPage }
176} 176}
177 177
178function mapToJSON (map: Map<any, any>) {
179 const obj: any = {}
180
181 for (const [ k, v ] of map) {
182 obj[k] = v
183 }
184
185 return obj
186}
187
178function buildPath (path: string) { 188function buildPath (path: string) {
179 if (isAbsolute(path)) return path 189 if (isAbsolute(path)) return path
180 190
@@ -263,6 +273,7 @@ export {
263 273
264 sha256, 274 sha256,
265 sha1, 275 sha1,
276 mapToJSON,
266 277
267 promisify0, 278 promisify0,
268 promisify1, 279 promisify1,
diff --git a/server/helpers/custom-validators/videos.ts b/server/helpers/custom-validators/videos.ts
index 40fecc09b..e99992236 100644
--- a/server/helpers/custom-validators/videos.ts
+++ b/server/helpers/custom-validators/videos.ts
@@ -8,7 +8,8 @@ import {
8 VIDEO_LICENCES, 8 VIDEO_LICENCES,
9 VIDEO_PRIVACIES, 9 VIDEO_PRIVACIES,
10 VIDEO_RATE_TYPES, 10 VIDEO_RATE_TYPES,
11 VIDEO_STATES 11 VIDEO_STATES,
12 VIDEO_LIVE
12} from '../../initializers/constants' 13} from '../../initializers/constants'
13import { exists, isArray, isDateValid, isFileValid } from './misc' 14import { exists, isArray, isDateValid, isFileValid } from './misc'
14import * as magnetUtil from 'magnet-uri' 15import * as magnetUtil from 'magnet-uri'
@@ -77,7 +78,7 @@ function isVideoRatingTypeValid (value: string) {
77} 78}
78 79
79function isVideoFileExtnameValid (value: string) { 80function isVideoFileExtnameValid (value: string) {
80 return exists(value) && MIMETYPES.VIDEO.EXT_MIMETYPE[value] !== undefined 81 return exists(value) && (value === VIDEO_LIVE.EXTENSION || MIMETYPES.VIDEO.EXT_MIMETYPE[value] !== undefined)
81} 82}
82 83
83function isVideoFile (files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[]) { 84function isVideoFile (files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[]) {
diff --git a/server/helpers/ffmpeg-utils.ts b/server/helpers/ffmpeg-utils.ts
index 02c66cd01..fac2595f1 100644
--- a/server/helpers/ffmpeg-utils.ts
+++ b/server/helpers/ffmpeg-utils.ts
@@ -1,13 +1,13 @@
1import * as ffmpeg from 'fluent-ffmpeg' 1import * as ffmpeg from 'fluent-ffmpeg'
2import { readFile, remove, writeFile } from 'fs-extra'
2import { dirname, join } from 'path' 3import { dirname, join } from 'path'
4import { VideoFileMetadata } from '@shared/models/videos/video-file-metadata'
3import { getMaxBitrate, getTargetBitrate, VideoResolution } from '../../shared/models/videos' 5import { getMaxBitrate, getTargetBitrate, VideoResolution } from '../../shared/models/videos'
6import { checkFFmpegEncoders } from '../initializers/checker-before-init'
7import { CONFIG } from '../initializers/config'
4import { FFMPEG_NICE, VIDEO_TRANSCODING_FPS } from '../initializers/constants' 8import { FFMPEG_NICE, VIDEO_TRANSCODING_FPS } from '../initializers/constants'
5import { processImage } from './image-utils' 9import { processImage } from './image-utils'
6import { logger } from './logger' 10import { logger } from './logger'
7import { checkFFmpegEncoders } from '../initializers/checker-before-init'
8import { readFile, remove, writeFile } from 'fs-extra'
9import { CONFIG } from '../initializers/config'
10import { VideoFileMetadata } from '@shared/models/videos/video-file-metadata'
11 11
12/** 12/**
13 * A toolbox to play with audio 13 * A toolbox to play with audio
@@ -74,9 +74,12 @@ namespace audio {
74 } 74 }
75} 75}
76 76
77function computeResolutionsToTranscode (videoFileResolution: number) { 77function computeResolutionsToTranscode (videoFileResolution: number, type: 'vod' | 'live') {
78 const configResolutions = type === 'vod'
79 ? CONFIG.TRANSCODING.RESOLUTIONS
80 : CONFIG.LIVE.TRANSCODING.RESOLUTIONS
81
78 const resolutionsEnabled: number[] = [] 82 const resolutionsEnabled: number[] = []
79 const configResolutions = CONFIG.TRANSCODING.RESOLUTIONS
80 83
81 // Put in the order we want to proceed jobs 84 // Put in the order we want to proceed jobs
82 const resolutions = [ 85 const resolutions = [
@@ -270,14 +273,13 @@ type TranscodeOptions =
270function transcode (options: TranscodeOptions) { 273function transcode (options: TranscodeOptions) {
271 return new Promise<void>(async (res, rej) => { 274 return new Promise<void>(async (res, rej) => {
272 try { 275 try {
273 // we set cwd explicitly because ffmpeg appears to create temporary files when trancoding which fails in read-only file systems 276 let command = getFFmpeg(options.inputPath)
274 let command = ffmpeg(options.inputPath, { niceness: FFMPEG_NICE.TRANSCODING, cwd: CONFIG.STORAGE.TMP_DIR })
275 .output(options.outputPath) 277 .output(options.outputPath)
276 278
277 if (options.type === 'quick-transcode') { 279 if (options.type === 'quick-transcode') {
278 command = buildQuickTranscodeCommand(command) 280 command = buildQuickTranscodeCommand(command)
279 } else if (options.type === 'hls') { 281 } else if (options.type === 'hls') {
280 command = await buildHLSCommand(command, options) 282 command = await buildHLSVODCommand(command, options)
281 } else if (options.type === 'merge-audio') { 283 } else if (options.type === 'merge-audio') {
282 command = await buildAudioMergeCommand(command, options) 284 command = await buildAudioMergeCommand(command, options)
283 } else if (options.type === 'only-audio') { 285 } else if (options.type === 'only-audio') {
@@ -286,11 +288,6 @@ function transcode (options: TranscodeOptions) {
286 command = await buildx264Command(command, options) 288 command = await buildx264Command(command, options)
287 } 289 }
288 290
289 if (CONFIG.TRANSCODING.THREADS > 0) {
290 // if we don't set any threads ffmpeg will chose automatically
291 command = command.outputOption('-threads ' + CONFIG.TRANSCODING.THREADS)
292 }
293
294 command 291 command
295 .on('error', (err, stdout, stderr) => { 292 .on('error', (err, stdout, stderr) => {
296 logger.error('Error in transcoding job.', { stdout, stderr }) 293 logger.error('Error in transcoding job.', { stdout, stderr })
@@ -356,16 +353,89 @@ function convertWebPToJPG (path: string, destination: string): Promise<void> {
356 }) 353 })
357} 354}
358 355
356function runLiveTranscoding (rtmpUrl: string, outPath: string, resolutions: number[]) {
357 const command = getFFmpeg(rtmpUrl)
358 command.inputOption('-fflags nobuffer')
359
360 const varStreamMap: string[] = []
361
362 command.complexFilter([
363 {
364 inputs: '[v:0]',
365 filter: 'split',
366 options: resolutions.length,
367 outputs: resolutions.map(r => `vtemp${r}`)
368 },
369
370 ...resolutions.map(r => ({
371 inputs: `vtemp${r}`,
372 filter: 'scale',
373 options: `w=-2:h=${r}`,
374 outputs: `vout${r}`
375 }))
376 ])
377
378 const liveFPS = VIDEO_TRANSCODING_FPS.AVERAGE
379
380 command.withFps(liveFPS)
381
382 command.outputOption('-b_strategy 1')
383 command.outputOption('-bf 16')
384 command.outputOption('-preset superfast')
385 command.outputOption('-level 3.1')
386 command.outputOption('-map_metadata -1')
387 command.outputOption('-pix_fmt yuv420p')
388
389 for (let i = 0; i < resolutions.length; i++) {
390 const resolution = resolutions[i]
391
392 command.outputOption(`-map [vout${resolution}]`)
393 command.outputOption(`-c:v:${i} libx264`)
394 command.outputOption(`-b:v:${i} ${getTargetBitrate(resolution, liveFPS, VIDEO_TRANSCODING_FPS)}`)
395
396 command.outputOption(`-map a:0`)
397 command.outputOption(`-c:a:${i} aac`)
398
399 varStreamMap.push(`v:${i},a:${i}`)
400 }
401
402 addDefaultLiveHLSParams(command, outPath)
403
404 command.outputOption('-var_stream_map', varStreamMap.join(' '))
405
406 command.run()
407
408 return command
409}
410
411function runLiveMuxing (rtmpUrl: string, outPath: string) {
412 const command = getFFmpeg(rtmpUrl)
413 command.inputOption('-fflags nobuffer')
414
415 command.outputOption('-c:v copy')
416 command.outputOption('-c:a copy')
417 command.outputOption('-map 0:a?')
418 command.outputOption('-map 0:v?')
419
420 addDefaultLiveHLSParams(command, outPath)
421
422 command.run()
423
424 return command
425}
426
359// --------------------------------------------------------------------------- 427// ---------------------------------------------------------------------------
360 428
361export { 429export {
362 getVideoStreamCodec, 430 getVideoStreamCodec,
363 getAudioStreamCodec, 431 getAudioStreamCodec,
432 runLiveMuxing,
364 convertWebPToJPG, 433 convertWebPToJPG,
365 getVideoStreamSize, 434 getVideoStreamSize,
366 getVideoFileResolution, 435 getVideoFileResolution,
367 getMetadataFromFile, 436 getMetadataFromFile,
368 getDurationFromVideoFile, 437 getDurationFromVideoFile,
438 runLiveTranscoding,
369 generateImageFromVideoFile, 439 generateImageFromVideoFile,
370 TranscodeOptions, 440 TranscodeOptions,
371 TranscodeOptionsType, 441 TranscodeOptionsType,
@@ -379,6 +449,25 @@ export {
379 449
380// --------------------------------------------------------------------------- 450// ---------------------------------------------------------------------------
381 451
452function addDefaultX264Params (command: ffmpeg.FfmpegCommand) {
453 command.outputOption('-level 3.1') // 3.1 is the minimal resource allocation for our highest supported resolution
454 .outputOption('-b_strategy 1') // NOTE: b-strategy 1 - heuristic algorithm, 16 is optimal B-frames for it
455 .outputOption('-bf 16') // NOTE: Why 16: https://github.com/Chocobozzz/PeerTube/pull/774. b-strategy 2 -> B-frames<16
456 .outputOption('-pix_fmt yuv420p') // allows import of source material with incompatible pixel formats (e.g. MJPEG video)
457 .outputOption('-map_metadata -1') // strip all metadata
458}
459
460function addDefaultLiveHLSParams (command: ffmpeg.FfmpegCommand, outPath: string) {
461 command.outputOption('-hls_time 4')
462 command.outputOption('-hls_list_size 15')
463 command.outputOption('-hls_flags delete_segments')
464 command.outputOption(`-hls_segment_filename ${join(outPath, '%v-%d.ts')}`)
465 command.outputOption('-master_pl_name master.m3u8')
466 command.outputOption(`-f hls`)
467
468 command.output(join(outPath, '%v.m3u8'))
469}
470
382async function buildx264Command (command: ffmpeg.FfmpegCommand, options: TranscodeOptions) { 471async function buildx264Command (command: ffmpeg.FfmpegCommand, options: TranscodeOptions) {
383 let fps = await getVideoFileFPS(options.inputPath) 472 let fps = await getVideoFileFPS(options.inputPath)
384 if ( 473 if (
@@ -438,7 +527,7 @@ function buildQuickTranscodeCommand (command: ffmpeg.FfmpegCommand) {
438 return command 527 return command
439} 528}
440 529
441async function buildHLSCommand (command: ffmpeg.FfmpegCommand, options: HLSTranscodeOptions) { 530async function buildHLSVODCommand (command: ffmpeg.FfmpegCommand, options: HLSTranscodeOptions) {
442 const videoPath = getHLSVideoPath(options) 531 const videoPath = getHLSVideoPath(options)
443 532
444 if (options.copyCodecs) command = presetCopy(command) 533 if (options.copyCodecs) command = presetCopy(command)
@@ -508,13 +597,10 @@ async function presetH264 (command: ffmpeg.FfmpegCommand, input: string, resolut
508 let localCommand = command 597 let localCommand = command
509 .format('mp4') 598 .format('mp4')
510 .videoCodec('libx264') 599 .videoCodec('libx264')
511 .outputOption('-level 3.1') // 3.1 is the minimal resource allocation for our highest supported resolution
512 .outputOption('-b_strategy 1') // NOTE: b-strategy 1 - heuristic algorithm, 16 is optimal B-frames for it
513 .outputOption('-bf 16') // NOTE: Why 16: https://github.com/Chocobozzz/PeerTube/pull/774. b-strategy 2 -> B-frames<16
514 .outputOption('-pix_fmt yuv420p') // allows import of source material with incompatible pixel formats (e.g. MJPEG video)
515 .outputOption('-map_metadata -1') // strip all metadata
516 .outputOption('-movflags faststart') 600 .outputOption('-movflags faststart')
517 601
602 addDefaultX264Params(localCommand)
603
518 const parsedAudio = await audio.get(input) 604 const parsedAudio = await audio.get(input)
519 605
520 if (!parsedAudio.audioStream) { 606 if (!parsedAudio.audioStream) {
@@ -565,3 +651,15 @@ function presetOnlyAudio (command: ffmpeg.FfmpegCommand): ffmpeg.FfmpegCommand {
565 .audioCodec('copy') 651 .audioCodec('copy')
566 .noVideo() 652 .noVideo()
567} 653}
654
655function getFFmpeg (input: string) {
656 // We set cwd explicitly because ffmpeg appears to create temporary files when trancoding which fails in read-only file systems
657 const command = ffmpeg(input, { niceness: FFMPEG_NICE.TRANSCODING, cwd: CONFIG.STORAGE.TMP_DIR })
658
659 if (CONFIG.TRANSCODING.THREADS > 0) {
660 // If we don't set any threads ffmpeg will chose automatically
661 command.outputOption('-threads ' + CONFIG.TRANSCODING.THREADS)
662 }
663
664 return command
665}
diff --git a/server/initializers/config.ts b/server/initializers/config.ts
index b40e525a5..7a8200ed9 100644
--- a/server/initializers/config.ts
+++ b/server/initializers/config.ts
@@ -198,6 +198,27 @@ const CONFIG = {
198 get ENABLED () { return config.get<boolean>('transcoding.webtorrent.enabled') } 198 get ENABLED () { return config.get<boolean>('transcoding.webtorrent.enabled') }
199 } 199 }
200 }, 200 },
201 LIVE: {
202 get ENABLED () { return config.get<boolean>('live.enabled') },
203
204 RTMP: {
205 get PORT () { return config.get<number>('live.rtmp.port') }
206 },
207
208 TRANSCODING: {
209 get ENABLED () { return config.get<boolean>('live.transcoding.enabled') },
210 get THREADS () { return config.get<number>('live.transcoding.threads') },
211
212 RESOLUTIONS: {
213 get '240p' () { return config.get<boolean>('live.transcoding.resolutions.240p') },
214 get '360p' () { return config.get<boolean>('live.transcoding.resolutions.360p') },
215 get '480p' () { return config.get<boolean>('live.transcoding.resolutions.480p') },
216 get '720p' () { return config.get<boolean>('live.transcoding.resolutions.720p') },
217 get '1080p' () { return config.get<boolean>('live.transcoding.resolutions.1080p') },
218 get '2160p' () { return config.get<boolean>('live.transcoding.resolutions.2160p') }
219 }
220 }
221 },
201 IMPORT: { 222 IMPORT: {
202 VIDEOS: { 223 VIDEOS: {
203 HTTP: { 224 HTTP: {
diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts
index 171e9e9c2..606eeba2d 100644
--- a/server/initializers/constants.ts
+++ b/server/initializers/constants.ts
@@ -23,7 +23,7 @@ import { CONFIG, registerConfigChangedHandler } from './config'
23 23
24// --------------------------------------------------------------------------- 24// ---------------------------------------------------------------------------
25 25
26const LAST_MIGRATION_VERSION = 530 26const LAST_MIGRATION_VERSION = 540
27 27
28// --------------------------------------------------------------------------- 28// ---------------------------------------------------------------------------
29 29
@@ -50,7 +50,8 @@ const WEBSERVER = {
50 SCHEME: '', 50 SCHEME: '',
51 WS: '', 51 WS: '',
52 HOSTNAME: '', 52 HOSTNAME: '',
53 PORT: 0 53 PORT: 0,
54 RTMP_URL: ''
54} 55}
55 56
56// Sortable columns per schema 57// Sortable columns per schema
@@ -264,7 +265,7 @@ const CONSTRAINTS_FIELDS = {
264 VIEWS: { min: 0 }, 265 VIEWS: { min: 0 },
265 LIKES: { min: 0 }, 266 LIKES: { min: 0 },
266 DISLIKES: { min: 0 }, 267 DISLIKES: { min: 0 },
267 FILE_SIZE: { min: 10 }, 268 FILE_SIZE: { min: -1 },
268 URL: { min: 3, max: 2000 } // Length 269 URL: { min: 3, max: 2000 } // Length
269 }, 270 },
270 VIDEO_PLAYLISTS: { 271 VIDEO_PLAYLISTS: {
@@ -370,39 +371,41 @@ const VIDEO_LICENCES = {
370 371
371const VIDEO_LANGUAGES: { [id: string]: string } = {} 372const VIDEO_LANGUAGES: { [id: string]: string } = {}
372 373
373const VIDEO_PRIVACIES = { 374const VIDEO_PRIVACIES: { [ id in VideoPrivacy ]: string } = {
374 [VideoPrivacy.PUBLIC]: 'Public', 375 [VideoPrivacy.PUBLIC]: 'Public',
375 [VideoPrivacy.UNLISTED]: 'Unlisted', 376 [VideoPrivacy.UNLISTED]: 'Unlisted',
376 [VideoPrivacy.PRIVATE]: 'Private', 377 [VideoPrivacy.PRIVATE]: 'Private',
377 [VideoPrivacy.INTERNAL]: 'Internal' 378 [VideoPrivacy.INTERNAL]: 'Internal'
378} 379}
379 380
380const VIDEO_STATES = { 381const VIDEO_STATES: { [ id in VideoState ]: string } = {
381 [VideoState.PUBLISHED]: 'Published', 382 [VideoState.PUBLISHED]: 'Published',
382 [VideoState.TO_TRANSCODE]: 'To transcode', 383 [VideoState.TO_TRANSCODE]: 'To transcode',
383 [VideoState.TO_IMPORT]: 'To import' 384 [VideoState.TO_IMPORT]: 'To import',
385 [VideoState.WAITING_FOR_LIVE]: 'Waiting for livestream',
386 [VideoState.LIVE_ENDED]: 'Livestream ended'
384} 387}
385 388
386const VIDEO_IMPORT_STATES = { 389const VIDEO_IMPORT_STATES: { [ id in VideoImportState ]: string } = {
387 [VideoImportState.FAILED]: 'Failed', 390 [VideoImportState.FAILED]: 'Failed',
388 [VideoImportState.PENDING]: 'Pending', 391 [VideoImportState.PENDING]: 'Pending',
389 [VideoImportState.SUCCESS]: 'Success', 392 [VideoImportState.SUCCESS]: 'Success',
390 [VideoImportState.REJECTED]: 'Rejected' 393 [VideoImportState.REJECTED]: 'Rejected'
391} 394}
392 395
393const ABUSE_STATES = { 396const ABUSE_STATES: { [ id in AbuseState ]: string } = {
394 [AbuseState.PENDING]: 'Pending', 397 [AbuseState.PENDING]: 'Pending',
395 [AbuseState.REJECTED]: 'Rejected', 398 [AbuseState.REJECTED]: 'Rejected',
396 [AbuseState.ACCEPTED]: 'Accepted' 399 [AbuseState.ACCEPTED]: 'Accepted'
397} 400}
398 401
399const VIDEO_PLAYLIST_PRIVACIES = { 402const VIDEO_PLAYLIST_PRIVACIES: { [ id in VideoPlaylistPrivacy ]: string } = {
400 [VideoPlaylistPrivacy.PUBLIC]: 'Public', 403 [VideoPlaylistPrivacy.PUBLIC]: 'Public',
401 [VideoPlaylistPrivacy.UNLISTED]: 'Unlisted', 404 [VideoPlaylistPrivacy.UNLISTED]: 'Unlisted',
402 [VideoPlaylistPrivacy.PRIVATE]: 'Private' 405 [VideoPlaylistPrivacy.PRIVATE]: 'Private'
403} 406}
404 407
405const VIDEO_PLAYLIST_TYPES = { 408const VIDEO_PLAYLIST_TYPES: { [ id in VideoPlaylistType ]: string } = {
406 [VideoPlaylistType.REGULAR]: 'Regular', 409 [VideoPlaylistType.REGULAR]: 'Regular',
407 [VideoPlaylistType.WATCH_LATER]: 'Watch later' 410 [VideoPlaylistType.WATCH_LATER]: 'Watch later'
408} 411}
@@ -600,6 +603,17 @@ const LRU_CACHE = {
600const HLS_STREAMING_PLAYLIST_DIRECTORY = join(CONFIG.STORAGE.STREAMING_PLAYLISTS_DIR, 'hls') 603const HLS_STREAMING_PLAYLIST_DIRECTORY = join(CONFIG.STORAGE.STREAMING_PLAYLISTS_DIR, 'hls')
601const HLS_REDUNDANCY_DIRECTORY = join(CONFIG.STORAGE.REDUNDANCY_DIR, 'hls') 604const HLS_REDUNDANCY_DIRECTORY = join(CONFIG.STORAGE.REDUNDANCY_DIR, 'hls')
602 605
606const VIDEO_LIVE = {
607 EXTENSION: '.ts',
608 RTMP: {
609 CHUNK_SIZE: 60000,
610 GOP_CACHE: true,
611 PING: 60,
612 PING_TIMEOUT: 30,
613 BASE_PATH: 'live'
614 }
615}
616
603const MEMOIZE_TTL = { 617const MEMOIZE_TTL = {
604 OVERVIEWS_SAMPLE: 1000 * 3600 * 4, // 4 hours 618 OVERVIEWS_SAMPLE: 1000 * 3600 * 4, // 4 hours
605 INFO_HASH_EXISTS: 1000 * 3600 * 12 // 12 hours 619 INFO_HASH_EXISTS: 1000 * 3600 * 12 // 12 hours
@@ -622,7 +636,8 @@ const REDUNDANCY = {
622const ACCEPT_HEADERS = [ 'html', 'application/json' ].concat(ACTIVITY_PUB.POTENTIAL_ACCEPT_HEADERS) 636const ACCEPT_HEADERS = [ 'html', 'application/json' ].concat(ACTIVITY_PUB.POTENTIAL_ACCEPT_HEADERS)
623 637
624const ASSETS_PATH = { 638const ASSETS_PATH = {
625 DEFAULT_AUDIO_BACKGROUND: join(root(), 'dist', 'server', 'assets', 'default-audio-background.jpg') 639 DEFAULT_AUDIO_BACKGROUND: join(root(), 'dist', 'server', 'assets', 'default-audio-background.jpg'),
640 DEFAULT_LIVE_BACKGROUND: join(root(), 'dist', 'server', 'assets', 'default-live-background.jpg')
626} 641}
627 642
628// --------------------------------------------------------------------------- 643// ---------------------------------------------------------------------------
@@ -688,9 +703,9 @@ if (isTestInstance() === true) {
688 STATIC_MAX_AGE.SERVER = '0' 703 STATIC_MAX_AGE.SERVER = '0'
689 704
690 ACTIVITY_PUB.COLLECTION_ITEMS_PER_PAGE = 2 705 ACTIVITY_PUB.COLLECTION_ITEMS_PER_PAGE = 2
691 ACTIVITY_PUB.ACTOR_REFRESH_INTERVAL = 10 * 1000 // 10 seconds 706 ACTIVITY_PUB.ACTOR_REFRESH_INTERVAL = 100 * 10000 // 10 seconds
692 ACTIVITY_PUB.VIDEO_REFRESH_INTERVAL = 10 * 1000 // 10 seconds 707 ACTIVITY_PUB.VIDEO_REFRESH_INTERVAL = 100 * 10000 // 10 seconds
693 ACTIVITY_PUB.VIDEO_PLAYLIST_REFRESH_INTERVAL = 10 * 1000 // 10 seconds 708 ACTIVITY_PUB.VIDEO_PLAYLIST_REFRESH_INTERVAL = 100 * 10000 // 10 seconds
694 709
695 CONSTRAINTS_FIELDS.ACTORS.AVATAR.FILE_SIZE.max = 100 * 1024 // 100KB 710 CONSTRAINTS_FIELDS.ACTORS.AVATAR.FILE_SIZE.max = 100 * 1024 // 100KB
696 711
@@ -737,6 +752,7 @@ const FILES_CONTENT_HASH = {
737export { 752export {
738 WEBSERVER, 753 WEBSERVER,
739 API_VERSION, 754 API_VERSION,
755 VIDEO_LIVE,
740 PEERTUBE_VERSION, 756 PEERTUBE_VERSION,
741 LAZY_STATIC_PATHS, 757 LAZY_STATIC_PATHS,
742 SEARCH_INDEX, 758 SEARCH_INDEX,
@@ -892,10 +908,14 @@ function buildVideoMimetypeExt () {
892function updateWebserverUrls () { 908function updateWebserverUrls () {
893 WEBSERVER.URL = sanitizeUrl(CONFIG.WEBSERVER.SCHEME + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT) 909 WEBSERVER.URL = sanitizeUrl(CONFIG.WEBSERVER.SCHEME + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT)
894 WEBSERVER.HOST = sanitizeHost(CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT, REMOTE_SCHEME.HTTP) 910 WEBSERVER.HOST = sanitizeHost(CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT, REMOTE_SCHEME.HTTP)
895 WEBSERVER.SCHEME = CONFIG.WEBSERVER.SCHEME
896 WEBSERVER.WS = CONFIG.WEBSERVER.WS 911 WEBSERVER.WS = CONFIG.WEBSERVER.WS
912
913 WEBSERVER.SCHEME = CONFIG.WEBSERVER.SCHEME
897 WEBSERVER.HOSTNAME = CONFIG.WEBSERVER.HOSTNAME 914 WEBSERVER.HOSTNAME = CONFIG.WEBSERVER.HOSTNAME
898 WEBSERVER.PORT = CONFIG.WEBSERVER.PORT 915 WEBSERVER.PORT = CONFIG.WEBSERVER.PORT
916 WEBSERVER.PORT = CONFIG.WEBSERVER.PORT
917
918 WEBSERVER.RTMP_URL = 'rtmp://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.LIVE.RTMP.PORT + '/' + VIDEO_LIVE.RTMP.BASE_PATH
899} 919}
900 920
901function updateWebserverConfig () { 921function updateWebserverConfig () {
diff --git a/server/initializers/database.ts b/server/initializers/database.ts
index a20cdacc3..128ed5b75 100644
--- a/server/initializers/database.ts
+++ b/server/initializers/database.ts
@@ -1,11 +1,11 @@
1import { QueryTypes, Transaction } from 'sequelize' 1import { QueryTypes, Transaction } from 'sequelize'
2import { Sequelize as SequelizeTypescript } from 'sequelize-typescript' 2import { Sequelize as SequelizeTypescript } from 'sequelize-typescript'
3import { AbuseModel } from '@server/models/abuse/abuse'
4import { AbuseMessageModel } from '@server/models/abuse/abuse-message'
5import { VideoAbuseModel } from '@server/models/abuse/video-abuse'
6import { VideoCommentAbuseModel } from '@server/models/abuse/video-comment-abuse'
7import { isTestInstance } from '../helpers/core-utils' 3import { isTestInstance } from '../helpers/core-utils'
8import { logger } from '../helpers/logger' 4import { logger } from '../helpers/logger'
5import { AbuseModel } from '../models/abuse/abuse'
6import { AbuseMessageModel } from '../models/abuse/abuse-message'
7import { VideoAbuseModel } from '../models/abuse/video-abuse'
8import { VideoCommentAbuseModel } from '../models/abuse/video-comment-abuse'
9import { AccountModel } from '../models/account/account' 9import { AccountModel } from '../models/account/account'
10import { AccountBlocklistModel } from '../models/account/account-blocklist' 10import { AccountBlocklistModel } from '../models/account/account-blocklist'
11import { AccountVideoRateModel } from '../models/account/account-video-rate' 11import { AccountVideoRateModel } from '../models/account/account-video-rate'
@@ -34,6 +34,7 @@ import { VideoChannelModel } from '../models/video/video-channel'
34import { VideoCommentModel } from '../models/video/video-comment' 34import { VideoCommentModel } from '../models/video/video-comment'
35import { VideoFileModel } from '../models/video/video-file' 35import { VideoFileModel } from '../models/video/video-file'
36import { VideoImportModel } from '../models/video/video-import' 36import { VideoImportModel } from '../models/video/video-import'
37import { VideoLiveModel } from '../models/video/video-live'
37import { VideoPlaylistModel } from '../models/video/video-playlist' 38import { VideoPlaylistModel } from '../models/video/video-playlist'
38import { VideoPlaylistElementModel } from '../models/video/video-playlist-element' 39import { VideoPlaylistElementModel } from '../models/video/video-playlist-element'
39import { VideoShareModel } from '../models/video/video-share' 40import { VideoShareModel } from '../models/video/video-share'
@@ -118,6 +119,7 @@ async function initDatabaseModels (silent: boolean) {
118 VideoViewModel, 119 VideoViewModel,
119 VideoRedundancyModel, 120 VideoRedundancyModel,
120 UserVideoHistoryModel, 121 UserVideoHistoryModel,
122 VideoLiveModel,
121 AccountBlocklistModel, 123 AccountBlocklistModel,
122 ServerBlocklistModel, 124 ServerBlocklistModel,
123 UserNotificationModel, 125 UserNotificationModel,
diff --git a/server/initializers/migrations/0535-video-live.ts b/server/initializers/migrations/0535-video-live.ts
new file mode 100644
index 000000000..35523efc4
--- /dev/null
+++ b/server/initializers/migrations/0535-video-live.ts
@@ -0,0 +1,39 @@
1import * as Sequelize from 'sequelize'
2
3async function up (utils: {
4 transaction: Sequelize.Transaction
5 queryInterface: Sequelize.QueryInterface
6 sequelize: Sequelize.Sequelize
7}): Promise<void> {
8 {
9 const query = `
10 CREATE TABLE IF NOT EXISTS "videoLive" (
11 "id" SERIAL ,
12 "streamKey" VARCHAR(255) NOT NULL,
13 "videoId" INTEGER NOT NULL REFERENCES "video" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
14 "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL,
15 "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL,
16 PRIMARY KEY ("id")
17 );
18 `
19
20 await utils.sequelize.query(query)
21 }
22
23 {
24 await utils.queryInterface.addColumn('video', 'isLive', {
25 type: Sequelize.BOOLEAN,
26 defaultValue: false,
27 allowNull: false
28 })
29 }
30}
31
32function down (options) {
33 throw new Error('Not implemented.')
34}
35
36export {
37 up,
38 down
39}
diff --git a/server/initializers/migrations/0540-video-file-infohash.ts b/server/initializers/migrations/0540-video-file-infohash.ts
new file mode 100644
index 000000000..550178dab
--- /dev/null
+++ b/server/initializers/migrations/0540-video-file-infohash.ts
@@ -0,0 +1,26 @@
1import * as Sequelize from 'sequelize'
2
3async function up (utils: {
4 transaction: Sequelize.Transaction
5 queryInterface: Sequelize.QueryInterface
6 sequelize: Sequelize.Sequelize
7}): Promise<void> {
8 {
9 const data = {
10 type: Sequelize.STRING,
11 defaultValue: null,
12 allowNull: true
13 }
14
15 await utils.queryInterface.changeColumn('videoFile', 'infoHash', data)
16 }
17}
18
19function down (options) {
20 throw new Error('Not implemented.')
21}
22
23export {
24 up,
25 down
26}
diff --git a/server/lib/hls.ts b/server/lib/hls.ts
index 76380b1f2..e38a8788c 100644
--- a/server/lib/hls.ts
+++ b/server/lib/hls.ts
@@ -65,7 +65,7 @@ async function updateMasterHLSPlaylist (video: MVideoWithFile) {
65 await writeFile(masterPlaylistPath, masterPlaylists.join('\n') + '\n') 65 await writeFile(masterPlaylistPath, masterPlaylists.join('\n') + '\n')
66} 66}
67 67
68async function updateSha256Segments (video: MVideoWithFile) { 68async function updateSha256VODSegments (video: MVideoWithFile) {
69 const json: { [filename: string]: { [range: string]: string } } = {} 69 const json: { [filename: string]: { [range: string]: string } } = {}
70 70
71 const playlistDirectory = join(HLS_STREAMING_PLAYLIST_DIRECTORY, video.uuid) 71 const playlistDirectory = join(HLS_STREAMING_PLAYLIST_DIRECTORY, video.uuid)
@@ -101,6 +101,11 @@ async function updateSha256Segments (video: MVideoWithFile) {
101 await outputJSON(outputPath, json) 101 await outputJSON(outputPath, json)
102} 102}
103 103
104async function buildSha256Segment (segmentPath: string) {
105 const buf = await readFile(segmentPath)
106 return sha256(buf)
107}
108
104function getRangesFromPlaylist (playlistContent: string) { 109function getRangesFromPlaylist (playlistContent: string) {
105 const ranges: { offset: number, length: number }[] = [] 110 const ranges: { offset: number, length: number }[] = []
106 const lines = playlistContent.split('\n') 111 const lines = playlistContent.split('\n')
@@ -187,7 +192,8 @@ function downloadPlaylistSegments (playlistUrl: string, destinationDir: string,
187 192
188export { 193export {
189 updateMasterHLSPlaylist, 194 updateMasterHLSPlaylist,
190 updateSha256Segments, 195 updateSha256VODSegments,
196 buildSha256Segment,
191 downloadPlaylistSegments, 197 downloadPlaylistSegments,
192 updateStreamingPlaylistsInfohashesIfNeeded 198 updateStreamingPlaylistsInfohashesIfNeeded
193} 199}
diff --git a/server/lib/job-queue/handlers/video-transcoding.ts b/server/lib/job-queue/handlers/video-transcoding.ts
index 7ebef46b4..6659ab716 100644
--- a/server/lib/job-queue/handlers/video-transcoding.ts
+++ b/server/lib/job-queue/handlers/video-transcoding.ts
@@ -84,7 +84,7 @@ async function onVideoFileOptimizerSuccess (videoArg: MVideoWithFile, payload: O
84 if (!videoDatabase) return undefined 84 if (!videoDatabase) return undefined
85 85
86 // Create transcoding jobs if there are enabled resolutions 86 // Create transcoding jobs if there are enabled resolutions
87 const resolutionsEnabled = computeResolutionsToTranscode(videoFileResolution) 87 const resolutionsEnabled = computeResolutionsToTranscode(videoFileResolution, 'vod')
88 logger.info( 88 logger.info(
89 'Resolutions computed for video %s and origin file resolution of %d.', videoDatabase.uuid, videoFileResolution, 89 'Resolutions computed for video %s and origin file resolution of %d.', videoDatabase.uuid, videoFileResolution,
90 { resolutions: resolutionsEnabled } 90 { resolutions: resolutionsEnabled }
diff --git a/server/lib/live-manager.ts b/server/lib/live-manager.ts
new file mode 100644
index 000000000..f602bfb6d
--- /dev/null
+++ b/server/lib/live-manager.ts
@@ -0,0 +1,310 @@
1
2import { AsyncQueue, queue } from 'async'
3import * as chokidar from 'chokidar'
4import { FfmpegCommand } from 'fluent-ffmpeg'
5import { ensureDir, readdir, remove } from 'fs-extra'
6import { basename, join } from 'path'
7import { computeResolutionsToTranscode, runLiveMuxing, runLiveTranscoding } from '@server/helpers/ffmpeg-utils'
8import { logger } from '@server/helpers/logger'
9import { CONFIG, registerConfigChangedHandler } from '@server/initializers/config'
10import { P2P_MEDIA_LOADER_PEER_VERSION, VIDEO_LIVE, WEBSERVER } from '@server/initializers/constants'
11import { VideoFileModel } from '@server/models/video/video-file'
12import { VideoLiveModel } from '@server/models/video/video-live'
13import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist'
14import { MStreamingPlaylist, MVideo, MVideoLiveVideo } from '@server/types/models'
15import { VideoState, VideoStreamingPlaylistType } from '@shared/models'
16import { buildSha256Segment } from './hls'
17import { getHLSDirectory } from './video-paths'
18
19const NodeRtmpServer = require('node-media-server/node_rtmp_server')
20const context = require('node-media-server/node_core_ctx')
21const nodeMediaServerLogger = require('node-media-server/node_core_logger')
22
23// Disable node media server logs
24nodeMediaServerLogger.setLogType(0)
25
26const config = {
27 rtmp: {
28 port: CONFIG.LIVE.RTMP.PORT,
29 chunk_size: VIDEO_LIVE.RTMP.CHUNK_SIZE,
30 gop_cache: VIDEO_LIVE.RTMP.GOP_CACHE,
31 ping: VIDEO_LIVE.RTMP.PING,
32 ping_timeout: VIDEO_LIVE.RTMP.PING_TIMEOUT
33 },
34 transcoding: {
35 ffmpeg: 'ffmpeg'
36 }
37}
38
39type SegmentSha256QueueParam = {
40 operation: 'update' | 'delete'
41 videoUUID: string
42 segmentPath: string
43}
44
45class LiveManager {
46
47 private static instance: LiveManager
48
49 private readonly transSessions = new Map<string, FfmpegCommand>()
50 private readonly segmentsSha256 = new Map<string, Map<string, string>>()
51
52 private segmentsSha256Queue: AsyncQueue<SegmentSha256QueueParam>
53 private rtmpServer: any
54
55 private constructor () {
56 }
57
58 init () {
59 this.getContext().nodeEvent.on('postPublish', (sessionId: string, streamPath: string) => {
60 logger.debug('RTMP received stream', { id: sessionId, streamPath })
61
62 const splittedPath = streamPath.split('/')
63 if (splittedPath.length !== 3 || splittedPath[1] !== VIDEO_LIVE.RTMP.BASE_PATH) {
64 logger.warn('Live path is incorrect.', { streamPath })
65 return this.abortSession(sessionId)
66 }
67
68 this.handleSession(sessionId, streamPath, splittedPath[2])
69 .catch(err => logger.error('Cannot handle sessions.', { err }))
70 })
71
72 this.getContext().nodeEvent.on('donePublish', sessionId => {
73 this.abortSession(sessionId)
74 })
75
76 this.segmentsSha256Queue = queue<SegmentSha256QueueParam, Error>((options, cb) => {
77 const promise = options.operation === 'update'
78 ? this.addSegmentSha(options)
79 : Promise.resolve(this.removeSegmentSha(options))
80
81 promise.then(() => cb())
82 .catch(err => {
83 logger.error('Cannot update/remove sha segment %s.', options.segmentPath, { err })
84 cb()
85 })
86 })
87
88 registerConfigChangedHandler(() => {
89 if (!this.rtmpServer && CONFIG.LIVE.ENABLED === true) {
90 this.run()
91 return
92 }
93
94 if (this.rtmpServer && CONFIG.LIVE.ENABLED === false) {
95 this.stop()
96 }
97 })
98 }
99
100 run () {
101 logger.info('Running RTMP server.')
102
103 this.rtmpServer = new NodeRtmpServer(config)
104 this.rtmpServer.run()
105 }
106
107 stop () {
108 logger.info('Stopping RTMP server.')
109
110 this.rtmpServer.stop()
111 this.rtmpServer = undefined
112 }
113
114 getSegmentsSha256 (videoUUID: string) {
115 return this.segmentsSha256.get(videoUUID)
116 }
117
118 private getContext () {
119 return context
120 }
121
122 private abortSession (id: string) {
123 const session = this.getContext().sessions.get(id)
124 if (session) session.stop()
125
126 const transSession = this.transSessions.get(id)
127 if (transSession) transSession.kill('SIGKILL')
128 }
129
130 private async handleSession (sessionId: string, streamPath: string, streamKey: string) {
131 const videoLive = await VideoLiveModel.loadByStreamKey(streamKey)
132 if (!videoLive) {
133 logger.warn('Unknown live video with stream key %s.', streamKey)
134 return this.abortSession(sessionId)
135 }
136
137 const video = videoLive.Video
138 const playlistUrl = WEBSERVER.URL + VideoStreamingPlaylistModel.getHlsMasterPlaylistStaticPath(video.uuid)
139
140 const session = this.getContext().sessions.get(sessionId)
141 const resolutionsEnabled = CONFIG.LIVE.TRANSCODING.ENABLED
142 ? computeResolutionsToTranscode(session.videoHeight, 'live')
143 : []
144
145 logger.info('Will mux/transcode live video of original resolution %d.', session.videoHeight, { resolutionsEnabled })
146
147 const [ videoStreamingPlaylist ] = await VideoStreamingPlaylistModel.upsert({
148 videoId: video.id,
149 playlistUrl,
150 segmentsSha256Url: WEBSERVER.URL + VideoStreamingPlaylistModel.getHlsSha256SegmentsStaticPath(video.uuid, video.isLive),
151 p2pMediaLoaderInfohashes: VideoStreamingPlaylistModel.buildP2PMediaLoaderInfoHashes(playlistUrl, resolutionsEnabled),
152 p2pMediaLoaderPeerVersion: P2P_MEDIA_LOADER_PEER_VERSION,
153
154 type: VideoStreamingPlaylistType.HLS
155 }, { returning: true }) as [ MStreamingPlaylist, boolean ]
156
157 video.state = VideoState.PUBLISHED
158 await video.save()
159
160 // FIXME: federation?
161
162 return this.runMuxing({
163 sessionId,
164 videoLive,
165 playlist: videoStreamingPlaylist,
166 streamPath,
167 originalResolution: session.videoHeight,
168 resolutionsEnabled
169 })
170 }
171
172 private async runMuxing (options: {
173 sessionId: string
174 videoLive: MVideoLiveVideo
175 playlist: MStreamingPlaylist
176 streamPath: string
177 resolutionsEnabled: number[]
178 originalResolution: number
179 }) {
180 const { sessionId, videoLive, playlist, streamPath, resolutionsEnabled, originalResolution } = options
181 const allResolutions = resolutionsEnabled.concat([ originalResolution ])
182
183 for (let i = 0; i < allResolutions.length; i++) {
184 const resolution = allResolutions[i]
185
186 VideoFileModel.upsert({
187 resolution,
188 size: -1,
189 extname: '.ts',
190 infoHash: null,
191 fps: -1,
192 videoStreamingPlaylistId: playlist.id
193 }).catch(err => {
194 logger.error('Cannot create file for live streaming.', { err })
195 })
196 }
197
198 const outPath = getHLSDirectory(videoLive.Video)
199 await ensureDir(outPath)
200
201 const rtmpUrl = 'rtmp://127.0.0.1:' + config.rtmp.port + streamPath
202 const ffmpegExec = CONFIG.LIVE.TRANSCODING.ENABLED
203 ? runLiveTranscoding(rtmpUrl, outPath, allResolutions)
204 : runLiveMuxing(rtmpUrl, outPath)
205
206 logger.info('Running live muxing/transcoding.')
207
208 this.transSessions.set(sessionId, ffmpegExec)
209
210 const onFFmpegEnded = () => {
211 watcher.close()
212 .catch(err => logger.error('Cannot close watcher of %s.', outPath, { err }))
213
214 this.onEndTransmuxing(videoLive.Video, playlist, streamPath, outPath)
215 .catch(err => logger.error('Error in closed transmuxing.', { err }))
216 }
217
218 ffmpegExec.on('error', (err, stdout, stderr) => {
219 onFFmpegEnded()
220
221 // Don't care that we killed the ffmpeg process
222 if (err?.message?.includes('SIGKILL')) return
223
224 logger.error('Live transcoding error.', { err, stdout, stderr })
225 })
226
227 ffmpegExec.on('end', () => onFFmpegEnded())
228
229 const videoUUID = videoLive.Video.uuid
230 const watcher = chokidar.watch(outPath + '/*.ts')
231
232 const updateHandler = segmentPath => this.segmentsSha256Queue.push({ operation: 'update', segmentPath, videoUUID })
233 const deleteHandler = segmentPath => this.segmentsSha256Queue.push({ operation: 'delete', segmentPath, videoUUID })
234
235 watcher.on('add', p => updateHandler(p))
236 watcher.on('change', p => updateHandler(p))
237 watcher.on('unlink', p => deleteHandler(p))
238 }
239
240 private async onEndTransmuxing (video: MVideo, playlist: MStreamingPlaylist, streamPath: string, outPath: string) {
241 logger.info('RTMP transmuxing for %s ended.', streamPath)
242
243 const files = await readdir(outPath)
244
245 for (const filename of files) {
246 if (
247 filename.endsWith('.ts') ||
248 filename.endsWith('.m3u8') ||
249 filename.endsWith('.mpd') ||
250 filename.endsWith('.m4s') ||
251 filename.endsWith('.tmp')
252 ) {
253 const p = join(outPath, filename)
254
255 remove(p)
256 .catch(err => logger.error('Cannot remove %s.', p, { err }))
257 }
258 }
259
260 playlist.destroy()
261 .catch(err => logger.error('Cannot remove live streaming playlist.', { err }))
262
263 video.state = VideoState.LIVE_ENDED
264 video.save()
265 .catch(err => logger.error('Cannot save new video state of live streaming.', { err }))
266 }
267
268 private async addSegmentSha (options: SegmentSha256QueueParam) {
269 const segmentName = basename(options.segmentPath)
270 logger.debug('Updating live sha segment %s.', options.segmentPath)
271
272 const shaResult = await buildSha256Segment(options.segmentPath)
273
274 if (!this.segmentsSha256.has(options.videoUUID)) {
275 this.segmentsSha256.set(options.videoUUID, new Map())
276 }
277
278 const filesMap = this.segmentsSha256.get(options.videoUUID)
279 filesMap.set(segmentName, shaResult)
280 }
281
282 private removeSegmentSha (options: SegmentSha256QueueParam) {
283 const segmentName = basename(options.segmentPath)
284
285 logger.debug('Removing live sha segment %s.', options.segmentPath)
286
287 const filesMap = this.segmentsSha256.get(options.videoUUID)
288 if (!filesMap) {
289 logger.warn('Unknown files map to remove sha for %s.', options.videoUUID)
290 return
291 }
292
293 if (!filesMap.has(segmentName)) {
294 logger.warn('Unknown segment in files map for video %s and segment %s.', options.videoUUID, options.segmentPath)
295 return
296 }
297
298 filesMap.delete(segmentName)
299 }
300
301 static get Instance () {
302 return this.instance || (this.instance = new this())
303 }
304}
305
306// ---------------------------------------------------------------------------
307
308export {
309 LiveManager
310}
diff --git a/server/lib/video-paths.ts b/server/lib/video-paths.ts
index a35661f02..b6cb39d25 100644
--- a/server/lib/video-paths.ts
+++ b/server/lib/video-paths.ts
@@ -27,7 +27,8 @@ function generateWebTorrentVideoName (uuid: string, resolution: number, extname:
27function getVideoFilePath (videoOrPlaylist: MVideo | MStreamingPlaylistVideo, videoFile: MVideoFile, isRedundancy = false) { 27function getVideoFilePath (videoOrPlaylist: MVideo | MStreamingPlaylistVideo, videoFile: MVideoFile, isRedundancy = false) {
28 if (isStreamingPlaylist(videoOrPlaylist)) { 28 if (isStreamingPlaylist(videoOrPlaylist)) {
29 const video = extractVideo(videoOrPlaylist) 29 const video = extractVideo(videoOrPlaylist)
30 return join(HLS_STREAMING_PLAYLIST_DIRECTORY, video.uuid, getVideoFilename(videoOrPlaylist, videoFile)) 30
31 return join(getHLSDirectory(video), getVideoFilename(videoOrPlaylist, videoFile))
31 } 32 }
32 33
33 const baseDir = isRedundancy ? CONFIG.STORAGE.REDUNDANCY_DIR : CONFIG.STORAGE.VIDEOS_DIR 34 const baseDir = isRedundancy ? CONFIG.STORAGE.REDUNDANCY_DIR : CONFIG.STORAGE.VIDEOS_DIR
diff --git a/server/lib/video-transcoding.ts b/server/lib/video-transcoding.ts
index 5a2dbc9f7..a7b73a30d 100644
--- a/server/lib/video-transcoding.ts
+++ b/server/lib/video-transcoding.ts
@@ -13,13 +13,14 @@ import { copyFile, ensureDir, move, remove, stat } from 'fs-extra'
13import { logger } from '../helpers/logger' 13import { logger } from '../helpers/logger'
14import { VideoResolution } from '../../shared/models/videos' 14import { VideoResolution } from '../../shared/models/videos'
15import { VideoFileModel } from '../models/video/video-file' 15import { VideoFileModel } from '../models/video/video-file'
16import { updateMasterHLSPlaylist, updateSha256Segments } from './hls' 16import { updateMasterHLSPlaylist, updateSha256VODSegments } from './hls'
17import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-playlist' 17import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-playlist'
18import { VideoStreamingPlaylistType } from '../../shared/models/videos/video-streaming-playlist.type' 18import { VideoStreamingPlaylistType } from '../../shared/models/videos/video-streaming-playlist.type'
19import { CONFIG } from '../initializers/config' 19import { CONFIG } from '../initializers/config'
20import { MStreamingPlaylistFilesVideo, MVideoFile, MVideoWithAllFiles, MVideoWithFile } from '@server/types/models' 20import { MStreamingPlaylistFilesVideo, MVideoFile, MVideoWithAllFiles, MVideoWithFile } from '@server/types/models'
21import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent' 21import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent'
22import { generateVideoStreamingPlaylistName, getVideoFilename, getVideoFilePath } from './video-paths' 22import { generateVideoStreamingPlaylistName, getVideoFilename, getVideoFilePath } from './video-paths'
23import { spawn } from 'child_process'
23 24
24/** 25/**
25 * Optimize the original video file and replace it. The resolution is not changed. 26 * Optimize the original video file and replace it. The resolution is not changed.
@@ -182,7 +183,7 @@ async function generateHlsPlaylist (video: MVideoWithFile, resolution: VideoReso
182 const [ videoStreamingPlaylist ] = await VideoStreamingPlaylistModel.upsert({ 183 const [ videoStreamingPlaylist ] = await VideoStreamingPlaylistModel.upsert({
183 videoId: video.id, 184 videoId: video.id,
184 playlistUrl, 185 playlistUrl,
185 segmentsSha256Url: WEBSERVER.URL + VideoStreamingPlaylistModel.getHlsSha256SegmentsStaticPath(video.uuid), 186 segmentsSha256Url: WEBSERVER.URL + VideoStreamingPlaylistModel.getHlsSha256SegmentsStaticPath(video.uuid, video.isLive),
186 p2pMediaLoaderInfohashes: VideoStreamingPlaylistModel.buildP2PMediaLoaderInfoHashes(playlistUrl, video.VideoFiles), 187 p2pMediaLoaderInfohashes: VideoStreamingPlaylistModel.buildP2PMediaLoaderInfoHashes(playlistUrl, video.VideoFiles),
187 p2pMediaLoaderPeerVersion: P2P_MEDIA_LOADER_PEER_VERSION, 188 p2pMediaLoaderPeerVersion: P2P_MEDIA_LOADER_PEER_VERSION,
188 189
@@ -213,7 +214,7 @@ async function generateHlsPlaylist (video: MVideoWithFile, resolution: VideoReso
213 video.setHLSPlaylist(videoStreamingPlaylist) 214 video.setHLSPlaylist(videoStreamingPlaylist)
214 215
215 await updateMasterHLSPlaylist(video) 216 await updateMasterHLSPlaylist(video)
216 await updateSha256Segments(video) 217 await updateSha256VODSegments(video)
217 218
218 return video 219 return video
219} 220}
diff --git a/server/lib/video.ts b/server/lib/video.ts
new file mode 100644
index 000000000..a28f31529
--- /dev/null
+++ b/server/lib/video.ts
@@ -0,0 +1,31 @@
1
2import { VideoModel } from '@server/models/video/video'
3import { FilteredModelAttributes } from '@server/types'
4import { VideoCreate, VideoPrivacy, VideoState } from '@shared/models'
5
6function buildLocalVideoFromCreate (videoInfo: VideoCreate, channelId: number): FilteredModelAttributes<VideoModel> {
7 return {
8 name: videoInfo.name,
9 remote: false,
10 category: videoInfo.category,
11 licence: videoInfo.licence,
12 language: videoInfo.language,
13 commentsEnabled: videoInfo.commentsEnabled !== false, // If the value is not "false", the default is "true"
14 downloadEnabled: videoInfo.downloadEnabled !== false,
15 waitTranscoding: videoInfo.waitTranscoding || false,
16 state: VideoState.WAITING_FOR_LIVE,
17 nsfw: videoInfo.nsfw || false,
18 description: videoInfo.description,
19 support: videoInfo.support,
20 privacy: videoInfo.privacy || VideoPrivacy.PRIVATE,
21 duration: 0,
22 channelId: channelId,
23 originallyPublishedAt: videoInfo.originallyPublishedAt
24 }
25}
26
27// ---------------------------------------------------------------------------
28
29export {
30 buildLocalVideoFromCreate
31}
diff --git a/server/middlewares/validators/videos/video-live.ts b/server/middlewares/validators/videos/video-live.ts
new file mode 100644
index 000000000..a4c364976
--- /dev/null
+++ b/server/middlewares/validators/videos/video-live.ts
@@ -0,0 +1,66 @@
1import * as express from 'express'
2import { body, param } from 'express-validator'
3import { checkUserCanManageVideo, doesVideoChannelOfAccountExist, doesVideoExist } from '@server/helpers/middlewares/videos'
4import { UserRight } from '@shared/models'
5import { isIdOrUUIDValid, isIdValid, toIntOrNull } from '../../../helpers/custom-validators/misc'
6import { isVideoNameValid } from '../../../helpers/custom-validators/videos'
7import { cleanUpReqFiles } from '../../../helpers/express-utils'
8import { logger } from '../../../helpers/logger'
9import { CONFIG } from '../../../initializers/config'
10import { areValidationErrors } from '../utils'
11import { getCommonVideoEditAttributes } from './videos'
12import { VideoLiveModel } from '@server/models/video/video-live'
13
14const videoLiveGetValidator = [
15 param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'),
16
17 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
18 logger.debug('Checking videoLiveGetValidator parameters', { parameters: req.body })
19
20 if (areValidationErrors(req, res)) return
21 if (!await doesVideoExist(req.params.videoId, res, 'all')) return
22
23 // Check if the user who did the request is able to update the video
24 const user = res.locals.oauth.token.User
25 if (!checkUserCanManageVideo(user, res.locals.videoAll, UserRight.UPDATE_ANY_VIDEO, res)) return
26
27 const videoLive = await VideoLiveModel.loadByVideoId(res.locals.videoAll.id)
28 if (!videoLive) return res.sendStatus(404)
29
30 res.locals.videoLive = videoLive
31
32 return next()
33 }
34]
35
36const videoLiveAddValidator = getCommonVideoEditAttributes().concat([
37 body('channelId')
38 .customSanitizer(toIntOrNull)
39 .custom(isIdValid).withMessage('Should have correct video channel id'),
40
41 body('name')
42 .custom(isVideoNameValid).withMessage('Should have a valid name'),
43
44 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
45 logger.debug('Checking videoLiveAddValidator parameters', { parameters: req.body })
46
47 if (CONFIG.LIVE.ENABLED !== true) {
48 return res.status(403)
49 .json({ error: 'Live is not enabled on this instance' })
50 }
51
52 if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
53
54 const user = res.locals.oauth.token.User
55 if (!await doesVideoChannelOfAccountExist(req.body.channelId, user, res)) return cleanUpReqFiles(req)
56
57 return next()
58 }
59])
60
61// ---------------------------------------------------------------------------
62
63export {
64 videoLiveAddValidator,
65 videoLiveGetValidator
66}
diff --git a/server/models/video/video-file.ts b/server/models/video/video-file.ts
index f95022383..6a321917c 100644
--- a/server/models/video/video-file.ts
+++ b/server/models/video/video-file.ts
@@ -123,8 +123,8 @@ export class VideoFileModel extends Model<VideoFileModel> {
123 @Column 123 @Column
124 extname: string 124 extname: string
125 125
126 @AllowNull(false) 126 @AllowNull(true)
127 @Is('VideoFileInfohash', value => throwIfNotValid(value, isVideoFileInfoHashValid, 'info hash')) 127 @Is('VideoFileInfohash', value => throwIfNotValid(value, isVideoFileInfoHashValid, 'info hash', true))
128 @Column 128 @Column
129 infoHash: string 129 infoHash: string
130 130
diff --git a/server/models/video/video-format-utils.ts b/server/models/video/video-format-utils.ts
index ad512fc7f..0dbd92a43 100644
--- a/server/models/video/video-format-utils.ts
+++ b/server/models/video/video-format-utils.ts
@@ -77,6 +77,8 @@ function videoModelToFormattedJSON (video: MVideoFormattable, options?: VideoFor
77 publishedAt: video.publishedAt, 77 publishedAt: video.publishedAt,
78 originallyPublishedAt: video.originallyPublishedAt, 78 originallyPublishedAt: video.originallyPublishedAt,
79 79
80 isLive: video.isLive,
81
80 account: video.VideoChannel.Account.toFormattedSummaryJSON(), 82 account: video.VideoChannel.Account.toFormattedSummaryJSON(),
81 channel: video.VideoChannel.toFormattedSummaryJSON(), 83 channel: video.VideoChannel.toFormattedSummaryJSON(),
82 84
diff --git a/server/models/video/video-live.ts b/server/models/video/video-live.ts
new file mode 100644
index 000000000..6929b9688
--- /dev/null
+++ b/server/models/video/video-live.ts
@@ -0,0 +1,74 @@
1import { AllowNull, BelongsTo, Column, CreatedAt, DataType, DefaultScope, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript'
2import { WEBSERVER } from '@server/initializers/constants'
3import { MVideoLive, MVideoLiveVideo } from '@server/types/models'
4import { VideoLive } from '@shared/models/videos/video-live.model'
5import { VideoModel } from './video'
6
7@DefaultScope(() => ({
8 include: [
9 {
10 model: VideoModel,
11 required: true
12 }
13 ]
14}))
15@Table({
16 tableName: 'videoLive',
17 indexes: [
18 {
19 fields: [ 'videoId' ],
20 unique: true
21 }
22 ]
23})
24export class VideoLiveModel extends Model<VideoLiveModel> {
25
26 @AllowNull(false)
27 @Column(DataType.STRING)
28 streamKey: string
29
30 @CreatedAt
31 createdAt: Date
32
33 @UpdatedAt
34 updatedAt: Date
35
36 @ForeignKey(() => VideoModel)
37 @Column
38 videoId: number
39
40 @BelongsTo(() => VideoModel, {
41 foreignKey: {
42 allowNull: false
43 },
44 onDelete: 'cascade'
45 })
46 Video: VideoModel
47
48 static loadByStreamKey (streamKey: string) {
49 const query = {
50 where: {
51 streamKey
52 }
53 }
54
55 return VideoLiveModel.findOne<MVideoLiveVideo>(query)
56 }
57
58 static loadByVideoId (videoId: number) {
59 const query = {
60 where: {
61 videoId
62 }
63 }
64
65 return VideoLiveModel.findOne<MVideoLive>(query)
66 }
67
68 toFormattedJSON (): VideoLive {
69 return {
70 rtmpUrl: WEBSERVER.RTMP_URL,
71 streamKey: this.streamKey
72 }
73 }
74}
diff --git a/server/models/video/video-streaming-playlist.ts b/server/models/video/video-streaming-playlist.ts
index 021b9b063..b8dc7c450 100644
--- a/server/models/video/video-streaming-playlist.ts
+++ b/server/models/video/video-streaming-playlist.ts
@@ -173,7 +173,9 @@ export class VideoStreamingPlaylistModel extends Model<VideoStreamingPlaylistMod
173 return join(STATIC_PATHS.STREAMING_PLAYLISTS.HLS, videoUUID, VideoStreamingPlaylistModel.getHlsPlaylistFilename(resolution)) 173 return join(STATIC_PATHS.STREAMING_PLAYLISTS.HLS, videoUUID, VideoStreamingPlaylistModel.getHlsPlaylistFilename(resolution))
174 } 174 }
175 175
176 static getHlsSha256SegmentsStaticPath (videoUUID: string) { 176 static getHlsSha256SegmentsStaticPath (videoUUID: string, isLive: boolean) {
177 if (isLive) return join('/live', 'segments-sha256', videoUUID)
178
177 return join(STATIC_PATHS.STREAMING_PLAYLISTS.HLS, videoUUID, VideoStreamingPlaylistModel.getHlsSha256SegmentsFilename()) 179 return join(STATIC_PATHS.STREAMING_PLAYLISTS.HLS, videoUUID, VideoStreamingPlaylistModel.getHlsSha256SegmentsFilename())
178 } 180 }
179 181
diff --git a/server/models/video/video.ts b/server/models/video/video.ts
index 410d71cb3..1037730e3 100644
--- a/server/models/video/video.ts
+++ b/server/models/video/video.ts
@@ -550,6 +550,11 @@ export class VideoModel extends Model<VideoModel> {
550 remote: boolean 550 remote: boolean
551 551
552 @AllowNull(false) 552 @AllowNull(false)
553 @Default(false)
554 @Column
555 isLive: boolean
556
557 @AllowNull(false)
553 @Is('VideoUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url')) 558 @Is('VideoUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url'))
554 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.URL.max)) 559 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.URL.max))
555 url: string 560 url: string
diff --git a/server/tests/api/check-params/config.ts b/server/tests/api/check-params/config.ts
index 3f2708f94..35cb333ef 100644
--- a/server/tests/api/check-params/config.ts
+++ b/server/tests/api/check-params/config.ts
@@ -100,6 +100,22 @@ describe('Test config API validators', function () {
100 enabled: false 100 enabled: false
101 } 101 }
102 }, 102 },
103 live: {
104 enabled: true,
105
106 transcoding: {
107 enabled: true,
108 threads: 4,
109 resolutions: {
110 '240p': true,
111 '360p': true,
112 '480p': true,
113 '720p': true,
114 '1080p': true,
115 '2160p': true
116 }
117 }
118 },
103 import: { 119 import: {
104 videos: { 120 videos: {
105 http: { 121 http: {
diff --git a/server/tests/api/server/config.ts b/server/tests/api/server/config.ts
index 60efd332c..a46e179c2 100644
--- a/server/tests/api/server/config.ts
+++ b/server/tests/api/server/config.ts
@@ -64,6 +64,7 @@ function checkInitialConfig (server: ServerInfo, data: CustomConfig) {
64 64
65 expect(data.user.videoQuota).to.equal(5242880) 65 expect(data.user.videoQuota).to.equal(5242880)
66 expect(data.user.videoQuotaDaily).to.equal(-1) 66 expect(data.user.videoQuotaDaily).to.equal(-1)
67
67 expect(data.transcoding.enabled).to.be.false 68 expect(data.transcoding.enabled).to.be.false
68 expect(data.transcoding.allowAdditionalExtensions).to.be.false 69 expect(data.transcoding.allowAdditionalExtensions).to.be.false
69 expect(data.transcoding.allowAudioFiles).to.be.false 70 expect(data.transcoding.allowAudioFiles).to.be.false
@@ -77,6 +78,16 @@ function checkInitialConfig (server: ServerInfo, data: CustomConfig) {
77 expect(data.transcoding.webtorrent.enabled).to.be.true 78 expect(data.transcoding.webtorrent.enabled).to.be.true
78 expect(data.transcoding.hls.enabled).to.be.true 79 expect(data.transcoding.hls.enabled).to.be.true
79 80
81 expect(data.live.enabled).to.be.false
82 expect(data.live.transcoding.enabled).to.be.false
83 expect(data.live.transcoding.threads).to.equal(2)
84 expect(data.live.transcoding.resolutions['240p']).to.be.false
85 expect(data.live.transcoding.resolutions['360p']).to.be.false
86 expect(data.live.transcoding.resolutions['480p']).to.be.false
87 expect(data.live.transcoding.resolutions['720p']).to.be.false
88 expect(data.live.transcoding.resolutions['1080p']).to.be.false
89 expect(data.live.transcoding.resolutions['2160p']).to.be.false
90
80 expect(data.import.videos.http.enabled).to.be.true 91 expect(data.import.videos.http.enabled).to.be.true
81 expect(data.import.videos.torrent.enabled).to.be.true 92 expect(data.import.videos.torrent.enabled).to.be.true
82 expect(data.autoBlacklist.videos.ofUsers.enabled).to.be.false 93 expect(data.autoBlacklist.videos.ofUsers.enabled).to.be.false
@@ -150,6 +161,16 @@ function checkUpdatedConfig (data: CustomConfig) {
150 expect(data.transcoding.hls.enabled).to.be.false 161 expect(data.transcoding.hls.enabled).to.be.false
151 expect(data.transcoding.webtorrent.enabled).to.be.true 162 expect(data.transcoding.webtorrent.enabled).to.be.true
152 163
164 expect(data.live.enabled).to.be.true
165 expect(data.live.transcoding.enabled).to.be.true
166 expect(data.live.transcoding.threads).to.equal(4)
167 expect(data.live.transcoding.resolutions['240p']).to.be.true
168 expect(data.live.transcoding.resolutions['360p']).to.be.true
169 expect(data.live.transcoding.resolutions['480p']).to.be.true
170 expect(data.live.transcoding.resolutions['720p']).to.be.true
171 expect(data.live.transcoding.resolutions['1080p']).to.be.true
172 expect(data.live.transcoding.resolutions['2160p']).to.be.true
173
153 expect(data.import.videos.http.enabled).to.be.false 174 expect(data.import.videos.http.enabled).to.be.false
154 expect(data.import.videos.torrent.enabled).to.be.false 175 expect(data.import.videos.torrent.enabled).to.be.false
155 expect(data.autoBlacklist.videos.ofUsers.enabled).to.be.true 176 expect(data.autoBlacklist.videos.ofUsers.enabled).to.be.true
@@ -301,6 +322,21 @@ describe('Test config', function () {
301 enabled: false 322 enabled: false
302 } 323 }
303 }, 324 },
325 live: {
326 enabled: true,
327 transcoding: {
328 enabled: true,
329 threads: 4,
330 resolutions: {
331 '240p': true,
332 '360p': true,
333 '480p': true,
334 '720p': true,
335 '1080p': true,
336 '2160p': true
337 }
338 }
339 },
304 import: { 340 import: {
305 videos: { 341 videos: {
306 http: { 342 http: {
diff --git a/server/tests/api/videos/video-transcoder.ts b/server/tests/api/videos/video-transcoder.ts
index e3fd0ec22..a1959e1a9 100644
--- a/server/tests/api/videos/video-transcoder.ts
+++ b/server/tests/api/videos/video-transcoder.ts
@@ -83,7 +83,7 @@ describe('Test video transcoding', function () {
83 }) 83 })
84 84
85 it('Should transcode video on server 2', async function () { 85 it('Should transcode video on server 2', async function () {
86 this.timeout(60000) 86 this.timeout(120000)
87 87
88 const videoAttributes = { 88 const videoAttributes = {
89 name: 'my super name for server 2', 89 name: 'my super name for server 2',
diff --git a/server/types/models/video/index.ts b/server/types/models/video/index.ts
index 25db23898..e586a4e42 100644
--- a/server/types/models/video/index.ts
+++ b/server/types/models/video/index.ts
@@ -9,6 +9,7 @@ export * from './video-channels'
9export * from './video-comment' 9export * from './video-comment'
10export * from './video-file' 10export * from './video-file'
11export * from './video-import' 11export * from './video-import'
12export * from './video-live'
12export * from './video-playlist' 13export * from './video-playlist'
13export * from './video-playlist-element' 14export * from './video-playlist-element'
14export * from './video-rate' 15export * from './video-rate'
diff --git a/server/types/models/video/video-live.ts b/server/types/models/video/video-live.ts
new file mode 100644
index 000000000..346052417
--- /dev/null
+++ b/server/types/models/video/video-live.ts
@@ -0,0 +1,15 @@
1import { VideoLiveModel } from '@server/models/video/video-live'
2import { PickWith } from '@shared/core-utils'
3import { MVideo } from './video'
4
5type Use<K extends keyof VideoLiveModel, M> = PickWith<VideoLiveModel, K, M>
6
7// ############################################################################
8
9export type MVideoLive = Omit<VideoLiveModel, 'Video'>
10
11// ############################################################################
12
13export type MVideoLiveVideo =
14 MVideoLive &
15 Use<'Video', MVideo>
diff --git a/server/typings/express/index.d.ts b/server/typings/express/index.d.ts
index cd8e544e0..a83619a0e 100644
--- a/server/typings/express/index.d.ts
+++ b/server/typings/express/index.d.ts
@@ -9,7 +9,8 @@ import {
9 MVideoFile, 9 MVideoFile,
10 MVideoImmutable, 10 MVideoImmutable,
11 MVideoPlaylistFull, 11 MVideoPlaylistFull,
12 MVideoPlaylistFullSummary 12 MVideoPlaylistFullSummary,
13 MVideoLive
13} from '@server/types/models' 14} from '@server/types/models'
14import { MOAuthTokenUser } from '@server/types/models/oauth/oauth-token' 15import { MOAuthTokenUser } from '@server/types/models/oauth/oauth-token'
15import { MPlugin, MServer, MServerBlocklist } from '@server/types/models/server' 16import { MPlugin, MServer, MServerBlocklist } from '@server/types/models/server'
@@ -68,6 +69,8 @@ declare module 'express' {
68 onlyVideoWithRights?: MVideoWithRights 69 onlyVideoWithRights?: MVideoWithRights
69 videoId?: MVideoIdThumbnail 70 videoId?: MVideoIdThumbnail
70 71
72 videoLive?: MVideoLive
73
71 videoShare?: MVideoShareActor 74 videoShare?: MVideoShareActor
72 75
73 videoFile?: MVideoFile 76 videoFile?: MVideoFile