From 2ecdaba183d112f68f5a0962e69415b1780586c8 Mon Sep 17 00:00:00 2001 From: tadscottsmith <78445808+tadscottsmith@users.noreply.github.com> Date: Wed, 15 Nov 2023 17:23:09 -0600 Subject: [PATCH 1/2] Initial commit. (#4) --- .../broadcast/BroadcastConfiguration.java | 2 + .../audio/broadcast/BroadcastFactory.java | 8 + .../audio/broadcast/BroadcastServerType.java | 1 + .../audio/broadcast/openmhz/FormField.java | 50 ++ .../broadcast/openmhz/OpenMHzBroadcaster.java | 623 ++++++++++++++++++ .../broadcast/openmhz/OpenMHzBuilder.java | 217 ++++++ .../openmhz/OpenMHzConfiguration.java | 132 ++++ .../broadcast/openmhz/OpenMHzEditor.java | 233 +++++++ .../openmhz/OpenMHzFeedConfiguration.java | 126 ++++ .../streaming/StreamEditorFactory.java | 2 + src/main/resources/images/openmhz.png | Bin 0 -> 15497 bytes 11 files changed, 1394 insertions(+) create mode 100644 src/main/java/io/github/dsheirer/audio/broadcast/openmhz/FormField.java create mode 100644 src/main/java/io/github/dsheirer/audio/broadcast/openmhz/OpenMHzBroadcaster.java create mode 100644 src/main/java/io/github/dsheirer/audio/broadcast/openmhz/OpenMHzBuilder.java create mode 100644 src/main/java/io/github/dsheirer/audio/broadcast/openmhz/OpenMHzConfiguration.java create mode 100644 src/main/java/io/github/dsheirer/audio/broadcast/openmhz/OpenMHzEditor.java create mode 100644 src/main/java/io/github/dsheirer/audio/broadcast/openmhz/OpenMHzFeedConfiguration.java create mode 100644 src/main/resources/images/openmhz.png 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 cfcc90410..8391c4928 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.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 @@ @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 255ef9698..f513938d3 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.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 static AbstractAudioBroadcaster getBroadcaster(BroadcastConfiguration con 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 static BroadcastConfiguration getConfiguration(BroadcastServerType server 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 aab98a001..b33e88c8f 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 000000000..c84c0f38e --- /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 000000000..c3ef5e255 --- /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 000000000..406aab2d0 --- /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 000000000..8d7788976 --- /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 000000000..520fe6fcc --- /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 000000000..a59897398 --- /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 2faf57c41..faa7ea5a9 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 static AbstractBroadcastEditor getEditor(BroadcastServerType broadcastSer 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 0000000000000000000000000000000000000000..9f72cfedececf6610b90c7d27cb32999654cbf03 GIT binary patch literal 15497 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 K46^m@0{<5!0?>W{ literal 0 HcmV?d00001 From 45c8bb73faaccd02255a9aba45d6c22b020de494 Mon Sep 17 00:00:00 2001 From: tadscottsmith <78445808+tadscottsmith@users.noreply.github.com> Date: Wed, 29 Nov 2023 14:40:45 -0600 Subject: [PATCH 2/2] Authorization features. --- .../broadcast/openmhz/OpenMHzBroadcaster.java | 69 +++++++++++-------- .../broadcast/openmhz/OpenMHzBuilder.java | 3 +- .../openmhz/OpenMHzConfiguration.java | 7 ++ .../broadcast/openmhz/OpenMHzEditor.java | 4 +- 4 files changed, 50 insertions(+), 33 deletions(-) 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 index c3ef5e255..4d1441a96 100644 --- a/src/main/java/io/github/dsheirer/audio/broadcast/openmhz/OpenMHzBroadcaster.java +++ b/src/main/java/io/github/dsheirer/audio/broadcast/openmhz/OpenMHzBroadcaster.java @@ -70,7 +70,7 @@ /** - * Audio broadcaster to push completed audio recordings to the Rdio Scanner call upload API. + * Audio broadcaster to push completed audio recordings to the OpenMHz call upload API. * */ public class OpenMHzBroadcaster extends AbstractAudioBroadcaster @@ -81,6 +81,7 @@ public class OpenMHzBroadcaster extends AbstractAudioBroadcaster mAudioRecordingQueue = new LinkedTransferQueue<>(); private ScheduledFuture mAudioRecordingProcessorFuture; private HttpClient mHttpClient = HttpClient.newBuilder() @@ -115,16 +116,16 @@ public void start() mLastConnectionAttempt = System.currentTimeMillis(); /** - * Rdio Scanner API does not currently expose a test method. + * OpenMHz API does not currently expose a test method. * TODO: FIX THIS */ - if(response != null)// && response.toLowerCase().startsWith("502 bad gateway")) + if(response == "OK")// && response.toLowerCase().startsWith("502 bad gateway")) { setBroadcastState(BroadcastState.CONNECTED); } else { - mLog.error("Error connecting to Rdio Scanner server on startup [" + response + "]"); + mLog.error("Error connecting to OpenMHz server on startup [" + response + "]"); setBroadcastState(BroadcastState.ERROR); } @@ -170,7 +171,7 @@ public void dispose() * 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. + * OpenMHz does not have a test API endpoint, so we look for the incomplete call response. */ private boolean connected() { @@ -182,7 +183,7 @@ private boolean connected() String response = testConnection(getBroadcastConfiguration()); mLastConnectionAttempt = System.currentTimeMillis(); - if(response != null && response.toLowerCase().startsWith("incomplete call data: no talkgroup")) + if(response != null && response == "200") { setBroadcastState(BroadcastState.CONNECTED); } @@ -256,11 +257,12 @@ private void processRecordingQueue() } catch(IOException e) { - mLog.error("Rdio Scanner API - audio recording file not found - ignoring upload"); + mLog.error("OpenMHz - audio recording file not found - ignoring upload"); } if(audioBytes != null) { + String uri = getBroadcastConfiguration().getHost() + "/" + getBroadcastConfiguration().getSystemName() + "/upload"; OpenMHzBuilder bodyBuilder = new OpenMHzBuilder(); bodyBuilder @@ -272,17 +274,14 @@ private void processRecordingQueue() .addPart(FormField.TALKGROUP_NUM, talkgroup) .addPart(FormField.EMERGENCY, 0) .addPart(FormField.API_KEY, getBroadcastConfiguration().getApiKey()) - .addPart(FormField.PATCHES, patches) + // For future use if OpenMHz supports patch information + //.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())) + .uri(URI.create(uri)) .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(); @@ -295,14 +294,14 @@ private void processRecordingQueue() //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 [" + + mLog.error("OpenMHz API file upload fail [" + fileResponse.statusCode() + "] response [" + fileResponse.body() + "]"); } else { setBroadcastState(BroadcastState.TEMPORARY_BROADCAST_ERROR); - mLog.error("Rdio Scanner API file upload fail [" + + mLog.error("OpenMHz API file upload fail [" + fileResponse.statusCode() + "] response [" + fileResponse.body() + "]"); } @@ -324,7 +323,7 @@ private void processRecordingQueue() else { setBroadcastState(BroadcastState.TEMPORARY_BROADCAST_ERROR); - mLog.error("Rdio Scanner API file upload fail [" + + mLog.error("OpenMHz API file upload fail [" + fileResponse.statusCode() + "] response [" + fileResponse.body() + "]"); } @@ -337,7 +336,7 @@ private void processRecordingQueue() else { //Register an error for the file not found exception - mLog.error("Rdio Scanner API - upload file not found [" + + mLog.error("OpenMHz API - upload file not found [" + audioRecording.getPath().toString() + "]"); incrementErrorAudioCount(); broadcast(new BroadcastEvent(OpenMHzBroadcaster.this, @@ -547,21 +546,19 @@ public static String getPatches(AudioRecording audioRecording) */ public static String testConnection(OpenMHzConfiguration configuration) { + String uri = configuration.getHost() + "/" + configuration.getSystemName() + "/authorize"; + 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()) + .uri(URI.create(uri)) + .header(HttpHeaders.CONTENT_TYPE, APPLICATION_FORM_URLENCODED) .header(HttpHeaders.USER_AGENT, "sdrtrunk") - .header(HttpHeaders.ACCEPT, "*/*") - .POST(bodyBuilder.build()) + .POST(HttpRequest.BodyPublishers.ofString("api_key="+configuration.getApiKey())) .build(); HttpResponse.BodyHandler responseHandler = HttpResponse.BodyHandlers.ofString(); @@ -570,7 +567,20 @@ public static String testConnection(OpenMHzConfiguration configuration) { HttpResponse response = httpClient.send(request, responseHandler); String responseBody = response.body(); - return (responseBody != null ? responseBody : "(no response)") + " Status Code:" + response.statusCode(); + if (response.statusCode() == 200) + { + return "OK"; + } + else if(response.statusCode() == 403) + { + return "Invalid API Key"; + } + else if(response.statusCode() == 500) + { + return "Invalid System Name"; + } + + return "No Response"; } catch(Exception e) { @@ -597,20 +607,21 @@ public static void main(String[] args) config.setSystemName("systemx"); String response = testConnection(config); + mLog.error("Response: " + response); - if(response == null) + if(response == "OK") { mLog.debug("Test Successful!"); } else { - if(response.contains("1 Invalid-API-Key")) + if(response.contains("Invalid API Key")) { mLog.error("Invalid API Key"); } - else if(response.contains("1 API-Key-Access-Denied")) + else if(response.contains("Invalid System Name")) { - mLog.error("System ID not valid for API Key"); + mLog.error("Invalid System Name"); } else { 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 index 406aab2d0..0e9717078 100644 --- a/src/main/java/io/github/dsheirer/audio/broadcast/openmhz/OpenMHzBuilder.java +++ b/src/main/java/io/github/dsheirer/audio/broadcast/openmhz/OpenMHzBuilder.java @@ -110,8 +110,7 @@ private String formatFilePart(String boundary) } 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"); + sb.append("Content-Disposition: form-data; name=\"").append("call").append("\"; filename=\"audio.mp3\"\r\n\r\n"); return sb.toString(); } 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 index 8d7788976..540c0f815 100644 --- a/src/main/java/io/github/dsheirer/audio/broadcast/openmhz/OpenMHzConfiguration.java +++ b/src/main/java/io/github/dsheirer/audio/broadcast/openmhz/OpenMHzConfiguration.java @@ -39,6 +39,8 @@ */ public class OpenMHzConfiguration extends BroadcastConfiguration { + public static final String PRODUCTION_ENDPOINT = "https://api.openmhz.com"; + private StringProperty mSystemName = new SimpleStringProperty(); private StringProperty mApiKey = new SimpleStringProperty(); @@ -58,6 +60,11 @@ public OpenMHzConfiguration(BroadcastFormat format) { super(format); + if(getHost() == null) + { + setHost(PRODUCTION_ENDPOINT); + } + //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)), 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 index 520fe6fcc..07ccbedbc 100644 --- a/src/main/java/io/github/dsheirer/audio/broadcast/openmhz/OpenMHzEditor.java +++ b/src/main/java/io/github/dsheirer/audio/broadcast/openmhz/OpenMHzEditor.java @@ -152,7 +152,7 @@ protected GridPane getEditorPane() GridPane.setConstraints(getApiKeyTextField(), 1, row); mEditorPane.getChildren().add(getApiKeyTextField()); - Label systemNameLabel = new Label("System Name"); + Label systemNameLabel = new Label("System Short Name"); GridPane.setHalignment(systemNameLabel, HPos.RIGHT); GridPane.setConstraints(systemNameLabel, 0, ++row); mEditorPane.getChildren().add(systemNameLabel); @@ -160,7 +160,7 @@ protected GridPane getEditorPane() GridPane.setConstraints(getSystemNameTextField(), 1, row); mEditorPane.getChildren().add(getSystemNameTextField()); - Label hostLabel = new Label("OpenMHz URL"); + Label hostLabel = new Label("OpenMHz Upload Server (API)"); GridPane.setHalignment(hostLabel, HPos.RIGHT); GridPane.setConstraints(hostLabel, 0, ++row); mEditorPane.getChildren().add(hostLabel);