/*
* 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.io.flv.impl;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.RandomAccessFile;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Semaphore;
import org.apache.mina.core.buffer.IoBuffer;
import org.red5.io.BufferType;
import org.red5.io.IKeyFrameMetaCache;
import org.red5.io.IStreamableFile;
import org.red5.io.ITag;
import org.red5.io.ITagReader;
import org.red5.io.IoConstants;
import org.red5.io.amf.Input;
import org.red5.io.amf.Output;
import org.red5.io.flv.FLVHeader;
import org.red5.io.flv.IKeyFrameDataAnalyzer;
import org.red5.io.object.Deserializer;
import org.red5.io.object.Serializer;
import org.red5.io.utils.IOUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* A Reader is used to read the contents of a FLV file.
* NOTE: This class is not implemented as threading-safe. The caller
* should make sure the threading-safety.
*
* @author The Red5 Project (red5@osflash.org)
* @author Dominick Accattato (daccattato@gmail.com)
* @author Luke Hubbard, Codegent Ltd (luke@codegent.com)
* @author Paul Gregoire, (mondain@gmail.com)
*/
public class FLVReader implements IoConstants, ITagReader, IKeyFrameDataAnalyzer {
/**
* Logger
*/
private static Logger log = LoggerFactory.getLogger(FLVReader.class);
/**
* File
*/
private File file;
/**
* File input stream
*/
private FileInputStream fis;
/**
* File channel
*/
private FileChannel channel;
private long channelSize;
/**
* Keyframe metadata
*/
private KeyFrameMeta keyframeMeta;
/**
* Input byte buffer
*/
private IoBuffer in;
/** Set to true to generate metadata automatically before the first tag. */
private boolean generateMetadata;
/** Position of first video tag. */
private long firstVideoTag = -1;
/** Position of first audio tag. */
private long firstAudioTag = -1;
/** metadata sent flag */
private boolean metadataSent = false;
/** Duration in milliseconds. */
private long duration;
/** Mapping between file position and timestamp in ms. */
private HashMap<Long, Long> posTimeMap;
/** Buffer type / style to use **/
private static BufferType bufferType = BufferType.AUTO;
private static int bufferSize = 1024;
/** Use load buffer */
private boolean useLoadBuf;
/** Cache for keyframe informations. */
private static IKeyFrameMetaCache keyframeCache;
/** The header of this FLV file. */
private FLVHeader header;
private final Semaphore lock = new Semaphore(1, true);
/** Constructs a new FLVReader. */
FLVReader() {
}
/**
* Creates FLV reader from file input stream.
*
* @param f File
* @throws IOException on error
*/
public FLVReader(File f) throws IOException {
this(f, false);
}
/**
* Creates FLV reader from file input stream, sets up metadata generation flag.
*
* @param f File input stream
* @param generateMetadata <code>true</code> if metadata generation required, <code>false</code> otherwise
* @throws IOException on error
*/
public FLVReader(File f, boolean generateMetadata) throws IOException {
if (null == f) {
log.warn("Reader was passed a null file");
log.debug("{}", org.apache.commons.lang3.builder.ToStringBuilder.reflectionToString(this));
}
this.file = f;
this.fis = new FileInputStream(f);
this.generateMetadata = generateMetadata;
channel = fis.getChannel();
channelSize = channel.size();
in = null;
fillBuffer();
postInitialize();
}
/**
* Creates FLV reader from file channel.
*
* @param channel
* @throws IOException on error
*/
public FLVReader(FileChannel channel) throws IOException {
if (null == channel) {
log.warn("Reader was passed a null channel");
log.debug("{}", org.apache.commons.lang3.builder.ToStringBuilder.reflectionToString(this));
}
if (!channel.isOpen()) {
log.warn("Reader was passed a closed channel");
return;
}
this.channel = channel;
channelSize = channel.size();
log.debug("Channel size: {}", channelSize);
if (channel.position() > 0) {
log.debug("Channel position: {}", channel.position());
channel.position(0);
}
fillBuffer();
postInitialize();
}
/**
* Accepts mapped file bytes to construct internal members.
*
* @param generateMetadata <code>true</code> if metadata generation required, <code>false</code> otherwise
* @param buffer IoBuffer
*/
public FLVReader(IoBuffer buffer, boolean generateMetadata) {
this.generateMetadata = generateMetadata;
in = buffer;
postInitialize();
}
public void setKeyFrameCache(IKeyFrameMetaCache keyframeCache) {
FLVReader.keyframeCache = keyframeCache;
}
/**
* Get the remaining bytes that could be read from a file or ByteBuffer.
*
* @return Number of remaining bytes
*/
private long getRemainingBytes() {
if (!useLoadBuf) {
return in.remaining();
}
try {
return channelSize - channel.position() + in.remaining();
} catch (Exception e) {
log.error("Error getRemainingBytes", e);
return 0;
}
}
/**
* Get the total readable bytes in a file or ByteBuffer.
*
* @return Total readable bytes
*/
public long getTotalBytes() {
if (!useLoadBuf) {
return in.capacity();
}
try {
return channelSize;
} catch (Exception e) {
log.error("Error getTotalBytes", e);
return 0;
}
}
/**
* Get the current position in a file or ByteBuffer.
*
* @return Current position in a file
*/
private long getCurrentPosition() {
long pos;
if (!useLoadBuf) {
return in.position();
}
try {
if (in != null) {
pos = (channel.position() - in.remaining());
} else {
pos = channel.position();
}
return pos;
} catch (Exception e) {
log.error("Error getCurrentPosition", e);
return 0;
}
}
/**
* Modifies current position.
*
* @param pos Current position in file
*/
private void setCurrentPosition(long pos) {
if (pos == Long.MAX_VALUE) {
pos = file.length();
}
if (!useLoadBuf) {
in.position((int) pos);
return;
}
try {
if (pos >= (channel.position() - in.limit()) && pos < channel.position()) {
in.position((int) (pos - (channel.position() - in.limit())));
} else {
channel.position(pos);
fillBuffer(bufferSize, true);
}
} catch (Exception e) {
log.error("Error setCurrentPosition", e);
}
}
/**
* Loads whole buffer from file channel, with no reloading (that is, appending).
*/
private void fillBuffer() {
fillBuffer(bufferSize, false);
}
/**
* Loads data from channel to buffer.
*
* @param amount Amount of data to load with no reloading
*/
private void fillBuffer(long amount) {
fillBuffer(amount, false);
}
/**
* Load enough bytes from channel to buffer.
* After the loading process, the caller can make sure the amount
* in buffer is of size 'amount' if we haven't reached the end of channel.
*
* @param amount The amount of bytes in buffer after returning,
* no larger than bufferSize
* @param reload Whether to reload or append
*/
private void fillBuffer(long amount, boolean reload) {
try {
if (amount > bufferSize) {
amount = bufferSize;
}
log.debug("Buffering amount: {} buffer size: {}", amount, bufferSize);
// Read all remaining bytes if the requested amount reach the end
// of channel.
if (channelSize - channel.position() < amount) {
amount = channelSize - channel.position();
}
if (in == null) {
switch (bufferType) {
case HEAP:
in = IoBuffer.allocate(bufferSize, false);
break;
case DIRECT:
in = IoBuffer.allocate(bufferSize, true);
break;
default:
in = IoBuffer.allocate(bufferSize);
}
channel.read(in.buf());
in.flip();
useLoadBuf = true;
}
if (!useLoadBuf) {
return;
}
if (reload || in.remaining() < amount) {
if (!reload) {
in.compact();
} else {
in.clear();
}
channel.read(in.buf());
in.flip();
}
} catch (Exception e) {
log.error("Error fillBuffer", e);
}
}
/**
* Post-initialization hook, reads keyframe metadata and decodes header (if any).
*/
private void postInitialize() {
if (log.isDebugEnabled()) {
log.debug("FLVReader 1 - Buffer size: {} position: {} remaining: {}", new Object[] { getTotalBytes(), getCurrentPosition(), getRemainingBytes() });
}
if (getRemainingBytes() >= 9) {
decodeHeader();
}
if (file != null) {
keyframeMeta = analyzeKeyFrames();
}
long old = getCurrentPosition();
log.debug("Position: {}", old);
}
/** {@inheritDoc} */
public boolean hasVideo() {
KeyFrameMeta meta = analyzeKeyFrames();
if (meta == null) {
return false;
}
return (!meta.audioOnly && meta.positions.length > 0);
}
/**
* Getter for buffer type (auto, direct or heap).
*
* @return Value for property 'bufferType'
*/
public static String getBufferType() {
switch (bufferType) {
case AUTO:
return "auto";
case DIRECT:
return "direct";
case HEAP:
return "heap";
default:
return null;
}
}
/**
* Setter for buffer type.
*
* @param bufferType Value to set for property 'bufferType'
*/
public static void setBufferType(String bufferType) {
int bufferTypeHash = bufferType.hashCode();
switch (bufferTypeHash) {
case 3198444: //heap
//Get a heap buffer from buffer pool
FLVReader.bufferType = BufferType.HEAP;
break;
case -1331586071: //direct
//Get a direct buffer from buffer pool
FLVReader.bufferType = BufferType.DIRECT;
break;
case 3005871: //auto
//Let MINA choose
default:
FLVReader.bufferType = BufferType.AUTO;
}
}
/**
* Getter for buffer size.
*
* @return Value for property 'bufferSize'
*/
public static int getBufferSize() {
return bufferSize;
}
/**
* Setter for property 'bufferSize'.
*
* @param bufferSize Value to set for property 'bufferSize'
*/
public static void setBufferSize(int bufferSize) {
// make sure buffer size is no less than 1024 bytes.
if (bufferSize < 1024) {
bufferSize = 1024;
}
FLVReader.bufferSize = bufferSize;
}
/**
* Returns the file buffer.
*
* @return File contents as byte buffer
*/
public IoBuffer getFileData() {
// TODO as of now, return null will disable cache
// we need to redesign the cache architecture so that
// the cache is layered underneath FLVReader not above it,
// thus both tag cache and file cache are feasible.
return null;
}
/** {@inheritDoc} */
public void decodeHeader() {
// flv header is 9 bytes
fillBuffer(9);
header = new FLVHeader();
// skip signature
in.skip(4);
header.setTypeFlags(in.get());
header.setDataOffset(in.getInt());
if (log.isDebugEnabled()) {
log.debug("Header: {}", header.toString());
}
}
/** {@inheritDoc}
*/
public IStreamableFile getFile() {
// TODO wondering if we need to have a reference
return null;
}
/** {@inheritDoc}
*/
public int getOffset() {
// XXX what's the difference from getBytesRead
return 0;
}
/** {@inheritDoc}
*/
public long getBytesRead() {
// XXX should summarize the total bytes read or
// just the current position?
return getCurrentPosition();
}
/** {@inheritDoc} */
public long getDuration() {
return duration;
}
public int getVideoCodecId() {
if (keyframeMeta != null) {
return keyframeMeta.videoCodecId;
}
return -1;
}
public int getAudioCodecId() {
if (keyframeMeta != null) {
return keyframeMeta.audioCodecId;
}
return -1;
}
/** {@inheritDoc}
*/
public boolean hasMoreTags() {
return getRemainingBytes() > 4;
}
/**
* Create tag for metadata event.
*
* @return Metadata event tag
*/
private ITag createFileMeta() {
// Create tag for onMetaData event
IoBuffer buf = IoBuffer.allocate(192);
buf.setAutoExpand(true);
Output out = new Output(buf);
// Duration property
out.writeString("onMetaData");
Map<Object, Object> props = new HashMap<Object, Object>();
props.put("duration", duration / 1000.0);
if (firstVideoTag != -1) {
long old = getCurrentPosition();
setCurrentPosition(firstVideoTag);
readTagHeader();
fillBuffer(1);
byte frametype = in.get();
// Video codec id
props.put("videocodecid", frametype & MASK_VIDEO_CODEC);
setCurrentPosition(old);
}
if (firstAudioTag != -1) {
long old = getCurrentPosition();
setCurrentPosition(firstAudioTag);
readTagHeader();
fillBuffer(1);
byte frametype = in.get();
// Audio codec id
props.put("audiocodecid", (frametype & MASK_SOUND_FORMAT) >> 4);
setCurrentPosition(old);
}
props.put("canSeekToEnd", true);
out.writeMap(props, new Serializer());
buf.flip();
ITag result = new Tag(IoConstants.TYPE_METADATA, 0, buf.limit(), null, 0);
result.setBody(buf);
//
out = null;
return result;
}
/** {@inheritDoc} */
public ITag readTag() {
ITag tag = null;
try {
lock.acquire();
long oldPos = getCurrentPosition();
tag = readTagHeader();
if (tag != null) {
boolean isMetaData = tag.getDataType() == TYPE_METADATA;
log.debug("readTag, oldPos: {}, tag header: \n{}", oldPos, tag);
if (!metadataSent && !isMetaData && generateMetadata) {
// Generate initial metadata automatically
setCurrentPosition(oldPos);
KeyFrameMeta meta = analyzeKeyFrames();
if (meta != null) {
return createFileMeta();
}
}
int bodySize = tag.getBodySize();
IoBuffer body = IoBuffer.allocate(bodySize, false);
// XXX Paul: this assists in 'properly' handling damaged FLV files
long newPosition = getCurrentPosition() + bodySize;
if (newPosition <= getTotalBytes()) {
int limit;
while (getCurrentPosition() < newPosition) {
fillBuffer(newPosition - getCurrentPosition());
if (getCurrentPosition() + in.remaining() > newPosition) {
limit = in.limit();
in.limit((int) (newPosition - getCurrentPosition()) + in.position());
body.put(in);
in.limit(limit);
} else {
body.put(in);
}
}
body.flip();
tag.setBody(body);
}
if (isMetaData) {
metadataSent = true;
}
} else {
log.debug("Tag was null");
}
} catch (InterruptedException e) {
log.warn("Exception acquiring lock", e);
} finally {
lock.release();
}
return tag;
}
/** {@inheritDoc}
*/
public void close() {
log.debug("Reader close");
if (in != null) {
in.free();
in = null;
}
if (channel != null) {
try {
channel.close();
fis.close();
} catch (IOException e) {
log.error("FLVReader :: close ::>\n", e);
}
}
}
/**
* Key frames analysis may be used as a utility method so
* synchronize it.
*
* @return Keyframe metadata
*/
public KeyFrameMeta analyzeKeyFrames() {
if (keyframeMeta != null) {
return keyframeMeta;
}
try {
lock.acquire();
// check for cached keyframe informations
if (keyframeCache != null) {
keyframeMeta = keyframeCache.loadKeyFrameMeta(file);
if (keyframeMeta != null) {
// Keyframe data loaded, create other mappings
duration = keyframeMeta.duration;
posTimeMap = new HashMap<Long, Long>();
for (int i = 0; i < keyframeMeta.positions.length; i++) {
posTimeMap.put(keyframeMeta.positions[i], (long) keyframeMeta.timestamps[i]);
}
return keyframeMeta;
}
}
// create a holder for the metadata
keyframeMeta = new KeyFrameMeta();
// Lists of video positions and timestamps
List<Long> positionList = new ArrayList<Long>();
List<Integer> timestampList = new ArrayList<Integer>();
// Lists of audio positions and timestamps
List<Long> audioPositionList = new ArrayList<Long>();
List<Integer> audioTimestampList = new ArrayList<Integer>();
long origPos = getCurrentPosition();
// point to the first tag
setCurrentPosition(9);
// number of tags read
int totalValidTags = 0;
// start off as audio only
boolean audioOnly = true;
while (hasMoreTags()) {
long pos = getCurrentPosition();
// Read tag header and duration
ITag tmpTag = this.readTagHeader();
if (tmpTag != null) {
totalValidTags++;
} else {
break;
}
duration = tmpTag.getTimestamp();
if (tmpTag.getDataType() == IoConstants.TYPE_VIDEO) {
if (audioOnly) {
audioOnly = false;
audioPositionList.clear();
audioTimestampList.clear();
}
if (firstVideoTag == -1) {
firstVideoTag = pos;
}
// Grab Frame type
fillBuffer(1);
byte frametype = in.get();
if (keyframeMeta.videoCodecId == -1) {
keyframeMeta.videoCodecId = frametype & MASK_VIDEO_CODEC;
}
if (((frametype & MASK_VIDEO_FRAMETYPE) >> 4) == FLAG_FRAMETYPE_KEYFRAME) {
positionList.add(pos);
timestampList.add(tmpTag.getTimestamp());
}
} else if (tmpTag.getDataType() == IoConstants.TYPE_AUDIO) {
if (firstAudioTag == -1) {
firstAudioTag = pos;
}
// Grab Frame type
fillBuffer(1);
byte frametype = in.get();
if (keyframeMeta.audioCodecId == -1) {
keyframeMeta.audioCodecId = frametype & MASK_SOUND_FORMAT;
}
if (audioOnly) {
audioPositionList.add(pos);
audioTimestampList.add(tmpTag.getTimestamp());
}
}
// XXX Paul: this 'properly' handles damaged FLV files - as far as
// duration/size is concerned
long newPosition = pos + tmpTag.getBodySize() + 15;
// log.debug("---->" + in.remaining() + " limit=" + in.limit() + "
// new pos=" + newPosition);
if (newPosition >= getTotalBytes()) {
log.error("New position exceeds limit");
if (log.isDebugEnabled()) {
log.debug("-----");
log.debug("Keyframe analysis");
log.debug(" data type=" + tmpTag.getDataType() + " bodysize=" + tmpTag.getBodySize());
log.debug(" remaining=" + getRemainingBytes() + " limit=" + getTotalBytes() + " new pos=" + newPosition);
log.debug(" pos=" + pos);
log.debug("-----");
}
//XXX Paul: A runtime exception is probably not needed here
log.info("New position {} exceeds limit {}", newPosition, getTotalBytes());
//just break from the loop
break;
} else {
setCurrentPosition(newPosition);
}
}
// restore the pos
setCurrentPosition(origPos);
log.debug("Total valid tags found: {}", totalValidTags);
keyframeMeta.duration = duration;
posTimeMap = new HashMap<Long, Long>();
if (audioOnly) {
// The flv only contains audio tags, use their lists
// to support pause and seeking
positionList = audioPositionList;
timestampList = audioTimestampList;
}
keyframeMeta.audioOnly = audioOnly;
keyframeMeta.positions = new long[positionList.size()];
keyframeMeta.timestamps = new int[timestampList.size()];
for (int i = 0; i < keyframeMeta.positions.length; i++) {
keyframeMeta.positions[i] = positionList.get(i);
keyframeMeta.timestamps[i] = timestampList.get(i);
posTimeMap.put((long) positionList.get(i), (long) timestampList.get(i));
}
if (keyframeCache != null) {
keyframeCache.saveKeyFrameMeta(file, keyframeMeta);
}
} catch (InterruptedException e) {
log.warn("Exception acquiring lock", e);
} finally {
lock.release();
}
return keyframeMeta;
}
/**
* Put the current position to pos.
* The caller must ensure the pos is a valid one
* (eg. not sit in the middle of a frame).
*
* @param pos New position in file. Pass <code>Long.MAX_VALUE</code> to seek to end of file.
*/
public void position(long pos) {
setCurrentPosition(pos);
}
/**
* Read only header part of a tag.
*
* @return Tag header
*/
private ITag readTagHeader() {
// previous tag size (4 bytes) + flv tag header size (11 bytes)
fillBuffer(15);
// if (log.isDebugEnabled()) {
// in.mark();
// StringBuilder sb = new StringBuilder();
// HexDump.dumpHex(sb, in.array());
// log.debug("\n{}", sb);
// in.reset();
// }
// previous tag's size
int previousTagSize = in.getInt();
// start of the flv tag
byte dataType = in.get();
// loop counter
int i = 0;
while (dataType != 8 && dataType != 9 && dataType != 18) {
log.debug("Invalid data type detected, reading ahead");
log.debug("Current position: {} limit: {}", in.position(), in.limit());
// only allow 10 loops
if (i++ > 10) {
return null;
}
// move ahead and see if we get a valid datatype
dataType = in.get();
}
int bodySize = IOUtils.readUnsignedMediumInt(in);
int timestamp = IOUtils.readExtendedMediumInt(in);
if (log.isDebugEnabled()) {
int streamId = IOUtils.readUnsignedMediumInt(in);
log.debug("Data type: {} timestamp: {} stream id: {} body size: {} previous tag size: {}", new Object[] { dataType, timestamp, streamId, bodySize, previousTagSize });
} else {
in.skip(3);
}
return new Tag(dataType, timestamp, bodySize, null, previousTagSize);
}
/**
* Returns the last tag's timestamp as the files duration.
*
* @param flvFile
* @return duration
*/
public static int getDuration(File flvFile) {
int duration = 0;
RandomAccessFile flv = null;
try {
flv = new RandomAccessFile(flvFile, "r");
long flvLength = Math.max(flvFile.length(), flv.length());
log.debug("File length: {}", flvLength);
if (flvLength > 13) {
flv.seek(flvLength - (4 + 1));
int lastTagSize = flv.readInt();
log.debug("Last tag size: {}", lastTagSize);
if (lastTagSize > 0 && (lastTagSize < flvLength)) {
// jump right to where tag timestamp would be
flv.seek(flvLength - (lastTagSize + 1));
// grab timestamp as a regular int
duration = flv.readInt();
// adjust value to match extended timestamp
duration = (duration >>> 8) | ((duration & 0x000000ff) << 24);
} else {
// attempt to read the metadata
flv.seek(13);
byte tagType = flv.readByte();
if (tagType == ITag.TYPE_METADATA) {
ByteBuffer buf = ByteBuffer.allocate(3);
flv.getChannel().read(buf);
int bodySize = IOUtils.readMediumInt(buf);
log.debug("Metadata body size: {}", bodySize);
flv.skipBytes(4); // timestamp
flv.skipBytes(3); // stream id
buf.clear();
buf = ByteBuffer.allocate(bodySize);
flv.getChannel().read(buf);
// construct the meta
IoBuffer ioBuf = IoBuffer.wrap(buf);
Input input = new Input(ioBuf);
Deserializer deserializer = new Deserializer();
String metaType = deserializer.deserialize(input, String.class);
log.debug("Metadata type: {}", metaType);
Map<String, ?> meta = deserializer.deserialize(input, Map.class);
Object tmp = meta.get("duration");
if (tmp != null) {
if (tmp instanceof Double) {
duration = ((Double) tmp).intValue();
} else {
duration = Integer.valueOf((String) tmp);
}
}
input = null;
meta.clear();
meta = null;
ioBuf.clear();
ioBuf.free();
ioBuf = null;
}
}
}
} catch (IOException e) {
log.warn("Exception getting file duration", e);
} finally {
try {
if (flv != null) {
flv.close();
}
} catch (IOException e) {
}
flv = null;
}
return duration;
}
}