Further enhance ID3 decoder + support

This commit is contained in:
Oliver Woodman 2016-10-18 15:02:35 +01:00
parent e2ff401ea1
commit 7594f5b78b
4 changed files with 175 additions and 181 deletions

View file

@ -16,6 +16,8 @@
package com.google.android.exoplayer2.extractor;
import com.google.android.exoplayer2.Format;
import com.google.android.exoplayer2.metadata.Metadata;
import com.google.android.exoplayer2.metadata.id3.CommentFrame;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@ -65,6 +67,25 @@ public final class GaplessInfoHolder {
return false;
}
/**
* Populates the holder with data parsed from ID3 {@link Metadata}.
*
* @param metadata The metadata from which to parse the gapless information.
* @return Whether the holder was populated.
*/
public boolean setFromMetadata(Metadata metadata) {
for (int i = 0; i < metadata.length(); i++) {
Metadata.Entry entry = metadata.get(i);
if (entry instanceof CommentFrame) {
CommentFrame commentFrame = (CommentFrame) entry;
if (setFromComment(commentFrame.description, commentFrame.text)) {
return true;
}
}
}
return false;
}
/**
* Populates the holder with data parsed from a gapless playback comment (stored in an ID3 header
* or MPEG 4 user data), if valid and non-zero.

View file

@ -1,95 +0,0 @@
/*
* Copyright (C) 2016 The Android Open Source Project
*
* 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 com.google.android.exoplayer2.extractor.mp3;
import com.google.android.exoplayer2.extractor.ExtractorInput;
import com.google.android.exoplayer2.metadata.Metadata;
import com.google.android.exoplayer2.metadata.MetadataDecoderException;
import com.google.android.exoplayer2.metadata.id3.Id3Decoder;
import com.google.android.exoplayer2.util.ParsableByteArray;
import com.google.android.exoplayer2.util.Util;
import java.io.IOException;
/**
* Utility for parsing ID3 version 2 metadata in MP3 files.
*/
/* package */ final class Id3Util {
/**
* The maximum valid length for metadata in bytes.
*/
private static final int MAXIMUM_METADATA_SIZE = 3 * 1024 * 1024;
private static final int ID3_TAG = Util.getIntegerCodeForString("ID3");
/**
* Peeks data from the input and parses ID3 metadata, including gapless playback information.
*
* @param input The {@link ExtractorInput} from which data should be peeked.
* @return The metadata, if present, {@code null} otherwise.
* @throws IOException If an error occurred peeking from the input.
* @throws InterruptedException If the thread was interrupted.
*/
public static Metadata parseId3(ExtractorInput input)
throws IOException, InterruptedException {
Metadata result = null;
ParsableByteArray scratch = new ParsableByteArray(10);
int peekedId3Bytes = 0;
while (true) {
input.peekFully(scratch.data, 0, 10);
scratch.setPosition(0);
if (scratch.readUnsignedInt24() != ID3_TAG) {
break;
}
int majorVersion = scratch.readUnsignedByte();
int minorVersion = scratch.readUnsignedByte();
int flags = scratch.readUnsignedByte();
int length = scratch.readSynchSafeInt();
int frameLength = length + 10;
try {
if (canParseMetadata(majorVersion, minorVersion, flags, length)) {
input.resetPeekPosition();
byte[] frame = new byte[frameLength];
input.peekFully(frame, 0, frameLength);
return new Id3Decoder().decode(frame, frameLength);
} else {
input.advancePeekPosition(length);
}
} catch (MetadataDecoderException e) {
e.printStackTrace();
}
peekedId3Bytes += frameLength;
}
input.resetPeekPosition();
input.advancePeekPosition(peekedId3Bytes);
return result;
}
private static boolean canParseMetadata(int majorVersion, int minorVersion, int flags,
int length) {
return minorVersion != 0xFF && majorVersion >= 2 && majorVersion <= 4
&& length <= MAXIMUM_METADATA_SIZE
&& !(majorVersion == 2 && ((flags & 0x3F) != 0 || (flags & 0x40) != 0))
&& !(majorVersion == 3 && (flags & 0x1F) != 0)
&& !(majorVersion == 4 && (flags & 0x0F) != 0);
}
private Id3Util() {}
}

View file

