/******************************************************************************* * sdrtrunk * Copyright (C) 2014-2016 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 <http://www.gnu.org/licenses/> * ******************************************************************************/ package audio.broadcast; import alias.id.broadcast.BroadcastChannel; import audio.AudioPacket; import channel.metadata.Metadata; import icon.IconManager; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import properties.SystemProperties; import sample.Broadcaster; import sample.Listener; import util.ThreadPool; import javax.swing.*; import javax.swing.table.AbstractTableModel; import java.io.IOException; import java.nio.file.DirectoryStream; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.HashMap; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.TimeUnit; import java.util.function.Consumer; import java.util.regex.Matcher; import java.util.regex.Pattern; public class BroadcastModel extends AbstractTableModel implements Listener<AudioPacket> { private final static Logger mLog = LoggerFactory.getLogger(BroadcastModel.class); public static final String TEMPORARY_STREAM_DIRECTORY = "streaming"; public static final String TEMPORARY_STREAM_FILE_SUFFIX = "temporary_streaming_file_"; private static final String UNIQUE_NAME_REGEX = "(.*)\\((\\d*)\\)"; public static final int COLUMN_SERVER_ICON = 0; public static final int COLUMN_STREAM_NAME = 1; public static final int COLUMN_BROADCASTER_STATUS = 2; public static final int COLUMN_BROADCASTER_QUEUE_SIZE = 3; public static final int COLUMN_BROADCASTER_STREAMED_COUNT = 4; public static final int COLUMN_BROADCASTER_AGED_OFF_COUNT = 5; public static final String[] COLUMN_NAMES = new String[] {"Streaming", "Name", "Status", "Queued", "Streamed", "Aged Off"}; private List<BroadcastConfiguration> mBroadcastConfigurations = new CopyOnWriteArrayList<>(); private List<AudioRecording> mRecordingQueue = new CopyOnWriteArrayList<>(); private Map<String,BroadcastConfiguration> mBroadcastConfigurationMap = new HashMap<>(); private Map<String,AudioBroadcaster> mBroadcasterMap = new HashMap<>(); private IconManager mIconManager; private StreamManager mStreamManager; private Broadcaster<BroadcastEvent> mBroadcastEventBroadcaster = new Broadcaster<>(); /** * Model for managing Broadcast configurations and any associated broadcaster instances. */ public BroadcastModel(IconManager iconManager) { mIconManager = iconManager; mStreamManager = new StreamManager(new CompletedRecordingListener(), BroadcastFormat.MP3, SystemProperties.getInstance().getApplicationFolder(TEMPORARY_STREAM_DIRECTORY)); mStreamManager.start(); //Monitor to remove temporary recording files that have been streamed by all audio broadcasters ThreadPool.SCHEDULED.scheduleAtFixedRate(new RecordingDeletionMonitor(), 15l, 15l, TimeUnit.SECONDS); removeOrphanedTemporaryRecordings(); } /** * List of broadcastAudio configuration names */ public List<String> getBroadcastConfigurationNames() { List<String> names = new ArrayList<>(); for(BroadcastConfiguration configuration : mBroadcastConfigurations) { names.add(configuration.getName()); } return names; } /** * Current list of broadcastAudio configurations */ public List<BroadcastConfiguration> getBroadcastConfigurations() { return mBroadcastConfigurations; } /** * Adds the list of broadcastAudio configurations to this model */ public void addBroadcastConfigurations(List<BroadcastConfiguration> configurations) { for(BroadcastConfiguration configuration : configurations) { addBroadcastConfiguration(configuration); } } /** * Adds the broadcastAudio configuration to this model */ public void addBroadcastConfiguration(BroadcastConfiguration configuration) { if(configuration != null) { ensureUniqueName(configuration); if(!mBroadcastConfigurations.contains(configuration)) { mBroadcastConfigurations.add(configuration); int index = mBroadcastConfigurations.size() - 1; fireTableRowsInserted(index, index); mBroadcastConfigurationMap.put(configuration.getName(), configuration); process(new BroadcastEvent(configuration, BroadcastEvent.Event.CONFIGURATION_ADD)); } } } /** * Clones the configuration and adds it this model with a unique configuration name */ public BroadcastConfiguration cloneBroadcastConfiguration(BroadcastConfiguration configuration) { if(configuration != null) { BroadcastConfiguration clone = configuration.copyOf(); addBroadcastConfiguration(clone); return clone; } return null; } /** * Updates the configuration's name so that it is unique among all other broadcast configurations * * @param configuration */ private void ensureUniqueName(BroadcastConfiguration configuration) { if(configuration.getName() == null || configuration.getName().isEmpty()) { configuration.setName("New Configuration"); } while(!isUniqueName(configuration.getName(), configuration)) { String currentName = configuration.getName(); if(currentName.matches(UNIQUE_NAME_REGEX)) { int currentVersion = 1; StringBuilder sb = new StringBuilder(); Matcher m = Pattern.compile(UNIQUE_NAME_REGEX).matcher(currentName); if(m.find()) { String version = m.group(2); try { currentVersion = Integer.parseInt(version); } catch(Exception e) { //Couldn't parse the version number -- keep incrementing until we find a winner } currentVersion++; sb.append(m.group(1)).append("(").append(currentVersion).append(")"); } else { sb.append(configuration.getName()).append("(").append(currentVersion).append(")"); } configuration.setName(sb.toString()); } else { StringBuilder sb = new StringBuilder(); sb.append(configuration.getName()).append("(2)"); configuration.setName(sb.toString()); } } } /** * Indicates if the name is unique among all of the broadcast configurations. Checks each of the configurations * in this model for a name collision. Assumes that the name argument will be assigned to the configuration * argument if the name is unique, therefore, the name will not be checked against the configuration argument for * a collision. * * @param name to check for uniqueness * @param configuration to ignore when checking the name for uniqueness * @return true if the name is not null and unique among all configurations managed by this model */ public boolean isUniqueName(String name, BroadcastConfiguration configuration) { if(name == null || name.isEmpty()) { return false; } for(BroadcastConfiguration configurationToCompare : mBroadcastConfigurations) { if(configurationToCompare != configuration && configurationToCompare.getName() != null && configurationToCompare.getName().equals(name)) { return false; } } return true; } public void removeBroadcastConfiguration(BroadcastConfiguration broadcastConfiguration) { if(broadcastConfiguration != null && mBroadcastConfigurations.contains(broadcastConfiguration)) { int index = mBroadcastConfigurations.indexOf(broadcastConfiguration); mBroadcastConfigurations.remove(broadcastConfiguration); mBroadcastConfigurationMap.remove(broadcastConfiguration.getName()); process(new BroadcastEvent(broadcastConfiguration, BroadcastEvent.Event.CONFIGURATION_DELETE)); fireTableRowsDeleted(index, index); } } /** * Returns the broadcaster associated with the stream name or null if there is no broadcaster setup for the name. */ public AudioBroadcaster getBroadcaster(String streamName) { return mBroadcasterMap.get(streamName); } @Override public void receive(AudioPacket audioPacket) { if(audioPacket.hasMetadata() && audioPacket.getMetadata().isStreamable()) { for(BroadcastChannel channel: audioPacket.getMetadata().getBroadcastChannels()) { if(mBroadcasterMap.containsKey(channel.getChannelName())) { mStreamManager.receive(audioPacket); return; } } } } /** * Creates a new broadcaster for the broadcast configuration and adds it to the model */ private void createBroadcaster(BroadcastConfiguration broadcastConfiguration) { if(broadcastConfiguration != null && broadcastConfiguration.isEnabled() && broadcastConfiguration.isValid() && !mBroadcasterMap.containsKey(broadcastConfiguration.getName())) { AudioBroadcaster audioBroadcaster = BroadcastFactory.getBroadcaster(broadcastConfiguration); if(audioBroadcaster != null) { audioBroadcaster.setListener(new Listener<BroadcastEvent>() { @Override public void receive(BroadcastEvent broadcastEvent) { process(broadcastEvent); } }); audioBroadcaster.start(); mBroadcasterMap.put(audioBroadcaster.getBroadcastConfiguration().getName(), audioBroadcaster); int index = mBroadcastConfigurations.indexOf(audioBroadcaster.getBroadcastConfiguration()); if(index >= 0) { fireTableRowsUpdated(index, index); } broadcast(new BroadcastEvent(audioBroadcaster, BroadcastEvent.Event.BROADCASTER_ADD)); } } } /** * Shut down a broadcaster created from the configuration and remove it from this model */ private void deleteBroadcaster(String name) { if(name != null && mBroadcasterMap.containsKey(name)) { AudioBroadcaster audioBroadcaster = mBroadcasterMap.remove(name); if(audioBroadcaster != null) { audioBroadcaster.stop(); audioBroadcaster.removeListener(); int index = mBroadcastConfigurations.indexOf(audioBroadcaster.getBroadcastConfiguration()); if(index >= 0) { fireTableRowsUpdated(index, index); } broadcast(new BroadcastEvent(audioBroadcaster, BroadcastEvent.Event.BROADCASTER_DELETE)); } } } /** * Returns the broadcast configuration identified by the stream name */ public BroadcastConfiguration getBroadcastConfiguration(String streamName) { return mBroadcastConfigurationMap.get(streamName); } /** * Registers the listener to receive broadcastAudio configuration events */ public void addListener(Listener<BroadcastEvent> listener) { mBroadcastEventBroadcaster.addListener(listener); } /** * Removes the listener from receiving broadcastAudio configuration events */ public void removeListener(Listener<BroadcastEvent> listener) { mBroadcastEventBroadcaster.removeListener(listener); } /** * Broadcasts the broadcastAudio configuration change event */ private void broadcast(BroadcastEvent event) { mBroadcastEventBroadcaster.broadcast(event); } /** * Process a broadcast event from one of the broadcasters managed by this model */ public void process(BroadcastEvent broadcastEvent) { if(broadcastEvent.isBroadcastConfigurationEvent()) { switch(broadcastEvent.getEvent()) { case CONFIGURATION_ADD: createBroadcaster(broadcastEvent.getBroadcastConfiguration()); break; case CONFIGURATION_CHANGE: BroadcastConfiguration broadcastConfiguration = broadcastEvent.getBroadcastConfiguration(); int index = mBroadcastConfigurations.indexOf(broadcastConfiguration); //Delete the existing broadcaster for any broadcast configuration changes String previousChannelName = cleanupMapAssociations(broadcastConfiguration); deleteBroadcaster(previousChannelName); //If the configuration is enabled, create a new broadcaster after a brief delay if(broadcastConfiguration.isEnabled()) { //Delay restarting the broadcaster to allow remote server time to cleanup ThreadPool.SCHEDULED.schedule(new DelayedBroadcasterStartup(broadcastConfiguration), 3, TimeUnit.SECONDS); } fireTableRowsUpdated(index, index); break; case CONFIGURATION_DELETE: deleteBroadcaster(broadcastEvent.getBroadcastConfiguration().getName()); break; } } else if(broadcastEvent.isAudioBroadcasterEvent()) { int row = mBroadcastConfigurations.indexOf(broadcastEvent.getAudioBroadcaster().getBroadcastConfiguration()); switch(broadcastEvent.getEvent()) { case BROADCASTER_QUEUE_CHANGE: if(row >= 0) { fireTableCellUpdated(row, COLUMN_BROADCASTER_QUEUE_SIZE); } break; case BROADCASTER_STATE_CHANGE: if(row >= 0) { fireTableCellUpdated(row, COLUMN_BROADCASTER_STATUS); } break; case BROADCASTER_STREAMED_COUNT_CHANGE: if(row >= 0) { fireTableCellUpdated(row, COLUMN_BROADCASTER_STREAMED_COUNT); } break; case BROADCASTER_AGED_OFF_COUNT_CHANGE: if(row >= 0) { fireTableCellUpdated(row, COLUMN_BROADCASTER_AGED_OFF_COUNT); } break; } } //Rebroadcast the event to any listeners of this model broadcast(broadcastEvent); } /** * When the name of a broadcast configuration changes, remove the old map associations so that they can be replaced * with the new map associations. Remove any broadcaster that is associated with the old configuration name. * * @param broadcastConfiguration * @return previous or current channel name that has been replaced with the current configuration channel name so * that anything else associated with the previous name can be cleaned up. */ private String cleanupMapAssociations(BroadcastConfiguration broadcastConfiguration) { String oldName = null; for(Map.Entry<String,BroadcastConfiguration> entry : mBroadcastConfigurationMap.entrySet()) { if(entry.getValue() == broadcastConfiguration) { oldName = entry.getKey(); continue; } } if(oldName != null) { mBroadcastConfigurationMap.remove(oldName); } mBroadcastConfigurationMap.put(broadcastConfiguration.getName(), broadcastConfiguration); return oldName; } @Override public int getRowCount() { return mBroadcastConfigurations.size(); } @Override public int getColumnCount() { return COLUMN_NAMES.length; } @Override public Object getValueAt(int rowIndex, int columnIndex) { try { if(rowIndex <= mBroadcastConfigurations.size()) { BroadcastConfiguration configuration = mBroadcastConfigurations.get(rowIndex); if(configuration != null) { switch(columnIndex) { case COLUMN_SERVER_ICON: String iconPath = configuration.getBroadcastServerType().getIconPath(); if(iconPath != null && mIconManager != null) { return mIconManager.getScaledIcon(new ImageIcon(iconPath), 14); } break; case COLUMN_STREAM_NAME: return configuration.getName(); case COLUMN_BROADCASTER_STATUS: AudioBroadcaster audioBroadcasterA = mBroadcasterMap.get(configuration.getName()); if(audioBroadcasterA != null) { return audioBroadcasterA.getBroadcastState(); } else if(!configuration.isEnabled()) { return BroadcastState.DISABLED; } else if(!configuration.isValid()) { return BroadcastState.INVALID_SETTINGS; } case COLUMN_BROADCASTER_QUEUE_SIZE: AudioBroadcaster audioBroadcasterB = mBroadcasterMap.get(configuration.getName()); if(audioBroadcasterB != null) { return audioBroadcasterB.getQueueSize(); } break; case COLUMN_BROADCASTER_STREAMED_COUNT: AudioBroadcaster audioBroadcasterC = mBroadcasterMap.get(configuration.getName()); if(audioBroadcasterC != null) { return audioBroadcasterC.getStreamedAudioCount(); } break; case COLUMN_BROADCASTER_AGED_OFF_COUNT: AudioBroadcaster audioBroadcasterD = mBroadcasterMap.get(configuration.getName()); if(audioBroadcasterD != null) { return audioBroadcasterD.getAgedOffAudioCount(); } break; default: break; } } } } catch(Exception e) { mLog.error("Error accessing data in broadcast model", e); } return null; } @Override public Class<?> getColumnClass(int columnIndex) { if(columnIndex == COLUMN_SERVER_ICON) { return ImageIcon.class; } return String.class; } /** * Broadcast configuration at the specified model row */ public BroadcastConfiguration getConfigurationAt(int rowIndex) { return mBroadcastConfigurations.get(rowIndex); } /** * Model row number for the specified configuration */ public int getRowForConfiguration(BroadcastConfiguration configuration) { return mBroadcastConfigurations.indexOf(configuration); } @Override public String getColumnName(int column) { if(0 <= column && column < COLUMN_NAMES.length) { return COLUMN_NAMES[column]; } return null; } /** * Cleanup method to remove a temporary recording file from disk. * * @param recording to remove */ private void removeRecording(AudioRecording recording) { try { Files.delete(recording.getPath()); } catch(IOException ioe) { mLog.error("Error deleting temporary internet recording file: " + recording.getPath().toString() + " - " + ioe.getMessage()); } } /** * Removes any temporary stream recordings left-over from the previous application run. * * This should only be invoked on startup. */ private void removeOrphanedTemporaryRecordings() { Path path = SystemProperties.getInstance().getApplicationFolder(BroadcastModel.TEMPORARY_STREAM_DIRECTORY); if(path != null && Files.isDirectory(path)) { StringBuilder sb = new StringBuilder(); sb.append(TEMPORARY_STREAM_FILE_SUFFIX); sb.append("*.*"); try(DirectoryStream<Path> directoryStream = Files.newDirectoryStream(path, sb.toString())) { directoryStream.forEach(new Consumer<Path>() { @Override public void accept(Path path) { try { Files.delete(path); } catch(IOException ioe) { mLog.error("Couldn't delete orphaned temporary recording: " + path.toString(), ioe); } } }); } catch(IOException ioe) { mLog.error("Error discovering orphaned temporary stream recording files", ioe); } } } /** * Provides delayed startup of a broadcaster to allow for the remote server to complete a disconnection. */ public class DelayedBroadcasterStartup implements Runnable { private BroadcastConfiguration mBroadcastConfiguration; public DelayedBroadcasterStartup(BroadcastConfiguration configuration) { mBroadcastConfiguration = configuration; } @Override public void run() { createBroadcaster(mBroadcastConfiguration); } } /** * Processes completed audio recordings and distributes them to the audio broadcasters. Adds the recording to * the audio recording queue to be monitored for deletion. */ public class CompletedRecordingListener implements Listener<AudioRecording> { @Override public void receive(AudioRecording audioRecording) { Metadata metadata = audioRecording.getMetadata(); if(metadata != null && metadata.isStreamable()) { for(BroadcastChannel broadcastChannel : metadata.getBroadcastChannels()) { String channelName = broadcastChannel.getChannelName(); if(channelName != null) { AudioBroadcaster audioBroadcaster = getBroadcaster(channelName); if(audioBroadcaster != null) { audioRecording.addPendingReplay(); audioBroadcaster.receive(audioRecording); } } } } mRecordingQueue.add(audioRecording); } } /** * Monitors the recording queue and removes any recordings that have no pending replays by audio broadcasters */ public class RecordingDeletionMonitor implements Runnable { @Override public void run() { try { List<AudioRecording> recordingsToDelete = new ArrayList<>(); Iterator<AudioRecording> it = mRecordingQueue.iterator(); AudioRecording recording; while(it.hasNext()) { recording = it.next(); if(!recording.hasPendingReplays()) { recordingsToDelete.add(recording); } } if(!recordingsToDelete.isEmpty()) { for(AudioRecording recordingToDelete : recordingsToDelete) { mRecordingQueue.remove(recordingToDelete); removeRecording(recordingToDelete); } } } catch(Exception e) { mLog.error("Error while checking audio recording queue for recordings to delete", e); } } } }