package org.red5.server.stream; /* * RED5 Open Source Flash Server - http://www.osflash.org/red5 * * Copyright (c) 2006-2009 by respective authors (see below). All rights reserved. * * This library is free software; you can redistribute it and/or modify it under the * terms of the GNU Lesser General Public License as published by the Free Software * Foundation; either version 2.1 of the License, or (at your option) any later * version. * * This library 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 Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public License along * with this library; if not, write to the Free Software Foundation, Inc., * 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA */ import java.io.File; import java.io.IOException; import java.util.Collection; import java.util.HashMap; import java.util.Map; import java.util.Set; import java.util.concurrent.CopyOnWriteArraySet; import javax.management.ObjectName; import org.apache.mina.common.ByteBuffer; import org.red5.server.api.IConnection; import org.red5.server.api.IScope; import org.red5.server.api.Red5; import org.red5.server.api.ScopeUtils; import org.red5.server.api.event.IEvent; import org.red5.server.api.event.IEventDispatcher; import org.red5.server.api.event.IEventListener; import org.red5.server.api.statistics.IClientBroadcastStreamStatistics; import org.red5.server.api.statistics.support.StatisticsCounter; import org.red5.server.api.stream.IClientBroadcastStream; import org.red5.server.api.stream.IStreamAwareScopeHandler; import org.red5.server.api.stream.IStreamCapableConnection; import org.red5.server.api.stream.IStreamCodecInfo; import org.red5.server.api.stream.IStreamFilenameGenerator; import org.red5.server.api.stream.IStreamListener; import org.red5.server.api.stream.IStreamPacket; import org.red5.server.api.stream.IVideoStreamCodec; import org.red5.server.api.stream.ResourceExistException; import org.red5.server.api.stream.ResourceNotFoundException; import org.red5.server.api.stream.IStreamFilenameGenerator.GenerationType; import org.red5.server.jmx.JMXAgent; import org.red5.server.jmx.JMXFactory; import org.red5.server.messaging.IConsumer; import org.red5.server.messaging.IFilter; import org.red5.server.messaging.IMessage; import org.red5.server.messaging.IMessageComponent; import org.red5.server.messaging.IMessageOutput; import org.red5.server.messaging.IPipe; import org.red5.server.messaging.IPipeConnectionListener; import org.red5.server.messaging.IProvider; import org.red5.server.messaging.IPushableConsumer; import org.red5.server.messaging.InMemoryPushPushPipe; import org.red5.server.messaging.OOBControlMessage; import org.red5.server.messaging.PipeConnectionEvent; import org.red5.server.net.rtmp.event.AudioData; import org.red5.server.net.rtmp.event.IRTMPEvent; import org.red5.server.net.rtmp.event.Invoke; import org.red5.server.net.rtmp.event.Notify; import org.red5.server.net.rtmp.event.VideoData; import org.red5.server.net.rtmp.status.Status; import org.red5.server.net.rtmp.status.StatusCodes; import org.red5.server.stream.codec.StreamCodecInfo; import org.red5.server.stream.consumer.FileConsumer; import org.red5.server.stream.message.RTMPMessage; import org.red5.server.stream.message.StatusMessage; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** * Represents live stream broadcasted from client. As Flash Media Server, Red5 supports * recording mode for live streams, that is, broadcasted stream has broadcast mode. It can be either * "live" or "record" and latter causes server-side application to record broadcasted stream. * * Note that recorded streams are recorded as FLV files. The same is correct for audio, because * NellyMoser codec that Flash Player uses prohibits on-the-fly transcoding to audio formats like MP3 * without paying of licensing fee or buying SDK. * * This type of stream uses two different pipes for live streaming and recording. */ public class ClientBroadcastStream extends AbstractClientStream implements IClientBroadcastStream, IFilter, IPushableConsumer, IPipeConnectionListener, IEventDispatcher, IClientBroadcastStreamStatistics, ClientBroadcastStreamMBean { /** * Logger */ private static final Logger log = LoggerFactory.getLogger(ClientBroadcastStream.class); /** Stores absolute time for video stream. */ private int audioTime = -1; /** * Total number of bytes received. */ private long bytesReceived; /** * Is there need to check video codec? */ private boolean checkVideoCodec = false; /** * Data is sent by chunks, each of them has size */ private int chunkSize = 0; /** * Is this stream still active? */ private boolean closed = false; /** * Output endpoint that providers use */ private IMessageOutput connMsgOut; /** * Timestamp the stream was created. */ private long creationTime; /** Stores absolute time for data stream. */ private int dataTime = -1; /** Stores timestamp of first packet. */ private int firstPacketTime = -1; /** * Pipe for live streaming */ private IPipe livePipe; /** * MBean object name used for de/registration purposes. */ private ObjectName oName; /** * Stream published name */ private String publishedName; /** * Whether we are recording or not */ private volatile boolean recording = false; /** * FileConsumer used to output recording to disk */ private FileConsumer recordingFile; /** * The filename we are recording to. */ private String recordingFilename; /** * Pipe for recording */ private IPipe recordPipe; /** * Is there need to send start notification? */ private boolean sendStartNotification = true; /** * Stores statistics about subscribers. */ private StatisticsCounter subscriberStats = new StatisticsCounter(); /** * Factory object for video codecs */ private VideoCodecFactory videoCodecFactory = null; /** Stores absolute time for video stream. */ private int videoTime = -1; /** Listeners to get notified about received packets. */ private Set<IStreamListener> listeners = new CopyOnWriteArraySet<IStreamListener>(); /** * Check and send notification if necessary * @param event Event */ private void checkSendNotifications(IEvent event) { IEventListener source = event.getSource(); sendStartNotifications(source); } /** * Closes stream, unsubscribes provides, sends stoppage notifications and broadcast close notification. */ public void close() { if (closed) { // Already closed return; } closed = true; if (livePipe != null) { livePipe.unsubscribe((IProvider) this); } if (recordPipe != null) { recordPipe.unsubscribe((IProvider) this); } if (recording) { sendRecordStopNotify(); } sendPublishStopNotify(); // TODO: can we sent the client something to make sure he stops sending data? connMsgOut.unsubscribe(this); notifyBroadcastClose(); // deregister with jmx JMXAgent.unregisterMBean(oName); } /** * Dispatches event * @param event Event to dispatch */ public void dispatchEvent(IEvent event) { if (!(event instanceof IRTMPEvent) && (event.getType() != IEvent.Type.STREAM_CONTROL) && (event.getType() != IEvent.Type.STREAM_DATA) || closed) { // ignored event if (log.isDebugEnabled()) { log.debug("dispatchEvent: " + event.getType()); } return; } // Get stream codec IStreamCodecInfo codecInfo = getCodecInfo(); StreamCodecInfo info = null; if (codecInfo instanceof StreamCodecInfo) { info = (StreamCodecInfo) codecInfo; } IRTMPEvent rtmpEvent; try { rtmpEvent = (IRTMPEvent) event; } catch (ClassCastException e) { log.error("Class cast exception in event dispatch", e); return; } int eventTime = -1; // If this is first packet save it's timestamp if (firstPacketTime == -1) { firstPacketTime = rtmpEvent.getTimestamp(); } //get the buffer only once per call ByteBuffer buf = null; if (rtmpEvent instanceof IStreamData && (buf = ((IStreamData) rtmpEvent).getData()) != null) { bytesReceived += buf.limit(); } if (rtmpEvent instanceof AudioData) { if (info != null) { info.setHasAudio(true); } if (rtmpEvent.getHeader().isTimerRelative()) { audioTime += rtmpEvent.getTimestamp(); } else { audioTime = rtmpEvent.getTimestamp(); } eventTime = audioTime; } else if (rtmpEvent instanceof VideoData) { IVideoStreamCodec videoStreamCodec = null; if (videoCodecFactory != null && checkVideoCodec) { videoStreamCodec = videoCodecFactory.getVideoCodec(buf); if (codecInfo instanceof StreamCodecInfo) { ((StreamCodecInfo) codecInfo) .setVideoCodec(videoStreamCodec); } checkVideoCodec = false; } else if (codecInfo != null) { videoStreamCodec = codecInfo.getVideoCodec(); } if (videoStreamCodec != null) { videoStreamCodec.addData(buf); } if (info != null) { info.setHasVideo(true); } if (rtmpEvent.getHeader().isTimerRelative()) { videoTime += rtmpEvent.getTimestamp(); } else { videoTime = rtmpEvent.getTimestamp(); } eventTime = videoTime; } else if (rtmpEvent instanceof Invoke) { if (rtmpEvent.getHeader().isTimerRelative()) { dataTime += rtmpEvent.getTimestamp(); } else { dataTime = rtmpEvent.getTimestamp(); } //do we want to return from here? //event / stream listeners will not be notified of invokes return; } else if (rtmpEvent instanceof Notify) { if (rtmpEvent.getHeader().isTimerRelative()) { dataTime += rtmpEvent.getTimestamp(); } else { dataTime = rtmpEvent.getTimestamp(); } eventTime = dataTime; } // Notify event listeners checkSendNotifications(event); // Create new RTMP message, initialize it and push through pipe RTMPMessage msg = new RTMPMessage(); msg.setBody(rtmpEvent); msg.getBody().setTimestamp(eventTime); try { if (livePipe != null) { livePipe.pushMessage(msg); } else { log.debug("Live pipe was null, message was not pushed"); } if (recordPipe != null) { recordPipe.pushMessage(msg); } else { log.debug("Record pipe was null, message was not pushed"); } } catch (IOException err) { sendRecordFailedNotify(err.getMessage()); stop(); } // Notify listeners about received packet if (rtmpEvent instanceof IStreamPacket) { for (IStreamListener listener: getStreamListeners()) { try { listener.packetReceived(this, (IStreamPacket) rtmpEvent); } catch (Exception e) { log.error("Error while notifying listener {}", listener, e); } } } } /** {@inheritDoc} */ public int getActiveSubscribers() { return subscriberStats.getCurrent(); } /** {@inheritDoc} */ public long getBytesReceived() { return bytesReceived; } /** {@inheritDoc} */ public long getCreationTime() { return creationTime; } /** {@inheritDoc} */ public int getCurrentTimestamp() { return Math.max(Math.max(videoTime, audioTime), dataTime); } /** {@inheritDoc} */ public int getMaxSubscribers() { return subscriberStats.getMax(); } /** * Getter for provider * @return Provider */ public IProvider getProvider() { return this; } /** * Getter for published name * @return Stream published name */ public String getPublishedName() { return publishedName; } /** {@inheritDoc} */ public String getSaveFilename() { return recordingFilename; } /** {@inheritDoc} */ public IClientBroadcastStreamStatistics getStatistics() { return this; } /** {@inheritDoc} */ public int getTotalSubscribers() { return subscriberStats.getTotal(); } /** * Notifies handler on stream broadcast stop */ private void notifyBroadcastClose() { IStreamAwareScopeHandler handler = getStreamAwareHandler(); if (handler != null) { try { handler.streamBroadcastClose(this); } catch (Throwable t) { log.error("Error in notifyBroadcastClose", t); } } } /** * Notifies handler on stream broadcast start */ private void notifyBroadcastStart() { IStreamAwareScopeHandler handler = getStreamAwareHandler(); if (handler != null) { try { handler.streamBroadcastStart(this); } catch (Throwable t) { log.error("Error in notifyBroadcastStart", t); } } } /** * Send OOB control message with chunk size */ private void notifyChunkSize() { if (chunkSize > 0 && livePipe != null) { OOBControlMessage setChunkSize = new OOBControlMessage(); setChunkSize.setTarget("ConnectionConsumer"); setChunkSize.setServiceName("chunkSize"); if (setChunkSize.getServiceParamMap() == null) { setChunkSize.setServiceParamMap(new HashMap<String, Object>()); } setChunkSize.getServiceParamMap().put("chunkSize", chunkSize); livePipe.sendOOBControlMessage(getProvider(), setChunkSize); } } /** * Out-of-band control message handler * * @param source OOB message source * @param pipe Pipe that used to send OOB message * @param oobCtrlMsg Out-of-band control message */ public void onOOBControlMessage(IMessageComponent source, IPipe pipe, OOBControlMessage oobCtrlMsg) { if (!"ClientBroadcastStream".equals(oobCtrlMsg.getTarget())) { return; } if ("chunkSize".equals(oobCtrlMsg.getServiceName())) { chunkSize = (Integer) oobCtrlMsg.getServiceParamMap().get( "chunkSize"); notifyChunkSize(); } } /** * Pipe connection event handler * @param event Pipe connection event */ @SuppressWarnings("unused") public void onPipeConnectionEvent(PipeConnectionEvent event) { switch (event.getType()) { case PipeConnectionEvent.PROVIDER_CONNECT_PUSH: if (event.getProvider() == this && event.getSource() != connMsgOut && (event.getParamMap() == null || !event.getParamMap() .containsKey("record"))) { this.livePipe = (IPipe) event.getSource(); for (IConsumer consumer : this.livePipe.getConsumers()) { subscriberStats.increment(); } } break; case PipeConnectionEvent.PROVIDER_DISCONNECT: if (this.livePipe == event.getSource()) { this.livePipe = null; } break; case PipeConnectionEvent.CONSUMER_CONNECT_PUSH: if (this.livePipe == event.getSource()) { notifyChunkSize(); } subscriberStats.increment(); break; case PipeConnectionEvent.CONSUMER_DISCONNECT: subscriberStats.decrement(); break; default: break; } } /** * Currently not implemented * * @param pipe Pipe * @param message Message */ public void pushMessage(IPipe pipe, IMessage message) { } /** * Save broadcasted stream. * * @param name Stream name * @param isAppend Append mode * @throws IOException File could not be created/written to. * @throws ResourceNotFoundException Resource doesn't exist when trying to append. * @throws ResourceExistException Resource exist when trying to create. */ public void saveAs(String name, boolean isAppend) throws IOException, ResourceNotFoundException, ResourceExistException { log.debug("SaveAs - name: {} append: {}", name, isAppend); // Get stream scope IStreamCapableConnection conn = getConnection(); if (conn == null) { // TODO: throw other exception here? throw new IOException("stream is no longer connected"); } IScope scope = conn.getScope(); // Get stream filename generator IStreamFilenameGenerator generator = (IStreamFilenameGenerator) ScopeUtils .getScopeService(scope, IStreamFilenameGenerator.class, DefaultStreamFilenameGenerator.class); // Generate filename String filename = generator.generateFilename(scope, name, ".flv", GenerationType.RECORD); // Get file for that filename File file; if (generator.resolvesToAbsolutePath()) { file = new File(filename); } else { file = scope.getContext().getResource(filename).getFile(); } // If append mode is on... if (!isAppend) { if (file.exists()) { // Per livedoc of FCS/FMS: // When "live" or "record" is used, // any previously recorded stream with the same stream URI is deleted. if (!file.delete()) { throw new IOException("file could not be deleted"); } } } else { if (!file.exists()) { // Per livedoc of FCS/FMS: // If a recorded stream at the same URI does not already exist, // "append" creates the stream as though "record" was passed. isAppend = false; } } if (!file.exists()) { // Make sure the destination directory exists String path = file.getAbsolutePath(); int slashPos = path.lastIndexOf(File.separator); if (slashPos != -1) { path = path.substring(0, slashPos); } File tmp = new File(path); if (!tmp.isDirectory()) { tmp.mkdirs(); } } if (!file.exists()) { file.createNewFile(); } if (log.isDebugEnabled()) { log.debug("Recording file: " + file.getCanonicalPath()); } recordingFile = new FileConsumer(scope, file); Map<Object, Object> paramMap = new HashMap<Object, Object>(); if (isAppend) { paramMap.put("mode", "append"); } else { paramMap.put("mode", "record"); } recordPipe.subscribe(recordingFile, paramMap); recording = true; recordingFilename = filename; } /** * Sends publish start notifications */ private void sendPublishStartNotify() { Status publishStatus = new Status(StatusCodes.NS_PUBLISH_START); publishStatus.setClientid(getStreamId()); publishStatus.setDetails(getPublishedName()); StatusMessage startMsg = new StatusMessage(); startMsg.setBody(publishStatus); try { connMsgOut.pushMessage(startMsg); } catch (IOException err) { log.error("Error while pushing message.", err); } } /** * Sends publish stop notifications */ private void sendPublishStopNotify() { Status stopStatus = new Status(StatusCodes.NS_UNPUBLISHED_SUCCESS); stopStatus.setClientid(getStreamId()); stopStatus.setDetails(getPublishedName()); StatusMessage stopMsg = new StatusMessage(); stopMsg.setBody(stopStatus); try { connMsgOut.pushMessage(stopMsg); } catch (IOException err) { log.error("Error while pushing message.", err); } } /** * Sends record failed notifications */ private void sendRecordFailedNotify(String reason) { Status failedStatus = new Status(StatusCodes.NS_RECORD_FAILED); failedStatus.setLevel(Status.ERROR); failedStatus.setClientid(getStreamId()); failedStatus.setDetails(getPublishedName()); failedStatus.setDesciption(reason); StatusMessage failedMsg = new StatusMessage(); failedMsg.setBody(failedStatus); try { connMsgOut.pushMessage(failedMsg); } catch (IOException err) { log.error("Error while pushing message.", err); } } /** * Sends record start notifications */ private void sendRecordStartNotify() { Status recordStatus = new Status(StatusCodes.NS_RECORD_START); recordStatus.setClientid(getStreamId()); recordStatus.setDetails(getPublishedName()); StatusMessage startMsg = new StatusMessage(); startMsg.setBody(recordStatus); try { connMsgOut.pushMessage(startMsg); } catch (IOException err) { log.error("Error while pushing message.", err); } } /** * Sends record stop notifications */ private void sendRecordStopNotify() { Status stopStatus = new Status(StatusCodes.NS_RECORD_STOP); stopStatus.setClientid(getStreamId()); stopStatus.setDetails(getPublishedName()); StatusMessage startMsg = new StatusMessage(); startMsg.setBody(stopStatus); try { connMsgOut.pushMessage(startMsg); } catch (IOException err) { log.error("Error while pushing message.", err); } } private void sendStartNotifications(IEventListener source) { if (sendStartNotification) { // Notify handler that stream starts recording/publishing sendStartNotification = false; if (source instanceof IConnection) { IScope scope = ((IConnection) source).getScope(); if (scope.hasHandler()) { Object handler = scope.getHandler(); if (handler instanceof IStreamAwareScopeHandler) { if (recording) { ((IStreamAwareScopeHandler) handler) .streamRecordStart(this); } else { ((IStreamAwareScopeHandler) handler) .streamPublishStart(this); } } } } // Send start notifications sendPublishStartNotify(); if (recording) { sendRecordStartNotify(); } notifyBroadcastStart(); } } /** * Setter for stream published name * @param name Name that used for publishing. Set at client side when begin to broadcast with NetStream#publish. */ public void setPublishedName(String name) { log.debug("setPublishedName: {}", name); //check to see if we are setting the name to the same string if (!name.equals(publishedName)) { // update an attribute JMXAgent.updateMBeanAttribute(oName, "publishedName", name); } else { //create a new mbean for this instance with the new name oName = JMXFactory.createObjectName("type", "ClientBroadcastStream", "publishedName", name); JMXAgent.registerMBean(this, this.getClass().getName(), ClientBroadcastStreamMBean.class, oName); } this.publishedName = name; } /** * Starts stream. Creates pipes, video codec from video codec factory bean, * connects */ public void start() { log.info("Stream start"); IConsumerService consumerManager = (IConsumerService) getScope() .getContext().getBean(IConsumerService.KEY); try { videoCodecFactory = (VideoCodecFactory) getScope().getContext() .getBean(VideoCodecFactory.KEY); checkVideoCodec = true; } catch (Exception err) { log.warn("No video codec factory available.", err); } firstPacketTime = audioTime = videoTime = dataTime = -1; connMsgOut = consumerManager.getConsumerOutput(this); connMsgOut.subscribe(this, null); recordPipe = new InMemoryPushPushPipe(); Map<Object, Object> recordParamMap = new HashMap<Object, Object>(); // Clear record flag recordParamMap.put("record", null); recordPipe.subscribe((IProvider) this, recordParamMap); recording = false; recordingFilename = null; setCodecInfo(new StreamCodecInfo()); closed = false; bytesReceived = 0; creationTime = System.currentTimeMillis(); } /** {@inheritDoc} */ public void startPublishing() { // We send the start messages before the first packet is received. // This is required so FME actually starts publishing. sendStartNotifications(Red5.getConnectionLocal()); } /** {@inheritDoc} */ public void stop() { stopRecording(); close(); } /** * Stops any currently active recordings. */ public void stopRecording() { if (recording) { recording = false; recordingFilename = null; recordPipe.unsubscribe(recordingFile); sendRecordStopNotify(); } } public boolean isRecording() { return recording; } /** {@inheritDoc} */ public void addStreamListener(IStreamListener listener) { listeners.add(listener); } /** {@inheritDoc} */ public Collection<IStreamListener> getStreamListeners() { return listeners; } /** {@inheritDoc} */ public void removeStreamListener(IStreamListener listener) { listeners.remove(listener); } }