diff options
author | Chocobozzz <me@florianbigard.com> | 2020-09-17 09:20:52 +0200 |
---|---|---|
committer | Chocobozzz <chocobozzz@cpy.re> | 2020-11-09 15:33:04 +0100 |
commit | c6c0fa6cd8fe8f752463d8982c3dbcd448739c4e (patch) | |
tree | 79304b0152b0a38d33b26e65d4acdad0da4032a7 /server | |
parent | 110d463fece85e87a26aca48a6048ae0017a27b3 (diff) | |
download | PeerTube-c6c0fa6cd8fe8f752463d8982c3dbcd448739c4e.tar.gz PeerTube-c6c0fa6cd8fe8f752463d8982c3dbcd448739c4e.tar.zst PeerTube-c6c0fa6cd8fe8f752463d8982c3dbcd448739c4e.zip |
Live streaming implementation first step
Diffstat (limited to 'server')
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 | ||
238 | async function updateCustomConfig (req: express.Request, res: express.Response) { | 246 | async 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 | ||
260 | function getRegisteredThemes () { | 268 | function getRegisteredThemes () { |
@@ -268,9 +276,13 @@ function getRegisteredThemes () { | |||
268 | })) | 276 | })) |
269 | } | 277 | } |
270 | 278 | ||
271 | function getEnabledResolutions () { | 279 | function 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' | |||
63 | import { videoCaptionsRouter } from './captions' | 63 | import { videoCaptionsRouter } from './captions' |
64 | import { videoCommentRouter } from './comment' | 64 | import { videoCommentRouter } from './comment' |
65 | import { videoImportsRouter } from './import' | 65 | import { videoImportsRouter } from './import' |
66 | import { liveRouter } from './live' | ||
66 | import { ownershipVideoRouter } from './ownership' | 67 | import { ownershipVideoRouter } from './ownership' |
67 | import { rateVideoRouter } from './rate' | 68 | import { rateVideoRouter } from './rate' |
68 | import { watchingRouter } from './watching' | 69 | import { watchingRouter } from './watching' |
@@ -96,6 +97,7 @@ videosRouter.use('/', videoCaptionsRouter) | |||
96 | videosRouter.use('/', videoImportsRouter) | 97 | videosRouter.use('/', videoImportsRouter) |
97 | videosRouter.use('/', ownershipVideoRouter) | 98 | videosRouter.use('/', ownershipVideoRouter) |
98 | videosRouter.use('/', watchingRouter) | 99 | videosRouter.use('/', watchingRouter) |
100 | videosRouter.use('/', liveRouter) | ||
99 | 101 | ||
100 | videosRouter.get('/categories', listVideoCategories) | 102 | videosRouter.get('/categories', listVideoCategories) |
101 | videosRouter.get('/licences', listVideoLicences) | 103 | videosRouter.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 | ||
310 | async function updateVideo (req: express.Request, res: express.Response) { | 312 | async 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 @@ | |||
1 | import * as express from 'express' | ||
2 | import { v4 as uuidv4 } from 'uuid' | ||
3 | import { createReqFiles } from '@server/helpers/express-utils' | ||
4 | import { CONFIG } from '@server/initializers/config' | ||
5 | import { ASSETS_PATH, MIMETYPES } from '@server/initializers/constants' | ||
6 | import { getVideoActivityPubUrl } from '@server/lib/activitypub/url' | ||
7 | import { videoLiveAddValidator, videoLiveGetValidator } from '@server/middlewares/validators/videos/video-live' | ||
8 | import { VideoLiveModel } from '@server/models/video/video-live' | ||
9 | import { MVideoDetails, MVideoFullLight } from '@server/types/models' | ||
10 | import { VideoCreate, VideoPrivacy, VideoState } from '../../../../shared' | ||
11 | import { ThumbnailType } from '../../../../shared/models/videos/thumbnail.type' | ||
12 | import { logger } from '../../../helpers/logger' | ||
13 | import { sequelizeTypescript } from '../../../initializers/database' | ||
14 | import { createVideoMiniatureFromExisting } from '../../../lib/thumbnail' | ||
15 | import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate } from '../../../middlewares' | ||
16 | import { TagModel } from '../../../models/video/tag' | ||
17 | import { VideoModel } from '../../../models/video/video' | ||
18 | import { buildLocalVideoFromCreate } from '@server/lib/video' | ||
19 | |||
20 | const liveRouter = express.Router() | ||
21 | |||
22 | const 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 | |||
31 | liveRouter.post('/live', | ||
32 | authenticate, | ||
33 | reqVideoFileLive, | ||
34 | asyncMiddleware(videoLiveAddValidator), | ||
35 | asyncRetryTransactionMiddleware(addLiveVideo) | ||
36 | ) | ||
37 | |||
38 | liveRouter.get('/live/:videoId', | ||
39 | authenticate, | ||
40 | asyncMiddleware(videoLiveGetValidator), | ||
41 | asyncRetryTransactionMiddleware(getVideoLive) | ||
42 | ) | ||
43 | |||
44 | // --------------------------------------------------------------------------- | ||
45 | |||
46 | export { | ||
47 | liveRouter | ||
48 | } | ||
49 | |||
50 | // --------------------------------------------------------------------------- | ||
51 | |||
52 | async function getVideoLive (req: express.Request, res: express.Response) { | ||
53 | const videoLive = res.locals.videoLive | ||
54 | |||
55 | return res.json(videoLive.toFormattedJSON()) | ||
56 | } | ||
57 | |||
58 | async 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' | |||
5 | export * from './services' | 5 | export * from './services' |
6 | export * from './static' | 6 | export * from './static' |
7 | export * from './lazy-static' | 7 | export * from './lazy-static' |
8 | export * from './live' | ||
8 | export * from './webfinger' | 9 | export * from './webfinger' |
9 | export * from './tracker' | 10 | export * from './tracker' |
10 | export * from './bots' | 11 | export * 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 @@ | |||
1 | import * as express from 'express' | ||
2 | import { mapToJSON } from '@server/helpers/core-utils' | ||
3 | import { LiveManager } from '@server/lib/live-manager' | ||
4 | |||
5 | const liveRouter = express.Router() | ||
6 | |||
7 | liveRouter.use('/segments-sha256/:videoUUID', | ||
8 | getSegmentsSha256 | ||
9 | ) | ||
10 | |||
11 | // --------------------------------------------------------------------------- | ||
12 | |||
13 | export { | ||
14 | liveRouter | ||
15 | } | ||
16 | |||
17 | // --------------------------------------------------------------------------- | ||
18 | |||
19 | function 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 | ||
178 | function 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 | |||
178 | function buildPath (path: string) { | 188 | function 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' |
13 | import { exists, isArray, isDateValid, isFileValid } from './misc' | 14 | import { exists, isArray, isDateValid, isFileValid } from './misc' |
14 | import * as magnetUtil from 'magnet-uri' | 15 | import * as magnetUtil from 'magnet-uri' |
@@ -77,7 +78,7 @@ function isVideoRatingTypeValid (value: string) { | |||
77 | } | 78 | } |
78 | 79 | ||
79 | function isVideoFileExtnameValid (value: string) { | 80 | function 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 | ||
83 | function isVideoFile (files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[]) { | 84 | function 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 @@ | |||
1 | import * as ffmpeg from 'fluent-ffmpeg' | 1 | import * as ffmpeg from 'fluent-ffmpeg' |
2 | import { readFile, remove, writeFile } from 'fs-extra' | ||
2 | import { dirname, join } from 'path' | 3 | import { dirname, join } from 'path' |
4 | import { VideoFileMetadata } from '@shared/models/videos/video-file-metadata' | ||
3 | import { getMaxBitrate, getTargetBitrate, VideoResolution } from '../../shared/models/videos' | 5 | import { getMaxBitrate, getTargetBitrate, VideoResolution } from '../../shared/models/videos' |
6 | import { checkFFmpegEncoders } from '../initializers/checker-before-init' | ||
7 | import { CONFIG } from '../initializers/config' | ||
4 | import { FFMPEG_NICE, VIDEO_TRANSCODING_FPS } from '../initializers/constants' | 8 | import { FFMPEG_NICE, VIDEO_TRANSCODING_FPS } from '../initializers/constants' |
5 | import { processImage } from './image-utils' | 9 | import { processImage } from './image-utils' |
6 | import { logger } from './logger' | 10 | import { logger } from './logger' |
7 | import { checkFFmpegEncoders } from '../initializers/checker-before-init' | ||
8 | import { readFile, remove, writeFile } from 'fs-extra' | ||
9 | import { CONFIG } from '../initializers/config' | ||
10 | import { 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 | ||
77 | function computeResolutionsToTranscode (videoFileResolution: number) { | 77 | function 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 = | |||
270 | function transcode (options: TranscodeOptions) { | 273 | function 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 | ||
356 | function 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 | |||
411 | function 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 | ||
361 | export { | 429 | export { |
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 | ||
452 | function 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 | |||
460 | function 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 | |||
382 | async function buildx264Command (command: ffmpeg.FfmpegCommand, options: TranscodeOptions) { | 471 | async 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 | ||
441 | async function buildHLSCommand (command: ffmpeg.FfmpegCommand, options: HLSTranscodeOptions) { | 530 | async 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 | |||
655 | function 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 | ||
26 | const LAST_MIGRATION_VERSION = 530 | 26 | const 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 | ||
371 | const VIDEO_LANGUAGES: { [id: string]: string } = {} | 372 | const VIDEO_LANGUAGES: { [id: string]: string } = {} |
372 | 373 | ||
373 | const VIDEO_PRIVACIES = { | 374 | const 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 | ||
380 | const VIDEO_STATES = { | 381 | const 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 | ||
386 | const VIDEO_IMPORT_STATES = { | 389 | const 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 | ||
393 | const ABUSE_STATES = { | 396 | const 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 | ||
399 | const VIDEO_PLAYLIST_PRIVACIES = { | 402 | const 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 | ||
405 | const VIDEO_PLAYLIST_TYPES = { | 408 | const 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 = { | |||
600 | const HLS_STREAMING_PLAYLIST_DIRECTORY = join(CONFIG.STORAGE.STREAMING_PLAYLISTS_DIR, 'hls') | 603 | const HLS_STREAMING_PLAYLIST_DIRECTORY = join(CONFIG.STORAGE.STREAMING_PLAYLISTS_DIR, 'hls') |
601 | const HLS_REDUNDANCY_DIRECTORY = join(CONFIG.STORAGE.REDUNDANCY_DIR, 'hls') | 604 | const HLS_REDUNDANCY_DIRECTORY = join(CONFIG.STORAGE.REDUNDANCY_DIR, 'hls') |
602 | 605 | ||
606 | const 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 | |||
603 | const MEMOIZE_TTL = { | 617 | const 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 = { | |||
622 | const ACCEPT_HEADERS = [ 'html', 'application/json' ].concat(ACTIVITY_PUB.POTENTIAL_ACCEPT_HEADERS) | 636 | const ACCEPT_HEADERS = [ 'html', 'application/json' ].concat(ACTIVITY_PUB.POTENTIAL_ACCEPT_HEADERS) |
623 | 637 | ||
624 | const ASSETS_PATH = { | 638 | const 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 = { | |||
737 | export { | 752 | export { |
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 () { | |||
892 | function updateWebserverUrls () { | 908 | function 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 | ||
901 | function updateWebserverConfig () { | 921 | function 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 @@ | |||
1 | import { QueryTypes, Transaction } from 'sequelize' | 1 | import { QueryTypes, Transaction } from 'sequelize' |
2 | import { Sequelize as SequelizeTypescript } from 'sequelize-typescript' | 2 | import { Sequelize as SequelizeTypescript } from 'sequelize-typescript' |
3 | import { AbuseModel } from '@server/models/abuse/abuse' | ||
4 | import { AbuseMessageModel } from '@server/models/abuse/abuse-message' | ||
5 | import { VideoAbuseModel } from '@server/models/abuse/video-abuse' | ||
6 | import { VideoCommentAbuseModel } from '@server/models/abuse/video-comment-abuse' | ||
7 | import { isTestInstance } from '../helpers/core-utils' | 3 | import { isTestInstance } from '../helpers/core-utils' |
8 | import { logger } from '../helpers/logger' | 4 | import { logger } from '../helpers/logger' |
5 | import { AbuseModel } from '../models/abuse/abuse' | ||
6 | import { AbuseMessageModel } from '../models/abuse/abuse-message' | ||
7 | import { VideoAbuseModel } from '../models/abuse/video-abuse' | ||
8 | import { VideoCommentAbuseModel } from '../models/abuse/video-comment-abuse' | ||
9 | import { AccountModel } from '../models/account/account' | 9 | import { AccountModel } from '../models/account/account' |
10 | import { AccountBlocklistModel } from '../models/account/account-blocklist' | 10 | import { AccountBlocklistModel } from '../models/account/account-blocklist' |
11 | import { AccountVideoRateModel } from '../models/account/account-video-rate' | 11 | import { AccountVideoRateModel } from '../models/account/account-video-rate' |
@@ -34,6 +34,7 @@ import { VideoChannelModel } from '../models/video/video-channel' | |||
34 | import { VideoCommentModel } from '../models/video/video-comment' | 34 | import { VideoCommentModel } from '../models/video/video-comment' |
35 | import { VideoFileModel } from '../models/video/video-file' | 35 | import { VideoFileModel } from '../models/video/video-file' |
36 | import { VideoImportModel } from '../models/video/video-import' | 36 | import { VideoImportModel } from '../models/video/video-import' |
37 | import { VideoLiveModel } from '../models/video/video-live' | ||
37 | import { VideoPlaylistModel } from '../models/video/video-playlist' | 38 | import { VideoPlaylistModel } from '../models/video/video-playlist' |
38 | import { VideoPlaylistElementModel } from '../models/video/video-playlist-element' | 39 | import { VideoPlaylistElementModel } from '../models/video/video-playlist-element' |
39 | import { VideoShareModel } from '../models/video/video-share' | 40 | import { 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 @@ | |||
1 | import * as Sequelize from 'sequelize' | ||
2 | |||
3 | async function up (utils: { | ||
4 | transaction: Sequelize.Transaction | ||
5 | queryInterface: Sequelize.QueryInterface | ||
6 | sequelize: Sequelize.Sequelize | ||
7 | }): 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 | |||
32 | function down (options) { | ||
33 | throw new Error('Not implemented.') | ||
34 | } | ||
35 | |||
36 | export { | ||
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 @@ | |||
1 | import * as Sequelize from 'sequelize' | ||
2 | |||
3 | async function up (utils: { | ||
4 | transaction: Sequelize.Transaction | ||
5 | queryInterface: Sequelize.QueryInterface | ||
6 | sequelize: Sequelize.Sequelize | ||
7 | }): 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 | |||
19 | function down (options) { | ||
20 | throw new Error('Not implemented.') | ||
21 | } | ||
22 | |||
23 | export { | ||
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 | ||
68 | async function updateSha256Segments (video: MVideoWithFile) { | 68 | async 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 | ||
104 | async function buildSha256Segment (segmentPath: string) { | ||
105 | const buf = await readFile(segmentPath) | ||
106 | return sha256(buf) | ||
107 | } | ||
108 | |||
104 | function getRangesFromPlaylist (playlistContent: string) { | 109 | function 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 | ||
188 | export { | 193 | export { |
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 | |||
2 | import { AsyncQueue, queue } from 'async' | ||
3 | import * as chokidar from 'chokidar' | ||
4 | import { FfmpegCommand } from 'fluent-ffmpeg' | ||
5 | import { ensureDir, readdir, remove } from 'fs-extra' | ||
6 | import { basename, join } from 'path' | ||
7 | import { computeResolutionsToTranscode, runLiveMuxing, runLiveTranscoding } from '@server/helpers/ffmpeg-utils' | ||
8 | import { logger } from '@server/helpers/logger' | ||
9 | import { CONFIG, registerConfigChangedHandler } from '@server/initializers/config' | ||
10 | import { P2P_MEDIA_LOADER_PEER_VERSION, VIDEO_LIVE, WEBSERVER } from '@server/initializers/constants' | ||
11 | import { VideoFileModel } from '@server/models/video/video-file' | ||
12 | import { VideoLiveModel } from '@server/models/video/video-live' | ||
13 | import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist' | ||
14 | import { MStreamingPlaylist, MVideo, MVideoLiveVideo } from '@server/types/models' | ||
15 | import { VideoState, VideoStreamingPlaylistType } from '@shared/models' | ||
16 | import { buildSha256Segment } from './hls' | ||
17 | import { getHLSDirectory } from './video-paths' | ||
18 | |||
19 | const NodeRtmpServer = require('node-media-server/node_rtmp_server') | ||
20 | const context = require('node-media-server/node_core_ctx') | ||
21 | const nodeMediaServerLogger = require('node-media-server/node_core_logger') | ||
22 | |||
23 | // Disable node media server logs | ||
24 | nodeMediaServerLogger.setLogType(0) | ||
25 | |||
26 | const 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 | |||
39 | type SegmentSha256QueueParam = { | ||
40 | operation: 'update' | 'delete' | ||
41 | videoUUID: string | ||
42 | segmentPath: string | ||
43 | } | ||
44 | |||
45 | class 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 | |||
308 | export { | ||
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: | |||
27 | function getVideoFilePath (videoOrPlaylist: MVideo | MStreamingPlaylistVideo, videoFile: MVideoFile, isRedundancy = false) { | 27 | function 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' | |||
13 | import { logger } from '../helpers/logger' | 13 | import { logger } from '../helpers/logger' |
14 | import { VideoResolution } from '../../shared/models/videos' | 14 | import { VideoResolution } from '../../shared/models/videos' |
15 | import { VideoFileModel } from '../models/video/video-file' | 15 | import { VideoFileModel } from '../models/video/video-file' |
16 | import { updateMasterHLSPlaylist, updateSha256Segments } from './hls' | 16 | import { updateMasterHLSPlaylist, updateSha256VODSegments } from './hls' |
17 | import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-playlist' | 17 | import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-playlist' |
18 | import { VideoStreamingPlaylistType } from '../../shared/models/videos/video-streaming-playlist.type' | 18 | import { VideoStreamingPlaylistType } from '../../shared/models/videos/video-streaming-playlist.type' |
19 | import { CONFIG } from '../initializers/config' | 19 | import { CONFIG } from '../initializers/config' |
20 | import { MStreamingPlaylistFilesVideo, MVideoFile, MVideoWithAllFiles, MVideoWithFile } from '@server/types/models' | 20 | import { MStreamingPlaylistFilesVideo, MVideoFile, MVideoWithAllFiles, MVideoWithFile } from '@server/types/models' |
21 | import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent' | 21 | import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent' |
22 | import { generateVideoStreamingPlaylistName, getVideoFilename, getVideoFilePath } from './video-paths' | 22 | import { generateVideoStreamingPlaylistName, getVideoFilename, getVideoFilePath } from './video-paths' |
23 | import { 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 | |||
2 | import { VideoModel } from '@server/models/video/video' | ||
3 | import { FilteredModelAttributes } from '@server/types' | ||
4 | import { VideoCreate, VideoPrivacy, VideoState } from '@shared/models' | ||
5 | |||
6 | function 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 | |||
29 | export { | ||
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 @@ | |||
1 | import * as express from 'express' | ||
2 | import { body, param } from 'express-validator' | ||
3 | import { checkUserCanManageVideo, doesVideoChannelOfAccountExist, doesVideoExist } from '@server/helpers/middlewares/videos' | ||
4 | import { UserRight } from '@shared/models' | ||
5 | import { isIdOrUUIDValid, isIdValid, toIntOrNull } from '../../../helpers/custom-validators/misc' | ||
6 | import { isVideoNameValid } from '../../../helpers/custom-validators/videos' | ||
7 | import { cleanUpReqFiles } from '../../../helpers/express-utils' | ||
8 | import { logger } from '../../../helpers/logger' | ||
9 | import { CONFIG } from '../../../initializers/config' | ||
10 | import { areValidationErrors } from '../utils' | ||
11 | import { getCommonVideoEditAttributes } from './videos' | ||
12 | import { VideoLiveModel } from '@server/models/video/video-live' | ||
13 | |||
14 | const 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 | |||
36 | const 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 | |||
63 | export { | ||
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 @@ | |||
1 | import { AllowNull, BelongsTo, Column, CreatedAt, DataType, DefaultScope, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript' | ||
2 | import { WEBSERVER } from '@server/initializers/constants' | ||
3 | import { MVideoLive, MVideoLiveVideo } from '@server/types/models' | ||
4 | import { VideoLive } from '@shared/models/videos/video-live.model' | ||
5 | import { 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 | }) | ||
24 | export 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' | |||
9 | export * from './video-comment' | 9 | export * from './video-comment' |
10 | export * from './video-file' | 10 | export * from './video-file' |
11 | export * from './video-import' | 11 | export * from './video-import' |
12 | export * from './video-live' | ||
12 | export * from './video-playlist' | 13 | export * from './video-playlist' |
13 | export * from './video-playlist-element' | 14 | export * from './video-playlist-element' |
14 | export * from './video-rate' | 15 | export * 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 @@ | |||
1 | import { VideoLiveModel } from '@server/models/video/video-live' | ||
2 | import { PickWith } from '@shared/core-utils' | ||
3 | import { MVideo } from './video' | ||
4 | |||
5 | type Use<K extends keyof VideoLiveModel, M> = PickWith<VideoLiveModel, K, M> | ||
6 | |||
7 | // ############################################################################ | ||
8 | |||
9 | export type MVideoLive = Omit<VideoLiveModel, 'Video'> | ||
10 | |||
11 | // ############################################################################ | ||
12 | |||
13 | export 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' |
14 | import { MOAuthTokenUser } from '@server/types/models/oauth/oauth-token' | 15 | import { MOAuthTokenUser } from '@server/types/models/oauth/oauth-token' |
15 | import { MPlugin, MServer, MServerBlocklist } from '@server/types/models/server' | 16 | import { 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 |