Fix Icecast publication service
With Icecast it is possible to output only an audio track. However, the tooling required always a video track and was throwing an error that was not properly handled. Now audio-only streams are allowed for publication services. datarhei/restreamer#429 datarhei/restreamer#483 datarhei/restreamer#542
This commit is contained in:
parent
0da1d6ba49
commit
f333d7fe95
@ -557,7 +557,7 @@ const mergeEgressMetadata = (metadata, base) => {
|
||||
return metadata;
|
||||
};
|
||||
|
||||
const validateProfile = (sources, profile) => {
|
||||
const validateProfile = (sources, profile, requireVideo = true) => {
|
||||
let validVideo = false;
|
||||
|
||||
profile = initProfile(profile);
|
||||
@ -604,14 +604,16 @@ const validateProfile = (sources, profile) => {
|
||||
|
||||
let complete = true;
|
||||
|
||||
if (profile.video.encoder.coder === 'none' || profile.video.source === -1 || profile.video.stream === -1) {
|
||||
complete = false;
|
||||
if (requireVideo === true) {
|
||||
if (profile.video.encoder.coder === 'none' || profile.video.source === -1 || profile.video.stream === -1) {
|
||||
complete = false;
|
||||
}
|
||||
}
|
||||
|
||||
return complete;
|
||||
};
|
||||
|
||||
const createInputsOutputs = (sources, profiles) => {
|
||||
const createInputsOutputs = (sources, profiles, requireVideo = true) => {
|
||||
const source2inputMap = new Map();
|
||||
|
||||
let global = [];
|
||||
@ -620,7 +622,7 @@ const createInputsOutputs = (sources, profiles) => {
|
||||
|
||||
// For each profile get the source and do the proper mapping
|
||||
for (let profile of profiles) {
|
||||
const complete = validateProfile(sources, profile);
|
||||
const complete = validateProfile(sources, profile, requireVideo);
|
||||
if (complete === false) {
|
||||
continue;
|
||||
}
|
||||
@ -717,37 +719,39 @@ const createInputsOutputs = (sources, profiles) => {
|
||||
return [global, inputs, outputs];
|
||||
};
|
||||
|
||||
const createOutputStreams = (sources, profiles) => {
|
||||
const createOutputStreams = (sources, profiles, requireVideo = true) => {
|
||||
const streams = [];
|
||||
|
||||
// Generate a list of output streams from the profiles
|
||||
for (let profile of profiles) {
|
||||
const complete = validateProfile(sources, profile);
|
||||
const complete = validateProfile(sources, profile, requireVideo);
|
||||
if (complete === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const source = sources[profile.video.source];
|
||||
const stream = source.streams[profile.video.stream];
|
||||
if (profile.video.encoder.coder !== 'none' && profile.video.source !== -1 && profile.video.stream !== -1) {
|
||||
const source = sources[profile.video.source];
|
||||
const stream = source.streams[profile.video.stream];
|
||||
|
||||
const s = initStream({
|
||||
index: 0,
|
||||
stream: streams.length,
|
||||
type: stream.type,
|
||||
width: stream.width,
|
||||
height: stream.height,
|
||||
});
|
||||
const s = initStream({
|
||||
index: 0,
|
||||
stream: streams.length,
|
||||
type: stream.type,
|
||||
width: stream.width,
|
||||
height: stream.height,
|
||||
});
|
||||
|
||||
if (profile.video.encoder.coder !== 'copy') {
|
||||
const encoder = Coders.Video.Get(profile.video.encoder.coder);
|
||||
if (encoder) {
|
||||
s.codec = encoder.codec;
|
||||
if (profile.video.encoder.coder !== 'copy') {
|
||||
const encoder = Coders.Video.Get(profile.video.encoder.coder);
|
||||
if (encoder) {
|
||||
s.codec = encoder.codec;
|
||||
}
|
||||
} else {
|
||||
s.codec = stream.codec;
|
||||
}
|
||||
} else {
|
||||
s.codec = stream.codec;
|
||||
}
|
||||
|
||||
streams.push(s);
|
||||
streams.push(s);
|
||||
}
|
||||
|
||||
if (profile.audio.encoder.coder !== 'none' && profile.audio.source !== -1 && profile.audio.stream !== -1) {
|
||||
const source = sources[profile.audio.source];
|
||||
|
||||
@ -170,7 +170,7 @@ export default function Wizard(props) {
|
||||
const profiles = data.profiles;
|
||||
const control = data.control;
|
||||
|
||||
const [global, inputs, outputs] = M.createInputsOutputs(sources, profiles);
|
||||
const [global, inputs, outputs] = M.createInputsOutputs(sources, profiles, true);
|
||||
|
||||
if (inputs.length === 0 || outputs.length === 0) {
|
||||
notify.Dispatch('error', 'save:ingest', i18n._(t`The input profile is not complete. Please define a video and audio source.`));
|
||||
|
||||
@ -291,7 +291,7 @@ export default function Edit(props) {
|
||||
const profiles = $data.profiles;
|
||||
const control = $data.control;
|
||||
|
||||
const [global, inputs, outputs] = M.createInputsOutputs(sources, profiles);
|
||||
const [global, inputs, outputs] = M.createInputsOutputs(sources, profiles, true);
|
||||
|
||||
if (inputs.length === 0 || outputs.length === 0) {
|
||||
notify.Dispatch('error', 'save:ingest', i18n._(t`The input profile is not complete. Please define a video and audio source.`));
|
||||
|
||||
@ -146,7 +146,7 @@ export default function Add(props) {
|
||||
...$settings,
|
||||
name: s.name,
|
||||
profiles: profiles,
|
||||
streams: M.createOutputStreams($sources, profiles),
|
||||
streams: M.createOutputStreams($sources, profiles, false),
|
||||
});
|
||||
|
||||
setTab('general');
|
||||
@ -179,17 +179,35 @@ export default function Add(props) {
|
||||
profiles[0][type].encoder = encoder;
|
||||
profiles[0][type].decoder = decoder;
|
||||
|
||||
const streams = M.createOutputStreams($sources, profiles, false);
|
||||
|
||||
let outputs = $settings.outputs;
|
||||
|
||||
service = Services.Get($service);
|
||||
if (service !== null) {
|
||||
if ('createOutputs' in service) {
|
||||
const serviceSkills = helper.conflateServiceSkills(service.requires, $skills);
|
||||
outputs = service.createOutputs($settings.settings, serviceSkills, $metadata, streams);
|
||||
}
|
||||
}
|
||||
|
||||
setSettings({
|
||||
...$settings,
|
||||
profiles: profiles,
|
||||
streams: M.createOutputStreams($sources, profiles),
|
||||
streams: streams,
|
||||
outputs: outputs,
|
||||
});
|
||||
};
|
||||
|
||||
const handleServiceDone = async () => {
|
||||
setSaving(true);
|
||||
|
||||
const [global, inputs, outputs] = helper.createInputsOutputs($sources, $settings.profiles, $settings.outputs);
|
||||
const [global, inputs, outputs] = helper.createInputsOutputs($sources, $settings.profiles, $settings.outputs, false);
|
||||
if (inputs.length === 0 || outputs.length === 0) {
|
||||
setSaving(false);
|
||||
notify.Dispatch('error', 'save:egress:' + $service, i18n._(t`The input profile is not complete. Please define a video and/or audio source.`));
|
||||
return;
|
||||
}
|
||||
|
||||
const [id, err] = await props.restreamer.CreateEgress(_channelid, $service, global, inputs, outputs, $settings.control);
|
||||
if (err !== null) {
|
||||
|
||||
@ -166,7 +166,7 @@ export default function Edit(props) {
|
||||
profiles[0].audio = helper.preselectProfile(profiles[0].audio, 'audio', ingest.streams, serviceSkills.codecs.audio, skills);
|
||||
|
||||
settings.profiles = profiles;
|
||||
settings.streams = M.createOutputStreams(sources, profiles);
|
||||
settings.streams = M.createOutputStreams(sources, profiles, false);
|
||||
|
||||
setSettings(settings);
|
||||
|
||||
@ -215,10 +215,19 @@ export default function Edit(props) {
|
||||
profiles[0][type].encoder = encoder;
|
||||
profiles[0][type].decoder = decoder;
|
||||
|
||||
const streams = M.createOutputStreams($sources, profiles, false);
|
||||
|
||||
let outputs = $settings.outputs;
|
||||
|
||||
if ('createOutputs' in $service) {
|
||||
outputs = $service.createOutputs($settings.settings, $serviceSkills, $metadata, streams);
|
||||
}
|
||||
|
||||
setSettings({
|
||||
...$settings,
|
||||
profiles: profiles,
|
||||
streams: M.createOutputStreams($sources, profiles),
|
||||
streams: streams,
|
||||
outputs: outputs,
|
||||
});
|
||||
|
||||
if (!automatic) {
|
||||
@ -229,7 +238,12 @@ export default function Edit(props) {
|
||||
const handleServiceDone = async () => {
|
||||
setSaving(true);
|
||||
|
||||
const [global, inputs, outputs] = helper.createInputsOutputs($sources, $settings.profiles, $settings.outputs);
|
||||
const [global, inputs, outputs] = helper.createInputsOutputs($sources, $settings.profiles, $settings.outputs, false);
|
||||
if (inputs.length === 0 || outputs.length === 0) {
|
||||
setSaving(false);
|
||||
notify.Dispatch('error', 'save:egress:' + _service, i18n._(t`The input profile is not complete. Please define a video and audio source.`));
|
||||
return;
|
||||
}
|
||||
|
||||
const [, err] = await props.restreamer.UpdateEgress(_channelid, id, global, inputs, outputs, $settings.control);
|
||||
if (err !== null) {
|
||||
@ -410,7 +424,13 @@ export default function Edit(props) {
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<ServiceControl settings={$settings.settings} skills={$serviceSkills} metadata={$metadata} onChange={handleServiceChange} />
|
||||
<ServiceControl
|
||||
settings={$settings.settings}
|
||||
skills={$serviceSkills}
|
||||
metadata={$metadata}
|
||||
streams={$settings.streams}
|
||||
onChange={handleServiceChange}
|
||||
/>
|
||||
</Grid>
|
||||
</TabContent>
|
||||
</TabPanel>
|
||||
|
||||
@ -146,6 +146,38 @@ function init(settings) {
|
||||
return initSettings;
|
||||
}
|
||||
|
||||
// createOutput creates the FFmpeg output options based on the settings,
|
||||
// skills, metadata, and/or streams. It returns an array of arrays of options
|
||||
// for each output.
|
||||
//
|
||||
// This function should be exported, such that it can be called from the
|
||||
// outside in case e.g. the streams changed (due to changes in the encoding
|
||||
// settings) and the output options depend on that information.
|
||||
function createOutputs(settings, skills, metadata, streams) {
|
||||
settings = init(settings);
|
||||
const outputs = [];
|
||||
|
||||
if (settings.stream_key.length === 0) {
|
||||
return outputs;
|
||||
}
|
||||
|
||||
if (settings.rtmp_primary) {
|
||||
outputs.push({
|
||||
address: 'rtmp://a.rtmp.youtube.com/live2/' + settings.stream_key,
|
||||
options: ['-codec', 'copy', '-f', 'flv'],
|
||||
});
|
||||
}
|
||||
|
||||
if (settings.rtmp_backup) {
|
||||
outputs.push({
|
||||
address: 'rtmp://b.rtmp.youtube.com/live2?backup=1/' + settings.stream_key,
|
||||
options: ['-codec', 'copy', '-f', 'flv'],
|
||||
});
|
||||
}
|
||||
|
||||
return outputs;
|
||||
}
|
||||
|
||||
// Service is a React component that implements a service.
|
||||
//
|
||||
// A service receives these props:
|
||||
@ -223,8 +255,6 @@ function init(settings) {
|
||||
// service. Its state is managed by the parent React component.
|
||||
function Service(props) {
|
||||
const settings = init(props.settings);
|
||||
const skills = props.skills;
|
||||
const metadata = props.metadata;
|
||||
|
||||
const handleChange = (what) => (event) => {
|
||||
const value = event.target.value;
|
||||
@ -235,36 +265,12 @@ function Service(props) {
|
||||
settings[what] = value;
|
||||
}
|
||||
|
||||
const outputs = createOutput(settings);
|
||||
const outputs = createOutputs(settings, props.skills, props.metadata, props.streams);
|
||||
|
||||
props.onChange(outputs, settings);
|
||||
};
|
||||
|
||||
const createOutput = (settings) => {
|
||||
const outputs = [];
|
||||
|
||||
if (settings.stream_key.length === 0) {
|
||||
return outputs;
|
||||
}
|
||||
|
||||
if (settings.rtmp_primary) {
|
||||
outputs.push({
|
||||
address: 'rtmp://a.rtmp.youtube.com/live2/' + settings.stream_key,
|
||||
options: ['-codec', 'copy', '-f', 'flv'],
|
||||
});
|
||||
}
|
||||
|
||||
if (settings.rtmp_backup) {
|
||||
outputs.push({
|
||||
address: 'rtmp://b.rtmp.youtube.com/live2?backup=1/' + settings.stream_key,
|
||||
options: ['-codec', 'copy', '-f', 'flv'],
|
||||
});
|
||||
}
|
||||
|
||||
return outputs;
|
||||
};
|
||||
|
||||
if (skills === null) {
|
||||
if (props.skills === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -297,4 +303,17 @@ Service.propTypes = {
|
||||
streams: PropTypes.array.isRequired,
|
||||
};
|
||||
|
||||
export { id, name, version, stream_key_link, description, image_copyright, author, category, requires, ServiceIcon as icon, Service as component };
|
||||
export {
|
||||
id,
|
||||
name,
|
||||
version,
|
||||
stream_key_link,
|
||||
description,
|
||||
image_copyright,
|
||||
author,
|
||||
category,
|
||||
requires,
|
||||
ServiceIcon as icon,
|
||||
Service as component,
|
||||
createOutputs,
|
||||
};
|
||||
|
||||
@ -56,7 +56,6 @@ function init(settings, metadata) {
|
||||
protocol: 'icecast://',
|
||||
address: '',
|
||||
options: {},
|
||||
profiles: {},
|
||||
...settings,
|
||||
};
|
||||
|
||||
@ -75,6 +74,61 @@ function init(settings, metadata) {
|
||||
return initSettings;
|
||||
}
|
||||
|
||||
function createOutputs(settings, skills, metadata, streams) {
|
||||
settings = init(settings, metadata);
|
||||
let hasVideo = false;
|
||||
let audioCodec = '';
|
||||
|
||||
for (let i = 0; i < streams.length; i++) {
|
||||
if (streams[i].type === 'video') {
|
||||
hasVideo = true;
|
||||
} else if (streams[i].type === 'audio') {
|
||||
audioCodec = streams[i].codec;
|
||||
}
|
||||
}
|
||||
|
||||
const options = [];
|
||||
|
||||
for (let key in settings.options) {
|
||||
if (settings.options[key].length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
options.push('-' + key, String(settings.options[key]));
|
||||
}
|
||||
|
||||
// https://gist.github.com/keiya/c8a5cbd4fe2594ddbb3390d9cf7dcac9#file-readme-md
|
||||
// https://wiki.xiph.org/Icecast_Server/Streaming_WebM_to_Icecast_with_FFmpeg
|
||||
|
||||
if (hasVideo === true) {
|
||||
options.push('-f', 'webm', '-cluster_size_limit', '2', '-cluster_time_limit', '5100', '-content_type', 'video/webm');
|
||||
} else {
|
||||
switch (audioCodec) {
|
||||
case 'aac':
|
||||
options.push('-content_type', 'audio/aac', '-f', 'adts');
|
||||
break;
|
||||
case 'vorbis':
|
||||
options.push('-content_type', 'audio/ogg', '-f', 'ogg');
|
||||
break;
|
||||
case 'opus':
|
||||
options.push('-content_type', 'audio/ogg', '-f', 'opus');
|
||||
break;
|
||||
case 'mp3':
|
||||
options.push('-content_type', 'audio/mpeg', '-f', 'mp3');
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const output = {
|
||||
address: settings.protocol + settings.address,
|
||||
options: options,
|
||||
};
|
||||
|
||||
return [output];
|
||||
}
|
||||
|
||||
function Service(props) {
|
||||
const settings = init(props.settings, props.metadata);
|
||||
|
||||
@ -91,63 +145,9 @@ function Service(props) {
|
||||
settings[what] = value;
|
||||
}
|
||||
|
||||
const output = createOutput(settings);
|
||||
const outputs = createOutputs(settings, props.skills, props.metadata, props.streams);
|
||||
|
||||
props.onChange([output], settings);
|
||||
};
|
||||
|
||||
const createOutput = (settings) => {
|
||||
let hasVideo = false;
|
||||
let audioCodec = '';
|
||||
|
||||
for (let i = 0; i < props.streams.length; i++) {
|
||||
if (props.streams[i].type === 'video') {
|
||||
hasVideo = true;
|
||||
} else if (props.streams[i].type === 'audio') {
|
||||
audioCodec = props.streams[i].codec;
|
||||
}
|
||||
}
|
||||
|
||||
const options = [];
|
||||
|
||||
for (let key in settings.options) {
|
||||
if (settings.options[key].length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
options.push('-' + key, String(settings.options[key]));
|
||||
}
|
||||
|
||||
// https://gist.github.com/keiya/c8a5cbd4fe2594ddbb3390d9cf7dcac9#file-readme-md
|
||||
// https://wiki.xiph.org/Icecast_Server/Streaming_WebM_to_Icecast_with_FFmpeg
|
||||
|
||||
if (hasVideo === true) {
|
||||
options.push('-f', 'webm', '-cluster_size_limit', '2', '-cluster_time_limit', '5100', '-content_type', 'video/webm');
|
||||
} else {
|
||||
switch (audioCodec) {
|
||||
case 'aac':
|
||||
options.push('-content_type', 'audio/aac', '-f', 'adts');
|
||||
break;
|
||||
case 'vorbis':
|
||||
options.push('-content_type', 'audio/ogg', '-f', 'ogg');
|
||||
break;
|
||||
case 'opus':
|
||||
options.push('-content_type', 'audio/ogg', '-f', 'opus');
|
||||
break;
|
||||
case 'mp3':
|
||||
options.push('-content_type', 'audio/mpeg', '-f', 'mp3');
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const output = {
|
||||
address: settings.protocol + settings.address,
|
||||
options: options,
|
||||
};
|
||||
|
||||
return output;
|
||||
props.onChange(outputs, settings);
|
||||
};
|
||||
|
||||
return (
|
||||
@ -259,4 +259,17 @@ Service.defaultProps = {
|
||||
onChange: function (output, settings) {},
|
||||
};
|
||||
|
||||
export { id, name, version, stream_key_link, description, image_copyright, author, category, requires, ServiceIcon as icon, Service as component };
|
||||
export {
|
||||
id,
|
||||
name,
|
||||
version,
|
||||
stream_key_link,
|
||||
description,
|
||||
image_copyright,
|
||||
author,
|
||||
category,
|
||||
requires,
|
||||
ServiceIcon as icon,
|
||||
Service as component,
|
||||
createOutputs,
|
||||
};
|
||||
|
||||
@ -34,8 +34,12 @@ export function createSourcesFromStreams(streams) {
|
||||
* @param {*} outputs service outputs (format options and address)
|
||||
* @returns
|
||||
*/
|
||||
export function createInputsOutputs(sources, profiles, outputs) {
|
||||
const [global, inpts, outpts] = M.createInputsOutputs(sources, profiles);
|
||||
export function createInputsOutputs(sources, profiles, outputs, requireVideo = true) {
|
||||
const [global, inpts, outpts] = M.createInputsOutputs(sources, profiles, requireVideo);
|
||||
|
||||
if (inpts.length === 0 || outpts.length === 0) {
|
||||
return [global, [], []];
|
||||
}
|
||||
|
||||
const out = [];
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user