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 b1e6753c8..cfcc90410 100644 --- a/src/main/java/io/github/dsheirer/audio/broadcast/BroadcastConfiguration.java +++ b/src/main/java/io/github/dsheirer/audio/broadcast/BroadcastConfiguration.java @@ -24,6 +24,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.icecast.IcecastConfiguration; import io.github.dsheirer.audio.broadcast.shoutcast.v1.ShoutcastV1Configuration; import io.github.dsheirer.audio.broadcast.shoutcast.v2.ShoutcastV2Configuration; @@ -46,6 +47,7 @@ @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "type") @JsonSubTypes({ @JsonSubTypes.Type(value = BroadcastifyCallConfiguration.class, name="broadcastifyCallConfiguration"), + @JsonSubTypes.Type(value = RdioScannerConfiguration.class, name="RdioScannerConfiguration"), @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 28bd61995..255ef9698 100644 --- a/src/main/java/io/github/dsheirer/audio/broadcast/BroadcastFactory.java +++ b/src/main/java/io/github/dsheirer/audio/broadcast/BroadcastFactory.java @@ -22,6 +22,9 @@ import io.github.dsheirer.audio.broadcast.broadcastify.BroadcastifyCallBroadcaster; import io.github.dsheirer.audio.broadcast.broadcastify.BroadcastifyCallConfiguration; import io.github.dsheirer.audio.broadcast.broadcastify.BroadcastifyFeedConfiguration; +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.icecast.IcecastHTTPAudioBroadcaster; import io.github.dsheirer.audio.broadcast.icecast.IcecastHTTPConfiguration; import io.github.dsheirer.audio.broadcast.icecast.IcecastTCPAudioBroadcaster; @@ -61,6 +64,9 @@ public static AbstractAudioBroadcaster getBroadcaster(BroadcastConfiguration con case BROADCASTIFY_CALL: return new BroadcastifyCallBroadcaster((BroadcastifyCallConfiguration)configuration, inputAudioFormat, mp3Setting, aliasModel); + case RDIOSCANNER_CALL: + return new RdioScannerBroadcaster((RdioScannerConfiguration)configuration, + inputAudioFormat, mp3Setting, aliasModel); case BROADCASTIFY: return new IcecastTCPAudioBroadcaster((BroadcastifyFeedConfiguration) configuration, inputAudioFormat, mp3Setting, aliasModel); @@ -99,6 +105,8 @@ public static BroadcastConfiguration getConfiguration(BroadcastServerType server { case BROADCASTIFY_CALL: return new BroadcastifyCallConfiguration(format); + case RDIOSCANNER_CALL: + return new RdioScannerConfiguration(format); case BROADCASTIFY: return new BroadcastifyFeedConfiguration(format); case ICECAST_HTTP: 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 a76138314..aab98a001 100644 --- a/src/main/java/io/github/dsheirer/audio/broadcast/BroadcastServerType.java +++ b/src/main/java/io/github/dsheirer/audio/broadcast/BroadcastServerType.java @@ -31,6 +31,7 @@ public enum BroadcastServerType BROADCASTIFY_CALL("Broadcastify Call", "images/broadcastify.png"), ICECAST_HTTP("Icecast 2 (v2.4+)", "images/icecast.png"), + RDIOSCANNER_CALL("Rdio Scanner", "images/rdioscanner.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/rdioscanner/FormField.java b/src/main/java/io/github/dsheirer/audio/broadcast/rdioscanner/FormField.java new file mode 100644 index 000000000..42a11a3a4 --- /dev/null +++ b/src/main/java/io/github/dsheirer/audio/broadcast/rdioscanner/FormField.java @@ -0,0 +1,56 @@ +/* + * ***************************************************************************** + * 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.rdioscanner; + +/** + * HTTP headers used for posting to Rdio Scanner API + */ +public enum FormField +{ + AUDIO("audio"), + AUDIO_NAME("audioName"), + AUDIO_TYPE("audioType"), + DATE_TIME("dateTime"), + FREQUENCIES("frequencies"), + FREQUENCY("frequency"), + KEY("key"), + PATCHES("patches"), + SOURCE("source"), + SOURCES("sources"), + SYSTEM("system"), + SYSTEM_LABEL("systemLabel"), + TALKGROUP_ID("talkgroup"), + TALKGROUP_GROUP("talkgroupGroup"), + TALKGROUP_LABEL("talkgroupLabel"), + TALKGROUP_TAG("talkgroupTag"), + TEST("test"); + + private String mHeader; + + FormField(String header) + { + mHeader = header; + } + + public String getHeader() + { + return mHeader; + } +} diff --git a/src/main/java/io/github/dsheirer/audio/broadcast/rdioscanner/RdioScannerBroadcaster.java b/src/main/java/io/github/dsheirer/audio/broadcast/rdioscanner/RdioScannerBroadcaster.java new file mode 100644 index 000000000..6f8f510ac --- /dev/null +++ b/src/main/java/io/github/dsheirer/audio/broadcast/rdioscanner/RdioScannerBroadcaster.java @@ -0,0 +1,622 @@ +/* + * ***************************************************************************** + * 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.rdioscanner; + +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 RdioScannerBroadcaster extends AbstractAudioBroadcaster +{ + private final static Logger mLog = LoggerFactory.getLogger(RdioScannerBroadcaster.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 RdioScannerBroadcaster(RdioScannerConfiguration 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. + */ + if(response != null && response.toLowerCase().startsWith("incomplete call data: no talkgroup")) + { + 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) + { + float durationSeconds = (float)(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) + { + + RdioScannerBuilder bodyBuilder = new RdioScannerBuilder(); + bodyBuilder.addPart(FormField.KEY, getBroadcastConfiguration().getApiKey()) + .addPart(FormField.SYSTEM, getBroadcastConfiguration().getSystemID()) + .addFile(audioBytes) + .addPart(FormField.DATE_TIME, timestampSeconds) + .addPart(FormField.TALKGROUP_ID, talkgroup) + .addPart(FormField.SOURCE, radioId) + .addPart(FormField.FREQUENCY, frequency) + .addPart(FormField.TALKGROUP_LABEL, talkgroupLabel) + .addPart(FormField.TALKGROUP_GROUP, talkgroupGroup) + .addPart(FormField.SYSTEM_LABEL, systemLabel) + .addPart(FormField.PATCHES, patches); + + 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, "audio/mpeg") + .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(RdioScannerBroadcaster.this, + BroadcastEvent.Event.BROADCASTER_ERROR_COUNT_CHANGE)); + } + else + { + String fileResponseString = fileResponse.body(); + + if(fileResponseString.contains("Call imported successfully.")) + { + incrementStreamedAudioCount(); + broadcast(new BroadcastEvent(RdioScannerBroadcaster.this, + BroadcastEvent.Event.BROADCASTER_STREAMED_COUNT_CHANGE)); + audioRecording.removePendingReplay(); + } + else if(fileResponseString.contains("duplicate call rejected")) + { + //Rdio Scanner is telling us to skip audio upload - someone already uploaded it + 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(RdioScannerBroadcaster.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.getPatchedGroupIdentifiers()) + { + sb.append(",").append(patched.getValue()); + } + + sb.append("]"); + return sb.toString(); + } + + return "[]"; + } + + /** + * Tests both the connection and configuration against the RdioScanner Call API service + * @param configuration containing API key and system id + * @return error string or null if test is successful + */ + public static String testConnection(RdioScannerConfiguration configuration) + { + HttpClient httpClient = HttpClient.newBuilder() + .version(HttpClient.Version.HTTP_1_1) + .followRedirects(HttpClient.Redirect.NORMAL) + .connectTimeout(Duration.ofSeconds(20)) + .build(); + + RdioScannerBuilder bodyBuilder = new RdioScannerBuilder(); + bodyBuilder.addPart(FormField.KEY, configuration.getApiKey()) + .addPart(FormField.SYSTEM, configuration.getSystemID()) + .addPart(FormField.TEST, 1); + + 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 ..."); + + RdioScannerConfiguration config = new RdioScannerConfiguration(); + config.setHost("https://api.RdioScanner.com/call-upload-dev"); + config.setApiKey("c33aae37-8572-11ea-bd8b-0ecc8ab9ccec"); + config.setSystemID(11); + + 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!"); + } +} diff --git a/src/main/java/io/github/dsheirer/audio/broadcast/rdioscanner/RdioScannerBuilder.java b/src/main/java/io/github/dsheirer/audio/broadcast/rdioscanner/RdioScannerBuilder.java new file mode 100644 index 000000000..2e19ea530 --- /dev/null +++ b/src/main/java/io/github/dsheirer/audio/broadcast/rdioscanner/RdioScannerBuilder.java @@ -0,0 +1,216 @@ +/* + * ***************************************************************************** + * 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.rdioscanner; + +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 RdioScanner call event + */ +public class RdioScannerBuilder +{ + 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 RdioScannerBuilder() + { + } + + /** + * Access the static multi-part boundary string + */ + public String getBoundary() + { + return BOUNDARY; + } + + /** + * Adds a Audio bytes part to the call + */ + public RdioScannerBuilder addFile(byte[] value) + { + audioBytes = value; + return this; + } + + /** + * Adds a string part to the call + */ + public RdioScannerBuilder 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 RdioScannerBuilder 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("audio").append("\"\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; + } + } +} diff --git a/src/main/java/io/github/dsheirer/audio/broadcast/rdioscanner/RdioScannerConfiguration.java b/src/main/java/io/github/dsheirer/audio/broadcast/rdioscanner/RdioScannerConfiguration.java new file mode 100644 index 000000000..59702855c --- /dev/null +++ b/src/main/java/io/github/dsheirer/audio/broadcast/rdioscanner/RdioScannerConfiguration.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.rdioscanner; + +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 RdioScannerConfiguration extends BroadcastConfiguration +{ + private IntegerProperty mSystemID = new SimpleIntegerProperty(); + private StringProperty mApiKey = new SimpleStringProperty(); + + /** + * Constructor for faster jackson + */ + public RdioScannerConfiguration() + { + this(BroadcastFormat.MP3); + } + + /** + * Public constructor. + * @param format to use for audio recording (MP3) + */ + public RdioScannerConfiguration(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.greaterThan(mSystemID, 0), Bindings.isNotNull(mApiKey)), + Bindings.isNotNull(mHost))); + } + + /** + * System ID as a property + */ + public IntegerProperty systemIDProperty() + { + return mSystemID; + } + + /** + * 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 ID as provided by RdioScanner.com + */ + @JacksonXmlProperty(isAttribute = true, localName = "system_id") + public int getSystemID() + { + return mSystemID.get(); + } + + /** + * Sets the system ID provided by RdioScanner.com + */ + public void setSystemID(int systemID) + { + mSystemID.set(systemID); + } + + @JacksonXmlProperty(isAttribute = true, localName = "type", namespace = "http://www.w3.org/2001/XMLSchema-instance") + @Override + public BroadcastServerType getBroadcastServerType() + { + return BroadcastServerType.RDIOSCANNER_CALL; + } + + @Override + public BroadcastConfiguration copyOf() + { + RdioScannerConfiguration copy = new RdioScannerConfiguration(); + copy.setSystemID(getSystemID()); + return copy; + } +} diff --git a/src/main/java/io/github/dsheirer/audio/broadcast/rdioscanner/RdioScannerFeedConfiguration.java b/src/main/java/io/github/dsheirer/audio/broadcast/rdioscanner/RdioScannerFeedConfiguration.java new file mode 100644 index 000000000..302ce13c4 --- /dev/null +++ b/src/main/java/io/github/dsheirer/audio/broadcast/rdioscanner/RdioScannerFeedConfiguration.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.rdioscanner; + +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 RdioScannerFeedConfiguration extends IcecastTCPConfiguration +{ + private final static Logger mLog = LoggerFactory.getLogger(RdioScannerFeedConfiguration.class); + + private int mFeedID; + + public RdioScannerFeedConfiguration() + { + //No-arg constructor for JAXB + this(BroadcastFormat.MP3); + } + + /** + * RdioScanner configuration for Icecast 2.3.2 compatible servers + * + * @param format of output audio (MP3) + */ + public RdioScannerFeedConfiguration(BroadcastFormat format) + { + super(format); + + setBitRate(16); + setChannels(1); + setSampleRate(8000); + setInline(true); + } + + public static RdioScannerFeedConfiguration from(UserFeedBroadcast userFeedBroadcast) + { + RdioScannerFeedConfiguration config = new RdioScannerFeedConfiguration(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() + { + RdioScannerFeedConfiguration copy = new RdioScannerFeedConfiguration(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()); + + //RdioScanner 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.RDIOSCANNER_CALL; + } + + @JacksonXmlProperty(isAttribute = true, localName = "feed_id") + public int getFeedID() + { + return mFeedID; + } + + public void setFeedID(int feedID) + { + mFeedID = feedID; + } +} diff --git a/src/main/java/io/github/dsheirer/gui/playlist/streaming/RdioScannerEditor.java b/src/main/java/io/github/dsheirer/gui/playlist/streaming/RdioScannerEditor.java new file mode 100644 index 000000000..b56e44f92 --- /dev/null +++ b/src/main/java/io/github/dsheirer/gui/playlist/streaming/RdioScannerEditor.java @@ -0,0 +1,234 @@ +/* + * ***************************************************************************** + * 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.rdioscanner.RdioScannerBroadcaster; +import io.github.dsheirer.audio.broadcast.rdioscanner.RdioScannerConfiguration; +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; + +/** + * RdioScanner calls API configuration editor + */ +public class RdioScannerEditor extends AbstractBroadcastEditor +{ + private final static Logger mLog = LoggerFactory.getLogger(RdioScannerEditor.class); + private IntegerTextField mSystemIdTextField; + private IntegerTextField mMaxAgeTextField; + private TextField mApiKeyTextField; + private TextField mHostTextField; + private GridPane mEditorPane; + + /** + * Constructs an instance + * @param playlistManager for accessing the broadcast model + */ + public RdioScannerEditor(PlaylistManager playlistManager) + { + super(playlistManager); + } + + @Override + public void setItem(RdioScannerConfiguration item) + { + super.setItem(item); + + getSystemIdTextField().setDisable(item == null); + getApiKeyTextField().setDisable(item == null); + getHostTextField().setDisable(item == null); + getMaxAgeTextField().setDisable(item == null); + + if(item != null) + { + getSystemIdTextField().set(item.getSystemID()); + getApiKeyTextField().setText(item.getApiKey()); + getHostTextField().setText(item.getHost()); + getMaxAgeTextField().set((int)(item.getMaximumRecordingAge() / 1000)); + } + else + { + getSystemIdTextField().set(0); + getApiKeyTextField().setText(null); + getHostTextField().setText(null); + getMaxAgeTextField().set(0); + } + + modifiedProperty().set(false); + } + + @Override + public void dispose() + { + } + + @Override + public void save() + { + if(getItem() != null) + { + int systemID = getSystemIdTextField().get() != null ? getSystemIdTextField().get() : 0; + getItem().setSystemID(systemID); + getItem().setHost(getHostTextField().getText()); + getItem().setApiKey(getApiKeyTextField().getText()); + getItem().setMaximumRecordingAge(getMaxAgeTextField().get() * 1000); + } + + super.save(); + } + + public BroadcastServerType getBroadcastServerType() + { + return BroadcastServerType.RDIOSCANNER_CALL; + } + + 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 systemIdLabel = new Label("System ID"); + GridPane.setHalignment(systemIdLabel, HPos.RIGHT); + GridPane.setConstraints(systemIdLabel, 0, ++row); + mEditorPane.getChildren().add(systemIdLabel); + + GridPane.setConstraints(getSystemIdTextField(), 1, row); + mEditorPane.getChildren().add(getSystemIdTextField()); + + Label hostLabel = new Label("RdioScanner 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 IntegerTextField getSystemIdTextField() + { + if(mSystemIdTextField == null) + { + mSystemIdTextField = new IntegerTextField(); + mSystemIdTextField.setDisable(true); + mSystemIdTextField.textProperty().addListener(mEditorModificationListener); + } + + return mSystemIdTextField; + } + + +} 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 4d49d8f5c..2faf57c41 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 @@ -38,6 +38,8 @@ public static AbstractBroadcastEditor getEditor(BroadcastServerType broadcastSer { case BROADCASTIFY: return new BroadcastifyStreamEditor(playlistManager); + case RDIOSCANNER_CALL: + return new RdioScannerEditor(playlistManager); case BROADCASTIFY_CALL: return new BroadcastifyCallEditor(playlistManager); case ICECAST_HTTP: diff --git a/src/main/resources/images/rdioscanner.png b/src/main/resources/images/rdioscanner.png new file mode 100644 index 000000000..141f4a10b Binary files /dev/null and b/src/main/resources/images/rdioscanner.png differ