Improve support for Ogg truncated content

#minor-release

Issue:#7608
PiperOrigin-RevId: 382081687
This commit is contained in:
kimvde 2021-06-29 15:35:02 +01:00 committed by Oliver Woodman
parent 155e27ec7c
commit 6e4508daec
6 changed files with 231 additions and 46 deletions

View File

@ -83,6 +83,8 @@
is set incorrectly is set incorrectly
([#4083](https://github.com/google/ExoPlayer/issues/4083)). Such content ([#4083](https://github.com/google/ExoPlayer/issues/4083)). Such content
is malformed and should be re-encoded. is malformed and should be re-encoded.
* Improve support for truncated Ogg streams
([#7608](https://github.com/google/ExoPlayer/issues/7608)).
* HLS: * HLS:
* Fix issue where playback of a live event could become stuck rather than * Fix issue where playback of a live event could become stuck rather than
transitioning to `STATE_ENDED` when the event ends transitioning to `STATE_ENDED` when the event ends

View File

@ -18,6 +18,7 @@ package com.google.android.exoplayer2.extractor;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.ParserException; import com.google.android.exoplayer2.ParserException;
import java.io.EOFException;
import java.io.IOException; import java.io.IOException;
import org.checkerframework.dataflow.qual.Pure; import org.checkerframework.dataflow.qual.Pure;
@ -62,5 +63,62 @@ public final class ExtractorUtil {
return totalBytesPeeked; return totalBytesPeeked;
} }
/**
* Equivalent to {@link ExtractorInput#readFully(byte[], int, int)} except that it returns {@code
* false} instead of throwing an {@link EOFException} if the end of input is encountered without
* having fully satisfied the read.
*/
public static boolean readFullyQuietly(
ExtractorInput input, byte[] output, int offset, int length) throws IOException {
try {
input.readFully(output, offset, length);
} catch (EOFException e) {
return false;
}
return true;
}
/**
* Equivalent to {@link ExtractorInput#skipFully(int)} except that it returns {@code false}
* instead of throwing an {@link EOFException} if the end of input is encountered without having
* fully satisfied the skip.
*/
public static boolean skipFullyQuietly(ExtractorInput input, int length) throws IOException {
try {
input.skipFully(length);
} catch (EOFException e) {
return false;
}
return true;
}
/**
* Peeks data from {@code input}, respecting {@code allowEndOfInput}. Returns true if the peek is
* successful.
*
* <p>If {@code allowEndOfInput=false} then encountering the end of the input (whether before or
* after reading some data) will throw {@link EOFException}.
*
* <p>If {@code allowEndOfInput=true} then encountering the end of the input (even after reading
* some data) will return {@code false}.
*
* <p>This is slightly different to the behaviour of {@link ExtractorInput#peekFully(byte[], int,
* int, boolean)}, where {@code allowEndOfInput=true} only returns false (and suppresses the
* exception) if the end of the input is reached before reading any data.
*/
public static boolean peekFullyQuietly(
ExtractorInput input, byte[] output, int offset, int length, boolean allowEndOfInput)
throws IOException {
try {
return input.peekFully(output, offset, length, /* allowEndOfInput= */ allowEndOfInput);
} catch (EOFException e) {
if (allowEndOfInput) {
return false;
} else {
throw e;
}
}
}
private ExtractorUtil() {} private ExtractorUtil() {}
} }

View File

@ -15,6 +15,8 @@
*/ */
package com.google.android.exoplayer2.extractor.ogg; package com.google.android.exoplayer2.extractor.ogg;
import static com.google.android.exoplayer2.extractor.ExtractorUtil.skipFullyQuietly;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting; import androidx.annotation.VisibleForTesting;
import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.C;
@ -229,13 +231,21 @@ import java.io.IOException;
if (!pageHeader.skipToNextPage(input)) { if (!pageHeader.skipToNextPage(input)) {
throw new EOFException(); throw new EOFException();
} }
do {
pageHeader.populate(input, /* quiet= */ false); pageHeader.populate(input, /* quiet= */ false);
input.skipFully(pageHeader.headerSize + pageHeader.bodySize); input.skipFully(pageHeader.headerSize + pageHeader.bodySize);
} while ((pageHeader.type & 0x04) != 0x04 long granulePosition = pageHeader.granulePosition;
while ((pageHeader.type & 0x04) != 0x04
&& pageHeader.skipToNextPage(input) && pageHeader.skipToNextPage(input)
&& input.getPosition() < payloadEndPosition); && input.getPosition() < payloadEndPosition) {
return pageHeader.granulePosition; boolean hasPopulated = pageHeader.populate(input, /* quiet= */ true);
if (!hasPopulated || !skipFullyQuietly(input, pageHeader.headerSize + pageHeader.bodySize)) {
// The input file contains a partial page at the end. Ignore it and return the granule
// position of the last complete page.
return granulePosition;
}
granulePosition = pageHeader.granulePosition;
}
return granulePosition;
} }
private final class OggSeekMap implements SeekMap { private final class OggSeekMap implements SeekMap {

View File

@ -15,6 +15,8 @@
*/ */
package com.google.android.exoplayer2.extractor.ogg; package com.google.android.exoplayer2.extractor.ogg;
import static com.google.android.exoplayer2.extractor.ExtractorUtil.readFullyQuietly;
import static com.google.android.exoplayer2.extractor.ExtractorUtil.skipFullyQuietly;
import static java.lang.Math.max; import static java.lang.Math.max;
import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.C;
@ -51,7 +53,7 @@ import java.util.Arrays;
* *
* @param input The {@link ExtractorInput} to read data from. * @param input The {@link ExtractorInput} to read data from.
* @return {@code true} if the read was successful. The read fails if the end of the input is * @return {@code true} if the read was successful. The read fails if the end of the input is
* encountered without reading data. * encountered without reading the whole packet.
* @throws IOException If reading from the input fails. * @throws IOException If reading from the input fails.
*/ */
public boolean populate(ExtractorInput input) throws IOException { public boolean populate(ExtractorInput input) throws IOException {
@ -76,7 +78,9 @@ import java.util.Arrays;
bytesToSkip += calculatePacketSize(segmentIndex); bytesToSkip += calculatePacketSize(segmentIndex);
segmentIndex += segmentCount; segmentIndex += segmentCount;
} }
input.skipFully(bytesToSkip); if (!skipFullyQuietly(input, bytesToSkip)) {
return false;
}
currentSegmentIndex = segmentIndex; currentSegmentIndex = segmentIndex;
} }
@ -84,7 +88,9 @@ import java.util.Arrays;
int segmentIndex = currentSegmentIndex + segmentCount; int segmentIndex = currentSegmentIndex + segmentCount;
if (size > 0) { if (size > 0) {
packetArray.ensureCapacity(packetArray.limit() + size); packetArray.ensureCapacity(packetArray.limit() + size);
input.readFully(packetArray.getData(), packetArray.limit(), size); if (!readFullyQuietly(input, packetArray.getData(), packetArray.limit(), size)) {
return false;
}
packetArray.setLimit(packetArray.limit() + size); packetArray.setLimit(packetArray.limit() + size);
populated = pageHeader.laces[segmentIndex - 1] != 255; populated = pageHeader.laces[segmentIndex - 1] != 255;
} }

