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:
Ingo Oppermann 2023-04-13 12:10:20 +02:00
parent 0da1d6ba49
commit f333d7fe95
No known key found for this signature in database
GPG Key ID: 2AB32426E9DD229E
8 changed files with 200 additions and 122 deletions

View File

@ -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];

View File

@ -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.`));

View File

@ -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.`));

View File

@ -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) {

View File

@ -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>

View File

@ -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,
};

View File

@ -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,
};

View File

@ -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 = [];