/* * RED5 Open Source Flash Server - http://code.google.com/p/red5/ * * Copyright 2006-2012 by respective authors (see below). All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package org.red5.server.stream; import java.io.File; import java.io.IOException; import java.lang.management.ManagementFactory; import java.lang.ref.WeakReference; import java.util.Collection; import java.util.HashMap; import java.util.Map; import java.util.Set; import java.util.concurrent.CopyOnWriteArraySet; import javax.management.InstanceAlreadyExistsException; import javax.management.MBeanServer; import javax.management.ObjectName; import javax.management.StandardMBean; import org.apache.commons.lang3.StringUtils; import org.apache.mina.core.buffer.IoBuffer; import org.red5.server.api.IConnection; import org.red5.server.api.Red5; 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.scope.IScope; import org.red5.server.api.statistics.IClientBroadcastStreamStatistics; import org.red5.server.api.statistics.support.StatisticsCounter; import org.red5.server.api.stream.IAudioStreamCodec; 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.IStreamListener; import org.red5.server.api.stream.IStreamPacket; import org.red5.server.api.stream.IVideoStreamCodec; import org.red5.server.jmx.mxbeans.ClientBroadcastStreamMXBean; 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.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.message.RTMPMessage; import org.red5.server.stream.message.StatusMessage; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.jmx.export.annotation.ManagedResource; /** * 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. * * @author The Red5 Project (red5@osflash.org) * @author Steven Gong * @author Paul Gregoire (mondain@gmail.com) * @author Vladimir Hmelyoff (vlhm@splitmedialabs.com) */ @ManagedResource(objectName = "org.red5.server:type=ClientBroadcastStream", description = "ClientBroadcastStream") public class ClientBroadcastStream extends AbstractClientStream implements IClientBroadcastStream, IFilter, IPushableConsumer, IPipeConnectionListener, IEventDispatcher, IClientBroadcastStreamStatistics, ClientBroadcastStreamMXBean { private static final Logger log = LoggerFactory.getLogger(ClientBroadcastStream.class); /** * Whether or not to automatically record the associated stream. */ protected boolean automaticRecording; /** * Total number of bytes received. */ protected long bytesReceived; /** * Is there need to check video codec? */ protected boolean checkVideoCodec = false; /** * Is there need to check audio codec? */ protected boolean checkAudioCodec = false; /** * Data is sent by chunks, each of them has size */ protected int chunkSize; /** * Is this stream still active? */ protected volatile boolean closed; /** * Output endpoint that providers use */ protected IMessageOutput connMsgOut; /** * Stores timestamp of first packet */ protected long firstPacketTime = -1; /** * Pipe for live streaming */ protected IPipe livePipe; /** * Stream published name */ protected String publishedName; /** * Streaming parameters */ protected Map<String, String> parameters; /** * Is there need to send start notification? */ protected boolean sendStartNotification = true; /** * Stores statistics about subscribers. */ private StatisticsCounter subscriberStats = new StatisticsCounter(); /** * Listeners to get notified about received packets. */ protected Set<IStreamListener> listeners = new CopyOnWriteArraySet<IStreamListener>(); /** * Recording listener */ private WeakReference<RecordingListener> recordingListener; protected long latestTimeStamp = -1; /** * 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() { log.info("Stream close"); if (closed) { // already closed return; } closed = true; if (livePipe != null) { livePipe.unsubscribe((IProvider) this); } // if we have a recording listener, inform that this stream is done if (recordingListener != null) { recordingListener.get().closeStream(); sendRecordStopNotify(); notifyRecordingStop(); } sendPublishStopNotify(); // TODO: can we send the client something to make sure he stops sending data? connMsgOut.unsubscribe(this); notifyBroadcastClose(); // clear the listener after all the notifications have been sent if (recordingListener != null) { recordingListener.clear(); } // deregister with jmx unregisterJMX(); } /** * Dispatches event * @param event Event to dispatch */ public void dispatchEvent(IEvent event) { if (event instanceof IRTMPEvent && !closed) { switch (event.getType()) { case STREAM_CONTROL: case STREAM_DATA: // create the event IRTMPEvent rtmpEvent; try { rtmpEvent = (IRTMPEvent) event; } catch (ClassCastException e) { log.error("Class cast exception in event dispatch", e); return; } int eventTime = -1; if (log.isTraceEnabled()) { // If this is first packet save its timestamp; expect it is // absolute? no matter: it's never used! if (firstPacketTime == -1) { firstPacketTime = rtmpEvent.getTimestamp(); log.trace(String.format("CBS=@%08x: rtmpEvent=%s creation=%s firstPacketTime=%d", System.identityHashCode(this), rtmpEvent.getClass().getSimpleName(), creationTime, firstPacketTime)); } else { log.trace(String.format("CBS=@%08x: rtmpEvent=%s creation=%s firstPacketTime=%d timestamp=%d", System.identityHashCode(this), rtmpEvent.getClass() .getSimpleName(), creationTime, firstPacketTime, rtmpEvent.getTimestamp())); } } //get the buffer only once per call IoBuffer buf = null; if (rtmpEvent instanceof IStreamData && (buf = ((IStreamData<?>) rtmpEvent).getData()) != null) { bytesReceived += buf.limit(); } // get stream codec IStreamCodecInfo codecInfo = getCodecInfo(); StreamCodecInfo info = null; if (codecInfo instanceof StreamCodecInfo) { info = (StreamCodecInfo) codecInfo; } //log.trace("Stream codec info: {}", info); if (rtmpEvent instanceof AudioData) { IAudioStreamCodec audioStreamCodec = null; if (checkAudioCodec) { audioStreamCodec = AudioCodecFactory.getAudioCodec(buf); if (info != null) { info.setAudioCodec(audioStreamCodec); } checkAudioCodec = false; } else if (codecInfo != null) { audioStreamCodec = codecInfo.getAudioCodec(); } if (audioStreamCodec != null) { audioStreamCodec.addData(buf.asReadOnlyBuffer()); } if (info != null) { info.setHasAudio(true); } eventTime = rtmpEvent.getTimestamp(); log.trace("Audio: {}", eventTime); } else if (rtmpEvent instanceof VideoData) { IVideoStreamCodec videoStreamCodec = null; if (checkVideoCodec) { videoStreamCodec = VideoCodecFactory.getVideoCodec(buf); if (info != null) { info.setVideoCodec(videoStreamCodec); } checkVideoCodec = false; } else if (codecInfo != null) { videoStreamCodec = codecInfo.getVideoCodec(); } if (videoStreamCodec != null) { videoStreamCodec.addData(buf.asReadOnlyBuffer()); } if (info != null) { info.setHasVideo(true); } eventTime = rtmpEvent.getTimestamp(); log.trace("Video: {}", eventTime); } else if (rtmpEvent instanceof Invoke) { eventTime = rtmpEvent.getTimestamp(); //do we want to return from here? //event / stream listeners will not be notified of invokes return; } else if (rtmpEvent instanceof Notify) { // store the metadata Notify notifyEvent = (Notify) rtmpEvent; if (metaData == null && notifyEvent.getHeader().getDataType() == Notify.TYPE_STREAM_METADATA) { try { metaData = notifyEvent.duplicate(); } catch (Exception e) { log.warn("Metadata could not be duplicated for this stream", e); } } eventTime = rtmpEvent.getTimestamp(); } // update last event time if (eventTime > latestTimeStamp) { latestTimeStamp = eventTime; } // notify event listeners checkSendNotifications(event); // note this timestamp is set in event/body but not in the associated header try { // route to live if (livePipe != null) { // create new RTMP message, initialize it and push through pipe RTMPMessage msg = RTMPMessage.build(rtmpEvent, eventTime); livePipe.pushMessage(msg); } else { log.debug("Live pipe was null, message was not pushed"); } } catch (IOException err) { 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); if (listener instanceof RecordingListener) { sendRecordFailedNotify(e.getMessage()); } } } } break; default: // ignored event log.debug("Ignoring event: {}", event.getType()); } } else { log.debug("Event was of wrong type or stream is closed ({})", closed); } } /** {@inheritDoc} */ public int getActiveSubscribers() { return subscriberStats.getCurrent(); } /** {@inheritDoc} */ public long getBytesReceived() { return bytesReceived; } /** {@inheritDoc} */ public int getCurrentTimestamp() { return (int) latestTimeStamp; } /** {@inheritDoc} */ public int getMaxSubscribers() { return subscriberStats.getMax(); } /** * Getter for provider * @return Provider */ public IProvider getProvider() { return this; } /** * 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); // a publish name of "false" is a special case, used when stopping a stream if (StringUtils.isNotEmpty(name) && !"false".equals(name)) { this.publishedName = name; registerJMX(); } } /** * Getter for published name * @return Stream published name */ public String getPublishedName() { return publishedName; } /** {@inheritDoc} */ public void setParameters(Map<String, String> params) { this.parameters = params; } /** {@inheritDoc} */ public Map<String, String> getParameters() { return parameters; } /** {@inheritDoc} */ public String getSaveFilename() { if (recordingListener != null) { return recordingListener.get().getFileName(); } return null; } /** {@inheritDoc} */ public IClientBroadcastStreamStatistics getStatistics() { return this; } /** {@inheritDoc} */ public int getTotalSubscribers() { return subscriberStats.getTotal(); } /** * @return the automaticRecording */ public boolean isAutomaticRecording() { return automaticRecording; } /** * @param automaticRecording the automaticRecording to set */ public void setAutomaticRecording(boolean automaticRecording) { this.automaticRecording = automaticRecording; } /** * Notifies handler on stream broadcast close */ 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 recording stop */ private void notifyRecordingStop() { IStreamAwareScopeHandler handler = getStreamAwareHandler(); if (handler != null) { try { handler.streamRecordStop(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) { String target = oobCtrlMsg.getTarget(); if ("ClientBroadcastStream".equals(target)) { String serviceName = oobCtrlMsg.getServiceName(); if ("chunkSize".equals(serviceName)) { chunkSize = (Integer) oobCtrlMsg.getServiceParamMap().get("chunkSize"); notifyChunkSize(); } else { log.debug("Unhandled OOB control message for service: {}", serviceName); } } else { log.debug("Unhandled OOB control message to target: {}", target); } } /** * Pipe connection event handler * @param event Pipe connection event */ @SuppressWarnings("unused") public void onPipeConnectionEvent(PipeConnectionEvent event) { switch (event.getType()) { case PipeConnectionEvent.PROVIDER_CONNECT_PUSH: log.info("Provider connect"); if (event.getProvider() == this && event.getSource() != connMsgOut && (event.getParamMap() == null || !event.getParamMap().containsKey("record"))) { this.livePipe = (IPipe) event.getSource(); log.debug("Provider: {}", this.livePipe.getClass().getName()); for (IConsumer consumer : this.livePipe.getConsumers()) { subscriberStats.increment(); } } break; case PipeConnectionEvent.PROVIDER_DISCONNECT: log.info("Provider disconnect"); if (log.isDebugEnabled() && this.livePipe != null) { log.debug("Provider: {}", this.livePipe.getClass().getName()); } if (this.livePipe == event.getSource()) { this.livePipe = null; } break; case PipeConnectionEvent.CONSUMER_CONNECT_PUSH: log.info("Consumer connect"); IPipe pipe = (IPipe) event.getSource(); if (log.isDebugEnabled() && pipe != null) { log.debug("Consumer: {}", pipe.getClass().getName()); } if (this.livePipe == pipe) { notifyChunkSize(); } subscriberStats.increment(); break; case PipeConnectionEvent.CONSUMER_DISCONNECT: log.info("Consumer disconnect"); log.debug("Consumer: {}", event.getSource().getClass().getName()); subscriberStats.decrement(); break; default: } } /** * 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 */ public void saveAs(String name, boolean isAppend) throws IOException { log.debug("SaveAs - name: {} append: {}", name, isAppend); // get connection to check if client is still streaming IStreamCapableConnection conn = getConnection(); if (conn == null) { throw new IOException("Stream is no longer connected"); } // one recording listener at a time via this entry point if (recordingListener == null) { // create a recording listener RecordingListener listener = new RecordingListener(); // initialize the listener if (listener.init(conn, name, isAppend)) { // get decoder info if it exists for the stream IStreamCodecInfo codecInfo = getCodecInfo(); log.debug("Codec info: {}", codecInfo); if (codecInfo instanceof StreamCodecInfo) { StreamCodecInfo info = (StreamCodecInfo) codecInfo; IVideoStreamCodec videoCodec = info.getVideoCodec(); log.debug("Video codec: {}", videoCodec); if (videoCodec != null) { //check for decoder configuration to send IoBuffer config = videoCodec.getDecoderConfiguration(); if (config != null) { log.debug("Decoder configuration is available for {}", videoCodec.getName()); VideoData videoConf = new VideoData(config.asReadOnlyBuffer()); try { log.debug("Setting decoder configuration for recording"); listener.getFileConsumer().setVideoDecoderConfiguration(videoConf); } finally { videoConf.release(); } } } else { log.debug("Could not initialize stream output, videoCodec is null."); } IAudioStreamCodec audioCodec = info.getAudioCodec(); log.debug("Audio codec: {}", audioCodec); if (audioCodec != null) { //check for decoder configuration to send IoBuffer config = audioCodec.getDecoderConfiguration(); if (config != null) { log.debug("Decoder configuration is available for {}", audioCodec.getName()); AudioData audioConf = new AudioData(config.asReadOnlyBuffer()); try { log.debug("Setting decoder configuration for recording"); listener.getFileConsumer().setAudioDecoderConfiguration(audioConf); } finally { audioConf.release(); } } } else { log.debug("No decoder configuration available, audioCodec is null."); } } // set as primary listener recordingListener = new WeakReference<RecordingListener>(listener); // add as a listener addStreamListener(listener); // start the listener thread listener.start(); } else { log.warn("Recording listener failed to initialize for stream: {}", name); } } else { log.info("Recording listener already exists for stream: {}", name); } } /** * 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 stopMsg = new StatusMessage(); stopMsg.setBody(stopStatus); try { connMsgOut.pushMessage(stopMsg); } 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 (recordingListener != null && recordingListener.get().isRecording()) { // callback for record start ((IStreamAwareScopeHandler) handler).streamRecordStart(this); } else { // delete any previously recorded versions of this now "live" stream per // http://livedocs.adobe.com/flashmediaserver/3.0/hpdocs/help.html?content=00000186.html try { File file = getRecordFile(scope, publishedName); if (file != null && file.exists()) { if (!file.delete()) { log.debug("File was not deleted: {}", file.getAbsoluteFile()); } } } catch (Exception e) { log.warn("Exception removing previously recorded file", e); } // callback for publish start ((IStreamAwareScopeHandler) handler).streamPublishStart(this); } } } } // send start notifications sendPublishStartNotify(); if (recordingListener != null && recordingListener.get().isRecording()) { sendRecordStartNotify(); } notifyBroadcastStart(); } } /** * Starts stream. Creates pipes, connects */ public void start() { log.info("Stream start"); IConsumerService consumerManager = (IConsumerService) getScope().getContext().getBean(IConsumerService.KEY); checkVideoCodec = true; checkAudioCodec = true; firstPacketTime = -1; latestTimeStamp = -1; connMsgOut = consumerManager.getConsumerOutput(this); connMsgOut.subscribe(this, 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()); // force recording if set if (automaticRecording) { log.debug("Starting automatic recording of {}", publishedName); try { saveAs(publishedName, false); } catch (Exception e) { log.warn("Start of automatic recording failed", e); } } } /** {@inheritDoc} */ public void stop() { log.info("Stream stop"); stopRecording(); close(); } /** * Stops any currently active recordings. */ public void stopRecording() { if (recordingListener != null && recordingListener.get().isRecording()) { sendRecordStopNotify(); notifyRecordingStop(); } } public boolean isRecording() { return recordingListener != null && recordingListener.get().isRecording(); } /** {@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); } /** * Get the file we'd be recording to based on scope and given name. * * @param scope * @param name * @return file */ protected File getRecordFile(IScope scope, String name) { return RecordingListener.getRecordFile(scope, name); } protected void registerJMX() { // register with jmx MBeanServer mbs = ManagementFactory.getPlatformMBeanServer(); try { ObjectName oName = new ObjectName(String.format("org.red5.server:type=ClientBroadcastStream,scope=%s,publishedName=%s", getScope().getName(), publishedName)); mbs.registerMBean(new StandardMBean(this, ClientBroadcastStreamMXBean.class, true), oName); } catch (InstanceAlreadyExistsException e) { log.info("Instance already registered", e); } catch (Exception e) { log.warn("Error on jmx registration", e); } } protected void unregisterJMX() { if (StringUtils.isNotEmpty(publishedName) && !"false".equals(publishedName)) { MBeanServer mbs = ManagementFactory.getPlatformMBeanServer(); try { ObjectName oName = new ObjectName(String.format("org.red5.server:type=ClientBroadcastStream,scope=%s,publishedName=%s", getScope().getName(), publishedName)); mbs.unregisterMBean(oName); } catch (Exception e) { log.warn("Exception unregistering", e); } } } }