From 7594f5b78ba216f6085675972f830b2ebfe230ee Mon Sep 17 00:00:00 2001 From: Oliver Woodman Date: Tue, 18 Oct 2016 15:02:35 +0100 Subject: [PATCH] Further enhance ID3 decoder + support --- .../extractor/GaplessInfoHolder.java | 21 +++ .../exoplayer2/extractor/mp3/Id3Util.java | 95 ---------- .../extractor/mp3/Mp3Extractor.java | 78 +++++++-- .../exoplayer2/metadata/id3/Id3Decoder.java | 162 ++++++++++-------- 4 files changed, 175 insertions(+), 181 deletions(-) delete mode 100644 library/src/main/java/com/google/android/exoplayer2/extractor/mp3/Id3Util.java diff --git a/library/src/main/java/com/google/android/exoplayer2/extractor/GaplessInfoHolder.java b/library/src/main/java/com/google/android/exoplayer2/extractor/GaplessInfoHolder.java index 6eb9bc50de..4b5fa977ee 100644 --- a/library/src/main/java/com/google/android/exoplayer2/extractor/GaplessInfoHolder.java +++ b/library/src/main/java/com/google/android/exoplayer2/extractor/GaplessInfoHolder.java @@ -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. diff --git a/library/src/main/java/com/google/android/exoplayer2/extractor/mp3/Id3Util.java b/library/src/main/java/com/google/android/exoplayer2/extractor/mp3/Id3Util.java deleted file mode 100644 index af08514889..0000000000 --- a/library/src/main/java/com/google/android/exoplayer2/extractor/mp3/Id3Util.java +++ /dev/null @@ -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() {} - -} diff --git a/library/src/main/java/com/google/android/exoplayer2/extractor/mp3/Mp3Extractor.java b/library/src/main/java/com/google/android/exoplayer2/extractor/mp3/Mp3Extractor.java index 54c4219e5a..acec0c5567 100644 --- a/library/src/main/java/com/google/android/exoplayer2/extractor/mp3/Mp3Extractor.java +++ b/library/src/main/java/com/google/android/exoplayer2/extractor/mp3/Mp3Extractor.java @@ -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 diff --git a/library/src/main/java/com/google/android/exoplayer2/metadata/id3/Id3Decoder.java b/library/src/main/java/com/google/android/exoplayer2/metadata/id3/Id3Decoder.java index 46b7dbde76..3e1bbe159a 100644 --- a/library/src/main/java/com/google/android/exoplayer2/metadata/id3/Id3Decoder.java +++ b/library/src/main/java/com/google/android/exoplayer2/metadata/id3/Id3Decoder.java @@ -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;