@ -15,6 +15,7 @@
*/
package com.google.android.exoplayer2.extractor.mp3;
import android.util.Log;
import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.Format;
import com.google.android.exoplayer2.ParserException;
@ -28,7 +29,8 @@ import com.google.android.exoplayer2.extractor.PositionHolder;
import com.google.android.exoplayer2.extractor.SeekMap;
import com.google.android.exoplayer2.extractor.TrackOutput;
import com.google.android.exoplayer2.metadata.Metadata;
import com.google.android.exoplayer2.metadata.id3.CommentFrame;
import com.google.android.exoplayer2.metadata.MetadataDecoderException;
import com.google.android.exoplayer2.metadata.id3.Id3Decoder;
import com.google.android.exoplayer2.util.ParsableByteArray;
import com.google.android.exoplayer2.util.Util;
import java.io.EOFException;
@ -51,6 +53,8 @@ public final class Mp3Extractor implements Extractor {
};
private static final String TAG = "Mp3Extractor";
/**
* The maximum number of bytes to search when synchronizing, before giving up.
*/
@ -59,6 +63,18 @@ public final class Mp3Extractor implements Extractor {
* The maximum number of bytes to peek when sniffing, excluding the ID3 header, before giving up.
*/
private static final int MAX_SNIFF_BYTES = MpegAudioHeader.MAX_FRAME_SIZE_BYTES;
/**
* First three bytes of a well formed ID3 tag header.
*/
private static final int ID3_TAG = Util.getIntegerCodeForString("ID3");
/**
* Length of an ID3 tag header.
*/
private static final int ID3_HEADER_LENGTH = 10;
/**
* Maximum length of data read into {@link #scratch}.
*/
private static final int SCRATCH_LENGTH = 10;
/**
* Mask that includes the audio header values that must match between frames.
@ -100,7 +116,7 @@ public final class Mp3Extractor implements Extractor {
*/
public Mp3Extractor(long forcedFirstSampleTimestampUs) {
this.forcedFirstSampleTimestampUs = forcedFirstSampleTimestampUs;
scratch = new ParsableByteArray(4);
scratch = new ParsableByteArray(SCRATCH_LENGTH);
synchronizedHeader = new MpegAudioHeader();
gaplessInfoHolder = new GaplessInfoHolder();
basisTimeUs = C.TIME_UNSET;
@ -147,7 +163,7 @@ public final class Mp3Extractor implements Extractor {
trackOutput.format(Format.createAudioSampleFormat(null, synchronizedHeader.mimeType, null,
Format.NO_VALUE, MpegAudioHeader.MAX_FRAME_SIZE_BYTES, synchronizedHeader.channels,
synchronizedHeader.sampleRate, Format.NO_VALUE, gaplessInfoHolder.encoderDelay,
gaplessInfoHolder.encoderPadding, null, null, 0, null, metadata));
gaplessInfoHolder.encoderPadding, null, null, 0, null, null));
}
return readSample(input);
}
@ -202,18 +218,7 @@ public final class Mp3Extractor implements Extractor {
int searchLimitBytes = sniffing ? MAX_SNIFF_BYTES : MAX_SYNC_BYTES;
input.resetPeekPosition();
if (input.getPosition() == 0) {
metadata = Id3Util.parseId3(input);
if (!gaplessInfoHolder.hasGaplessInfo()) {
for (int i = 0; i < metadata.length(); i++) {
Metadata.Entry entry = metadata.get(i);
if (entry instanceof CommentFrame) {
CommentFrame commentFrame = (CommentFrame) entry;
if (gaplessInfoHolder.setFromComment(commentFrame.description, commentFrame.text)) {
break;
}
}
}
}
peekId3Data(input);
peekedId3Bytes = (int) input.getPeekPosition();
if (!sniffing) {
input.skipFully(peekedId3Bytes);
@ -267,6 +272,49 @@ public final class Mp3Extractor implements Extractor {
return true;
}
/**
* Peeks ID3 data from the input, including gapless playback information.
*
* @param input The {@link ExtractorInput} from which data should be peeked.
* @throws IOException If an error occurred peeking from the input.
* @throws InterruptedException If the thread was interrupted.
*/
private void peekId3Data(ExtractorInput input) throws IOException, InterruptedException {
int peekedId3Bytes = 0;
while (true) {
input.peekFully(scratch.data, 0, ID3_HEADER_LENGTH);
scratch.setPosition(0);
if (scratch.readUnsignedInt24() != ID3_TAG) {
// Not an ID3 tag.
break;
}
scratch.skipBytes(3); // Skip major version, minor version and flags.
int framesLength = scratch.readSynchSafeInt();
int tagLength = ID3_HEADER_LENGTH + framesLength;
try {
if (metadata == null) {
byte[] id3Data = new byte[tagLength];
System.arraycopy(scratch.data, 0, id3Data, 0, ID3_HEADER_LENGTH);
input.peekFully(id3Data, ID3_HEADER_LENGTH, framesLength);
metadata = new Id3Decoder().decode(id3Data, tagLength);
if (metadata != null) {
gaplessInfoHolder.setFromMetadata(metadata);
}
} else {
input.advancePeekPosition(framesLength);
}
} catch (MetadataDecoderException e) {
Log.e(TAG, "Failed to decode ID3 tag", e);
}
peekedId3Bytes += tagLength;
}
input.resetPeekPosition();
input.advancePeekPosition(peekedId3Bytes);
}
/**
* Returns a {@link Seeker} to seek using metadata read from {@code input}, which should provide
* data from the start of the first frame in the stream. On returning, the input's position will

View file

@ -63,7 +63,7 @@ public final class Id3Decoder implements MetadataDecoder {
int frameHeaderSize = id3Header.majorVersion == 2 ? 6 : 10;
while (id3Data.bytesLeft() >= frameHeaderSize) {
Id3Frame frame = decodeFrame(id3Header, id3Data);
Id3Frame frame = decodeFrame(id3Header.majorVersion, id3Data);
if (frame != null) {
id3Frames.add(frame);
}
@ -72,6 +72,40 @@ public final class Id3Decoder implements MetadataDecoder {
return new Metadata(id3Frames);
}
// TODO: Move the following three methods nearer to the bottom of the file.
private static int indexOfEos(byte[] data, int fromIndex, int encoding) {
int terminationPos = indexOfZeroByte(data, fromIndex);
// For single byte encoding charsets, we're done.
if (encoding == ID3_TEXT_ENCODING_ISO_8859_1 || encoding == ID3_TEXT_ENCODING_UTF_8) {
return terminationPos;
}
// Otherwise ensure an even index and look for a second zero byte.
while (terminationPos < data.length - 1) {
if (terminationPos % 2 == 0 && data[terminationPos + 1] == (byte) 0) {
return terminationPos;
}
terminationPos = indexOfZeroByte(data, terminationPos + 1);
}
return data.length;
}
private static int indexOfZeroByte(byte[] data, int fromIndex) {
for (int i = fromIndex; i < data.length; i++) {
if (data[i] == (byte) 0) {
return i;
}
}
return data.length;
}
private static int delimiterLength(int encodingByte) {
return (encodingByte == ID3_TEXT_ENCODING_ISO_8859_1 || encodingByte == ID3_TEXT_ENCODING_UTF_8)
? 1 : 2;
}
/**
* @param data A {@link ParsableByteArray} from which the header should be read.
* @return The parsed header, or null if the ID3 tag is unsupported.
@ -126,15 +160,15 @@ public final class Id3Decoder implements MetadataDecoder {
return new Id3Header(majorVersion, isUnsynchronized, framesSize);
}
private Id3Frame decodeFrame(Id3Header id3Header, ParsableByteArray id3Data)
private Id3Frame decodeFrame(int majorVersion, ParsableByteArray id3Data)
throws MetadataDecoderException {
int frameId0 = id3Data.readUnsignedByte();
int frameId1 = id3Data.readUnsignedByte();
int frameId2 = id3Data.readUnsignedByte();
int frameId3 = id3Header.majorVersion >= 3 ? id3Data.readUnsignedByte() : 0;
int frameId3 = majorVersion >= 3 ? id3Data.readUnsignedByte() : 0;
int frameSize;
if (id3Header.majorVersion == 4) {
if (majorVersion == 4) {
frameSize = id3Data.readUnsignedIntToInt();
if ((frameSize & 0x808080L) == 0) {
// Parse the frame size as a syncsafe integer, as per the spec.
@ -144,13 +178,13 @@ public final class Id3Decoder implements MetadataDecoder {
// Proceed using the frame size read as an unsigned integer.
Log.w(TAG, "Frame size not specified as syncsafe integer");
}
} else if (id3Header.majorVersion == 3) {
} else if (majorVersion == 3) {
frameSize = id3Data.readUnsignedIntToInt();
} else /* id3Header.majorVersion == 2 */ {
frameSize = id3Data.readUnsignedInt24();
}
int flags = id3Header.majorVersion >= 2 ? id3Data.readShort() : 0;
int flags = majorVersion >= 3 ? id3Data.readShort() : 0;
if (frameId0 == 0 && frameId1 == 0 && frameId2 == 0 && frameId3 == 0 && frameSize == 0
&& flags == 0) {
// We must be reading zero padding at the end of the tag.
@ -159,6 +193,9 @@ public final class Id3Decoder implements MetadataDecoder {
}
int nextFramePosition = id3Data.getPosition() + frameSize;
if (nextFramePosition > id3Data.limit()) {
return null;
}
// Frame flags.
boolean isCompressed = false;
@ -166,12 +203,12 @@ public final class Id3Decoder implements MetadataDecoder {
boolean isUnsynchronized = false;
boolean hasDataLength = false;
boolean hasGroupIdentifier = false;
if (id3Header.majorVersion == 3) {
if (majorVersion == 3) {
isCompressed = (flags & 0x0080) != 0;
isEncrypted = (flags & 0x0040) != 0;
hasGroupIdentifier = (flags & 0x0020) != 0;
hasDataLength = isCompressed;
} else if (id3Header.majorVersion == 4) {
} else if (majorVersion == 4) {
hasGroupIdentifier = (flags & 0x0040) != 0;
isCompressed = (flags & 0x0008) != 0;
isEncrypted = (flags & 0x0004) != 0;
@ -199,26 +236,29 @@ public final class Id3Decoder implements MetadataDecoder {
try {
Id3Frame frame;
if (frameId0 == 'T' && frameId1 == 'X' && frameId2 == 'X' && frameId3 == 'X') {
if (frameId0 == 'T' && frameId1 == 'X' && frameId2 == 'X'
&& (majorVersion == 2 || frameId3 == 'X')) {
frame = decodeTxxxFrame(id3Data, frameSize);
} else if (frameId0 == 'P' && frameId1 == 'R' && frameId2 == 'I' && frameId3 == 'V') {
frame = decodePrivFrame(id3Data, frameSize);
} else if (frameId0 == 'G' && frameId1 == 'E' && frameId2 == 'O' && frameId3 == 'B') {
} else if (frameId0 == 'G' && frameId1 == 'E' && frameId2 == 'O'
&& (frameId3 == 'B' || majorVersion == 2)) {
frame = decodeGeobFrame(id3Data, frameSize);
} else if (frameId0 == 'A' && frameId1 == 'P' && frameId2 == 'I' && frameId3 == 'C') {
frame = decodeApicFrame(id3Data, frameSize);
} else if (majorVersion == 2 ? (frameId0 == 'P' && frameId1 == 'I' && frameId2 == 'C')
: (frameId0 == 'A' && frameId1 == 'P' && frameId2 == 'I' && frameId3 == 'C')) {
frame = decodeApicFrame(id3Data, frameSize, majorVersion);
} else if (frameId0 == 'T') {
String id = frameId3 != 0 ?
String.format(Locale.US, "%c%c%c%c", frameId0, frameId1, frameId2, frameId3) :
String.format(Locale.US, "%c%c%c", frameId0, frameId1, frameId2);
String id = majorVersion == 2
? String.format(Locale.US, "%c%c%c", frameId0, frameId1, frameId2)
: String.format(Locale.US, "%c%c%c%c", frameId0, frameId1, frameId2, frameId3);
frame = decodeTextInformationFrame(id3Data, frameSize, id);
} else if (frameId0 == 'C' && frameId1 == 'O' && frameId2 == 'M' &&
(frameId3 == 'M' || frameId3 == 0)) {
} else if (frameId0 == 'C' && frameId1 == 'O' && frameId2 == 'M'
&& (frameId3 == 'M' || majorVersion == 2)) {
frame = decodeCommentFrame(id3Data, frameSize);
} else {
String id = frameId3 != 0 ?
String.format(Locale.US, "%c%c%c%c", frameId0, frameId1, frameId2, frameId3) :
String.format(Locale.US, "%c%c%c", frameId0, frameId1, frameId2);
String id = majorVersion == 2
? String.format(Locale.US, "%c%c%c", frameId0, frameId1, frameId2)
: String.format(Locale.US, "%c%c%c%c", frameId0, frameId1, frameId2, frameId3);
frame = decodeBinaryFrame(id3Data, frameSize, id);
}
return frame;
@ -288,16 +328,29 @@ public final class Id3Decoder implements MetadataDecoder {
return new GeobFrame(mimeType, filename, description, objectData);
}
private static ApicFrame decodeApicFrame(ParsableByteArray id3Data, int frameSize)
throws UnsupportedEncodingException {
private static ApicFrame decodeApicFrame(ParsableByteArray id3Data, int frameSize,
int majorVersion) throws UnsupportedEncodingException {
int encoding = id3Data.readUnsignedByte();
String charset = getCharsetName(encoding);
byte[] data = new byte[frameSize - 1];
id3Data.readBytes(data, 0, frameSize - 1);
int mimeTypeEndIndex = indexOfZeroByte(data, 0);
String mimeType = new String(data, 0, mimeTypeEndIndex, "ISO-8859-1");
String mimeType;
int mimeTypeEndIndex;
if (majorVersion == 2) {
mimeTypeEndIndex = 2;
mimeType = "image/" + new String(data, 0, 3, "ISO-8859-1").toLowerCase();
if (mimeType.equals("image/jpg")) {
mimeType = "image/jpeg";
}
} else {
mimeTypeEndIndex = indexOfZeroByte(data, 0);
mimeType = new String(data, 0, mimeTypeEndIndex, "ISO-8859-1").toLowerCase();
if (mimeType.indexOf('/') == -1) {
mimeType = "image/" + mimeType;
}
}
int pictureType = data[mimeTypeEndIndex + 1] & 0xFF;
@ -312,20 +365,6 @@ public final class Id3Decoder implements MetadataDecoder {
return new ApicFrame(mimeType, description, pictureType, pictureData);
}
private static TextInformationFrame decodeTextInformationFrame(ParsableByteArray id3Data,
int frameSize, String id) throws UnsupportedEncodingException {
int encoding = id3Data.readUnsignedByte();
String charset = getCharsetName(encoding);
byte[] data = new byte[frameSize - 1];
id3Data.readBytes(data, 0, frameSize - 1);
int descriptionEndIndex = indexOfEos(data, 0, encoding);
String description = new String(data, 0, descriptionEndIndex, charset);
return new TextInformationFrame(id, description);
}
private static CommentFrame decodeCommentFrame(ParsableByteArray id3Data, int frameSize)
throws UnsupportedEncodingException {
int encoding = id3Data.readUnsignedByte();
@ -348,6 +387,20 @@ public final class Id3Decoder implements MetadataDecoder {
return new CommentFrame(language, description, text);
}
private static TextInformationFrame decodeTextInformationFrame(ParsableByteArray id3Data,
int frameSize, String id) throws UnsupportedEncodingException {
int encoding = id3Data.readUnsignedByte();
String charset = getCharsetName(encoding);
byte[] data = new byte[frameSize - 1];
id3Data.readBytes(data, 0, frameSize - 1);
int descriptionEndIndex = indexOfEos(data, 0, encoding);
String description = new String(data, 0, descriptionEndIndex, charset);
return new TextInformationFrame(id, description);
}
private static BinaryFrame decodeBinaryFrame(ParsableByteArray id3Data, int frameSize,
String id) {
byte[] frame = new byte[frameSize];
@ -395,39 +448,6 @@ public final class Id3Decoder implements MetadataDecoder {
}
}
private static int indexOfEos(byte[] data, int fromIndex, int encoding) {
int terminationPos = indexOfZeroByte(data, fromIndex);
// For single byte encoding charsets, we're done.
if (encoding == ID3_TEXT_ENCODING_ISO_8859_1 || encoding == ID3_TEXT_ENCODING_UTF_8) {
return terminationPos;
}
// Otherwise ensure an even index and look for a second zero byte.
while (terminationPos < data.length - 1) {
if (terminationPos % 2 == 0 && data[terminationPos + 1] == (byte) 0) {
return terminationPos;
}
terminationPos = indexOfZeroByte(data, terminationPos + 1);
}
return data.length;
}
private static int indexOfZeroByte(byte[] data, int fromIndex) {
for (int i = fromIndex; i < data.length; i++) {
if (data[i] == (byte) 0) {
return i;
}
}
return data.length;
}
private static int delimiterLength(int encodingByte) {
return (encodingByte == ID3_TEXT_ENCODING_ISO_8859_1 || encodingByte == ID3_TEXT_ENCODING_UTF_8)
? 1 : 2;
}
private static final class Id3Header {
private final int majorVersion;