diff --git a/src/misc/controls/RTMP.js b/src/misc/controls/RTMP.js new file mode 100644 index 0000000..22aaf1f --- /dev/null +++ b/src/misc/controls/RTMP.js @@ -0,0 +1,55 @@ +import React from 'react'; + +import { Trans } from '@lingui/macro'; +import Grid from '@mui/material/Grid'; +import Typography from '@mui/material/Typography'; + +import Checkbox from '../Checkbox'; + +function init(settings) { + const initSettings = { + enable: false, + ...settings, + }; + + return initSettings; +} + +export default function Control(props) { + const settings = init(props.settings); + + // Set the defaults + React.useEffect(() => { + props.onChange(settings, true); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const handleChange = (what) => (event) => { + const value = event.target.value; + + if (['enable'].includes(what)) { + settings[what] = !settings[what]; + } else { + settings[what] = value; + } + + props.onChange(settings, false); + }; + return ( + + + {/* Todo: Check availability with props.enabled */} + Enable} checked={settings.enable} onChange={handleChange('enable')} /> + + Make the channel available as an RTMP stream. + + + + ); +} + +Control.defaulProps = { + settings: {}, + enabled: false, + onChange: function (settings, automatic) {}, +}; diff --git a/src/misc/controls/SRT.js b/src/misc/controls/SRT.js new file mode 100644 index 0000000..ace64a3 --- /dev/null +++ b/src/misc/controls/SRT.js @@ -0,0 +1,55 @@ +import React from 'react'; + +import { Trans } from '@lingui/macro'; +import Grid from '@mui/material/Grid'; +import Typography from '@mui/material/Typography'; + +import Checkbox from '../Checkbox'; + +function init(settings) { + const initSettings = { + enable: false, + ...settings, + }; + + return initSettings; +} + +export default function Control(props) { + const settings = init(props.settings); + + // Set the defaults + React.useEffect(() => { + props.onChange(settings, true); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const handleChange = (what) => (event) => { + const value = event.target.value; + + if (['enable'].includes(what)) { + settings[what] = !settings[what]; + } else { + settings[what] = value; + } + + props.onChange(settings, false); + }; + return ( + + + {/* Todo: Check availability with props.enabled */} + Enable} checked={settings.enable} onChange={handleChange('enable')} /> + + Make the channel available as an SRT stream. + + + + ); +} + +Control.defaulProps = { + settings: {}, + enabled: false, + onChange: function (settings, automatic) {}, +}; diff --git a/src/utils/metadata.js b/src/utils/metadata.js index d8b1154..4f7fc93 100644 --- a/src/utils/metadata.js +++ b/src/utils/metadata.js @@ -249,6 +249,12 @@ const defaultIngestMetadata = { segmentDuration: 2, listSize: 6, }, + rtmp: { + enable: false, + }, + srt: { + enable: false, + }, process: { autostart: true, reconnect: true, diff --git a/src/utils/restreamer.js b/src/utils/restreamer.js index ded7d11..8416054 100644 --- a/src/utils/restreamer.js +++ b/src/utils/restreamer.js @@ -922,6 +922,71 @@ class Restreamer { return [address]; } + // Get all RTMP/SRT/SNAPSHOT+MEMFS/HLS+MEMFS addresses + GetAddresses(what, channelId) { + const config = this.ConfigActive(); + const host = new URL(this.Address()).hostname; + + let address = ''; + + function getPort(servicePort) { + let port = servicePort.split(/:([0-9]+)$/)[1]; + if (port && !port.includes(':')) { + port = `:${port}`; + } + if (port) { + return port; + } else { + return ''; + } + } + + // rtmp/s + if (what && what === 'rtmp') { + const port = getPort(config.source.network.rtmp.host); + + if (config.source.network.rtmp.secure) { + address = + `rtmps://${host}${port}/` + + (config.source.network.rtmp.app.length !== 0 ? config.source.network.rtmp.app : '') + + channelId + + '.stream' + + (config.source.network.rtmp.token.length !== 0 ? `?token=${config.source.network.rtmp.token}` : ''); + } else { + address = + `rtmp://${host}${port}/` + + (config.source.network.rtmp.app.length !== 0 ? config.source.network.rtmp.app : '') + + channelId + + '.stream' + + (config.source.network.rtmp.token.length !== 0 ? `?token=${config.source.network.rtmp.token}` : ''); + } + + // srt + } else if (what && what === 'srt') { + const port = getPort(config.source.network.srt.host); + + address = + `srt://${host}${port}/?mode=caller&streamid=#!:m=request,r=${channelId}` + + (config.source.network.srt.token.length !== 0 ? `,token=${config.source.network.srt.token}` : '') + + '&transtype=live' + + (config.source.network.srt.passphrase.length !== 0 ? `&passphrase=${config.source.network.srt.passphrase}` : ''); + + // snapshot+memfs + } else if (what && what === 'snapshotMemFs') { + const port = getPort(config.source.network.hls.host); + + address = (config.http.secure === true ? 'https://' : 'http://') + `${host}${port}/memfs/${channelId}.jpg`; + + // hls+memfs + } else { + const port = getPort(config.source.network.hls.host); + + address = (config.http.secure === true ? 'https://' : 'http://') + `${host}${port}/memfs/${channelId}.m3u8`; + } + + return [address]; + } + // Channels async _discoverChannels() { @@ -1482,19 +1547,22 @@ class Restreamer { }); } + // set hls storage endpoint + let hlsStore = 'memfs'; + const output = { id: 'output_0', - address: `{memfs}/${channel.channelid}.m3u8`, + address: `{` + hlsStore + `}/${channel.channelid}.m3u8`, options: ['-dn', '-sn', ...outputs[0].options.map((o) => '' + o)], cleanup: [ { - pattern: control.hls.version >= 7 ? `memfs:/${channel.channelid}_*.mp4` : `memfs:/${channel.channelid}_*.ts`, + pattern: control.hls.version >= 7 ? hlsStore + `:/${channel.channelid}_*.mp4` : hlsStore + `:/${channel.channelid}_*.ts`, max_files: parseInt(control.hls.listSize) + 6, max_file_age_seconds: control.hls.cleanup ? parseInt(control.hls.segmentDuration) * (parseInt(control.hls.listSize) + 6) : 0, purge_on_delete: true, }, { - pattern: `memfs:/${channel.channelid}.m3u8`, + pattern: hlsStore + `:/${channel.channelid}.m3u8`, max_file_age_seconds: control.hls.cleanup ? parseInt(control.hls.segmentDuration) * (parseInt(control.hls.listSize) + 6) : 0, purge_on_delete: true, }, @@ -1507,6 +1575,30 @@ class Restreamer { output.options.push(...metadata_options); + // fetch core config + const core_config = this.ConfigActive(); + + // fetch rtmp settings + const rtmp_config = core_config.source.network.rtmp; + let rtmp_enabled = false; + if (control.rtmp && control.rtmp.enable && rtmp_config.enabled) { + rtmp_enabled = true; + } + + // fetch srt settings + const srt_config = core_config.source.network.srt; + let srt_enabled = false; + if (control.srt && control.srt.enable && srt_config.enabled) { + srt_enabled = true; + } + + // 'tee_muxer' is required for the delivery of one output to multiple endpoints without processing the input for each output + // http://ffmpeg.org/ffmpeg-all.html#tee-1 + let tee_muxer = false; + if (rtmp_enabled || srt_enabled) { + tee_muxer = true; + } + // Manifest versions // https://developer.apple.com/documentation/http_live_streaming/about_the_ext-x-version_tag // https://ffmpeg.org/ffmpeg-all.html#Options-53 @@ -1544,16 +1636,23 @@ class Restreamer { ['hls_list_size', '' + parseInt(control.hls.listSize)], ['hls_flags', 'append_list+delete_segments+program_date_time+independent_segments'], ['hls_delete_threshold', '4'], - ['hls_segment_filename', `{memfs}/${channel.channelid}_%04d.ts`], - ['segment_format_options', 'mpegts_flags=mpegts_copyts=1'], - ['max_muxing_queue_size', '400'], + [ + 'hls_segment_filename', + tee_muxer ? `{` + hlsStore + `^:}/${channel.channelid}_%04d.ts` : `{` + hlsStore + `}/${channel.channelid}_%04d.ts`, + ], ['method', 'PUT'], ]; case 7: // fix Malformed AAC bitstream detected for hls version 7 - if (control.hls.version === 7 && output.options.includes('-codec:a') && output.options.includes('copy')) { + if (output.options.includes('-codec:a') && output.options.includes('copy')) { output.options.push('-bsf:a', 'aac_adtstoasc'); } + // mp4 manifest cleanup + output.cleanup.push({ + pattern: hlsStore + `:/${channel.channelid}.mp4`, + max_file_age_seconds: control.hls.cleanup ? parseInt(control.hls.segmentDuration) * (parseInt(control.hls.listSize) + 6) : 0, + purge_on_delete: true, + }); return [ ['f', 'hls'], ['start_number', '0'], @@ -1562,10 +1661,11 @@ class Restreamer { ['hls_flags', 'append_list+delete_segments+program_date_time+independent_segments'], ['hls_delete_threshold', '4'], ['hls_segment_type', 'fmp4'], - ['hls_fmp4_init_filename', `${channel.channelid}_init.mp4`], - ['hls_segment_filename', `{memfs}/${channel.channelid}_%04d.mp4`], - ['segment_format_options', 'mpegts_flags=mpegts_copyts=1'], - ['max_muxing_queue_size', '400'], + ['hls_fmp4_init_filename', `${channel.channelid}.mp4`], + [ + 'hls_segment_filename', + tee_muxer ? `{` + hlsStore + `^:}/${channel.channelid}_%04d.mp4` : `{` + hlsStore + `}/${channel.channelid}_%04d.mp4`, + ], ['method', 'PUT'], ]; // case 3 @@ -1577,9 +1677,10 @@ class Restreamer { ['hls_list_size', '' + parseInt(control.hls.listSize)], ['hls_flags', 'append_list+delete_segments+program_date_time'], ['hls_delete_threshold', '4'], - ['hls_segment_filename', `{memfs}/${channel.channelid}_%04d.ts`], - ['segment_format_options', 'mpegts_flags=mpegts_copyts=1'], - ['max_muxing_queue_size', '400'], + [ + 'hls_segment_filename', + tee_muxer ? `{` + hlsStore + `^:}/${channel.channelid}_%04d.ts` : `{` + hlsStore + `}/${channel.channelid}_%04d.ts`, + ], ['method', 'PUT'], ]; } @@ -1587,28 +1688,31 @@ class Restreamer { }; const hls_params_raw = getHLSParams(control.hls.lhls, control.hls.version); - // 'tee_muxer' is required for the delivery of one output to multiple endpoints without processing the input for each output - // http://ffmpeg.org/ffmpeg-all.html#tee-1 - const tee_muxer = false; + // push -y + proc.options.push('-y'); // Returns the l/hls parameters with or without tee_muxer if (tee_muxer) { // f=hls:start_number=0... const hls_params = hls_params_raw .filter((o) => { - if (o[0] === 'segment_format_options' || o[0] === 'max_muxing_queue_size') { - return false; - } - - return true; + // unsupported in tee_muxer + return !(o[0] === 'segment_format_options' || o[0] === 'max_muxing_queue_size'); }) .map((o) => o[0] + '=' + o[1]) .join(':'); - output.options.push('-tag:v', '7', '-tag:a', '10', '-f', 'tee'); - // WARN: It is a magic function. Returns 'Invalid process config' and the process.id is lost (Core v16.8.0) <= this is not the case anymore with the latest dev branch + output.options.push('-flags', '+global_header', '-tag:v', '7', '-tag:a', '10', '-f', 'tee'); // ['f=hls:start_number=0...]address.m3u8 - output.address = `[` + hls_params + `]{memfs}/${channel.channelid}.m3u8`; + // use tee_muxer formatting + output.address = + `[` + + hls_params + + `]{` + + hlsStore + + `}/${channel.channelid}.m3u8` + + (rtmp_enabled ? `|[f=flv]{rtmp,name=${channel.channelid}.stream}` : '') + + (srt_enabled ? `|[f=mpegts]{srt,name=${channel.channelid},mode=publish}` : ''); } else { // ['-f', 'hls', '-start_number', '0', ...] // adding the '-' in front of the first option, then flatten everything diff --git a/src/version.js b/src/version.js index a72eac1..1dedd3d 100644 --- a/src/version.js +++ b/src/version.js @@ -1,6 +1,6 @@ import { name, version, bundle } from '../package.json'; -const Core = '^15.0.0 || ^16.0.0'; +const Core = '^16.9.0'; const FFmpeg = '^4.1.0 || ^5.0.0'; const UI = bundle ? bundle : name + ' v' + version; diff --git a/src/views/Edit/index.js b/src/views/Edit/index.js index 430a3c0..f1a5e30 100644 --- a/src/views/Edit/index.js +++ b/src/views/Edit/index.js @@ -31,7 +31,9 @@ import PaperThumb from '../../misc/PaperThumb'; import ProcessControl from '../../misc/controls/Process'; import Profile from './Profile'; import ProfileSummary from './ProfileSummary'; +import RTMPControl from '../../misc/controls/RTMP'; import SnapshotControl from '../../misc/controls/Snapshot'; +import SRTControl from '../../misc/controls/SRT'; import TabPanel from '../../misc/TabPanel'; import TabsVerticalGrid from '../../misc/TabsVerticalGrid'; @@ -477,6 +479,36 @@ export default function Edit(props) { + + + RTMP + + + + + + + + + + + SRT + + + + + + + + Snapshot diff --git a/src/views/Main/index.js b/src/views/Main/index.js index 99d5001..c43ad83 100644 --- a/src/views/Main/index.js +++ b/src/views/Main/index.js @@ -10,6 +10,7 @@ import Stack from '@mui/material/Stack'; import Typography from '@mui/material/Typography'; import WarningIcon from '@mui/icons-material/Warning'; +import * as M from '../../utils/metadata'; import useInterval from '../../hooks/useInterval'; import ActionButton from '../../misc/ActionButton'; import CopyButton from '../../misc/CopyButton'; @@ -65,6 +66,7 @@ export default function Main(props) { state: 'disconnected', onConnect: null, }); + const [$metadata, setMetadata] = React.useState(M.getDefaultIngestMetadata()); const [$processDetails, setProcessDetails] = React.useState({ open: false, data: { @@ -87,11 +89,24 @@ export default function Main(props) { React.useEffect(() => { (async () => { + await load(); await update(); })(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + const load = async () => { + let metadata = await props.restreamer.GetIngestMetadata(_channelid); + if (metadata.version && metadata.version === 1) { + setMetadata({ + ...$metadata, + ...metadata, + }); + } + + await update(); + }; + const update = async () => { const channelid = props.restreamer.SelectChannel(_channelid); if (channelid === '' || channelid !== _channelid) { @@ -365,10 +380,40 @@ export default function Main(props) { Content URL - + HLS - + {$metadata.control.rtmp.enable && ( + + RTMP + + )} + {$metadata.control.srt.enable && ( + + SRT + + )} + Snapshot diff --git a/src/views/Settings.js b/src/views/Settings.js index 54d7c44..e164cc0 100644 --- a/src/views/Settings.js +++ b/src/views/Settings.js @@ -767,7 +767,12 @@ export default function Settings(props) { config.address = config.address.split(':').join(''); config.tls.address = config.tls.address.split(':').join(''); config.rtmp.address = config.rtmp.address.split(':').join(''); - config.rtmp.address_tls = config.rtmp.address_tls.split(':').join(''); + // fix: Cannot read properties of undefined + if (config.rtmp.address_tls) { + config.rtmp.address_tls = config.rtmp.address_tls.split(':').join(''); + } else { + config.rtmp.address_tls = '1936'; + } config.srt.address = config.srt.address.split(':').join(''); if (config.tls.auto === true) { @@ -1836,53 +1841,6 @@ export default function Settings(props) { />{' '} {env('rtmp.enable') && } - - - - - - Port} - env={env('rtmp.address')} - disabled={env('rtmp.address') || (!config.rtmp.enable && !config.rtmp.enable_tls)} - value={config.rtmp.address} - onChange={handleChange('rtmp.address')} - /> - - - RTMP server listen address. - - - - App} - env={env('rtmp.app')} - disabled={env('rtmp.app') || (!config.rtmp.enable && !config.rtmp.enable_tls)} - value={config.rtmp.app} - onChange={handleChange('rtmp.app')} - /> - - - RTMP app for publishing. - - - - Token} - env={env('rtmp.token')} - disabled={env('rtmp.token') || (!config.rtmp.enable && !config.rtmp.enable_tls)} - value={config.rtmp.token} - onChange={handleChange('rtmp.token')} - /> - - - RTMP token for publishing and playing. The token is the value of the URL query parameter 'token.' - - - - - - RTMPS server} checked={config.rtmp.enable_tls} @@ -1896,9 +1854,9 @@ export default function Settings(props) { Requires activation{' '} { - setTab('auth'); + setTab('network'); }} > TLS/HTTPS @@ -1907,11 +1865,27 @@ export default function Settings(props) { )} - + + + + Port} + label={RTMP Port} + env={env('rtmp.address')} + disabled={env('rtmp.address') || (!config.rtmp.enable && !config.rtmp.enable_tls)} + value={config.rtmp.address} + onChange={handleChange('rtmp.address')} + /> + + + RTMP server listen address. + + + + RTMPS Port} env={env('rtmp.address_tls')} - disabled={env('rtmp.address_tls') || (!config.rtmp.enable && !config.rtmp.enable_tls)} + disabled={env('rtmp.address_tls') || (!config.rtmp.enable_tls) || (!config.tls.auto)} value={config.rtmp.address_tls} onChange={handleChange('rtmp.address_tls')} /> @@ -1920,6 +1894,32 @@ export default function Settings(props) { RTMPS server listen address. + + App} + env={env('rtmp.app')} + disabled={env('rtmp.app') || (!config.rtmp.enable)} + value={config.rtmp.app} + onChange={handleChange('rtmp.app')} + /> + + + RTMP app for publishing. + + + + Token} + env={env('rtmp.token')} + disabled={env('rtmp.token') || (!config.rtmp.enable)} + value={config.rtmp.token} + onChange={handleChange('rtmp.token')} + /> + + + RTMP token for publishing and playing. The token is the value of the URL query parameter 'token.' + +