View File

@ -15,12 +15,13 @@
*/ */
package com.google.android.exoplayer2.extractor.ogg; package com.google.android.exoplayer2.extractor.ogg;
import static com.google.android.exoplayer2.extractor.ExtractorUtil.peekFullyQuietly;
import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.ParserException; import com.google.android.exoplayer2.ParserException;
import com.google.android.exoplayer2.extractor.ExtractorInput; import com.google.android.exoplayer2.extractor.ExtractorInput;
import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Assertions;
import com.google.android.exoplayer2.util.ParsableByteArray; import com.google.android.exoplayer2.util.ParsableByteArray;
import java.io.EOFException;
import java.io.IOException; import java.io.IOException;
/** Data object to store header information. */ /** Data object to store header information. */
@ -102,7 +103,8 @@ import java.io.IOException;
Assertions.checkArgument(input.getPosition() == input.getPeekPosition()); Assertions.checkArgument(input.getPosition() == input.getPeekPosition());
scratch.reset(/* limit= */ CAPTURE_PATTERN_SIZE); scratch.reset(/* limit= */ CAPTURE_PATTERN_SIZE);
while ((limit == C.POSITION_UNSET || input.getPosition() + CAPTURE_PATTERN_SIZE < limit) while ((limit == C.POSITION_UNSET || input.getPosition() + CAPTURE_PATTERN_SIZE < limit)
&& peekSafely(input, scratch.getData(), 0, CAPTURE_PATTERN_SIZE, /* quiet= */ true)) { && peekFullyQuietly(
input, scratch.getData(), 0, CAPTURE_PATTERN_SIZE, /* allowEndOfInput= */ true)) {
scratch.setPosition(0); scratch.setPosition(0);
if (scratch.readUnsignedInt() == CAPTURE_PATTERN) { if (scratch.readUnsignedInt() == CAPTURE_PATTERN) {
input.resetPeekPosition(); input.resetPeekPosition();
@ -123,14 +125,13 @@ import java.io.IOException;
* @param input The {@link ExtractorInput} to read from. * @param input The {@link ExtractorInput} to read from.
* @param quiet Whether to return {@code false} rather than throwing an exception if the header * @param quiet Whether to return {@code false} rather than throwing an exception if the header
* cannot be populated. * cannot be populated.
* @return Whether the read was successful. The read fails if the end of the input is encountered * @return Whether the header was entirely populated.
* without reading data.
* @throws IOException If reading data fails or the stream is invalid. * @throws IOException If reading data fails or the stream is invalid.
*/ */
public boolean populate(ExtractorInput input, boolean quiet) throws IOException { public boolean populate(ExtractorInput input, boolean quiet) throws IOException {
reset(); reset();
scratch.reset(/* limit= */ EMPTY_PAGE_HEADER_SIZE); scratch.reset(/* limit= */ EMPTY_PAGE_HEADER_SIZE);
if (!peekSafely(input, scratch.getData(), 0, EMPTY_PAGE_HEADER_SIZE, quiet) if (!peekFullyQuietly(input, scratch.getData(), 0, EMPTY_PAGE_HEADER_SIZE, quiet)
|| scratch.readUnsignedInt() != CAPTURE_PATTERN) { || scratch.readUnsignedInt() != CAPTURE_PATTERN) {
return false; return false;
} }
@ -155,7 +156,9 @@ import java.io.IOException;
// calculate total size of header including laces // calculate total size of header including laces
scratch.reset(/* limit= */ pageSegmentCount); scratch.reset(/* limit= */ pageSegmentCount);
input.peekFully(scratch.getData(), 0, pageSegmentCount); if (!peekFullyQuietly(input, scratch.getData(), 0, pageSegmentCount, quiet)) {
return false;
}
for (int i = 0; i < pageSegmentCount; i++) { for (int i = 0; i < pageSegmentCount; i++) {
laces[i] = scratch.readUnsignedByte(); laces[i] = scratch.readUnsignedByte();
bodySize += laces[i]; bodySize += laces[i];
@ -163,31 +166,4 @@ import java.io.IOException;
return true; return true;
} }
/**
* Peek data from {@code input}, respecting {@code quiet}. Return true if the peek is successful.
*
* <p>If {@code quiet=false} then encountering the end of the input (whether before or after
* reading some data) will throw {@link EOFException}.
*
* <p>If {@code quiet=true} then encountering the end of the input (even after reading some data)
* will return {@code false}.
*
* <p>This is slightly different to the behaviour of {@link ExtractorInput#peekFully(byte[], int,
* int, boolean)}, where {@code allowEndOfInput=true} only returns false (and suppresses the
* exception) if the end of the input is reached before reading any data.
*/
private static boolean peekSafely(
ExtractorInput input, byte[] output, int offset, int length, boolean quiet)
throws IOException {
try {
return input.peekFully(output, offset, length, /* allowEndOfInput= */ quiet);
} catch (EOFException e) {
if (quiet) {
return false;
} else {
throw e;
}
}
}
} }

View File

@ -16,12 +16,14 @@
package com.google.android.exoplayer2.extractor; package com.google.android.exoplayer2.extractor;
import static com.google.common.truth.Truth.assertThat; import static com.google.common.truth.Truth.assertThat;
import static org.junit.Assert.assertThrows;
import android.net.Uri; import android.net.Uri;
import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.ext.junit.runners.AndroidJUnit4;
import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.testutil.FakeDataSource; import com.google.android.exoplayer2.testutil.FakeDataSource;
import com.google.android.exoplayer2.upstream.DataSpec; import com.google.android.exoplayer2.upstream.DataSpec;
import java.io.EOFException;
import java.util.Arrays; import java.util.Arrays;
import org.junit.Test; import org.junit.Test;
import org.junit.runner.RunWith; import org.junit.runner.RunWith;
@ -36,12 +38,12 @@ public class ExtractorUtilTest {
private static final byte[] TEST_DATA = new byte[] {0, 1, 2, 3, 4, 5, 6, 7, 8}; private static final byte[] TEST_DATA = new byte[] {0, 1, 2, 3, 4, 5, 6, 7, 8};
@Test @Test
public void peekToLengthEndNotReached() throws Exception { public void peekToLength_endNotReached() throws Exception {
FakeDataSource testDataSource = new FakeDataSource(); FakeDataSource testDataSource = new FakeDataSource();
testDataSource testDataSource
.getDataSet() .getDataSet()
.newDefaultData() .newDefaultData()
.appendReadData(Arrays.copyOfRange(TEST_DATA, 0, 3)) .appendReadData(Arrays.copyOf(TEST_DATA, 3))
.appendReadData(Arrays.copyOfRange(TEST_DATA, 3, 6)) .appendReadData(Arrays.copyOfRange(TEST_DATA, 3, 6))
.appendReadData(Arrays.copyOfRange(TEST_DATA, 6, 9)); .appendReadData(Arrays.copyOfRange(TEST_DATA, 6, 9));
testDataSource.open(new DataSpec(Uri.parse(TEST_URI))); testDataSource.open(new DataSpec(Uri.parse(TEST_URI)));
@ -59,12 +61,12 @@ public class ExtractorUtilTest {
} }
@Test @Test
public void peekToLengthEndReached() throws Exception { public void peekToLength_endReached() throws Exception {
FakeDataSource testDataSource = new FakeDataSource(); FakeDataSource testDataSource = new FakeDataSource();
testDataSource testDataSource
.getDataSet() .getDataSet()
.newDefaultData() .newDefaultData()
.appendReadData(Arrays.copyOfRange(TEST_DATA, 0, 3)) .appendReadData(Arrays.copyOf(TEST_DATA, 3))
.appendReadData(Arrays.copyOfRange(TEST_DATA, 3, 6)) .appendReadData(Arrays.copyOfRange(TEST_DATA, 3, 6))
.appendReadData(Arrays.copyOfRange(TEST_DATA, 6, 9)); .appendReadData(Arrays.copyOfRange(TEST_DATA, 6, 9));
testDataSource.open(new DataSpec(Uri.parse(TEST_URI))); testDataSource.open(new DataSpec(Uri.parse(TEST_URI)));
@ -79,4 +81,135 @@ public class ExtractorUtilTest {
assertThat(input.getPeekPosition()).isEqualTo(TEST_DATA.length); assertThat(input.getPeekPosition()).isEqualTo(TEST_DATA.length);
assertThat(target).isEqualTo(TEST_DATA); assertThat(target).isEqualTo(TEST_DATA);
} }
@Test
public void readFullyQuietly_endNotReached_isTrueAndReadsData() throws Exception {
FakeDataSource testDataSource = new FakeDataSource();
testDataSource
.getDataSet()
.newDefaultData()
.appendReadData(Arrays.copyOf(TEST_DATA, 3))
.appendReadData(Arrays.copyOfRange(TEST_DATA, 3, 6))
.appendReadData(Arrays.copyOfRange(TEST_DATA, 6, 9));
testDataSource.open(new DataSpec(Uri.parse(TEST_URI)));
ExtractorInput input = new DefaultExtractorInput(testDataSource, 0, C.LENGTH_UNSET);
byte[] target = new byte[TEST_DATA.length];
int offset = 2;
int length = 4;
boolean hasRead = ExtractorUtil.readFullyQuietly(input, target, offset, length);
assertThat(hasRead).isTrue();
assertThat(input.getPosition()).isEqualTo(length);
assertThat(Arrays.copyOfRange(target, offset, offset + length))
.isEqualTo(Arrays.copyOf(TEST_DATA, length));
}
@Test
public void readFullyQuietly_endReached_isFalse() throws Exception {
FakeDataSource testDataSource = new FakeDataSource();
testDataSource.getDataSet().newDefaultData().appendReadData(Arrays.copyOf(TEST_DATA, 3));
testDataSource.open(new DataSpec(Uri.parse(TEST_URI)));
ExtractorInput input = new DefaultExtractorInput(testDataSource, 0, C.LENGTH_UNSET);
byte[] target = new byte[TEST_DATA.length];
int offset = 0;
int length = TEST_DATA.length + 1;
boolean hasRead = ExtractorUtil.readFullyQuietly(input, target, offset, length);
assertThat(hasRead).isFalse();
assertThat(input.getPosition()).isEqualTo(0);
}
@Test
public void skipFullyQuietly_endNotReached_isTrueAndSkipsData() throws Exception {
FakeDataSource testDataSource = new FakeDataSource();
testDataSource
.getDataSet()
.newDefaultData()
.appendReadData(Arrays.copyOf(TEST_DATA, 3))
.appendReadData(Arrays.copyOfRange(TEST_DATA, 3, 6))
.appendReadData(Arrays.copyOfRange(TEST_DATA, 6, 9));
testDataSource.open(new DataSpec(Uri.parse(TEST_URI)));
ExtractorInput input = new DefaultExtractorInput(testDataSource, 0, C.LENGTH_UNSET);
int length = 4;
boolean hasRead = ExtractorUtil.skipFullyQuietly(input, length);
assertThat(hasRead).isTrue();
assertThat(input.getPosition()).isEqualTo(length);
}
@Test
public void skipFullyQuietly_endReached_isFalse() throws Exception {
FakeDataSource testDataSource = new FakeDataSource();
testDataSource.getDataSet().newDefaultData().appendReadData(Arrays.copyOf(TEST_DATA, 3));
testDataSource.open(new DataSpec(Uri.parse(TEST_URI)));
ExtractorInput input = new DefaultExtractorInput(testDataSource, 0, C.LENGTH_UNSET);
int length = TEST_DATA.length + 1;
boolean hasRead = ExtractorUtil.skipFullyQuietly(input, length);
assertThat(hasRead).isFalse();
assertThat(input.getPosition()).isEqualTo(0);
}
@Test
public void peekFullyQuietly_endNotReached_isTrueAndPeeksData() throws Exception {
FakeDataSource testDataSource = new FakeDataSource();
testDataSource
.getDataSet()
.newDefaultData()
.appendReadData(Arrays.copyOf(TEST_DATA, 3))
.appendReadData(Arrays.copyOfRange(TEST_DATA, 3, 6))
.appendReadData(Arrays.copyOfRange(TEST_DATA, 6, 9));
testDataSource.open(new DataSpec(Uri.parse(TEST_URI)));
ExtractorInput input = new DefaultExtractorInput(testDataSource, 0, C.LENGTH_UNSET);
byte[] target = new byte[TEST_DATA.length];
int offset = 2;
int length = 4;
boolean hasRead =
ExtractorUtil.peekFullyQuietly(input, target, offset, length, /* allowEndOfInput= */ false);
assertThat(hasRead).isTrue();
assertThat(input.getPeekPosition()).isEqualTo(length);
assertThat(Arrays.copyOfRange(target, offset, offset + length))
.isEqualTo(Arrays.copyOf(TEST_DATA, length));
}
@Test
public void peekFullyQuietly_endReachedWithEndOfInputAllowed_isFalse() throws Exception {
FakeDataSource testDataSource = new FakeDataSource();
testDataSource.getDataSet().newDefaultData().appendReadData(Arrays.copyOf(TEST_DATA, 3));
testDataSource.open(new DataSpec(Uri.parse(TEST_URI)));
ExtractorInput input = new DefaultExtractorInput(testDataSource, 0, C.LENGTH_UNSET);
byte[] target = new byte[TEST_DATA.length];
int offset = 0;
int length = TEST_DATA.length + 1;
boolean hasRead =
ExtractorUtil.peekFullyQuietly(input, target, offset, length, /* allowEndOfInput= */ true);
assertThat(hasRead).isFalse();
assertThat(input.getPeekPosition()).isEqualTo(0);
}
@Test
public void peekFullyQuietly_endReachedWithoutEndOfInputAllowed_throws() throws Exception {
FakeDataSource testDataSource = new FakeDataSource();
testDataSource.getDataSet().newDefaultData().appendReadData(Arrays.copyOf(TEST_DATA, 3));
testDataSource.open(new DataSpec(Uri.parse(TEST_URI)));
ExtractorInput input = new DefaultExtractorInput(testDataSource, 0, C.LENGTH_UNSET);
byte[] target = new byte[TEST_DATA.length];
int offset = 0;
int length = TEST_DATA.length + 1;
assertThrows(
EOFException.class,
() ->
ExtractorUtil.peekFullyQuietly(
input, target, offset, length, /* allowEndOfInput= */ false));
assertThat(input.getPeekPosition()).isEqualTo(0);
}
} }