diff --git a/src/main/java/io/github/dsheirer/audio/broadcast/BroadcastConfiguration.java b/src/main/java/io/github/dsheirer/audio/broadcast/BroadcastConfiguration.java
index cfcc9041..8391c492 100644
--- a/src/main/java/io/github/dsheirer/audio/broadcast/BroadcastConfiguration.java
+++ b/src/main/java/io/github/dsheirer/audio/broadcast/BroadcastConfiguration.java
@@ -25,6 +25,7 @@ import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty;
import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlRootElement;
import io.github.dsheirer.audio.broadcast.broadcastify.BroadcastifyCallConfiguration;
import io.github.dsheirer.audio.broadcast.rdioscanner.RdioScannerConfiguration;
+import io.github.dsheirer.audio.broadcast.openmhz.OpenMHzConfiguration;
import io.github.dsheirer.audio.broadcast.icecast.IcecastConfiguration;
import io.github.dsheirer.audio.broadcast.shoutcast.v1.ShoutcastV1Configuration;
import io.github.dsheirer.audio.broadcast.shoutcast.v2.ShoutcastV2Configuration;
@@ -48,6 +49,7 @@ import java.net.SocketAddress;
@JsonSubTypes({
@JsonSubTypes.Type(value = BroadcastifyCallConfiguration.class, name="broadcastifyCallConfiguration"),
@JsonSubTypes.Type(value = RdioScannerConfiguration.class, name="RdioScannerConfiguration"),
+ @JsonSubTypes.Type(value = OpenMHzConfiguration.class, name="OpenMHzConfiguration"),
@JsonSubTypes.Type(value = IcecastConfiguration.class, name="icecastConfiguration"),
@JsonSubTypes.Type(value = ShoutcastV1Configuration.class, name="shoutcastV1Configuration"),
@JsonSubTypes.Type(value = ShoutcastV2Configuration.class, name="shoutcastV2Configuration"),
diff --git a/src/main/java/io/github/dsheirer/audio/broadcast/BroadcastFactory.java b/src/main/java/io/github/dsheirer/audio/broadcast/BroadcastFactory.java
index 255ef969..f513938d 100644
--- a/src/main/java/io/github/dsheirer/audio/broadcast/BroadcastFactory.java
+++ b/src/main/java/io/github/dsheirer/audio/broadcast/BroadcastFactory.java
@@ -25,6 +25,9 @@ import io.github.dsheirer.audio.broadcast.broadcastify.BroadcastifyFeedConfigura
import io.github.dsheirer.audio.broadcast.rdioscanner.RdioScannerBroadcaster;
import io.github.dsheirer.audio.broadcast.rdioscanner.RdioScannerConfiguration;
import io.github.dsheirer.audio.broadcast.rdioscanner.RdioScannerFeedConfiguration;
+import io.github.dsheirer.audio.broadcast.openmhz.OpenMHzBroadcaster;
+import io.github.dsheirer.audio.broadcast.openmhz.OpenMHzConfiguration;
+import io.github.dsheirer.audio.broadcast.openmhz.OpenMHzFeedConfiguration;
import io.github.dsheirer.audio.broadcast.icecast.IcecastHTTPAudioBroadcaster;
import io.github.dsheirer.audio.broadcast.icecast.IcecastHTTPConfiguration;
import io.github.dsheirer.audio.broadcast.icecast.IcecastTCPAudioBroadcaster;
@@ -67,6 +70,9 @@ public class BroadcastFactory
case RDIOSCANNER_CALL:
return new RdioScannerBroadcaster((RdioScannerConfiguration)configuration,
inputAudioFormat, mp3Setting, aliasModel);
+ case OPENMHZ:
+ return new OpenMHzBroadcaster((OpenMHzConfiguration)configuration,
+ inputAudioFormat, mp3Setting, aliasModel);
case BROADCASTIFY:
return new IcecastTCPAudioBroadcaster((BroadcastifyFeedConfiguration) configuration,
inputAudioFormat, mp3Setting, aliasModel);
@@ -109,6 +115,8 @@ public class BroadcastFactory
return new RdioScannerConfiguration(format);
case BROADCASTIFY:
return new BroadcastifyFeedConfiguration(format);
+ case OPENMHZ:
+ return new OpenMHzConfiguration(format);
case ICECAST_HTTP:
return new IcecastHTTPConfiguration(format);
case ICECAST_TCP:
diff --git a/src/main/java/io/github/dsheirer/audio/broadcast/BroadcastServerType.java b/src/main/java/io/github/dsheirer/audio/broadcast/BroadcastServerType.java
index aab98a00..b33e88c8 100644
--- a/src/main/java/io/github/dsheirer/audio/broadcast/BroadcastServerType.java
+++ b/src/main/java/io/github/dsheirer/audio/broadcast/BroadcastServerType.java
@@ -32,6 +32,7 @@ public enum BroadcastServerType
ICECAST_HTTP("Icecast 2 (v2.4+)", "images/icecast.png"),
RDIOSCANNER_CALL("Rdio Scanner", "images/rdioscanner.png"),
+ OPENMHZ("OpenMHz", "images/openmhz.png"),
ICECAST_TCP("Icecast (v2.3)", "images/icecast.png"),
SHOUTCAST_V1("Shoutcast v1.x", "images/shoutcast.png"),
SHOUTCAST_V2("Shoutcast v2.x", "images/shoutcast.png"),
diff --git a/src/main/java/io/github/dsheirer/audio/broadcast/openmhz/FormField.java b/src/main/java/io/github/dsheirer/audio/broadcast/openmhz/FormField.java
new file mode 100644
index 00000000..c84c0f38
--- /dev/null
+++ b/src/main/java/io/github/dsheirer/audio/broadcast/openmhz/FormField.java
@@ -0,0 +1,50 @@
+/*
+ * *****************************************************************************
+ * Copyright (C) 2014-2020 Dennis Sheirer
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see
+ * ****************************************************************************
+ */
+
+ package io.github.dsheirer.audio.broadcast.openmhz;
+
+ /**
+ * HTTP headers used for posting to Rdio Scanner API
+ */
+ public enum FormField
+ {
+ CALL("call"),
+ FREQ("freq"),
+ START_TIME("start_time"),
+ STOP_TIME("stop_time"),
+ CALL_LENGTH("call_length"),
+ TALKGROUP_NUM("talkgroup_num"),
+ EMERGENCY("emergency"),
+ API_KEY("api_key"),
+ SOURCE_LIST("source_list"),
+ FREQ_LIST("freq_list"),
+ PATCHES("patches");
+
+ private String mHeader;
+
+ FormField(String header)
+ {
+ mHeader = header;
+ }
+
+ public String getHeader()
+ {
+ return mHeader;
+ }
+ }
\ No newline at end of file
diff --git a/src/main/java/io/github/dsheirer/audio/broadcast/openmhz/OpenMHzBroadcaster.java b/src/main/java/io/github/dsheirer/audio/broadcast/openmhz/OpenMHzBroadcaster.java
new file mode 100644
index 00000000..c3ef5e25
--- /dev/null
+++ b/src/main/java/io/github/dsheirer/audio/broadcast/openmhz/OpenMHzBroadcaster.java
@@ -0,0 +1,623 @@
+/*
+ * *****************************************************************************
+ * Copyright (C) 2014-2022 Dennis Sheirer
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see
+ * ****************************************************************************
+ */
+
+ package io.github.dsheirer.audio.broadcast.openmhz;
+
+ import com.google.common.net.HttpHeaders;
+ import com.google.common.base.Joiner;
+ import io.github.dsheirer.alias.Alias;
+ import io.github.dsheirer.alias.AliasList;
+ import io.github.dsheirer.alias.AliasModel;
+ import io.github.dsheirer.audio.broadcast.AbstractAudioBroadcaster;
+ import io.github.dsheirer.audio.broadcast.AudioRecording;
+ import io.github.dsheirer.audio.broadcast.BroadcastEvent;
+ import io.github.dsheirer.audio.broadcast.BroadcastState;
+ import io.github.dsheirer.audio.convert.InputAudioFormat;
+ import io.github.dsheirer.audio.convert.MP3Setting;
+ import io.github.dsheirer.gui.playlist.radioreference.RadioReferenceDecoder;
+ import io.github.dsheirer.identifier.Form;
+ import io.github.dsheirer.identifier.Identifier;
+ import io.github.dsheirer.identifier.IdentifierClass;
+ import io.github.dsheirer.identifier.MutableIdentifierCollection;
+ import io.github.dsheirer.identifier.Role;
+ import io.github.dsheirer.identifier.configuration.ConfigurationLongIdentifier;
+ import io.github.dsheirer.identifier.patch.PatchGroup;
+ import io.github.dsheirer.identifier.patch.PatchGroupIdentifier;
+ import io.github.dsheirer.identifier.radio.RadioIdentifier;
+ import io.github.dsheirer.identifier.talkgroup.TalkgroupIdentifier;
+ import io.github.dsheirer.util.ThreadPool;
+ import org.slf4j.Logger;
+ import org.slf4j.LoggerFactory;
+
+ import java.io.File;
+ import java.nio.file.Files;
+ import java.io.FileInputStream;
+ import java.io.FileNotFoundException;
+ import java.io.IOException;
+ import java.io.PrintWriter;
+ import java.io.BufferedReader;
+ import java.io.InputStreamReader;
+ import java.io.OutputStreamWriter;
+ import java.net.URI;
+ import java.net.URL;
+ import java.net.URLConnection;
+ import java.net.http.HttpClient;
+ import java.net.http.HttpRequest;
+ import java.net.http.HttpResponse;
+ import java.time.Duration;
+ import java.util.List;
+ import java.util.Queue;
+ import java.util.concurrent.CompletionException;
+ import java.util.concurrent.LinkedTransferQueue;
+ import java.util.concurrent.ScheduledFuture;
+ import java.util.concurrent.TimeUnit;
+
+
+ /**
+ * Audio broadcaster to push completed audio recordings to the Rdio Scanner call upload API.
+ *
+ */
+ public class OpenMHzBroadcaster extends AbstractAudioBroadcaster
+ {
+ private final static Logger mLog = LoggerFactory.getLogger(OpenMHzBroadcaster.class);
+
+ private static final String ENCODING_TYPE_MP3 = "mp3";
+ private static final String MULTIPART_TYPE = "multipart";
+ private static final String DEFAULT_SUBTYPE = "form-data";
+ private static final String MULTIPART_FORM_DATA = MULTIPART_TYPE + "/" + DEFAULT_SUBTYPE;
+ private Queue mAudioRecordingQueue = new LinkedTransferQueue<>();
+ private ScheduledFuture> mAudioRecordingProcessorFuture;
+ private HttpClient mHttpClient = HttpClient.newBuilder()
+ .version(HttpClient.Version.HTTP_2)
+ .followRedirects(HttpClient.Redirect.NORMAL)
+ .connectTimeout(Duration.ofSeconds(20))
+ .build();
+ private long mLastConnectionAttempt;
+ private long mConnectionAttemptInterval = 5000; //Every 5 seconds
+ private AliasModel mAliasModel;
+
+ /**
+ * Constructs an instance of the broadcaster
+ * @param config to use
+ * @param aliasModel for access to aliases
+ */
+ public OpenMHzBroadcaster(OpenMHzConfiguration config, InputAudioFormat inputAudioFormat,
+ MP3Setting mp3Setting, AliasModel aliasModel)
+ {
+ super(config);
+ mAliasModel = aliasModel;
+ }
+
+ /**
+ * Starts the audio recording processor thread
+ */
+ @Override
+ public void start()
+ {
+ setBroadcastState(BroadcastState.CONNECTING);
+ String response = testConnection(getBroadcastConfiguration());
+ mLastConnectionAttempt = System.currentTimeMillis();
+
+ /**
+ * Rdio Scanner API does not currently expose a test method.
+ * TODO: FIX THIS
+ */
+ if(response != null)// && response.toLowerCase().startsWith("502 bad gateway"))
+ {
+ setBroadcastState(BroadcastState.CONNECTED);
+ }
+ else
+ {
+ mLog.error("Error connecting to Rdio Scanner server on startup [" + response + "]");
+ setBroadcastState(BroadcastState.ERROR);
+ }
+
+ if(mAudioRecordingProcessorFuture == null)
+ {
+ mAudioRecordingProcessorFuture = ThreadPool.SCHEDULED.scheduleAtFixedRate(new AudioRecordingProcessor(),
+ 0, 500, TimeUnit.MILLISECONDS);
+ }
+ }
+
+ /**
+ * Stops the audio recording processor thread
+ */
+ @Override
+ public void stop()
+ {
+ if(mAudioRecordingProcessorFuture != null)
+ {
+ mAudioRecordingProcessorFuture.cancel(true);
+ mAudioRecordingProcessorFuture = null;
+ dispose();
+ setBroadcastState(BroadcastState.DISCONNECTED);
+ }
+ }
+
+ /**
+ * Prepares for disposal
+ */
+ @Override
+ public void dispose()
+ {
+ AudioRecording audioRecording = mAudioRecordingQueue.poll();
+
+ while(audioRecording != null)
+ {
+ audioRecording.removePendingReplay();
+ audioRecording = mAudioRecordingQueue.poll();
+ }
+ }
+
+ /**
+ * Indicates if this broadcaster continues to have successful connections to and transactions with the remote
+ * server. If there is a connectivity or other issue, the broadcast state is set to temporary error and
+ * the audio processor thread will persistently invoke this method to attempt a reconnect.
+ *
+ * Rdio Scanner does not have a test API endpoint, so we look for the incomplete call response.
+ */
+ private boolean connected()
+ {
+ if(getBroadcastState() != BroadcastState.CONNECTED &&
+ (System.currentTimeMillis() - mLastConnectionAttempt > mConnectionAttemptInterval))
+ {
+ setBroadcastState(BroadcastState.CONNECTING);
+
+ String response = testConnection(getBroadcastConfiguration());
+ mLastConnectionAttempt = System.currentTimeMillis();
+
+ if(response != null && response.toLowerCase().startsWith("incomplete call data: no talkgroup"))
+ {
+ setBroadcastState(BroadcastState.CONNECTED);
+ }
+ else
+ {
+ setBroadcastState(BroadcastState.ERROR);
+ }
+ }
+
+ return getBroadcastState() == BroadcastState.CONNECTED;
+ }
+
+ @Override
+ public int getAudioQueueSize()
+ {
+ return mAudioRecordingQueue.size();
+ }
+
+ @Override
+ public void receive(AudioRecording audioRecording)
+ {
+ mAudioRecordingQueue.offer(audioRecording);
+ broadcast(new BroadcastEvent(this, BroadcastEvent.Event.BROADCASTER_QUEUE_CHANGE));
+ }
+
+ /**
+ * Indicates if the audio recording is non-null and not too old, meaning that the age of the recording has not
+ * exceeded the max age value indicated in the broadcast configuration. Audio recordings that are too old will be
+ * deleted to ensure that the in-memory queue size doesn't blow up.
+ * @param audioRecording to test
+ * @return true if the recording is valid
+ */
+ private boolean isValid(AudioRecording audioRecording)
+ {
+ return audioRecording != null && System.currentTimeMillis() - audioRecording.getStartTime() <=
+ getBroadcastConfiguration().getMaximumRecordingAge();
+ }
+
+ /**
+ * Processes any enqueued audio recordings. This method employs asynchronous
+ * interaction with the server, so multiple audio recording uploads can occur simultaneously.
+ */
+ private void processRecordingQueue()
+ {
+
+
+ while(connected() && !mAudioRecordingQueue.isEmpty())
+ {
+ final AudioRecording audioRecording = mAudioRecordingQueue.poll();
+ broadcast(new BroadcastEvent(this, BroadcastEvent.Event.BROADCASTER_QUEUE_CHANGE));
+
+ if(isValid(audioRecording) && audioRecording.getRecordingLength() > 0)
+ {
+ int durationSeconds = (int)(audioRecording.getRecordingLength() / 1E3f);
+ long timestampSeconds = (int)(audioRecording.getStartTime() / 1E3);
+ String talkgroup = getTo(audioRecording);
+ String radioId = getFrom(audioRecording);
+ Long frequency = getFrequency(audioRecording);
+ String patches = getPatches(audioRecording);
+ String talkgroupLabel = getTalkgroupLabel(audioRecording);
+ String talkgroupGroup = getTalkgroupGroup(audioRecording);
+ String systemLabel = getSystemLabel(audioRecording);
+
+ try
+ {
+ byte[] audioBytes = null;
+
+ try
+ {
+ audioBytes = Files.readAllBytes(audioRecording.getPath());
+ }
+ catch(IOException e)
+ {
+ mLog.error("Rdio Scanner API - audio recording file not found - ignoring upload");
+ }
+
+ if(audioBytes != null)
+ {
+
+ OpenMHzBuilder bodyBuilder = new OpenMHzBuilder();
+ bodyBuilder
+ .addFile(audioBytes)
+ .addPart(FormField.FREQ, frequency)
+ .addPart(FormField.START_TIME, timestampSeconds)
+ .addPart(FormField.STOP_TIME, timestampSeconds)
+ .addPart(FormField.CALL_LENGTH, durationSeconds)
+ .addPart(FormField.TALKGROUP_NUM, talkgroup)
+ .addPart(FormField.EMERGENCY, 0)
+ .addPart(FormField.API_KEY, getBroadcastConfiguration().getApiKey())
+ .addPart(FormField.PATCHES, patches)
+ .addPart(FormField.SOURCE_LIST, "[{ \"pos\": 0.00, \"src\": " + radioId + "}]");
+ //.addPart(FormField.FREQ_LIST, frequency)
+
+
+
+ HttpRequest fileRequest = HttpRequest.newBuilder()
+ .uri(URI.create(getBroadcastConfiguration().getHost()))
+ .header(HttpHeaders.CONTENT_TYPE, MULTIPART_FORM_DATA + "; boundary=" + bodyBuilder.getBoundary())
+ .header(HttpHeaders.USER_AGENT, "sdrtrunk")
+ //.header(HttpHeaders.CONTENT_TYPE, "application/octet-stream")
+ .POST(bodyBuilder.build())
+ .build();
+
+ mHttpClient.sendAsync(fileRequest, HttpResponse.BodyHandlers.ofString())
+ .whenComplete((fileResponse, throwable1) -> {
+ if(throwable1 != null || fileResponse.statusCode() != 200)
+ {
+ if(throwable1 instanceof IOException || throwable1 instanceof CompletionException)
+ {
+ //We get socket reset exceptions occasionally when the remote server doesn't
+ //fully read our request and immediately responds.
+ setBroadcastState(BroadcastState.TEMPORARY_BROADCAST_ERROR);
+ mLog.error("Rdio Scanner API file upload fail [" +
+ fileResponse.statusCode() + "] response [" +
+ fileResponse.body() + "]");
+ }
+ else
+ {
+ setBroadcastState(BroadcastState.TEMPORARY_BROADCAST_ERROR);
+ mLog.error("Rdio Scanner API file upload fail [" +
+ fileResponse.statusCode() + "] response [" +
+ fileResponse.body() + "]");
+ }
+
+ incrementErrorAudioCount();
+ broadcast(new BroadcastEvent(OpenMHzBroadcaster.this,
+ BroadcastEvent.Event.BROADCASTER_ERROR_COUNT_CHANGE));
+ }
+ else
+ {
+
+ if(fileResponse.statusCode() == 200)
+ {
+ incrementStreamedAudioCount();
+ broadcast(new BroadcastEvent(OpenMHzBroadcaster.this,
+ BroadcastEvent.Event.BROADCASTER_STREAMED_COUNT_CHANGE));
+ audioRecording.removePendingReplay();
+ }
+ else
+ {
+ setBroadcastState(BroadcastState.TEMPORARY_BROADCAST_ERROR);
+ mLog.error("Rdio Scanner API file upload fail [" +
+ fileResponse.statusCode() + "] response [" +
+ fileResponse.body() + "]");
+ }
+
+
+ }
+
+ });
+ }
+ else
+ {
+ //Register an error for the file not found exception
+ mLog.error("Rdio Scanner API - upload file not found [" +
+ audioRecording.getPath().toString() + "]");
+ incrementErrorAudioCount();
+ broadcast(new BroadcastEvent(OpenMHzBroadcaster.this,
+ BroadcastEvent.Event.BROADCASTER_ERROR_COUNT_CHANGE));
+ audioRecording.removePendingReplay();
+ }
+ }
+ catch(Exception e)
+ {
+ mLog.error("Unknown Error", e);
+ setBroadcastState(BroadcastState.ERROR);
+ incrementErrorAudioCount();
+ broadcast(new BroadcastEvent(this, BroadcastEvent.Event.BROADCASTER_ERROR_COUNT_CHANGE));
+ audioRecording.removePendingReplay();
+ }
+ }
+ }
+
+ //If we're not connected and there are recordings in the queue, check the recording at the head of the queue
+ // and start age-off once the recordings become too old. The recordings should be time ordered in the queue.
+ AudioRecording audioRecording = mAudioRecordingQueue.peek();
+
+ while(audioRecording != null)
+ {
+ if(isValid(audioRecording))
+ {
+ return;
+ }
+ else
+ {
+ //Remove the recording from the queue, remove a replay, and peek at the next recording in the queue
+ mAudioRecordingQueue.poll();
+ audioRecording.removePendingReplay();
+ incrementAgedOffAudioCount();
+ broadcast(new BroadcastEvent(this, BroadcastEvent.Event.BROADCASTER_AGED_OFF_COUNT_CHANGE));
+ audioRecording = mAudioRecordingQueue.peek();
+ }
+ }
+ }
+
+ /**
+ * Creates a frequency value from the audio recording identifier collection.
+ */
+ private static Long getFrequency(AudioRecording audioRecording)
+ {
+ Identifier identifier = audioRecording.getIdentifierCollection().getIdentifier(IdentifierClass.CONFIGURATION,
+ Form.CHANNEL_FREQUENCY, Role.ANY);
+
+ if(identifier instanceof ConfigurationLongIdentifier)
+ {
+ Long value = ((ConfigurationLongIdentifier)identifier).getValue();
+
+ if(value != null)
+ {
+ return value;
+ }
+ }
+
+ return Long.valueOf(0);
+ }
+
+ /**
+ * Creates a formatted string with the FROM identifier or uses a default of zero(0)
+ */
+ private static String getFrom(AudioRecording audioRecording)
+ {
+ for(Identifier identifier: audioRecording.getIdentifierCollection().getIdentifiers(Role.FROM))
+ {
+ if(identifier instanceof RadioIdentifier)
+ {
+ return ((RadioIdentifier)identifier).getValue().toString();
+ }
+ }
+
+ return "0";
+ }
+
+ /**
+ * Creates a formatted string with the TO identifiers or uses a default of zero (0)
+ *
+ */
+ private static String getTo(AudioRecording audioRecording)
+ {
+ Identifier identifier = audioRecording.getIdentifierCollection().getToIdentifier();
+
+ if(identifier instanceof PatchGroupIdentifier patchGroupIdentifier)
+ {
+ return patchGroupIdentifier.getValue().getPatchGroup().getValue().toString();
+ }
+ else if(identifier instanceof TalkgroupIdentifier talkgroupIdentifier)
+ {
+ return String.valueOf(RadioReferenceDecoder.convertToRadioReferenceTalkgroup(talkgroupIdentifier.getValue(),
+ talkgroupIdentifier.getProtocol()));
+ }
+ else if(identifier instanceof RadioIdentifier radioIdentifier)
+ {
+ return radioIdentifier.getValue().toString();
+ }
+
+ return "0";
+ }
+
+ /**
+ * Creates a formatted string with the Talkgroup Label from the Audio Recording Alias
+ * If this is a PatchGroup we return only the first label as the primary talkgroup label.
+ *
+ */
+ private String getTalkgroupLabel(AudioRecording audioRecording)
+ {
+
+ AliasList aliasList = mAliasModel.getAliasList(audioRecording.getIdentifierCollection());
+ Identifier identifier = audioRecording.getIdentifierCollection().getToIdentifier();
+
+ StringBuilder sb = new StringBuilder();
+ if(identifier != null)
+ {
+ List aliases = aliasList.getAliases(identifier);
+ if(!aliases.isEmpty())
+ {
+ sb.append(aliases.get(0));
+ }
+
+ }
+
+ return sb.toString();
+ }
+
+ /**
+ * Creates a formatted string with the Talkgroup Group from the Audio Recording Alias
+ * If this is a PatchGroup we return only the first group as the primary talkgroup group.
+ *
+ */
+ private String getTalkgroupGroup(AudioRecording audioRecording)
+ {
+
+ AliasList aliasList = mAliasModel.getAliasList(audioRecording.getIdentifierCollection());
+ Identifier identifier = audioRecording.getIdentifierCollection().getToIdentifier();
+
+ StringBuilder sb = new StringBuilder();
+ if(identifier != null)
+ {
+ List aliases = aliasList.getAliases(identifier);
+ if(!aliases.isEmpty())
+ {
+ sb.append(aliases.get(0).getGroup());
+ }
+ }
+
+ return sb.toString();
+ }
+
+ /**
+ * Creates a formatted string with the System Label from the Audio Recording Alias
+ * If this is a PatchGroup we return only the first sytem as the primary talkgroup system.
+ *
+ */
+ private String getSystemLabel(AudioRecording audioRecording)
+ {
+ List systems = audioRecording.getIdentifierCollection().getIdentifiers(Form.SYSTEM);
+
+ StringBuilder sb = new StringBuilder();
+ if(!systems.isEmpty())
+ {
+ sb.append(systems.get(0));
+ }
+
+ return sb.toString();
+ }
+
+ /**
+ * Creates a formatted string with the patched talkgroups
+ */
+ public static String getPatches(AudioRecording audioRecording)
+ {
+ Identifier identifier = audioRecording.getIdentifierCollection().getToIdentifier();
+
+ if(identifier instanceof PatchGroupIdentifier patchGroupIdentifier)
+ {
+ PatchGroup patchGroup = patchGroupIdentifier.getValue();
+
+ StringBuilder sb = new StringBuilder();
+ sb.append("[");
+
+ sb.append(patchGroup.getPatchGroup().getValue().toString());
+
+ for(TalkgroupIdentifier patched: patchGroup.getPatchedTalkgroupIdentifiers())
+ {
+ sb.append(",").append(patched.getValue());
+ }
+
+ for(RadioIdentifier patched: patchGroup.getPatchedRadioIdentifiers())
+ {
+ sb.append(",").append(patched.getValue());
+ }
+
+ sb.append("]");
+ return sb.toString();
+ }
+
+ return "[]";
+ }
+
+ /**
+ * Tests both the connection and configuration against the OpenMHz Call API service
+ * @param configuration containing API key and system id
+ * @return error string or null if test is successful
+ */
+ public static String testConnection(OpenMHzConfiguration configuration)
+ {
+ HttpClient httpClient = HttpClient.newBuilder()
+ .version(HttpClient.Version.HTTP_1_1)
+ .followRedirects(HttpClient.Redirect.NORMAL)
+ .connectTimeout(Duration.ofSeconds(20))
+ .build();
+
+ OpenMHzBuilder bodyBuilder = new OpenMHzBuilder();
+ bodyBuilder.addPart(FormField.API_KEY, configuration.getApiKey());
+
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create(configuration.getHost()))
+ .header(HttpHeaders.CONTENT_TYPE, MULTIPART_FORM_DATA + "; boundary=" + bodyBuilder.getBoundary())
+ .header(HttpHeaders.USER_AGENT, "sdrtrunk")
+ .header(HttpHeaders.ACCEPT, "*/*")
+ .POST(bodyBuilder.build())
+ .build();
+
+ HttpResponse.BodyHandler responseHandler = HttpResponse.BodyHandlers.ofString();
+
+ try
+ {
+ HttpResponse response = httpClient.send(request, responseHandler);
+ String responseBody = response.body();
+ return (responseBody != null ? responseBody : "(no response)") + " Status Code:" + response.statusCode();
+ }
+ catch(Exception e)
+ {
+ return e.getLocalizedMessage();
+ }
+ }
+
+ public class AudioRecordingProcessor implements Runnable
+ {
+ @Override
+ public void run()
+ {
+ processRecordingQueue();
+ }
+ }
+
+ public static void main(String[] args)
+ {
+ mLog.debug("Starting ...");
+
+ OpenMHzConfiguration config = new OpenMHzConfiguration();
+ config.setHost("https://api.OpenMHz.com/call-upload-dev");
+ config.setApiKey("c33aae37-8572-11ea-bd8b-0ecc8ab9ccec");
+ config.setSystemName("systemx");
+
+ String response = testConnection(config);
+
+ if(response == null)
+ {
+ mLog.debug("Test Successful!");
+ }
+ else
+ {
+ if(response.contains("1 Invalid-API-Key"))
+ {
+ mLog.error("Invalid API Key");
+ }
+ else if(response.contains("1 API-Key-Access-Denied"))
+ {
+ mLog.error("System ID not valid for API Key");
+ }
+ else
+ {
+ mLog.debug("Response: " + response);
+ }
+ }
+
+ mLog.debug("Finished!");
+ }
+ }
\ No newline at end of file
diff --git a/src/main/java/io/github/dsheirer/audio/broadcast/openmhz/OpenMHzBuilder.java b/src/main/java/io/github/dsheirer/audio/broadcast/openmhz/OpenMHzBuilder.java
new file mode 100644
index 00000000..406aab2d
--- /dev/null
+++ b/src/main/java/io/github/dsheirer/audio/broadcast/openmhz/OpenMHzBuilder.java
@@ -0,0 +1,217 @@
+/*
+ * *****************************************************************************
+ * Copyright (C) 2014-2020 Dennis Sheirer
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see
+ * ****************************************************************************
+ */
+
+ package io.github.dsheirer.audio.broadcast.openmhz;
+
+ import java.net.http.HttpRequest;
+ import java.util.ArrayList;
+ import java.util.List;
+ import java.lang.*;
+ import java.io.ByteArrayOutputStream;
+ import java.io.IOException;
+
+ /**
+ * Builder for an HTTP body publisher to produce a OpenMHz call event
+ */
+ public class OpenMHzBuilder
+ {
+ private static final String DASH_DASH = "--";
+ private static final String BOUNDARY = "--sdrtrunk-sdrtrunk-sdrtrunk";
+ private List mParts = new ArrayList<>();
+ private byte[] audioBytes = null;
+
+ /**
+ * Constructs an instance
+ */
+ public OpenMHzBuilder()
+ {
+ }
+
+ /**
+ * Access the static multi-part boundary string
+ */
+ public String getBoundary()
+ {
+ return BOUNDARY;
+ }
+
+ /**
+ * Adds a Audio bytes part to the call
+ */
+ public OpenMHzBuilder addFile(byte[] value)
+ {
+ audioBytes = value;
+ return this;
+ }
+
+ /**
+ * Adds a string part to the call
+ */
+ public OpenMHzBuilder addPart(FormField key, String value)
+ {
+ if(key != null && value != null)
+ {
+ mParts.add(new Part(key.getHeader(), value));
+ }
+
+ return this;
+ }
+
+ /**
+ * Adds a number part to the call
+ */
+ public OpenMHzBuilder addPart(FormField key, Number value)
+ {
+ if(key != null && value != null)
+ {
+ mParts.add(new Part(key.getHeader(), value.toString()));
+ }
+
+ return this;
+ }
+
+ /**
+ * Creates a form-data item
+ */
+ private static String formatPart(Part part, String boundary)
+ {
+ StringBuilder sb = new StringBuilder();
+ sb.append(DASH_DASH).append(boundary).append("\r\n");
+ sb.append("Content-Disposition: form-data; name=\"").append(part.mKey).append("\"\r\n\r\n");
+ sb.append(part.getValue()).append("\r\n");
+ return sb.toString();
+ }
+
+ /**
+ * Creates the audio file item
+ */
+ private String formatFilePart(String boundary)
+ {
+
+ if(audioBytes == null)
+ {
+ return "";
+ }
+ StringBuilder sb= new StringBuilder();
+ sb.append(DASH_DASH).append(boundary).append("\r\n");
+ sb.append("Content-Disposition: form-data; name=\"").append("call").append("\"; filename=\"test.m4a\"\r\n");
+ sb.append("Content-Type: application/octet-stream\r\n\r\n");
+ return sb.toString();
+
+ }
+
+ /**
+ * Creates the boundary closing item
+ */
+ private static String getClosingBoundary(String boundary)
+ {
+ StringBuilder sb = new StringBuilder();
+ sb.append(DASH_DASH).append(boundary).append(DASH_DASH).append("\r\n");
+ return sb.toString();
+ }
+
+ /**
+ * Creates a BodyPublisher for accessing the call form data
+ */
+ public HttpRequest.BodyPublisher build()
+ {
+ StringBuilder sb = new StringBuilder();
+
+ for(Part part: mParts)
+ {
+ sb.append(formatPart(part, BOUNDARY));
+ }
+
+ sb.append(formatFilePart(BOUNDARY));
+
+
+ /**
+ * We need to create a ByteArray consisting of the Sting "parts" and the audio file bytes
+ */
+ ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
+
+ try
+ {
+
+ outputStream.write(sb.toString().getBytes());
+
+ if(audioBytes != null)
+ {
+ outputStream.write(audioBytes);
+ }
+
+ sb = new StringBuilder();
+ sb.append("\r\n");
+ sb.append(getClosingBoundary(BOUNDARY));
+
+ outputStream.write(sb.toString().getBytes());
+ }
+ catch(IOException e)
+ {
+ //mLog.error("Rdio Scanner API - unable to create POST reqeust.");
+ }
+
+ return HttpRequest.BodyPublishers.ofByteArray(outputStream.toByteArray());
+ }
+
+ @Override
+ public String toString()
+ {
+ StringBuilder sb = new StringBuilder();
+
+ for(Part part: mParts)
+ {
+ sb.append(formatPart(part, BOUNDARY));
+ }
+
+ sb.append(getClosingBoundary(BOUNDARY));
+
+ return sb.toString();
+ }
+
+ /**
+ * Key:Value pair holder
+ */
+ public class Part
+ {
+ private String mKey;
+ private String mValue;
+
+ /**
+ * Constructs a new part
+ * @param key value
+ * @param value item
+ */
+ public Part(String key, String value)
+ {
+ mKey = key;
+ mValue = value;
+ }
+
+ public String getKey()
+ {
+ return mKey;
+ }
+
+ public String getValue()
+ {
+ return mValue;
+ }
+ }
+ }
\ No newline at end of file
diff --git a/src/main/java/io/github/dsheirer/audio/broadcast/openmhz/OpenMHzConfiguration.java b/src/main/java/io/github/dsheirer/audio/broadcast/openmhz/OpenMHzConfiguration.java
new file mode 100644
index 00000000..8d778897
--- /dev/null
+++ b/src/main/java/io/github/dsheirer/audio/broadcast/openmhz/OpenMHzConfiguration.java
@@ -0,0 +1,132 @@
+/*
+ * *****************************************************************************
+ * Copyright (C) 2014-2020 Dennis Sheirer
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see
+ * ****************************************************************************
+ */
+
+ package io.github.dsheirer.audio.broadcast.openmhz;
+
+ import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty;
+ import io.github.dsheirer.audio.broadcast.BroadcastConfiguration;
+ import io.github.dsheirer.audio.broadcast.BroadcastFormat;
+ import io.github.dsheirer.audio.broadcast.BroadcastServerType;
+ import javafx.beans.binding.Bindings;
+ import javafx.beans.property.BooleanProperty;
+ import javafx.beans.property.IntegerProperty;
+ import javafx.beans.property.SimpleBooleanProperty;
+ import javafx.beans.property.SimpleIntegerProperty;
+ import javafx.beans.property.SimpleStringProperty;
+ import javafx.beans.property.StringProperty;
+
+ /**
+ * Streaming configuration for Rdio Scanner API.
+ *
+ * Note: this API is not a streaming audio service, rather a completed call push service. However, it fits nicely
+ * with the structure of the audio streaming subsystem in sdrtrunk
+ */
+ public class OpenMHzConfiguration extends BroadcastConfiguration
+ {
+ private StringProperty mSystemName = new SimpleStringProperty();
+ private StringProperty mApiKey = new SimpleStringProperty();
+
+ /**
+ * Constructor for faster jackson
+ */
+ public OpenMHzConfiguration()
+ {
+ this(BroadcastFormat.MP3);
+ }
+
+ /**
+ * Public constructor.
+ * @param format to use for audio recording (MP3)
+ */
+ public OpenMHzConfiguration(BroadcastFormat format)
+ {
+ super(format);
+
+ //The parent class binds this property, so we unbind it and rebind it here
+ mValid.unbind();
+ mValid.bind(Bindings.and(Bindings.and(Bindings.isNotNull(mSystemName), Bindings.isNotNull(mApiKey)),
+ Bindings.isNotNull(mHost)));
+ }
+
+ /**
+ * System ID as a property
+ */
+ public StringProperty systemIDProperty()
+ {
+ return mSystemName;
+ }
+
+ /**
+ * API key as a property
+ */
+ public StringProperty apiKeyProperty()
+ {
+ return mApiKey;
+ }
+
+ /**
+ * API Key
+ */
+ @JacksonXmlProperty(isAttribute = true, localName = "api_key")
+ public String getApiKey()
+ {
+ return mApiKey.get();
+ }
+
+ /**
+ * Sets the api key
+ * @param apiKey
+ */
+ public void setApiKey(String apiKey)
+ {
+ mApiKey.setValue(apiKey);
+ }
+
+ /**
+ * System name as provided by OpenMHz.com
+ */
+ @JacksonXmlProperty(isAttribute = true, localName = "system_name")
+ public String getSystemName()
+ {
+ return mSystemName.get();
+ }
+
+ /**
+ * Sets the system name provided by OpenMHz.com
+ */
+ public void setSystemName(String systemName)
+ {
+ mSystemName.set(systemName);
+ }
+
+ @JacksonXmlProperty(isAttribute = true, localName = "type", namespace = "http://www.w3.org/2001/XMLSchema-instance")
+ @Override
+ public BroadcastServerType getBroadcastServerType()
+ {
+ return BroadcastServerType.OPENMHZ;
+ }
+
+ @Override
+ public BroadcastConfiguration copyOf()
+ {
+ OpenMHzConfiguration copy = new OpenMHzConfiguration();
+ copy.setSystemName(getSystemName());
+ return copy;
+ }
+ }
\ No newline at end of file
diff --git a/src/main/java/io/github/dsheirer/audio/broadcast/openmhz/OpenMHzEditor.java b/src/main/java/io/github/dsheirer/audio/broadcast/openmhz/OpenMHzEditor.java
new file mode 100644
index 00000000..520fe6fc
--- /dev/null
+++ b/src/main/java/io/github/dsheirer/audio/broadcast/openmhz/OpenMHzEditor.java
@@ -0,0 +1,233 @@
+/*
+ * *****************************************************************************
+ * Copyright (C) 2014-2020 Dennis Sheirer
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see
+ * ****************************************************************************
+ */
+
+ package io.github.dsheirer.gui.playlist.streaming;
+
+ import io.github.dsheirer.audio.broadcast.BroadcastServerType;
+ import io.github.dsheirer.audio.broadcast.openmhz.OpenMHzBroadcaster;
+ import io.github.dsheirer.audio.broadcast.openmhz.OpenMHzConfiguration;
+ import io.github.dsheirer.gui.control.IntegerTextField;
+ import io.github.dsheirer.playlist.PlaylistManager;
+ import javafx.geometry.HPos;
+ import javafx.geometry.Insets;
+ import javafx.scene.control.Alert;
+ import javafx.scene.control.Button;
+ import javafx.scene.control.ButtonType;
+ import javafx.scene.control.Label;
+ import javafx.scene.control.TextField;
+ import javafx.scene.layout.GridPane;
+ import org.slf4j.Logger;
+ import org.slf4j.LoggerFactory;
+ import org.controlsfx.control.ToggleSwitch;
+
+ /**
+ * OpenMHz calls API configuration editor
+ */
+ public class OpenMHzEditor extends AbstractBroadcastEditor
+ {
+ private final static Logger mLog = LoggerFactory.getLogger(OpenMHzEditor.class);
+ private TextField mSystemNameTextField;
+ private IntegerTextField mMaxAgeTextField;
+ private TextField mApiKeyTextField;
+ private TextField mHostTextField;
+ private GridPane mEditorPane;
+
+ /**
+ * Constructs an instance
+ * @param playlistManager for accessing the broadcast model
+ */
+ public OpenMHzEditor(PlaylistManager playlistManager)
+ {
+ super(playlistManager);
+ }
+
+ @Override
+ public void setItem(OpenMHzConfiguration item)
+ {
+ super.setItem(item);
+
+ getSystemNameTextField().setDisable(item == null);
+ getApiKeyTextField().setDisable(item == null);
+ getHostTextField().setDisable(item == null);
+ getMaxAgeTextField().setDisable(item == null);
+
+ if(item != null)
+ {
+ getSystemNameTextField().setText(item.getSystemName());
+ getApiKeyTextField().setText(item.getApiKey());
+ getHostTextField().setText(item.getHost());
+ getMaxAgeTextField().set((int)(item.getMaximumRecordingAge() / 1000));
+ }
+ else
+ {
+ getSystemNameTextField().setText(null);
+ getApiKeyTextField().setText(null);
+ getHostTextField().setText(null);
+ getMaxAgeTextField().set(0);
+ }
+
+ modifiedProperty().set(false);
+ }
+
+ @Override
+ public void dispose()
+ {
+ }
+
+ @Override
+ public void save()
+ {
+ if(getItem() != null)
+ {
+ getItem().setSystemName(getSystemNameTextField().getText());
+ getItem().setHost(getHostTextField().getText());
+ getItem().setApiKey(getApiKeyTextField().getText());
+ getItem().setMaximumRecordingAge(getMaxAgeTextField().get() * 1000);
+ }
+
+ super.save();
+ }
+
+ public BroadcastServerType getBroadcastServerType()
+ {
+ return BroadcastServerType.OPENMHZ;
+ }
+
+ protected GridPane getEditorPane()
+ {
+ if(mEditorPane == null)
+ {
+ mEditorPane = new GridPane();
+ mEditorPane.setPadding(new Insets(10, 5, 10,10));
+ mEditorPane.setVgap(10);
+ mEditorPane.setHgap(5);
+
+ int row = 0;
+
+ Label formatLabel = new Label("Format");
+ GridPane.setHalignment(formatLabel, HPos.RIGHT);
+ GridPane.setConstraints(formatLabel, 0, row);
+ mEditorPane.getChildren().add(formatLabel);
+
+ GridPane.setConstraints(getFormatField(), 1, row);
+ mEditorPane.getChildren().add(getFormatField());
+
+ Label enabledLabel = new Label("Enabled");
+ GridPane.setHalignment(enabledLabel, HPos.RIGHT);
+ GridPane.setConstraints(enabledLabel, 2, row);
+ mEditorPane.getChildren().add(enabledLabel);
+
+ GridPane.setConstraints(getEnabledSwitch(), 3, row);
+ mEditorPane.getChildren().add(getEnabledSwitch());
+
+ Label systemLabel = new Label("Name");
+ GridPane.setHalignment(systemLabel, HPos.RIGHT);
+ GridPane.setConstraints(systemLabel, 0, ++row);
+ mEditorPane.getChildren().add(systemLabel);
+
+ GridPane.setConstraints(getNameTextField(), 1, row);
+ mEditorPane.getChildren().add(getNameTextField());
+
+ Label apiKeyLabel = new Label("API Key");
+ GridPane.setHalignment(apiKeyLabel, HPos.RIGHT);
+ GridPane.setConstraints(apiKeyLabel, 0, ++row);
+ mEditorPane.getChildren().add(apiKeyLabel);
+
+ GridPane.setConstraints(getApiKeyTextField(), 1, row);
+ mEditorPane.getChildren().add(getApiKeyTextField());
+
+ Label systemNameLabel = new Label("System Name");
+ GridPane.setHalignment(systemNameLabel, HPos.RIGHT);
+ GridPane.setConstraints(systemNameLabel, 0, ++row);
+ mEditorPane.getChildren().add(systemNameLabel);
+
+ GridPane.setConstraints(getSystemNameTextField(), 1, row);
+ mEditorPane.getChildren().add(getSystemNameTextField());
+
+ Label hostLabel = new Label("OpenMHz URL");
+ GridPane.setHalignment(hostLabel, HPos.RIGHT);
+ GridPane.setConstraints(hostLabel, 0, ++row);
+ mEditorPane.getChildren().add(hostLabel);
+
+ GridPane.setConstraints(getHostTextField(), 1, row);
+ mEditorPane.getChildren().add(getHostTextField());
+
+ Label maxAgeLabel = new Label("Max Recording Age (seconds)");
+ GridPane.setHalignment(maxAgeLabel, HPos.RIGHT);
+ GridPane.setConstraints(maxAgeLabel, 0, ++row);
+ mEditorPane.getChildren().add(maxAgeLabel);
+
+ GridPane.setConstraints(getMaxAgeTextField(), 1, row);
+ mEditorPane.getChildren().add(getMaxAgeTextField());
+
+ }
+
+ return mEditorPane;
+ }
+
+ private IntegerTextField getMaxAgeTextField()
+ {
+ if(mMaxAgeTextField == null)
+ {
+ mMaxAgeTextField = new IntegerTextField();
+ mMaxAgeTextField.setDisable(true);
+ mMaxAgeTextField.textProperty().addListener(mEditorModificationListener);
+ }
+
+ return mMaxAgeTextField;
+ }
+
+ private TextField getHostTextField()
+ {
+ if(mHostTextField == null)
+ {
+ mHostTextField = new TextField();
+ mHostTextField.setDisable(true);
+ mHostTextField.textProperty().addListener(mEditorModificationListener);
+ }
+
+ return mHostTextField;
+ }
+
+ private TextField getApiKeyTextField()
+ {
+ if(mApiKeyTextField == null)
+ {
+ mApiKeyTextField = new TextField();
+ mApiKeyTextField.setDisable(true);
+ mApiKeyTextField.textProperty().addListener(mEditorModificationListener);
+ }
+
+ return mApiKeyTextField;
+ }
+
+ private TextField getSystemNameTextField()
+ {
+ if(mSystemNameTextField == null)
+ {
+ mSystemNameTextField = new TextField();
+ mSystemNameTextField.setDisable(true);
+ mSystemNameTextField.textProperty().addListener(mEditorModificationListener);
+ }
+
+ return mSystemNameTextField;
+ }
+
+
+ }
\ No newline at end of file
diff --git a/src/main/java/io/github/dsheirer/audio/broadcast/openmhz/OpenMHzFeedConfiguration.java b/src/main/java/io/github/dsheirer/audio/broadcast/openmhz/OpenMHzFeedConfiguration.java
new file mode 100644
index 00000000..a5989739
--- /dev/null
+++ b/src/main/java/io/github/dsheirer/audio/broadcast/openmhz/OpenMHzFeedConfiguration.java
@@ -0,0 +1,126 @@
+/*
+ *
+ * * ******************************************************************************
+ * * Copyright (C) 2014-2020 Dennis Sheirer
+ * *
+ * * This program is free software: you can redistribute it and/or modify
+ * * it under the terms of the GNU General Public License as published by
+ * * the Free Software Foundation, either version 3 of the License, or
+ * * (at your option) any later version.
+ * *
+ * * This program is distributed in the hope that it will be useful,
+ * * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * * GNU General Public License for more details.
+ * *
+ * * You should have received a copy of the GNU General Public License
+ * * along with this program. If not, see
+ * * *****************************************************************************
+ *
+ *
+ */
+package io.github.dsheirer.audio.broadcast.openmhz;
+
+import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty;
+import io.github.dsheirer.audio.broadcast.BroadcastConfiguration;
+import io.github.dsheirer.audio.broadcast.BroadcastFormat;
+import io.github.dsheirer.audio.broadcast.BroadcastServerType;
+import io.github.dsheirer.audio.broadcast.icecast.IcecastTCPConfiguration;
+import io.github.dsheirer.rrapi.type.UserFeedBroadcast;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class OpenMHzFeedConfiguration extends IcecastTCPConfiguration
+{
+ private final static Logger mLog = LoggerFactory.getLogger(OpenMHzFeedConfiguration.class);
+
+ private int mFeedID;
+
+ public OpenMHzFeedConfiguration()
+ {
+ //No-arg constructor for JAXB
+ this(BroadcastFormat.MP3);
+ }
+
+ /**
+ * OpenMHz configuration for Icecast 2.3.2 compatible servers
+ *
+ * @param format of output audio (MP3)
+ */
+ public OpenMHzFeedConfiguration(BroadcastFormat format)
+ {
+ super(format);
+
+ setBitRate(16);
+ setChannels(1);
+ setSampleRate(8000);
+ setInline(true);
+ }
+
+ public static OpenMHzFeedConfiguration from(UserFeedBroadcast userFeedBroadcast)
+ {
+ OpenMHzFeedConfiguration config = new OpenMHzFeedConfiguration(BroadcastFormat.MP3);
+ config.setName(userFeedBroadcast.getDescription());
+ config.setHost(userFeedBroadcast.getHostname());
+ config.setMountPoint(userFeedBroadcast.getMount());
+ config.setFeedID(userFeedBroadcast.getFeedId());
+ config.setPassword(userFeedBroadcast.getPassword());
+
+ try
+ {
+ config.setPort(Integer.parseInt(userFeedBroadcast.getPort()));
+ }
+ catch(Exception e)
+ {
+ mLog.error("Error creating Rdio Scanner configuration from radio reference user feed instance");
+ }
+
+ return config;
+ }
+
+ @Override
+ public BroadcastConfiguration copyOf()
+ {
+ OpenMHzFeedConfiguration copy = new OpenMHzFeedConfiguration(getBroadcastFormat());
+
+ //Broadcast Configuration Parameters
+ copy.setName(getName());
+ copy.setHost(getHost());
+ copy.setPort(getPort());
+ copy.setInline(getInline());
+ copy.setPassword(getPassword());
+ copy.setDelay(getDelay());
+ copy.setEnabled(false);
+
+ //Icecast Configuration Parameters
+ copy.setUserName(getUserName());
+ copy.setMountPoint(getMountPoint());
+ copy.setDescription(getDescription());
+ copy.setGenre(getGenre());
+ copy.setPublic(isPublic());
+ copy.setURL(getURL());
+
+ //OpenMHz Configuration Parameters
+ copy.setFeedID(getFeedID());
+
+ return copy;
+ }
+
+ @JacksonXmlProperty(isAttribute = true, localName = "type", namespace = "http://www.w3.org/2001/XMLSchema-instance")
+ @Override
+ public BroadcastServerType getBroadcastServerType()
+ {
+ return BroadcastServerType.OPENMHZ;
+ }
+
+ @JacksonXmlProperty(isAttribute = true, localName = "feed_id")
+ public int getFeedID()
+ {
+ return mFeedID;
+ }
+
+ public void setFeedID(int feedID)
+ {
+ mFeedID = feedID;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/io/github/dsheirer/gui/playlist/streaming/StreamEditorFactory.java b/src/main/java/io/github/dsheirer/gui/playlist/streaming/StreamEditorFactory.java
index 2faf57c4..faa7ea5a 100644
--- a/src/main/java/io/github/dsheirer/gui/playlist/streaming/StreamEditorFactory.java
+++ b/src/main/java/io/github/dsheirer/gui/playlist/streaming/StreamEditorFactory.java
@@ -40,6 +40,8 @@ public class StreamEditorFactory
return new BroadcastifyStreamEditor(playlistManager);
case RDIOSCANNER_CALL:
return new RdioScannerEditor(playlistManager);
+ case OPENMHZ:
+ return new OpenMHzEditor(playlistManager);
case BROADCASTIFY_CALL:
return new BroadcastifyCallEditor(playlistManager);
case ICECAST_HTTP:
diff --git a/src/main/resources/images/openmhz.png b/src/main/resources/images/openmhz.png
new file mode 100644
index 00000000..9f72cfed
Binary files /dev/null and b/src/main/resources/images/openmhz.png differ