/*
* RED5 Open Source Flash Server - http://code.google.com/p/red5/
*
* Copyright 2006-2014 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.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.CopyOnWriteArraySet;
import org.red5.server.api.scheduling.IScheduledJob;
import org.red5.server.api.scheduling.ISchedulingService;
import org.red5.server.api.scope.IScope;
import org.red5.server.api.stream.IPlayItem;
import org.red5.server.api.stream.IPlaylistController;
import org.red5.server.api.stream.IServerStream;
import org.red5.server.api.stream.IStreamFilenameGenerator;
import org.red5.server.api.stream.IStreamFilenameGenerator.GenerationType;
import org.red5.server.api.stream.IStreamListener;
import org.red5.server.api.stream.IStreamPacket;
import org.red5.server.api.stream.ResourceExistException;
import org.red5.server.api.stream.ResourceNotFoundException;
import org.red5.server.messaging.IFilter;
import org.red5.server.messaging.IMessage;
import org.red5.server.messaging.IMessageComponent;
import org.red5.server.messaging.IMessageInput;
import org.red5.server.messaging.IMessageOutput;
import org.red5.server.messaging.IPassive;
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.VideoData;
import org.red5.server.stream.consumer.FileConsumer;
import org.red5.server.stream.message.RTMPMessage;
import org.red5.server.stream.message.ResetMessage;
import org.red5.server.util.ScopeUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.io.Resource;
/**
* An implementation for server side stream.
*
* @author The Red5 Project
*/
public class NoSyncServerStream extends AbstractStream implements IServerStream, IFilter, IPushableConsumer, IPipeConnectionListener {
/**
* Enumeration for states
*/
private enum State {
CLOSED, PLAYING, STOPPED, UNINIT, PAUSED
}
/**
* Logger
*/
private static final Logger log = LoggerFactory.getLogger(NoSyncServerStream.class);
/**
* Actual playlist controller
*/
private IPlaylistController controller;
/**
* Current item
*/
private IPlayItem currentItem;
/**
* Current item index
*/
private int currentItemIndex;
/**
* Default playlist controller
*/
private IPlaylistController defaultController;
/**
* Random flag state
*/
private boolean isRandom;
/**
* Repeat flag state
*/
private boolean isRepeat;
/**
* Rewind flag state
*/
private boolean isRewind;
/**
* List of items in this playlist
*/
private List<IPlayItem> items;
/**
* Live broadcasting scheduled job name
*/
private String liveJobName;
/**
* Message input
*/
private IMessageInput msgIn;
/**
* Message output
*/
private IMessageOutput msgOut;
/**
* Next msg's audio timestamp
*/
private long nextAudioTS;
/**
* Next msg's data timestamp
*/
private long nextDataTS;
/**
* Next RTMP message
*/
private RTMPMessage nextRTMPMessage;
/**
* Next msg's timestamp
*/
private long nextTS;
/**
* Next msg's video timestamp
*/
private long nextVideoTS;
/**
* Stream published name
*/
private String publishedName;
/**
* The filename we are recording to.
*/
private String recordingFilename;
/**
* Pipe for recording
*/
private IPipe recordPipe;
/**
* Scheduling service
*/
private ISchedulingService scheduler;
/**
* Server start timestamp
*/
private long serverStartTS;
/**
* Current state
*/
private State state;
/**
* VOD scheduled job name
*/
private String vodJobName;
/**
* VOD start timestamp
*/
private long vodStartTS;
/** Listeners to get notified about received packets. */
private Set<IStreamListener> listeners = new CopyOnWriteArraySet<IStreamListener>();
/** Constructs a new ServerStream. */
public NoSyncServerStream() {
defaultController = new SimplePlaylistController();
items = new CopyOnWriteArrayList<IPlayItem>();
state = State.UNINIT;
}
/** {@inheritDoc} */
public void addItem(IPlayItem item) {
items.add(item);
}
/** {@inheritDoc} */
public void addItem(IPlayItem item, int index) {
items.add(index, item);
}
/** {@inheritDoc} */
public void close() {
if (state == State.PLAYING || state == State.PAUSED) {
stop();
}
if (msgOut != null) {
msgOut.unsubscribe(this);
}
recordPipe.unsubscribe((IProvider) this);
state = State.CLOSED;
}
/** {@inheritDoc} */
public IPlayItem getCurrentItem() {
return currentItem;
}
/** {@inheritDoc} */
public int getCurrentItemIndex() {
return currentItemIndex;
}
/** {@inheritDoc} */
public IPlayItem getItem(int index) {
try {
return items.get(index);
} catch (IndexOutOfBoundsException e) {
return null;
}
}
/** {@inheritDoc} */
public int getItemSize() {
return items.size();
}
/**
* Getter for next RTMP message.
*
* @return Next RTMP message
*/
private RTMPMessage getNextRTMPMessage() {
IMessage message;
do {
// Pull message from message input object...
try {
message = msgIn.pullMessage();
} catch (IOException err) {
log.error("Error while pulling message.", err);
message = null;
}
// If message is null then return null
if (message == null) {
return null;
}
} while (!(message instanceof RTMPMessage));
// Cast and return
return (RTMPMessage) message;
}
/** {@inheritDoc} */
public IProvider getProvider() {
return this;
}
/** {@inheritDoc} */
public String getPublishedName() {
return publishedName;
}
/** {@inheritDoc} */
public String getSaveFilename() {
return recordingFilename;
}
/** {@inheritDoc} */
public boolean hasMoreItems() {
int nextItem = currentItemIndex + 1;
if (nextItem >= items.size() && !isRepeat) {
return false;
} else {
return true;
}
}
/** {@inheritDoc} */
public boolean isRandom() {
return isRandom;
}
/** {@inheritDoc} */
public boolean isRepeat() {
return isRepeat;
}
/** {@inheritDoc} */
public boolean isRewind() {
return isRewind;
}
/**
* Move to the next item updating the currentItemIndex. Should be called
* in context.
*/
private void moveToNext() {
if (currentItemIndex >= items.size()) {
currentItemIndex = items.size() - 1;
}
if (controller != null) {
currentItemIndex = controller.nextItem(this, currentItemIndex);
} else {
currentItemIndex = defaultController.nextItem(this, currentItemIndex);
}
}
/**
* Move to the previous item updating the currentItemIndex. Should be
* called in context.
*/
private void moveToPrevious() {
if (currentItemIndex >= items.size()) {
currentItemIndex = items.size() - 1;
}
if (controller != null) {
currentItemIndex = controller.previousItem(this, currentItemIndex);
} else {
currentItemIndex = defaultController.previousItem(this, currentItemIndex);
}
}
/** {@inheritDoc} */
public void nextItem() {
stop();
moveToNext();
if (currentItemIndex == -1) {
return;
}
IPlayItem item = items.get(currentItemIndex);
play(item);
}
/**
* Play next item on item end
*/
private void onItemEnd() {
nextItem();
}
/** {@inheritDoc} */
public void onOOBControlMessage(IMessageComponent source, IPipe pipe, OOBControlMessage oobCtrlMsg) {
}
/**
* Pipe connection event handler. There are two types of pipe connection
* events so far, provider push connection event and provider
* disconnection event.
*
* Pipe events handling is the most common way of working with pipes.
*
* @param event Pipe connection event context
*/
public void onPipeConnectionEvent(PipeConnectionEvent event) {
switch (event.getType()) {
case PipeConnectionEvent.PROVIDER_CONNECT_PUSH:
if (event.getProvider() == this && (event.getParamMap() == null || !event.getParamMap().containsKey("record"))) {
this.msgOut = (IMessageOutput) event.getSource();
}
break;
case PipeConnectionEvent.PROVIDER_DISCONNECT:
if (this.msgOut == event.getSource()) {
this.msgOut = null;
}
break;
default:
break;
}
}
/**
* Play a specific IPlayItem. The strategy for now is VOD first, Live
* second. Should be called in a context.
*
* @param item
* Item to play
*/
private void play(IPlayItem item) {
// Return if already playing
if (state != State.STOPPED) {
return;
}
// Assume this is not live stream
boolean isLive = false;
// Get provider service from Spring bean factory
IProviderService providerService = (IProviderService) getScope().getContext().getBean(IProviderService.BEAN_NAME);
msgIn = providerService.getVODProviderInput(getScope(), item.getName());
if (msgIn == null) {
msgIn = providerService.getLiveProviderInput(getScope(), item.getName(), true);
isLive = true;
}
if (msgIn == null) {
log.warn("ABNORMAL Can't get both VOD and Live input from providerService");
return;
}
state = State.PLAYING;
currentItem = item;
sendResetMessage();
msgIn.subscribe(this, null);
if (isLive) {
if (item.getLength() >= 0) {
liveJobName = scheduler.addScheduledOnceJob(item.getLength(), new IScheduledJob() {
/** {@inheritDoc} */
public void execute(ISchedulingService service) {
if (liveJobName == null) {
return;
}
liveJobName = null;
onItemEnd();
}
});
}
} else {
long start = item.getStart();
if (start < 0) {
start = 0;
}
sendVODInitCM(msgIn, (int) start);
startBroadcastVOD();
}
}
/** {@inheritDoc} */
public void previousItem() {
stop();
moveToPrevious();
if (currentItemIndex == -1) {
return;
}
IPlayItem item = items.get(currentItemIndex);
play(item);
}
/**
* Push message
*
* @param message
* Message
*/
private void pushMessage(IMessage message) throws IOException {
msgOut.pushMessage(message);
recordPipe.pushMessage(message);
// Notify listeners about received packet
if (message instanceof RTMPMessage) {
final IRTMPEvent rtmpEvent = ((RTMPMessage) message).getBody();
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 void pushMessage(IPipe pipe, IMessage message) throws IOException {
pushMessage(message);
}
/** {@inheritDoc} */
public void removeAllItems() {
items.clear();
}
/** {@inheritDoc} */
public void removeItem(int index) {
if (index < 0 || index >= items.size()) {
return;
}
items.remove(index);
}
/** {@inheritDoc} */
public void saveAs(String name, boolean isAppend) throws IOException, ResourceNotFoundException, ResourceExistException {
try {
IScope scope = getScope();
IStreamFilenameGenerator generator = (IStreamFilenameGenerator) ScopeUtils.getScopeService(scope, IStreamFilenameGenerator.class, DefaultStreamFilenameGenerator.class);
String filename = generator.generateFilename(scope, name, ".flv", GenerationType.RECORD);
Resource res = scope.getContext().getResource(filename);
if (!isAppend) {
if (res.exists()) {
// Per livedoc of FCS/FMS:
// When "live" or "record" is used,
// any previously recorded stream with the same stream
// URI is deleted.
if (!res.getFile().delete())
throw new IOException("file could not be deleted");
}
} else {
if (!res.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 (!res.exists()) {
// Make sure the destination directory exists
try {
String path = res.getFile().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();
}
} catch (IOException err) {
log.error("Could not create destination directory.", err);
}
res = scope.getResource(filename);
}
if (!res.exists()) {
if (!res.getFile().canWrite()) {
log.warn("File cannot be written to " + res.getFile().getCanonicalPath());
}
res.getFile().createNewFile();
}
FileConsumer fc = new FileConsumer(scope, res.getFile());
Map<String, Object> paramMap = new HashMap<String, Object>();
if (isAppend) {
paramMap.put("mode", "append");
} else {
paramMap.put("mode", "record");
}
if (null == recordPipe) {
recordPipe = new InMemoryPushPushPipe();
}
recordPipe.subscribe(fc, paramMap);
recordingFilename = filename;
} catch (IOException e) {
log.warn("Save as exception", e);
}
}
/**
* Pull the next message from IMessageInput and schedule it for push
* according to the timestamp.
*/
private void scheduleNextMessage() {
boolean first = nextRTMPMessage == null;
nextRTMPMessage = getNextRTMPMessage();
if (nextRTMPMessage == null) {
onItemEnd();
return;
}
IRTMPEvent rtmpEvent = null;
if (first) {
rtmpEvent = nextRTMPMessage.getBody();
// FIXME hack the first Metadata Tag from FLVReader
// the FLVReader will issue a metadata tag of ts 0
// even if it is seeked to somewhere in the middle
// which will cause the stream to wait too long.
// Is this an FLVReader's bug?
if (!(rtmpEvent instanceof VideoData) && !(rtmpEvent instanceof AudioData) && rtmpEvent.getTimestamp() == 0) {
rtmpEvent.release();
nextRTMPMessage = getNextRTMPMessage();
if (nextRTMPMessage == null) {
onItemEnd();
return;
}
}
}
rtmpEvent = nextRTMPMessage.getBody();
if (rtmpEvent instanceof VideoData) {
nextVideoTS = rtmpEvent.getTimestamp();
nextTS = nextVideoTS;
} else if (rtmpEvent instanceof AudioData) {
nextAudioTS = rtmpEvent.getTimestamp();
nextTS = nextAudioTS;
} else {
nextDataTS = rtmpEvent.getTimestamp();
nextTS = nextDataTS;
}
if (first) {
vodStartTS = nextTS;
}
long delta = nextTS - vodStartTS - (System.currentTimeMillis() - serverStartTS);
vodJobName = scheduler.addScheduledOnceJob(delta, new IScheduledJob() {
/** {@inheritDoc} */
public void execute(ISchedulingService service) {
if (vodJobName == null) {
return;
}
vodJobName = null;
try {
pushMessage(nextRTMPMessage);
} catch (IOException err) {
log.error("Error while sending message.", err);
}
nextRTMPMessage.getBody().release();
long start = currentItem.getStart();
if (start < 0) {
start = 0;
}
if (currentItem.getLength() >= 0 && nextTS - currentItem.getStart() > currentItem.getLength()) {
onItemEnd();
return;
}
scheduleNextMessage();
}
});
}
/**
* Send reset message
*/
private void sendResetMessage() {
// Send new reset message
try {
pushMessage(new ResetMessage());
} catch (IOException err) {
log.error("Error while sending reset message.", err);
}
}
/**
* Send VOD initialization control message
*
* @param msgIn
* Message input
* @param start
* Start timestamp
*/
private void sendVODInitCM(IMessageInput msgIn, int start) {
// Create new out-of-band control message
OOBControlMessage oobCtrlMsg = new OOBControlMessage();
// Set passive type
oobCtrlMsg.setTarget(IPassive.KEY);
// Set service name of init
oobCtrlMsg.setServiceName("init");
// Create map for parameters
Map<String, Object> paramMap = new HashMap<String, Object>();
// Put start timestamp into Map of params
paramMap.put("startTS", start);
// Attach to OOB control message and send it
oobCtrlMsg.setServiceParamMap(paramMap);
msgIn.sendOOBControlMessage(this, oobCtrlMsg);
}
/** {@inheritDoc} */
public void setItem(int index) {
if (index < 0 || index >= items.size()) {
return;
}
stop();
currentItemIndex = index;
IPlayItem item = items.get(currentItemIndex);
play(item);
}
/** {@inheritDoc} */
public void setPlaylistController(IPlaylistController controller) {
this.controller = controller;
}
/** {@inheritDoc} */
public void setPublishedName(String name) {
publishedName = name;
}
/** {@inheritDoc} */
public void setRandom(boolean random) {
isRandom = random;
}
/** {@inheritDoc} */
public void setRepeat(boolean repeat) {
isRepeat = repeat;
}
/** {@inheritDoc} */
public void setRewind(boolean rewind) {
isRewind = rewind;
}
/**
* Start this server-side stream
*/
public void start() {
if (state != State.UNINIT) {
throw new IllegalStateException("State " + state + " not valid to start");
}
if (items.size() == 0) {
throw new IllegalStateException("At least one item should be specified to start");
}
if (publishedName == null) {
throw new IllegalStateException("A published name is needed to start");
}
// publish this server-side stream
IProviderService providerService = (IProviderService) getScope().getContext().getBean(IProviderService.BEAN_NAME);
providerService.registerBroadcastStream(getScope(), publishedName, this);
Map<String, Object> recordParamMap = new HashMap<String, Object>();
recordPipe = new InMemoryPushPushPipe();
recordParamMap.put("record", null);
recordPipe.subscribe((IProvider) this, recordParamMap);
recordingFilename = null;
scheduler = (ISchedulingService) getScope().getContext().getBean(ISchedulingService.BEAN_NAME);
state = State.STOPPED;
currentItemIndex = -1;
nextItem();
}
/**
* Begin VOD broadcasting
*/
private void startBroadcastVOD() {
nextVideoTS = nextAudioTS = nextDataTS = 0;
nextRTMPMessage = null;
vodStartTS = 0;
serverStartTS = System.currentTimeMillis();
scheduleNextMessage();
}
/**
* Stop this server-side stream
*/
public void stop() {
if (state != State.PLAYING && state != State.PAUSED) {
return;
}
if (liveJobName != null) {
scheduler.removeScheduledJob(liveJobName);
liveJobName = null;
}
if (vodJobName != null) {
scheduler.removeScheduledJob(vodJobName);
vodJobName = null;
}
if (msgIn != null) {
msgIn.unsubscribe(this);
msgIn = null;
}
if (nextRTMPMessage != null) {
nextRTMPMessage.getBody().release();
}
state = State.STOPPED;
}
/** {@inheritDoc} */
public void pause() {
if (state == State.PLAYING) {
state = State.PAUSED;
} else if (state == State.PAUSED) {
state = State.PLAYING;
vodStartTS = 0;
serverStartTS = System.currentTimeMillis();
scheduleNextMessage();
}
}
/** {@inheritDoc} */
public void seek(int position) {
if (state != State.PLAYING && state != State.PAUSED)
// Can't seek when stopped/closed
return;
sendVODSeekCM(msgIn, position);
}
/**
* Send VOD seek control message
*
* @param msgIn Message input
* @param position New timestamp to play from
*/
private void sendVODSeekCM(IMessageInput msgIn, int position) {
OOBControlMessage oobCtrlMsg = new OOBControlMessage();
oobCtrlMsg.setTarget(ISeekableProvider.KEY);
oobCtrlMsg.setServiceName("seek");
Map<String, Object> paramMap = new HashMap<String, Object>();
paramMap.put("position", new Integer(position));
oobCtrlMsg.setServiceParamMap(paramMap);
msgIn.sendOOBControlMessage(this, oobCtrlMsg);
synchronized (this) {
// Reset properties
vodStartTS = 0;
serverStartTS = System.currentTimeMillis();
if (nextRTMPMessage != null) {
try {
pushMessage(nextRTMPMessage);
} catch (IOException err) {
log.error("Error while sending message.", err);
}
nextRTMPMessage.getBody().release();
nextRTMPMessage = null;
}
ResetMessage reset = new ResetMessage();
try {
pushMessage(reset);
} catch (IOException err) {
log.error("Error while sending message.", err);
}
scheduleNextMessage();
}
}
/** {@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);
}
}