From c6c0fa6cd8fe8f752463d8982c3dbcd448739c4e Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Thu, 17 Sep 2020 09:20:52 +0200 Subject: Live streaming implementation first step --- server/assets/default-live-background.jpg | Bin 0 -> 93634 bytes server/controllers/api/config.ts | 39 ++- server/controllers/api/videos/index.ts | 4 +- server/controllers/api/videos/live.ts | 116 ++++++++ server/controllers/index.ts | 1 + server/controllers/live.ts | 29 ++ server/controllers/static.ts | 9 +- server/helpers/core-utils.ts | 11 + server/helpers/custom-validators/videos.ts | 5 +- server/helpers/ffmpeg-utils.ts | 138 +++++++-- server/initializers/config.ts | 21 ++ server/initializers/constants.ts | 50 +++- server/initializers/database.ts | 10 +- server/initializers/migrations/0535-video-live.ts | 39 +++ .../migrations/0540-video-file-infohash.ts | 26 ++ server/lib/hls.ts | 10 +- server/lib/job-queue/handlers/video-transcoding.ts | 2 +- server/lib/live-manager.ts | 310 +++++++++++++++++++++ server/lib/video-paths.ts | 3 +- server/lib/video-transcoding.ts | 7 +- server/lib/video.ts | 31 +++ server/middlewares/validators/videos/video-live.ts | 66 +++++ server/models/video/video-file.ts | 4 +- server/models/video/video-format-utils.ts | 2 + server/models/video/video-live.ts | 74 +++++ server/models/video/video-streaming-playlist.ts | 4 +- server/models/video/video.ts | 5 + server/tests/api/check-params/config.ts | 16 ++ server/tests/api/server/config.ts | 36 +++ server/tests/api/videos/video-transcoder.ts | 2 +- server/types/models/video/index.ts | 1 + server/types/models/video/video-live.ts | 15 + server/typings/express/index.d.ts | 5 +- 33 files changed, 1030 insertions(+), 61 deletions(-) create mode 100644 server/assets/default-live-background.jpg create mode 100644 server/controllers/api/videos/live.ts create mode 100644 server/controllers/live.ts create mode 100644 server/initializers/migrations/0535-video-live.ts create mode 100644 server/initializers/migrations/0540-video-file-infohash.ts create mode 100644 server/lib/live-manager.ts create mode 100644 server/lib/video.ts create mode 100644 server/middlewares/validators/videos/video-live.ts create mode 100644 server/models/video/video-live.ts create mode 100644 server/types/models/video/video-live.ts (limited to 'server') diff --git a/server/assets/default-live-background.jpg b/server/assets/default-live-background.jpg new file mode 100644 index 000000000..2743af7fc Binary files /dev/null and b/server/assets/default-live-background.jpg 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) { webtorrent: { enabled: CONFIG.TRANSCODING.WEBTORRENT.ENABLED }, - enabledResolutions: getEnabledResolutions() + enabledResolutions: getEnabledResolutions('vod') + }, + live: { + enabled: CONFIG.LIVE.ENABLED, + + transcoding: { + enabled: CONFIG.LIVE.TRANSCODING.ENABLED, + enabledResolutions: getEnabledResolutions('live') + } }, import: { videos: { @@ -232,7 +240,7 @@ async function deleteCustomConfig (req: express.Request, res: express.Response) const data = customConfig() - return res.json(data).end() + return res.json(data) } async function updateCustomConfig (req: express.Request, res: express.Response) { @@ -254,7 +262,7 @@ async function updateCustomConfig (req: express.Request, res: express.Response) oldCustomConfigAuditKeys ) - return res.json(data).end() + return res.json(data) } function getRegisteredThemes () { @@ -268,9 +276,13 @@ function getRegisteredThemes () { })) } -function getEnabledResolutions () { - return Object.keys(CONFIG.TRANSCODING.RESOLUTIONS) - .filter(key => CONFIG.TRANSCODING.ENABLED && CONFIG.TRANSCODING.RESOLUTIONS[key] === true) +function getEnabledResolutions (type: 'vod' | 'live') { + const transcoding = type === 'vod' + ? CONFIG.TRANSCODING + : CONFIG.LIVE.TRANSCODING + + return Object.keys(transcoding.RESOLUTIONS) + .filter(key => transcoding.ENABLED && transcoding.RESOLUTIONS[key] === true) .map(r => parseInt(r, 10)) } @@ -411,6 +423,21 @@ function customConfig (): CustomConfig { enabled: CONFIG.TRANSCODING.HLS.ENABLED } }, + live: { + enabled: CONFIG.LIVE.ENABLED, + transcoding: { + enabled: CONFIG.LIVE.TRANSCODING.ENABLED, + threads: CONFIG.LIVE.TRANSCODING.THREADS, + resolutions: { + '240p': CONFIG.LIVE.TRANSCODING.RESOLUTIONS['240p'], + '360p': CONFIG.LIVE.TRANSCODING.RESOLUTIONS['360p'], + '480p': CONFIG.LIVE.TRANSCODING.RESOLUTIONS['480p'], + '720p': CONFIG.LIVE.TRANSCODING.RESOLUTIONS['720p'], + '1080p': CONFIG.LIVE.TRANSCODING.RESOLUTIONS['1080p'], + '2160p': CONFIG.LIVE.TRANSCODING.RESOLUTIONS['2160p'] + } + } + }, import: { videos: { 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' import { videoCaptionsRouter } from './captions' import { videoCommentRouter } from './comment' import { videoImportsRouter } from './import' +import { liveRouter } from './live' import { ownershipVideoRouter } from './ownership' import { rateVideoRouter } from './rate' import { watchingRouter } from './watching' @@ -96,6 +97,7 @@ videosRouter.use('/', videoCaptionsRouter) videosRouter.use('/', videoImportsRouter) videosRouter.use('/', ownershipVideoRouter) videosRouter.use('/', watchingRouter) +videosRouter.use('/', liveRouter) videosRouter.get('/categories', listVideoCategories) videosRouter.get('/licences', listVideoLicences) @@ -304,7 +306,7 @@ async function addVideo (req: express.Request, res: express.Response) { id: videoCreated.id, uuid: videoCreated.uuid } - }).end() + }) } 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 @@ +import * as express from 'express' +import { v4 as uuidv4 } from 'uuid' +import { createReqFiles } from '@server/helpers/express-utils' +import { CONFIG } from '@server/initializers/config' +import { ASSETS_PATH, MIMETYPES } from '@server/initializers/constants' +import { getVideoActivityPubUrl } from '@server/lib/activitypub/url' +import { videoLiveAddValidator, videoLiveGetValidator } from '@server/middlewares/validators/videos/video-live' +import { VideoLiveModel } from '@server/models/video/video-live' +import { MVideoDetails, MVideoFullLight } from '@server/types/models' +import { VideoCreate, VideoPrivacy, VideoState } from '../../../../shared' +import { ThumbnailType } from '../../../../shared/models/videos/thumbnail.type' +import { logger } from '../../../helpers/logger' +import { sequelizeTypescript } from '../../../initializers/database' +import { createVideoMiniatureFromExisting } from '../../../lib/thumbnail' +import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate } from '../../../middlewares' +import { TagModel } from '../../../models/video/tag' +import { VideoModel } from '../../../models/video/video' +import { buildLocalVideoFromCreate } from '@server/lib/video' + +const liveRouter = express.Router() + +const reqVideoFileLive = createReqFiles( + [ 'thumbnailfile', 'previewfile' ], + MIMETYPES.IMAGE.MIMETYPE_EXT, + { + thumbnailfile: CONFIG.STORAGE.TMP_DIR, + previewfile: CONFIG.STORAGE.TMP_DIR + } +) + +liveRouter.post('/live', + authenticate, + reqVideoFileLive, + asyncMiddleware(videoLiveAddValidator), + asyncRetryTransactionMiddleware(addLiveVideo) +) + +liveRouter.get('/live/:videoId', + authenticate, + asyncMiddleware(videoLiveGetValidator), + asyncRetryTransactionMiddleware(getVideoLive) +) + +// --------------------------------------------------------------------------- + +export { + liveRouter +} + +// --------------------------------------------------------------------------- + +async function getVideoLive (req: express.Request, res: express.Response) { + const videoLive = res.locals.videoLive + + return res.json(videoLive.toFormattedJSON()) +} + +async function addLiveVideo (req: express.Request, res: express.Response) { + const videoInfo: VideoCreate = req.body + + // Prepare data so we don't block the transaction + const videoData = buildLocalVideoFromCreate(videoInfo, res.locals.videoChannel.id) + videoData.isLive = true + + const videoLive = new VideoLiveModel() + videoLive.streamKey = uuidv4() + + const video = new VideoModel(videoData) as MVideoDetails + video.url = getVideoActivityPubUrl(video) // We use the UUID, so set the URL after building the object + + // Process thumbnail or create it from the video + const thumbnailField = req.files ? req.files['thumbnailfile'] : null + const thumbnailModel = thumbnailField + ? await createVideoMiniatureFromExisting(thumbnailField[0].path, video, ThumbnailType.MINIATURE, false) + : await createVideoMiniatureFromExisting(ASSETS_PATH.DEFAULT_LIVE_BACKGROUND, video, ThumbnailType.MINIATURE, true) + + // Process preview or create it from the video + const previewField = req.files ? req.files['previewfile'] : null + const previewModel = previewField + ? await createVideoMiniatureFromExisting(previewField[0].path, video, ThumbnailType.PREVIEW, false) + : await createVideoMiniatureFromExisting(ASSETS_PATH.DEFAULT_LIVE_BACKGROUND, video, ThumbnailType.PREVIEW, true) + + const { videoCreated } = await sequelizeTypescript.transaction(async t => { + const sequelizeOptions = { transaction: t } + + const videoCreated = await video.save(sequelizeOptions) as MVideoFullLight + + if (thumbnailModel) await videoCreated.addAndSaveThumbnail(thumbnailModel, t) + if (previewModel) await videoCreated.addAndSaveThumbnail(previewModel, t) + + // Do not forget to add video channel information to the created video + videoCreated.VideoChannel = res.locals.videoChannel + + videoLive.videoId = videoCreated.id + await videoLive.save(sequelizeOptions) + + // Create tags + if (videoInfo.tags !== undefined) { + const tagInstances = await TagModel.findOrCreateTags(videoInfo.tags, t) + + await video.$set('Tags', tagInstances, sequelizeOptions) + video.Tags = tagInstances + } + + logger.info('Video live %s with uuid %s created.', videoInfo.name, videoCreated.uuid) + + return { videoCreated } + }) + + return res.json({ + video: { + id: videoCreated.id, + uuid: videoCreated.uuid + } + }) +} 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' export * from './services' export * from './static' export * from './lazy-static' +export * from './live' export * from './webfinger' export * from './tracker' 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 @@ +import * as express from 'express' +import { mapToJSON } from '@server/helpers/core-utils' +import { LiveManager } from '@server/lib/live-manager' + +const liveRouter = express.Router() + +liveRouter.use('/segments-sha256/:videoUUID', + getSegmentsSha256 +) + +// --------------------------------------------------------------------------- + +export { + liveRouter +} + +// --------------------------------------------------------------------------- + +function getSegmentsSha256 (req: express.Request, res: express.Response) { + const videoUUID = req.params.videoUUID + + const result = LiveManager.Instance.getSegmentsSha256(videoUUID) + + if (!result) { + return res.sendStatus(404) + } + + return res.json(mapToJSON(result)) +} 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) { webtorrent: { enabled: CONFIG.TRANSCODING.WEBTORRENT.ENABLED }, - enabledResolutions: getEnabledResolutions() + enabledResolutions: getEnabledResolutions('vod') + }, + live: { + enabled: CONFIG.LIVE.ENABLED, + transcoding: { + enabled: CONFIG.LIVE.TRANSCODING.ENABLED, + enabledResolutions: getEnabledResolutions('live') + } }, import: { 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) { return { start, count: itemsPerPage } } +function mapToJSON (map: Map) { + const obj: any = {} + + for (const [ k, v ] of map) { + obj[k] = v + } + + return obj +} + function buildPath (path: string) { if (isAbsolute(path)) return path @@ -263,6 +273,7 @@ export { sha256, sha1, + mapToJSON, promisify0, 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 { VIDEO_LICENCES, VIDEO_PRIVACIES, VIDEO_RATE_TYPES, - VIDEO_STATES + VIDEO_STATES, + VIDEO_LIVE } from '../../initializers/constants' import { exists, isArray, isDateValid, isFileValid } from './misc' import * as magnetUtil from 'magnet-uri' @@ -77,7 +78,7 @@ function isVideoRatingTypeValid (value: string) { } function isVideoFileExtnameValid (value: string) { - return exists(value) && MIMETYPES.VIDEO.EXT_MIMETYPE[value] !== undefined + return exists(value) && (value === VIDEO_LIVE.EXTENSION || MIMETYPES.VIDEO.EXT_MIMETYPE[value] !== undefined) } 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 @@ import * as ffmpeg from 'fluent-ffmpeg' +import { readFile, remove, writeFile } from 'fs-extra' import { dirname, join } from 'path' +import { VideoFileMetadata } from '@shared/models/videos/video-file-metadata' import { getMaxBitrate, getTargetBitrate, VideoResolution } from '../../shared/models/videos' +import { checkFFmpegEncoders } from '../initializers/checker-before-init' +import { CONFIG } from '../initializers/config' import { FFMPEG_NICE, VIDEO_TRANSCODING_FPS } from '../initializers/constants' import { processImage } from './image-utils' import { logger } from './logger' -import { checkFFmpegEncoders } from '../initializers/checker-before-init' -import { readFile, remove, writeFile } from 'fs-extra' -import { CONFIG } from '../initializers/config' -import { VideoFileMetadata } from '@shared/models/videos/video-file-metadata' /** * A toolbox to play with audio @@ -74,9 +74,12 @@ namespace audio { } } -function computeResolutionsToTranscode (videoFileResolution: number) { +function computeResolutionsToTranscode (videoFileResolution: number, type: 'vod' | 'live') { + const configResolutions = type === 'vod' + ? CONFIG.TRANSCODING.RESOLUTIONS + : CONFIG.LIVE.TRANSCODING.RESOLUTIONS + const resolutionsEnabled: number[] = [] - const configResolutions = CONFIG.TRANSCODING.RESOLUTIONS // Put in the order we want to proceed jobs const resolutions = [ @@ -270,14 +273,13 @@ type TranscodeOptions = function transcode (options: TranscodeOptions) { return new Promise(async (res, rej) => { try { - // we set cwd explicitly because ffmpeg appears to create temporary files when trancoding which fails in read-only file systems - let command = ffmpeg(options.inputPath, { niceness: FFMPEG_NICE.TRANSCODING, cwd: CONFIG.STORAGE.TMP_DIR }) + let command = getFFmpeg(options.inputPath) .output(options.outputPath) if (options.type === 'quick-transcode') { command = buildQuickTranscodeCommand(command) } else if (options.type === 'hls') { - command = await buildHLSCommand(command, options) + command = await buildHLSVODCommand(command, options) } else if (options.type === 'merge-audio') { command = await buildAudioMergeCommand(command, options) } else if (options.type === 'only-audio') { @@ -286,11 +288,6 @@ function transcode (options: TranscodeOptions) { command = await buildx264Command(command, options) } - if (CONFIG.TRANSCODING.THREADS > 0) { - // if we don't set any threads ffmpeg will chose automatically - command = command.outputOption('-threads ' + CONFIG.TRANSCODING.THREADS) - } - command .on('error', (err, stdout, stderr) => { logger.error('Error in transcoding job.', { stdout, stderr }) @@ -356,16 +353,89 @@ function convertWebPToJPG (path: string, destination: string): Promise { }) } +function runLiveTranscoding (rtmpUrl: string, outPath: string, resolutions: number[]) { + const command = getFFmpeg(rtmpUrl) + command.inputOption('-fflags nobuffer') + + const varStreamMap: string[] = [] + + command.complexFilter([ + { + inputs: '[v:0]', + filter: 'split', + options: resolutions.length, + outputs: resolutions.map(r => `vtemp${r}`) + }, + + ...resolutions.map(r => ({ + inputs: `vtemp${r}`, + filter: 'scale', + options: `w=-2:h=${r}`, + outputs: `vout${r}` + })) + ]) + + const liveFPS = VIDEO_TRANSCODING_FPS.AVERAGE + + command.withFps(liveFPS) + + command.outputOption('-b_strategy 1') + command.outputOption('-bf 16') + command.outputOption('-preset superfast') + command.outputOption('-level 3.1') + command.outputOption('-map_metadata -1') + command.outputOption('-pix_fmt yuv420p') + + for (let i = 0; i < resolutions.length; i++) { + const resolution = resolutions[i] + + command.outputOption(`-map [vout${resolution}]`) + command.outputOption(`-c:v:${i} libx264`) + command.outputOption(`-b:v:${i} ${getTargetBitrate(resolution, liveFPS, VIDEO_TRANSCODING_FPS)}`) + + command.outputOption(`-map a:0`) + command.outputOption(`-c:a:${i} aac`) + + varStreamMap.push(`v:${i},a:${i}`) + } + + addDefaultLiveHLSParams(command, outPath) + + command.outputOption('-var_stream_map', varStreamMap.join(' ')) + + command.run() + + return command +} + +function runLiveMuxing (rtmpUrl: string, outPath: string) { + const command = getFFmpeg(rtmpUrl) + command.inputOption('-fflags nobuffer') + + command.outputOption('-c:v copy') + command.outputOption('-c:a copy') + command.outputOption('-map 0:a?') + command.outputOption('-map 0:v?') + + addDefaultLiveHLSParams(command, outPath) + + command.run() + + return command +} + // --------------------------------------------------------------------------- export { getVideoStreamCodec, getAudioStreamCodec, + runLiveMuxing, convertWebPToJPG, getVideoStreamSize, getVideoFileResolution, getMetadataFromFile, getDurationFromVideoFile, + runLiveTranscoding, generateImageFromVideoFile, TranscodeOptions, TranscodeOptionsType, @@ -379,6 +449,25 @@ export { // --------------------------------------------------------------------------- +function addDefaultX264Params (command: ffmpeg.FfmpegCommand) { + command.outputOption('-level 3.1') // 3.1 is the minimal resource allocation for our highest supported resolution + .outputOption('-b_strategy 1') // NOTE: b-strategy 1 - heuristic algorithm, 16 is optimal B-frames for it + .outputOption('-bf 16') // NOTE: Why 16: https://github.com/Chocobozzz/PeerTube/pull/774. b-strategy 2 -> B-frames<16 + .outputOption('-pix_fmt yuv420p') // allows import of source material with incompatible pixel formats (e.g. MJPEG video) + .outputOption('-map_metadata -1') // strip all metadata +} + +function addDefaultLiveHLSParams (command: ffmpeg.FfmpegCommand, outPath: string) { + command.outputOption('-hls_time 4') + command.outputOption('-hls_list_size 15') + command.outputOption('-hls_flags delete_segments') + command.outputOption(`-hls_segment_filename ${join(outPath, '%v-%d.ts')}`) + command.outputOption('-master_pl_name master.m3u8') + command.outputOption(`-f hls`) + + command.output(join(outPath, '%v.m3u8')) +} + async function buildx264Command (command: ffmpeg.FfmpegCommand, options: TranscodeOptions) { let fps = await getVideoFileFPS(options.inputPath) if ( @@ -438,7 +527,7 @@ function buildQuickTranscodeCommand (command: ffmpeg.FfmpegCommand) { return command } -async function buildHLSCommand (command: ffmpeg.FfmpegCommand, options: HLSTranscodeOptions) { +async function buildHLSVODCommand (command: ffmpeg.FfmpegCommand, options: HLSTranscodeOptions) { const videoPath = getHLSVideoPath(options) if (options.copyCodecs) command = presetCopy(command) @@ -508,13 +597,10 @@ async function presetH264 (command: ffmpeg.FfmpegCommand, input: string, resolut let localCommand = command .format('mp4') .videoCodec('libx264') - .outputOption('-level 3.1') // 3.1 is the minimal resource allocation for our highest supported resolution - .outputOption('-b_strategy 1') // NOTE: b-strategy 1 - heuristic algorithm, 16 is optimal B-frames for it - .outputOption('-bf 16') // NOTE: Why 16: https://github.com/Chocobozzz/PeerTube/pull/774. b-strategy 2 -> B-frames<16 - .outputOption('-pix_fmt yuv420p') // allows import of source material with incompatible pixel formats (e.g. MJPEG video) - .outputOption('-map_metadata -1') // strip all metadata .outputOption('-movflags faststart') + addDefaultX264Params(localCommand) + const parsedAudio = await audio.get(input) if (!parsedAudio.audioStream) { @@ -565,3 +651,15 @@ function presetOnlyAudio (command: ffmpeg.FfmpegCommand): ffmpeg.FfmpegCommand { .audioCodec('copy') .noVideo() } + +function getFFmpeg (input: string) { + // We set cwd explicitly because ffmpeg appears to create temporary files when trancoding which fails in read-only file systems + const command = ffmpeg(input, { niceness: FFMPEG_NICE.TRANSCODING, cwd: CONFIG.STORAGE.TMP_DIR }) + + if (CONFIG.TRANSCODING.THREADS > 0) { + // If we don't set any threads ffmpeg will chose automatically + command.outputOption('-threads ' + CONFIG.TRANSCODING.THREADS) + } + + return command +} 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 = { get ENABLED () { return config.get('transcoding.webtorrent.enabled') } } }, + LIVE: { + get ENABLED () { return config.get('live.enabled') }, + + RTMP: { + get PORT () { return config.get('live.rtmp.port') } + }, + + TRANSCODING: { + get ENABLED () { return config.get('live.transcoding.enabled') }, + get THREADS () { return config.get('live.transcoding.threads') }, + + RESOLUTIONS: { + get '240p' () { return config.get('live.transcoding.resolutions.240p') }, + get '360p' () { return config.get('live.transcoding.resolutions.360p') }, + get '480p' () { return config.get('live.transcoding.resolutions.480p') }, + get '720p' () { return config.get('live.transcoding.resolutions.720p') }, + get '1080p' () { return config.get('live.transcoding.resolutions.1080p') }, + get '2160p' () { return config.get('live.transcoding.resolutions.2160p') } + } + } + }, IMPORT: { VIDEOS: { 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' // --------------------------------------------------------------------------- -const LAST_MIGRATION_VERSION = 530 +const LAST_MIGRATION_VERSION = 540 // --------------------------------------------------------------------------- @@ -50,7 +50,8 @@ const WEBSERVER = { SCHEME: '', WS: '', HOSTNAME: '', - PORT: 0 + PORT: 0, + RTMP_URL: '' } // Sortable columns per schema @@ -264,7 +265,7 @@ const CONSTRAINTS_FIELDS = { VIEWS: { min: 0 }, LIKES: { min: 0 }, DISLIKES: { min: 0 }, - FILE_SIZE: { min: 10 }, + FILE_SIZE: { min: -1 }, URL: { min: 3, max: 2000 } // Length }, VIDEO_PLAYLISTS: { @@ -370,39 +371,41 @@ const VIDEO_LICENCES = { const VIDEO_LANGUAGES: { [id: string]: string } = {} -const VIDEO_PRIVACIES = { +const VIDEO_PRIVACIES: { [ id in VideoPrivacy ]: string } = { [VideoPrivacy.PUBLIC]: 'Public', [VideoPrivacy.UNLISTED]: 'Unlisted', [VideoPrivacy.PRIVATE]: 'Private', [VideoPrivacy.INTERNAL]: 'Internal' } -const VIDEO_STATES = { +const VIDEO_STATES: { [ id in VideoState ]: string } = { [VideoState.PUBLISHED]: 'Published', [VideoState.TO_TRANSCODE]: 'To transcode', - [VideoState.TO_IMPORT]: 'To import' + [VideoState.TO_IMPORT]: 'To import', + [VideoState.WAITING_FOR_LIVE]: 'Waiting for livestream', + [VideoState.LIVE_ENDED]: 'Livestream ended' } -const VIDEO_IMPORT_STATES = { +const VIDEO_IMPORT_STATES: { [ id in VideoImportState ]: string } = { [VideoImportState.FAILED]: 'Failed', [VideoImportState.PENDING]: 'Pending', [VideoImportState.SUCCESS]: 'Success', [VideoImportState.REJECTED]: 'Rejected' } -const ABUSE_STATES = { +const ABUSE_STATES: { [ id in AbuseState ]: string } = { [AbuseState.PENDING]: 'Pending', [AbuseState.REJECTED]: 'Rejected', [AbuseState.ACCEPTED]: 'Accepted' } -const VIDEO_PLAYLIST_PRIVACIES = { +const VIDEO_PLAYLIST_PRIVACIES: { [ id in VideoPlaylistPrivacy ]: string } = { [VideoPlaylistPrivacy.PUBLIC]: 'Public', [VideoPlaylistPrivacy.UNLISTED]: 'Unlisted', [VideoPlaylistPrivacy.PRIVATE]: 'Private' } -const VIDEO_PLAYLIST_TYPES = { +const VIDEO_PLAYLIST_TYPES: { [ id in VideoPlaylistType ]: string } = { [VideoPlaylistType.REGULAR]: 'Regular', [VideoPlaylistType.WATCH_LATER]: 'Watch later' } @@ -600,6 +603,17 @@ const LRU_CACHE = { const HLS_STREAMING_PLAYLIST_DIRECTORY = join(CONFIG.STORAGE.STREAMING_PLAYLISTS_DIR, 'hls') const HLS_REDUNDANCY_DIRECTORY = join(CONFIG.STORAGE.REDUNDANCY_DIR, 'hls') +const VIDEO_LIVE = { + EXTENSION: '.ts', + RTMP: { + CHUNK_SIZE: 60000, + GOP_CACHE: true, + PING: 60, + PING_TIMEOUT: 30, + BASE_PATH: 'live' + } +} + const MEMOIZE_TTL = { OVERVIEWS_SAMPLE: 1000 * 3600 * 4, // 4 hours INFO_HASH_EXISTS: 1000 * 3600 * 12 // 12 hours @@ -622,7 +636,8 @@ const REDUNDANCY = { const ACCEPT_HEADERS = [ 'html', 'application/json' ].concat(ACTIVITY_PUB.POTENTIAL_ACCEPT_HEADERS) const ASSETS_PATH = { - DEFAULT_AUDIO_BACKGROUND: join(root(), 'dist', 'server', 'assets', 'default-audio-background.jpg') + DEFAULT_AUDIO_BACKGROUND: join(root(), 'dist', 'server', 'assets', 'default-audio-background.jpg'), + DEFAULT_LIVE_BACKGROUND: join(root(), 'dist', 'server', 'assets', 'default-live-background.jpg') } // --------------------------------------------------------------------------- @@ -688,9 +703,9 @@ if (isTestInstance() === true) { STATIC_MAX_AGE.SERVER = '0' ACTIVITY_PUB.COLLECTION_ITEMS_PER_PAGE = 2 - ACTIVITY_PUB.ACTOR_REFRESH_INTERVAL = 10 * 1000 // 10 seconds - ACTIVITY_PUB.VIDEO_REFRESH_INTERVAL = 10 * 1000 // 10 seconds - ACTIVITY_PUB.VIDEO_PLAYLIST_REFRESH_INTERVAL = 10 * 1000 // 10 seconds + ACTIVITY_PUB.ACTOR_REFRESH_INTERVAL = 100 * 10000 // 10 seconds + ACTIVITY_PUB.VIDEO_REFRESH_INTERVAL = 100 * 10000 // 10 seconds + ACTIVITY_PUB.VIDEO_PLAYLIST_REFRESH_INTERVAL = 100 * 10000 // 10 seconds CONSTRAINTS_FIELDS.ACTORS.AVATAR.FILE_SIZE.max = 100 * 1024 // 100KB @@ -737,6 +752,7 @@ const FILES_CONTENT_HASH = { export { WEBSERVER, API_VERSION, + VIDEO_LIVE, PEERTUBE_VERSION, LAZY_STATIC_PATHS, SEARCH_INDEX, @@ -892,10 +908,14 @@ function buildVideoMimetypeExt () { function updateWebserverUrls () { WEBSERVER.URL = sanitizeUrl(CONFIG.WEBSERVER.SCHEME + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT) WEBSERVER.HOST = sanitizeHost(CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT, REMOTE_SCHEME.HTTP) - WEBSERVER.SCHEME = CONFIG.WEBSERVER.SCHEME WEBSERVER.WS = CONFIG.WEBSERVER.WS + + WEBSERVER.SCHEME = CONFIG.WEBSERVER.SCHEME WEBSERVER.HOSTNAME = CONFIG.WEBSERVER.HOSTNAME WEBSERVER.PORT = CONFIG.WEBSERVER.PORT + WEBSERVER.PORT = CONFIG.WEBSERVER.PORT + + WEBSERVER.RTMP_URL = 'rtmp://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.LIVE.RTMP.PORT + '/' + VIDEO_LIVE.RTMP.BASE_PATH } 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 @@ import { QueryTypes, Transaction } from 'sequelize' import { Sequelize as SequelizeTypescript } from 'sequelize-typescript' -import { AbuseModel } from '@server/models/abuse/abuse' -import { AbuseMessageModel } from '@server/models/abuse/abuse-message' -import { VideoAbuseModel } from '@server/models/abuse/video-abuse' -import { VideoCommentAbuseModel } from '@server/models/abuse/video-comment-abuse' import { isTestInstance } from '../helpers/core-utils' import { logger } from '../helpers/logger' +import { AbuseModel } from '../models/abuse/abuse' +import { AbuseMessageModel } from '../models/abuse/abuse-message' +import { VideoAbuseModel } from '../models/abuse/video-abuse' +import { VideoCommentAbuseModel } from '../models/abuse/video-comment-abuse' import { AccountModel } from '../models/account/account' import { AccountBlocklistModel } from '../models/account/account-blocklist' import { AccountVideoRateModel } from '../models/account/account-video-rate' @@ -34,6 +34,7 @@ import { VideoChannelModel } from '../models/video/video-channel' import { VideoCommentModel } from '../models/video/video-comment' import { VideoFileModel } from '../models/video/video-file' import { VideoImportModel } from '../models/video/video-import' +import { VideoLiveModel } from '../models/video/video-live' import { VideoPlaylistModel } from '../models/video/video-playlist' import { VideoPlaylistElementModel } from '../models/video/video-playlist-element' import { VideoShareModel } from '../models/video/video-share' @@ -118,6 +119,7 @@ async function initDatabaseModels (silent: boolean) { VideoViewModel, VideoRedundancyModel, UserVideoHistoryModel, + VideoLiveModel, AccountBlocklistModel, ServerBlocklistModel, 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 @@ +import * as Sequelize from 'sequelize' + +async function up (utils: { + transaction: Sequelize.Transaction + queryInterface: Sequelize.QueryInterface + sequelize: Sequelize.Sequelize +}): Promise { + { + const query = ` + CREATE TABLE IF NOT EXISTS "videoLive" ( + "id" SERIAL , + "streamKey" VARCHAR(255) NOT NULL, + "videoId" INTEGER NOT NULL REFERENCES "video" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, + "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL, + PRIMARY KEY ("id") + ); + ` + + await utils.sequelize.query(query) + } + + { + await utils.queryInterface.addColumn('video', 'isLive', { + type: Sequelize.BOOLEAN, + defaultValue: false, + allowNull: false + }) + } +} + +function down (options) { + throw new Error('Not implemented.') +} + +export { + up, + down +} 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 @@ +import * as Sequelize from 'sequelize' + +async function up (utils: { + transaction: Sequelize.Transaction + queryInterface: Sequelize.QueryInterface + sequelize: Sequelize.Sequelize +}): Promise { + { + const data = { + type: Sequelize.STRING, + defaultValue: null, + allowNull: true + } + + await utils.queryInterface.changeColumn('videoFile', 'infoHash', data) + } +} + +function down (options) { + throw new Error('Not implemented.') +} + +export { + up, + down +} 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) { await writeFile(masterPlaylistPath, masterPlaylists.join('\n') + '\n') } -async function updateSha256Segments (video: MVideoWithFile) { +async function updateSha256VODSegments (video: MVideoWithFile) { const json: { [filename: string]: { [range: string]: string } } = {} const playlistDirectory = join(HLS_STREAMING_PLAYLIST_DIRECTORY, video.uuid) @@ -101,6 +101,11 @@ async function updateSha256Segments (video: MVideoWithFile) { await outputJSON(outputPath, json) } +async function buildSha256Segment (segmentPath: string) { + const buf = await readFile(segmentPath) + return sha256(buf) +} + function getRangesFromPlaylist (playlistContent: string) { const ranges: { offset: number, length: number }[] = [] const lines = playlistContent.split('\n') @@ -187,7 +192,8 @@ function downloadPlaylistSegments (playlistUrl: string, destinationDir: string, export { updateMasterHLSPlaylist, - updateSha256Segments, + updateSha256VODSegments, + buildSha256Segment, downloadPlaylistSegments, updateStreamingPlaylistsInfohashesIfNeeded } 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 if (!videoDatabase) return undefined // Create transcoding jobs if there are enabled resolutions - const resolutionsEnabled = computeResolutionsToTranscode(videoFileResolution) + const resolutionsEnabled = computeResolutionsToTranscode(videoFileResolution, 'vod') logger.info( 'Resolutions computed for video %s and origin file resolution of %d.', videoDatabase.uuid, videoFileResolution, { 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 @@ + +import { AsyncQueue, queue } from 'async' +import * as chokidar from 'chokidar' +import { FfmpegCommand } from 'fluent-ffmpeg' +import { ensureDir, readdir, remove } from 'fs-extra' +import { basename, join } from 'path' +import { computeResolutionsToTranscode, runLiveMuxing, runLiveTranscoding } from '@server/helpers/ffmpeg-utils' +import { logger } from '@server/helpers/logger' +import { CONFIG, registerConfigChangedHandler } from '@server/initializers/config' +import { P2P_MEDIA_LOADER_PEER_VERSION, VIDEO_LIVE, WEBSERVER } from '@server/initializers/constants' +import { VideoFileModel } from '@server/models/video/video-file' +import { VideoLiveModel } from '@server/models/video/video-live' +import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist' +import { MStreamingPlaylist, MVideo, MVideoLiveVideo } from '@server/types/models' +import { VideoState, VideoStreamingPlaylistType } from '@shared/models' +import { buildSha256Segment } from './hls' +import { getHLSDirectory } from './video-paths' + +const NodeRtmpServer = require('node-media-server/node_rtmp_server') +const context = require('node-media-server/node_core_ctx') +const nodeMediaServerLogger = require('node-media-server/node_core_logger') + +// Disable node media server logs +nodeMediaServerLogger.setLogType(0) + +const config = { + rtmp: { + port: CONFIG.LIVE.RTMP.PORT, + chunk_size: VIDEO_LIVE.RTMP.CHUNK_SIZE, + gop_cache: VIDEO_LIVE.RTMP.GOP_CACHE, + ping: VIDEO_LIVE.RTMP.PING, + ping_timeout: VIDEO_LIVE.RTMP.PING_TIMEOUT + }, + transcoding: { + ffmpeg: 'ffmpeg' + } +} + +type SegmentSha256QueueParam = { + operation: 'update' | 'delete' + videoUUID: string + segmentPath: string +} + +class LiveManager { + + private static instance: LiveManager + + private readonly transSessions = new Map() + private readonly segmentsSha256 = new Map>() + + private segmentsSha256Queue: AsyncQueue + private rtmpServer: any + + private constructor () { + } + + init () { + this.getContext().nodeEvent.on('postPublish', (sessionId: string, streamPath: string) => { + logger.debug('RTMP received stream', { id: sessionId, streamPath }) + + const splittedPath = streamPath.split('/') + if (splittedPath.length !== 3 || splittedPath[1] !== VIDEO_LIVE.RTMP.BASE_PATH) { + logger.warn('Live path is incorrect.', { streamPath }) + return this.abortSession(sessionId) + } + + this.handleSession(sessionId, streamPath, splittedPath[2]) + .catch(err => logger.error('Cannot handle sessions.', { err })) + }) + + this.getContext().nodeEvent.on('donePublish', sessionId => { + this.abortSession(sessionId) + }) + + this.segmentsSha256Queue = queue((options, cb) => { + const promise = options.operation === 'update' + ? this.addSegmentSha(options) + : Promise.resolve(this.removeSegmentSha(options)) + + promise.then(() => cb()) + .catch(err => { + logger.error('Cannot update/remove sha segment %s.', options.segmentPath, { err }) + cb() + }) + }) + + registerConfigChangedHandler(() => { + if (!this.rtmpServer && CONFIG.LIVE.ENABLED === true) { + this.run() + return + } + + if (this.rtmpServer && CONFIG.LIVE.ENABLED === false) { + this.stop() + } + }) + } + + run () { + logger.info('Running RTMP server.') + + this.rtmpServer = new NodeRtmpServer(config) + this.rtmpServer.run() + } + + stop () { + logger.info('Stopping RTMP server.') + + this.rtmpServer.stop() + this.rtmpServer = undefined + } + + getSegmentsSha256 (videoUUID: string) { + return this.segmentsSha256.get(videoUUID) + } + + private getContext () { + return context + } + + private abortSession (id: string) { + const session = this.getContext().sessions.get(id) + if (session) session.stop() + + const transSession = this.transSessions.get(id) + if (transSession) transSession.kill('SIGKILL') + } + + private async handleSession (sessionId: string, streamPath: string, streamKey: string) { + const videoLive = await VideoLiveModel.loadByStreamKey(streamKey) + if (!videoLive) { + logger.warn('Unknown live video with stream key %s.', streamKey) + return this.abortSession(sessionId) + } + + const video = videoLive.Video + const playlistUrl = WEBSERVER.URL + VideoStreamingPlaylistModel.getHlsMasterPlaylistStaticPath(video.uuid) + + const session = this.getContext().sessions.get(sessionId) + const resolutionsEnabled = CONFIG.LIVE.TRANSCODING.ENABLED + ? computeResolutionsToTranscode(session.videoHeight, 'live') + : [] + + logger.info('Will mux/transcode live video of original resolution %d.', session.videoHeight, { resolutionsEnabled }) + + const [ videoStreamingPlaylist ] = await VideoStreamingPlaylistModel.upsert({ + videoId: video.id, + playlistUrl, + segmentsSha256Url: WEBSERVER.URL + VideoStreamingPlaylistModel.getHlsSha256SegmentsStaticPath(video.uuid, video.isLive), + p2pMediaLoaderInfohashes: VideoStreamingPlaylistModel.buildP2PMediaLoaderInfoHashes(playlistUrl, resolutionsEnabled), + p2pMediaLoaderPeerVersion: P2P_MEDIA_LOADER_PEER_VERSION, + + type: VideoStreamingPlaylistType.HLS + }, { returning: true }) as [ MStreamingPlaylist, boolean ] + + video.state = VideoState.PUBLISHED + await video.save() + + // FIXME: federation? + + return this.runMuxing({ + sessionId, + videoLive, + playlist: videoStreamingPlaylist, + streamPath, + originalResolution: session.videoHeight, + resolutionsEnabled + }) + } + + private async runMuxing (options: { + sessionId: string + videoLive: MVideoLiveVideo + playlist: MStreamingPlaylist + streamPath: string + resolutionsEnabled: number[] + originalResolution: number + }) { + const { sessionId, videoLive, playlist, streamPath, resolutionsEnabled, originalResolution } = options + const allResolutions = resolutionsEnabled.concat([ originalResolution ]) + + for (let i = 0; i < allResolutions.length; i++) { + const resolution = allResolutions[i] + + VideoFileModel.upsert({ + resolution, + size: -1, + extname: '.ts', + infoHash: null, + fps: -1, + videoStreamingPlaylistId: playlist.id + }).catch(err => { + logger.error('Cannot create file for live streaming.', { err }) + }) + } + + const outPath = getHLSDirectory(videoLive.Video) + await ensureDir(outPath) + + const rtmpUrl = 'rtmp://127.0.0.1:' + config.rtmp.port + streamPath + const ffmpegExec = CONFIG.LIVE.TRANSCODING.ENABLED + ? runLiveTranscoding(rtmpUrl, outPath, allResolutions) + : runLiveMuxing(rtmpUrl, outPath) + + logger.info('Running live muxing/transcoding.') + + this.transSessions.set(sessionId, ffmpegExec) + + const onFFmpegEnded = () => { + watcher.close() + .catch(err => logger.error('Cannot close watcher of %s.', outPath, { err })) + + this.onEndTransmuxing(videoLive.Video, playlist, streamPath, outPath) + .catch(err => logger.error('Error in closed transmuxing.', { err })) + } + + ffmpegExec.on('error', (err, stdout, stderr) => { + onFFmpegEnded() + + // Don't care that we killed the ffmpeg process + if (err?.message?.includes('SIGKILL')) return + + logger.error('Live transcoding error.', { err, stdout, stderr }) + }) + + ffmpegExec.on('end', () => onFFmpegEnded()) + + const videoUUID = videoLive.Video.uuid + const watcher = chokidar.watch(outPath + '/*.ts') + + const updateHandler = segmentPath => this.segmentsSha256Queue.push({ operation: 'update', segmentPath, videoUUID }) + const deleteHandler = segmentPath => this.segmentsSha256Queue.push({ operation: 'delete', segmentPath, videoUUID }) + + watcher.on('add', p => updateHandler(p)) + watcher.on('change', p => updateHandler(p)) + watcher.on('unlink', p => deleteHandler(p)) + } + + private async onEndTransmuxing (video: MVideo, playlist: MStreamingPlaylist, streamPath: string, outPath: string) { + logger.info('RTMP transmuxing for %s ended.', streamPath) + + const files = await readdir(outPath) + + for (const filename of files) { + if ( + filename.endsWith('.ts') || + filename.endsWith('.m3u8') || + filename.endsWith('.mpd') || + filename.endsWith('.m4s') || + filename.endsWith('.tmp') + ) { + const p = join(outPath, filename) + + remove(p) + .catch(err => logger.error('Cannot remove %s.', p, { err })) + } + } + + playlist.destroy() + .catch(err => logger.error('Cannot remove live streaming playlist.', { err })) + + video.state = VideoState.LIVE_ENDED + video.save() + .catch(err => logger.error('Cannot save new video state of live streaming.', { err })) + } + + private async addSegmentSha (options: SegmentSha256QueueParam) { + const segmentName = basename(options.segmentPath) + logger.debug('Updating live sha segment %s.', options.segmentPath) + + const shaResult = await buildSha256Segment(options.segmentPath) + + if (!this.segmentsSha256.has(options.videoUUID)) { + this.segmentsSha256.set(options.videoUUID, new Map()) + } + + const filesMap = this.segmentsSha256.get(options.videoUUID) + filesMap.set(segmentName, shaResult) + } + + private removeSegmentSha (options: SegmentSha256QueueParam) { + const segmentName = basename(options.segmentPath) + + logger.debug('Removing live sha segment %s.', options.segmentPath) + + const filesMap = this.segmentsSha256.get(options.videoUUID) + if (!filesMap) { + logger.warn('Unknown files map to remove sha for %s.', options.videoUUID) + return + } + + if (!filesMap.has(segmentName)) { + logger.warn('Unknown segment in files map for video %s and segment %s.', options.videoUUID, options.segmentPath) + return + } + + filesMap.delete(segmentName) + } + + static get Instance () { + return this.instance || (this.instance = new this()) + } +} + +// --------------------------------------------------------------------------- + +export { + LiveManager +} 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: function getVideoFilePath (videoOrPlaylist: MVideo | MStreamingPlaylistVideo, videoFile: MVideoFile, isRedundancy = false) { if (isStreamingPlaylist(videoOrPlaylist)) { const video = extractVideo(videoOrPlaylist) - return join(HLS_STREAMING_PLAYLIST_DIRECTORY, video.uuid, getVideoFilename(videoOrPlaylist, videoFile)) + + return join(getHLSDirectory(video), getVideoFilename(videoOrPlaylist, videoFile)) } 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' import { logger } from '../helpers/logger' import { VideoResolution } from '../../shared/models/videos' import { VideoFileModel } from '../models/video/video-file' -import { updateMasterHLSPlaylist, updateSha256Segments } from './hls' +import { updateMasterHLSPlaylist, updateSha256VODSegments } from './hls' import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-playlist' import { VideoStreamingPlaylistType } from '../../shared/models/videos/video-streaming-playlist.type' import { CONFIG } from '../initializers/config' import { MStreamingPlaylistFilesVideo, MVideoFile, MVideoWithAllFiles, MVideoWithFile } from '@server/types/models' import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent' import { generateVideoStreamingPlaylistName, getVideoFilename, getVideoFilePath } from './video-paths' +import { spawn } from 'child_process' /** * Optimize the original video file and replace it. The resolution is not changed. @@ -182,7 +183,7 @@ async function generateHlsPlaylist (video: MVideoWithFile, resolution: VideoReso const [ videoStreamingPlaylist ] = await VideoStreamingPlaylistModel.upsert({ videoId: video.id, playlistUrl, - segmentsSha256Url: WEBSERVER.URL + VideoStreamingPlaylistModel.getHlsSha256SegmentsStaticPath(video.uuid), + segmentsSha256Url: WEBSERVER.URL + VideoStreamingPlaylistModel.getHlsSha256SegmentsStaticPath(video.uuid, video.isLive), p2pMediaLoaderInfohashes: VideoStreamingPlaylistModel.buildP2PMediaLoaderInfoHashes(playlistUrl, video.VideoFiles), p2pMediaLoaderPeerVersion: P2P_MEDIA_LOADER_PEER_VERSION, @@ -213,7 +214,7 @@ async function generateHlsPlaylist (video: MVideoWithFile, resolution: VideoReso video.setHLSPlaylist(videoStreamingPlaylist) await updateMasterHLSPlaylist(video) - await updateSha256Segments(video) + await updateSha256VODSegments(video) return video } 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 @@ + +import { VideoModel } from '@server/models/video/video' +import { FilteredModelAttributes } from '@server/types' +import { VideoCreate, VideoPrivacy, VideoState } from '@shared/models' + +function buildLocalVideoFromCreate (videoInfo: VideoCreate, channelId: number): FilteredModelAttributes { + return { + name: videoInfo.name, + remote: false, + category: videoInfo.category, + licence: videoInfo.licence, + language: videoInfo.language, + commentsEnabled: videoInfo.commentsEnabled !== false, // If the value is not "false", the default is "true" + downloadEnabled: videoInfo.downloadEnabled !== false, + waitTranscoding: videoInfo.waitTranscoding || false, + state: VideoState.WAITING_FOR_LIVE, + nsfw: videoInfo.nsfw || false, + description: videoInfo.description, + support: videoInfo.support, + privacy: videoInfo.privacy || VideoPrivacy.PRIVATE, + duration: 0, + channelId: channelId, + originallyPublishedAt: videoInfo.originallyPublishedAt + } +} + +// --------------------------------------------------------------------------- + +export { + buildLocalVideoFromCreate +} 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 @@ +import * as express from 'express' +import { body, param } from 'express-validator' +import { checkUserCanManageVideo, doesVideoChannelOfAccountExist, doesVideoExist } from '@server/helpers/middlewares/videos' +import { UserRight } from '@shared/models' +import { isIdOrUUIDValid, isIdValid, toIntOrNull } from '../../../helpers/custom-validators/misc' +import { isVideoNameValid } from '../../../helpers/custom-validators/videos' +import { cleanUpReqFiles } from '../../../helpers/express-utils' +import { logger } from '../../../helpers/logger' +import { CONFIG } from '../../../initializers/config' +import { areValidationErrors } from '../utils' +import { getCommonVideoEditAttributes } from './videos' +import { VideoLiveModel } from '@server/models/video/video-live' + +const videoLiveGetValidator = [ + param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'), + + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + logger.debug('Checking videoLiveGetValidator parameters', { parameters: req.body }) + + if (areValidationErrors(req, res)) return + if (!await doesVideoExist(req.params.videoId, res, 'all')) return + + // Check if the user who did the request is able to update the video + const user = res.locals.oauth.token.User + if (!checkUserCanManageVideo(user, res.locals.videoAll, UserRight.UPDATE_ANY_VIDEO, res)) return + + const videoLive = await VideoLiveModel.loadByVideoId(res.locals.videoAll.id) + if (!videoLive) return res.sendStatus(404) + + res.locals.videoLive = videoLive + + return next() + } +] + +const videoLiveAddValidator = getCommonVideoEditAttributes().concat([ + body('channelId') + .customSanitizer(toIntOrNull) + .custom(isIdValid).withMessage('Should have correct video channel id'), + + body('name') + .custom(isVideoNameValid).withMessage('Should have a valid name'), + + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + logger.debug('Checking videoLiveAddValidator parameters', { parameters: req.body }) + + if (CONFIG.LIVE.ENABLED !== true) { + return res.status(403) + .json({ error: 'Live is not enabled on this instance' }) + } + + if (areValidationErrors(req, res)) return cleanUpReqFiles(req) + + const user = res.locals.oauth.token.User + if (!await doesVideoChannelOfAccountExist(req.body.channelId, user, res)) return cleanUpReqFiles(req) + + return next() + } +]) + +// --------------------------------------------------------------------------- + +export { + videoLiveAddValidator, + videoLiveGetValidator +} 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 { @Column extname: string - @AllowNull(false) - @Is('VideoFileInfohash', value => throwIfNotValid(value, isVideoFileInfoHashValid, 'info hash')) + @AllowNull(true) + @Is('VideoFileInfohash', value => throwIfNotValid(value, isVideoFileInfoHashValid, 'info hash', true)) @Column infoHash: string 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 publishedAt: video.publishedAt, originallyPublishedAt: video.originallyPublishedAt, + isLive: video.isLive, + account: video.VideoChannel.Account.toFormattedSummaryJSON(), channel: video.VideoChannel.toFormattedSummaryJSON(), 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 @@ +import { AllowNull, BelongsTo, Column, CreatedAt, DataType, DefaultScope, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript' +import { WEBSERVER } from '@server/initializers/constants' +import { MVideoLive, MVideoLiveVideo } from '@server/types/models' +import { VideoLive } from '@shared/models/videos/video-live.model' +import { VideoModel } from './video' + +@DefaultScope(() => ({ + include: [ + { + model: VideoModel, + required: true + } + ] +})) +@Table({ + tableName: 'videoLive', + indexes: [ + { + fields: [ 'videoId' ], + unique: true + } + ] +}) +export class VideoLiveModel extends Model { + + @AllowNull(false) + @Column(DataType.STRING) + streamKey: string + + @CreatedAt + createdAt: Date + + @UpdatedAt + updatedAt: Date + + @ForeignKey(() => VideoModel) + @Column + videoId: number + + @BelongsTo(() => VideoModel, { + foreignKey: { + allowNull: false + }, + onDelete: 'cascade' + }) + Video: VideoModel + + static loadByStreamKey (streamKey: string) { + const query = { + where: { + streamKey + } + } + + return VideoLiveModel.findOne(query) + } + + static loadByVideoId (videoId: number) { + const query = { + where: { + videoId + } + } + + return VideoLiveModel.findOne(query) + } + + toFormattedJSON (): VideoLive { + return { + rtmpUrl: WEBSERVER.RTMP_URL, + streamKey: this.streamKey + } + } +} 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 { @Column remote: boolean + @AllowNull(false) + @Default(false) + @Column + isLive: boolean + @AllowNull(false) @Is('VideoUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url')) @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.URL.max)) 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 () { enabled: false } }, + live: { + enabled: true, + + transcoding: { + enabled: true, + threads: 4, + resolutions: { + '240p': true, + '360p': true, + '480p': true, + '720p': true, + '1080p': true, + '2160p': true + } + } + }, import: { videos: { 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) { expect(data.user.videoQuota).to.equal(5242880) expect(data.user.videoQuotaDaily).to.equal(-1) + expect(data.transcoding.enabled).to.be.false expect(data.transcoding.allowAdditionalExtensions).to.be.false expect(data.transcoding.allowAudioFiles).to.be.false @@ -77,6 +78,16 @@ function checkInitialConfig (server: ServerInfo, data: CustomConfig) { expect(data.transcoding.webtorrent.enabled).to.be.true expect(data.transcoding.hls.enabled).to.be.true + expect(data.live.enabled).to.be.false + expect(data.live.transcoding.enabled).to.be.false + expect(data.live.transcoding.threads).to.equal(2) + expect(data.live.transcoding.resolutions['240p']).to.be.false + expect(data.live.transcoding.resolutions['360p']).to.be.false + expect(data.live.transcoding.resolutions['480p']).to.be.false + expect(data.live.transcoding.resolutions['720p']).to.be.false + expect(data.live.transcoding.resolutions['1080p']).to.be.false + expect(data.live.transcoding.resolutions['2160p']).to.be.false + expect(data.import.videos.http.enabled).to.be.true expect(data.import.videos.torrent.enabled).to.be.true expect(data.autoBlacklist.videos.ofUsers.enabled).to.be.false @@ -150,6 +161,16 @@ function checkUpdatedConfig (data: CustomConfig) { expect(data.transcoding.hls.enabled).to.be.false expect(data.transcoding.webtorrent.enabled).to.be.true + expect(data.live.enabled).to.be.true + expect(data.live.transcoding.enabled).to.be.true + expect(data.live.transcoding.threads).to.equal(4) + expect(data.live.transcoding.resolutions['240p']).to.be.true + expect(data.live.transcoding.resolutions['360p']).to.be.true + expect(data.live.transcoding.resolutions['480p']).to.be.true + expect(data.live.transcoding.resolutions['720p']).to.be.true + expect(data.live.transcoding.resolutions['1080p']).to.be.true + expect(data.live.transcoding.resolutions['2160p']).to.be.true + expect(data.import.videos.http.enabled).to.be.false expect(data.import.videos.torrent.enabled).to.be.false expect(data.autoBlacklist.videos.ofUsers.enabled).to.be.true @@ -301,6 +322,21 @@ describe('Test config', function () { enabled: false } }, + live: { + enabled: true, + transcoding: { + enabled: true, + threads: 4, + resolutions: { + '240p': true, + '360p': true, + '480p': true, + '720p': true, + '1080p': true, + '2160p': true + } + } + }, import: { videos: { 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 () { }) it('Should transcode video on server 2', async function () { - this.timeout(60000) + this.timeout(120000) const videoAttributes = { 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' export * from './video-comment' export * from './video-file' export * from './video-import' +export * from './video-live' export * from './video-playlist' export * from './video-playlist-element' 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 @@ +import { VideoLiveModel } from '@server/models/video/video-live' +import { PickWith } from '@shared/core-utils' +import { MVideo } from './video' + +type Use = PickWith + +// ############################################################################ + +export type MVideoLive = Omit + +// ############################################################################ + +export type MVideoLiveVideo = + MVideoLive & + 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 { MVideoFile, MVideoImmutable, MVideoPlaylistFull, - MVideoPlaylistFullSummary + MVideoPlaylistFullSummary, + MVideoLive } from '@server/types/models' import { MOAuthTokenUser } from '@server/types/models/oauth/oauth-token' import { MPlugin, MServer, MServerBlocklist } from '@server/types/models/server' @@ -68,6 +69,8 @@ declare module 'express' { onlyVideoWithRights?: MVideoWithRights videoId?: MVideoIdThumbnail + videoLive?: MVideoLive + videoShare?: MVideoShareActor videoFile?: MVideoFile -- cgit v1.2.3