/*
* Copyright (c) 2010, 2013, Oracle and/or its affiliates. All rights reserved.
* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
*
* This code is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License version 2 only, as
* published by the Free Software Foundation. Oracle designates this
* particular file as subject to the "Classpath" exception as provided
* by Oracle in the LICENSE file that accompanied this code.
*
* This code is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* version 2 for more details (a copy is included in the LICENSE file that
* accompanied this code).
*
* You should have received a copy of the GNU General Public License version
* 2 along with this work; if not, write to the Free Software Foundation,
* Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
*
* Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
* or visit www.oracle.com if you need additional information or have any
* questions.
*/
package com.sun.media.jfxmediaimpl.platform.java;
import com.sun.media.jfxmediaimpl.MetadataParserImpl;
import java.io.IOException;
import java.util.Arrays;
import com.sun.media.jfxmedia.locator.Locator;
import com.sun.media.jfxmedia.logging.Logger;
import java.nio.charset.Charset;
import java.nio.charset.UnsupportedCharsetException;
final class ID3MetadataParser extends MetadataParserImpl {
private static final int ID3_VERSION_MIN = 2;
private static final int ID3_VERSION_MAX = 4;
private static final String CHARSET_UTF_8 = "UTF-8";
private static final String CHARSET_ISO_8859_1 = "ISO-8859-1";
private static final String CHARSET_UTF_16 = "UTF-16";
private static final String CHARSET_UTF_16BE = "UTF-16BE";
private int COMMCount = 0;
private int TXXXCount = 0;
private int version = 3; // Default to 3
private boolean unsynchronized = false;
public ID3MetadataParser(Locator locator) {
super(locator);
}
protected void parse() {
try {
// We will need ISO-8859-1
if (!Charset.isSupported(CHARSET_ISO_8859_1)) {
throw new UnsupportedCharsetException(CHARSET_ISO_8859_1);
}
//
// An ID3v2 tag can be detected with the following pattern:
//
// byte 0 1 2 3 4 5 6 7 8 9
// value 0x49 0x44 0x33 yy yy xx zz zz zz zz
//
// Where yy is less than 0xFF, xx is the 'flags' byte and zz is
// less than 0x80. We also require the version, byte 3,
// to be exactly 3 to indicate ID3v2.3.0, period.
//
// http://id3.org/id3v2.3.0#head-697d09c50ed7fa96fb66c6b0a9d93585e2652b0b
//
byte[] buf = getBytes(10);
version = (int)(buf[3] & 0xFF);
if (buf[0] == 0x49 && buf[1] == 0x44 && buf[2] == 0x33 &&
(version >= ID3_VERSION_MIN && version <= ID3_VERSION_MAX)) {
int flags = buf[5] & 0xFF;
if ((flags & 0x80) == 0x80) {
unsynchronized = true;
}
int tagSize = 0;
for (int i = 6, shift = 21; i < 10; i++) {
tagSize += (buf[i] & 0x7f) << shift;
shift -= 7;
}
startRawMetadata(tagSize + 10);
stuffRawMetadata(buf, 0, 10); // put the header back in the raw metadata blob
readRawMetadata(tagSize);
setParseRawMetadata(true);
skipBytes(10); // reposition to past the ID3 header
while (getStreamPosition() < tagSize) { // size
int frameSize;
byte[] idBytes;
if (2 == version) {
// v2 has a six byte header
idBytes = getBytes(3);
frameSize = getU24();
} else {
idBytes = getBytes(4);
frameSize = getFrameSize();
skipBytes(2);
}
if (0 == idBytes[0]) {
// terminate on zero padding, NULL characters not allowed in frame ID
if (Logger.canLog(Logger.DEBUG)) {
Logger.logMsg(Logger.DEBUG, "ID3MetadataParser", "parse",
"ID3 parser: zero padding detected at "
+getStreamPosition()+", terminating");
}
break;
}
String frameID = new String(idBytes, Charset.forName(CHARSET_ISO_8859_1));
if (Logger.canLog(Logger.DEBUG)) {
Logger.logMsg(Logger.DEBUG, "ID3MetadataParser", "parse",
getStreamPosition()+"\\"+tagSize
+": frame ID "+frameID+", size "+frameSize);
}
if (frameID.equals("APIC") || frameID.equals("PIC")) {
byte[] data = getBytes(frameSize);
if (unsynchronized) {
data = unsynchronizeBuffer(data);
}
byte[] image = frameID.equals("PIC") ? getImageFromPIC(data) : getImageFromAPIC(data);
if (image != null) {
addMetadataItem("image", image);
}
} else if (frameID.startsWith("T") && !frameID.equals("TXXX")) {
String encoding = getEncoding();
byte[] data = getBytes(frameSize - 1);
if (unsynchronized) {
data = unsynchronizeBuffer(data);
}
String value = new String(data, encoding);
String[] tag = getTagFromFrameID(frameID);
if (tag != null) {
for (int i = 0; i < tag.length; i++) {
Object tagValue = convertValue(tag[i], value);
if (tagValue != null) {
addMetadataItem(tag[i], tagValue);
}
}
}
} else if (frameID.equals("COMM") || frameID.equals("COM")) {
String encoding = getEncoding();
// Get language
byte[] data = getBytes(3);
if (unsynchronized) {
data = unsynchronizeBuffer(data);
}
String language = new String(data, Charset.forName(CHARSET_ISO_8859_1));
// Get content description and comment
data = getBytes(frameSize - 4);
if (unsynchronized) {
data = unsynchronizeBuffer(data);
}
String value = new String(data, encoding);
if (value != null) {
int index = value.indexOf(0x00);
String content = "";
String comment;
if (index == 0) {
if (isTwoByteEncoding(encoding)) {
comment = value.substring(2);
} else {
comment = value.substring(1);
}
} else {
content = value.substring(0, index);
if (isTwoByteEncoding(encoding)) {
comment = value.substring(index + 2);
} else {
comment = value.substring(index + 1);
}
}
String[] tag = getTagFromFrameID(frameID);
if (tag != null) {
for (int i = 0; i < tag.length; i++) {
addMetadataItem(tag[i] + "-" + COMMCount, content + "[" + language + "]=" + comment);
COMMCount++;
}
}
}
} else if (frameID.equals("TXX") || frameID.equals("TXXX")) {
String encoding = getEncoding();
byte[] data = getBytes(frameSize-1);
if (unsynchronized) {
data = unsynchronizeBuffer(data);
}
String value = new String(data, encoding);
if (null != value){
int index = value.indexOf(0x00);
String description = (index != 0) ? value.substring(0, index) : "";
String text = isTwoByteEncoding(encoding) ? value.substring(index+2) : value.substring(index+1);
String[] tag = getTagFromFrameID(frameID);
if (tag != null) {
for (int i = 0; i < tag.length; i++) {
if (description.equals("")) {
addMetadataItem(tag[i] + "-" + TXXXCount, text);
} else {
addMetadataItem(tag[i] + "-" + TXXXCount, description + "=" + text);
}
TXXXCount++;
}
}
}
} else {
// Unknown or unsupported frame.
skipBytes(frameSize);
}
}
}
} catch (Exception ex) {
// Ignore all exceptions as it is preferable to play audio.
// fail gracefully, probably just hit the end of the buffer
if (Logger.canLog(Logger.WARNING)) {
Logger.logMsg(Logger.WARNING, "ID3MetadataParser", "parse",
"Exception while processing ID3v2 metadata: "+ex);
}
} finally {
if (null != rawMetaBlob) {
setParseRawMetadata(false);
addRawMetadata(RAW_ID3_METADATA_NAME);
disposeRawMetadata();
}
done();
}
}
private int getFrameSize() throws IOException {
if (version == 4) {
byte[] buf = getBytes(4);
int size = 0;
for (int i = 0, shift = 21; i < 4; i++) {
size += (buf[i] & 0x7f) << shift;
shift -= 7;
}
return size;
} else {
return getInteger();
}
}
private String getEncoding() throws IOException {
byte encodingType = getNextByte();
if (encodingType == 0x00) {
return CHARSET_ISO_8859_1;
} else if (encodingType == 0x01) {
return CHARSET_UTF_16;
} else if (encodingType == 0x02) {
return CHARSET_UTF_16BE;
} else if (encodingType == 0x03) {
return CHARSET_UTF_8;
} else {
throw new IllegalArgumentException();
}
}
private boolean isTwoByteEncoding(String encoding) {
if (encoding.equals(CHARSET_ISO_8859_1) || encoding.equals(CHARSET_UTF_8)) {
return false;
} else if (encoding.equals(CHARSET_UTF_16) || encoding.equals(CHARSET_UTF_16BE)) {
return true;
} else {
throw new IllegalArgumentException();
}
}
/* Supported tags:
* PIC / APIC -> image
* TP2 / TPE2 -> ALBUMARTIST_TAG_NAME
* TAL / TALB -> ALBUM_TAG_NAME
* TP1 / TPE1 -> ARTIST_TAG_NAME
* COM / COMM -> COMMENT_TAG_NAME
* TCM / TCOM -> COMPOSER_TAG_NAME
* TLE / TLEN -> DURATION_TAG_NAME
* TCO / TCON -> GENRE_TAG_NAME
* TT2 / TIT2 -> TITLE_TAG_NAME
* TRK / TRCK -> TRACKNUMBER_TAG_NAME, TRACKCOUNT_TAG_NAME
* TPA / TPOS -> DISCNUMBER_TAG_NAME DISCCOUNT_TAG_NAME
* TYE / TYER / TDRC -> YEAR_TAG_NAME
* TXX / TXXX -> TEXT_TAG_NAME
*/
private String[] getTagFromFrameID(String frameID) {
if (frameID.equals("TPE2") || frameID.equals("TP2")) {
return new String[]{MetadataParserImpl.ALBUMARTIST_TAG_NAME};
} else if (frameID.equals("TALB") || frameID.equals("TAL")) {
return new String[]{MetadataParserImpl.ALBUM_TAG_NAME};
} else if (frameID.equals("TPE1") || frameID.equals("TP1")) {
return new String[]{MetadataParserImpl.ARTIST_TAG_NAME};
} else if (frameID.equals("COMM") || frameID.equals("COM")) {
return new String[]{MetadataParserImpl.COMMENT_TAG_NAME};
} else if (frameID.equals("TCOM") || frameID.equals("TCM")) {
return new String[]{MetadataParserImpl.COMPOSER_TAG_NAME};
} else if (frameID.equals("TLEN") || frameID.equals("TLE")) {
return new String[]{MetadataParserImpl.DURATION_TAG_NAME};
} else if (frameID.equals("TCON") || frameID.equals("TCO")) {
return new String[]{MetadataParserImpl.GENRE_TAG_NAME};
} else if (frameID.equals("TIT2") || frameID.equals("TT2")) {
return new String[]{MetadataParserImpl.TITLE_TAG_NAME};
} else if (frameID.equals("TRCK") || frameID.equals("TRK")) {
return new String[]{MetadataParserImpl.TRACKNUMBER_TAG_NAME, MetadataParserImpl.TRACKCOUNT_TAG_NAME};
} else if (frameID.equals("TPOS") || frameID.equals("TPA")) {
return new String[]{MetadataParserImpl.DISCNUMBER_TAG_NAME, MetadataParserImpl.DISCCOUNT_TAG_NAME};
} else if (frameID.equals("TYER") || frameID.equals("TDRC")) {
return new String[]{MetadataParserImpl.YEAR_TAG_NAME};
} else if (frameID.equals("TXX") || frameID.equals("TXXX")) {
return new String[]{MetadataParserImpl.TEXT_TAG_NAME};
}
return null;
}
private byte[] getImageFromPIC(byte[] data) {
/*
* Attached picture "PIC"
* Frame size $xx xx xx
* (data byte array starts here)
* Text encoding $xx
* Image format $xx xx xx (PNG/JPG)
* Picture type $xx
* Description <textstring> $00 (00)
* Picture data <binary data>
*/
// find end of description string
int imgOffset = 5;
while (0 != data[imgOffset] && imgOffset < data.length) {
imgOffset++;
}
if (imgOffset == data.length) {
// only description? maybe it's a URI
return null;
}
String type = new String(data, 1, 3, Charset.forName(CHARSET_ISO_8859_1));
if (Logger.canLog(Logger.DEBUG)) {
Logger.logMsg(Logger.DEBUG, "ID3MetadataParser", "getImageFromPIC",
"PIC type: "+type);
}
if (type.equalsIgnoreCase("PNG") || type.equalsIgnoreCase("JPG")) {
// image data follows description
return Arrays.copyOfRange(data, imgOffset+1, data.length);
}
if (Logger.canLog(Logger.WARNING)) {
Logger.logMsg(Logger.WARNING, "ID3MetadataParser", "getImageFromPIC",
"Unsupported picture type found \""+type+"\"");
}
return null;
}
private byte[] getImageFromAPIC(byte[] data) {
boolean isImageJPEG = false;
boolean isImagePNG = false;
// Look for string "image/".
int maxIndex = data.length - 10;
int offset = 0;
for (int j = 0; j < maxIndex; j++) {
if (data[j] == 'i'
&& data[j + 1] == 'm'
&& data[j + 2] == 'a'
&& data[j + 3] == 'g'
&& data[j + 4] == 'e'
&& data[j + 5] == '/') {
// Found "image/"; offset by its length.
j += 6;
// Look for "jpeg".
if (data[j] == 'j'
&& data[j + 1] == 'p'
&& data[j + 2] == 'e'
&& data[j + 3] == 'g') {
// MIME type "image/jpeg" found; save offset.
isImageJPEG = true;
offset = j + 4;
break;
} // Look for "png".
else if (data[j] == 'p'
&& data[j + 1] == 'n'
&& data[j + 2] == 'g') {
// MIME type "image/png" found; save offset.
isImagePNG = true;
offset = j + 3;
break;
}
}
} // for j
if (isImageJPEG) {
// Look for JPEG signature.
boolean isSignatureFound = false;
int upperBound = data.length - 1;
for (int j = offset; j < upperBound; j++) {
// JPEG start of image (SOI) marker is 0xff 0xd8
if (-1 == data[j] && -40 == data[j + 1]) {
// JPEG SOI found.
isSignatureFound = true;
offset = j;
break; // JPEG image
}
}
if (isSignatureFound) {
// Save JPEG data stream starting at SOI.
return Arrays.copyOfRange(data, offset, data.length);
}
} // isImageJPEG
if (isImagePNG) {
// Look for PNG signature.
boolean isSignatureFound = false;
int upperBound = data.length - 7;
for (int j = offset; j < upperBound; j++) {
// PNG decimal signature {137 80 78 71 13 10 26 10}.
if (-119 == data[j]
&& 80 == data[j + 1]
&& 78 == data[j + 2]
&& 71 == data[j + 3]
&& 13 == data[j + 4]
&& 10 == data[j + 5]
&& 26 == data[j + 6]
&& 10 == data[j + 7]) // increment by 1
{
// PNG signature found.
isSignatureFound = true;
offset = j;
break; // PNG image
}
}
if (isSignatureFound) {
// Save PNG data stream starting at signature.
return Arrays.copyOfRange(data, offset, data.length);
}
} // isImagePNG
return null;
}
private byte[] unsynchronizeBuffer(byte[] data) {
byte[] udata = new byte[data.length];
int udatalen = 0;
for (int i = 0; i < data.length; i++) {
if (((data[i] & 0xFF) == 0xFF && data[i + 1] == 0x00 && data[i + 2] == 0x00)
|| ((data[i] & 0xFF) == 0xFF && data[i + 1] == 0x00 && (data[i + 2] & 0xE0) == 0xE0)) {
udata[udatalen] = data[i];
udatalen++;
udata[udatalen] = data[i + 2];
udatalen++;
i += 2;
} else {
udata[udatalen] = data[i];
udatalen++;
}
}
return Arrays.copyOf(udata, udatalen);
}
}