/*
* WmaTag.java
*
* Created on Jun 9, 2007, 1:17:17 PM
*
* This code was originally taken from Limewire: http://limewire.com, which in
* turn got it from some other places, follow the trail...
*
*/
package com.pugh.sockso.music.tag;
import com.pugh.sockso.Utils;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.FileInputStream;
import java.io.BufferedInputStream;
import java.io.DataInputStream;
import java.io.UnsupportedEncodingException;
import java.io.FilterInputStream;
import java.io.EOFException;
import java.util.Arrays;
import org.apache.log4j.Logger;
public class WmaTag extends AudioTag {
private static final Logger log = Logger.getLogger( WmaTag.class );
// data types we know about in the extended content description.
// THESE ARE WRONG (but close enough for now)
private static final int TYPE_STRING = 0;
private static final int TYPE_BINARY = 1;
private static final int TYPE_BOOLEAN = 2;
private static final int TYPE_INT = 3;
private static final int TYPE_LONG = 4;
private String _album, _artist, _albumArtist, _title, _year, _copyright,
_rating, _genre, _comment, _drmType;
private short _track = -1;
private int _bitrate = -1, _length = -1, _width = -1, _height = -1;
private boolean _hasAudio, _hasVideo;
@Override
public String getAlbum() { return notnull(_album); }
@Override
public String getArtist() { return notnull(_artist); }
@Override
public String getAlbumArtist() { return notnull(_albumArtist); }
@Override
public String getTrack() { return notnull(_title); }
@Override
public String getGenre() { return notnull(_genre); }
String getTitle() { return _title; }
String getYear() { return _year; }
String getCopyright() { return _copyright; }
String getRating() { return _rating; }
String getComment() { return _comment; }
int getBitrate() { return _bitrate; }
int getLength() { return _length; }
int getWidth() { return _width; }
int getHeight() { return _height; }
boolean hasAudio() { return _hasAudio; }
boolean hasVideo() { return _hasVideo; }
/**
* Takes a string and if it's null returns the empty string
*
* @param str
*
* @return
*
*/
protected String notnull( final String str ) {
return ( str == null ) ? "" : str;
}
/**
* Parses the given file for metadata we understand.
*/
public void parse( final File f ) throws IOException {
InputStream is = null;
try {
is = new BufferedInputStream( new FileInputStream(f) );
parse( is );
}
finally {
Utils.close( is );
}
}
/**
* Parses a ASF input stream's metadata.
* This first checks that the marker (16 bytes) is correct, reads the data offset & object count,
* and then iterates through the objects, reading them.
* Each object is stored in the format:
* ObjectID (16 bytes)
* Object Size (4 bytes)
* Object (Object Size bytes)
*/
private void parse( final InputStream is ) throws IOException {
final CountingInputStream counter = new CountingInputStream(is);
final DataInputStream ds = new DataInputStream(counter);
final byte[] marker = new byte[ IDs.HEADER_ID.length ];
ds.readFully( marker );
if ( !Arrays.equals(marker,IDs.HEADER_ID) )
throw new IOException( "not an ASF file" );
final long dataOffset = ByteOrder.leb2long( ds );
final int objectCount = ByteOrder.leb2int( ds );
ensureSkip( ds, 2 );
if ( dataOffset < 0 )
throw new IOException( "ASF file is corrupt. Data offset negative:" +dataOffset );
if ( objectCount < 0 )
throw new IOException( "ASF file is corrupt. Object count unreasonable:" +ByteOrder.uint2long(objectCount) );
if ( objectCount > 100 )
throw new IOException( "object count very high: " + objectCount );
final byte[] object = new byte[ 16 ];
for ( int i=0; i<objectCount; i++ ) {
ds.readFully(object);
final long size = ByteOrder.leb2long(ds) - 24;
if ( size < 0 )
throw new IOException( "ASF file is corrupt. Object size < 0 :" +size );
counter.clearAmountRead();
readObject( ds, object, size );
final int read = counter.getAmountRead();
if ( read > size )
throw new IOException( "read (" +read+ ") more than size (" +size+ ")" );
else if ( read != size ) {
ensureSkip( ds, size - read );
}
}
}
/**
* Reads a single object from a ASF metadata stream.
* The objectID has already been read. Each object is stored differently.
*/
private void readObject( final DataInputStream ds, final byte[] id, final long size ) throws IOException {
if( Arrays.equals(id, IDs.FILE_PROPERTIES_ID) )
parseFileProperties(ds);
else if( Arrays.equals(id, IDs.STREAM_PROPERTIES_ID) )
parseStreamProperties(ds);
else if( Arrays.equals(id, IDs.EXTENDED_STREAM_PROPERTIES_ID) )
parseExtendedStreamProperties(ds);
else if( Arrays.equals(id, IDs.CONTENT_DESCRIPTION_ID) )
parseContentDescription(ds);
else if( Arrays.equals(id, IDs.EXTENDED_CONTENT_DESCRIPTION_ID) )
parseExtendedContentDescription(ds);
else if( Arrays.equals(id, IDs.CONTENT_ENCRYPTION_ID) )
parseContentEncryption(ds);
else {
// for debugging.
final byte[] temp = new byte[ (int) size ];
ds.readFully( temp );
log.debug("id: " + string(id) + ", data: " + string(temp) );
}
}
private static long ensureSkip( final InputStream in, final long length ) throws IOException {
long skipped = 0;
while( skipped < length ) {
final long current = in.skip(length - skipped);
if ( current == -1 || current == 0 )
throw new EOFException( "eof" );
else
skipped += current;
}
return skipped;
}
/**
* Parses known information out of the file properties object.
*/
private void parseFileProperties( final DataInputStream ds ) throws IOException {
ensureSkip( ds, 48 );
final int duration = (int)(ByteOrder.leb2long(ds) / 10000000);
if ( duration < 0 )
throw new IOException( "ASF file corrupt. Duration < 0:" +duration );
_length = duration;
ensureSkip( ds, 20 );
final int maxBR = ByteOrder.leb2int(ds);
if ( maxBR < 0 )
throw new IOException("ASF file corrupt. Max bitrate > 2 Gb/s:" +ByteOrder.uint2long(maxBR) );
_bitrate = maxBR / 1000;
}
/**
* Parses stream properties to see if we have audio or video data.
*/
private void parseStreamProperties( final DataInputStream ds ) throws IOException {
final byte[] streamID = new byte[16];
ds.readFully(streamID);
if ( Arrays.equals(streamID, IDs.AUDIO_STREAM_ID) ) {
_hasAudio = true;
}
else if ( Arrays.equals(streamID, IDs.VIDEO_STREAM_ID) ) {
_hasVideo = true;
ensureSkip( ds, 38 );
_width = ByteOrder.leb2int( ds );
if ( _width < 0 )
throw new IOException( "ASF file corrupt. Video width excessive:" +ByteOrder.uint2long(_width) );
_height = ByteOrder.leb2int( ds );
if ( _height < 0 )
throw new IOException( "ASF file corrupt. Video height excessive:" +ByteOrder.uint2long(_height) );
}
// we aren't reading everything, but we'll skip over just fine.
}
/**
* Parses known information out of the extended stream properties object.
*/
private void parseExtendedStreamProperties( final DataInputStream ds ) throws IOException {
ensureSkip(ds, 56);
final int sampleRate = ByteOrder.leb2int(ds);
final int byteRate = ByteOrder.leb2int(ds);
if ( sampleRate < 0 )
throw new IOException( "ASF file corrupt. Sample rate excessive:" +ByteOrder.uint2long(sampleRate) );
if ( byteRate < 0 )
throw new IOException( "ASF file corrupt. Byte rate excessive:" +ByteOrder.uint2long(byteRate) );
if ( _bitrate == -1 )
_bitrate = byteRate * 8 / 1000;
}
/**
* Parses the content encryption object, to determine if the file is protected.
* We parse through it all, even though we don't use all of it, to ensure
* that the object is well-formed.
*/
private void parseContentEncryption( final DataInputStream ds ) throws IOException {
long skipSize = ByteOrder.uint2long(ByteOrder.leb2int(ds)); // data
ensureSkip(ds, skipSize);
final int typeSize = ByteOrder.leb2int( ds ); // type
if ( typeSize < 0 )
throw new IOException("ASF file is corrupt. Type size < 0: "+typeSize);
final byte[] b = new byte[ typeSize ];
ds.readFully( b );
_drmType = new String( b ).trim();
skipSize = ByteOrder.uint2long(ByteOrder.leb2int(ds)); // data
ensureSkip( ds, skipSize );
skipSize = ByteOrder.uint2long(ByteOrder.leb2int(ds)); // url
ensureSkip( ds, skipSize );
}
/**
* Parses known information out of the Content Description object.
* The data is stored as:
* 10 bytes of sizes (2 bytes for each size).
* The data corresponding to each size. The data is stored in order of:
* Title, Author, Copyright, Description, Rating.
*/
private void parseContentDescription( final DataInputStream ds ) throws IOException {
final int[] sizes = { -1, -1, -1, -1, -1 };
for( int i=0; i<sizes.length; i++ )
sizes[i] = ByteOrder.ushort2int( ByteOrder.leb2short(ds) );
final byte[][] info = new byte[5][];
for( int i=0; i<sizes.length; i++ )
info[i] = new byte[ sizes[i] ];
for ( int i=0; i<info.length; i++ )
ds.readFully( info[i] );
_title = string( info[0] );
_artist = string( info[1] );
_copyright = string( info[2] );
_comment = string( info[3] );
_rating = string( info[4] );
}
/**
* Reads the extended Content Description object.
* The extended tag has an arbitrary number of fields.
* The number of fields is stored first, as:
* Field Count (2 bytes)
*
* Each field is stored as:
* Field Size (2 bytes)
* Field (Field Size bytes)
* Data Type (2 bytes)
* Data Size (2 bytes)
* Data (Data Size bytes)
*/
private void parseExtendedContentDescription( final DataInputStream ds ) throws IOException {
final int fieldCount = ByteOrder.ushort2int(ByteOrder.leb2short(ds));
for ( int i=0; i<fieldCount; i++ ) {
final int fieldSize = ByteOrder.ushort2int( ByteOrder.leb2short(ds) );
final byte[] field = new byte[fieldSize];
ds.readFully( field );
final String fieldName = string( field );
final int dataType = ByteOrder.ushort2int( ByteOrder.leb2short(ds) );
final int dataSize = ByteOrder.ushort2int( ByteOrder.leb2short(ds) );
switch ( dataType ) {
case TYPE_STRING:
parseExtendedString( fieldName, dataSize, ds );
break;
case TYPE_BINARY:
parseExtendedBinary( dataSize, ds );
break;
case TYPE_BOOLEAN:
parseExtendedBoolean( dataSize, ds );
break;
case TYPE_INT:
parseExtendedInt( fieldName, dataSize, ds );
break;
case TYPE_LONG:
parseExtendedInt( fieldName, dataSize, ds );
break;
default:
ensureSkip( ds, dataSize );
}
}
}
/**
* Parses a value from an extended tag, assuming the value is of the 'string' dataType.
*
*/
private void parseExtendedString( final String field, final int size, final DataInputStream ds ) throws IOException {
final byte[] data = new byte[ Math.min(250, size) ];
ds.readFully( data );
final int leftover = Math.max(0, size - 250);
ensureSkip( ds, leftover );
final String info = string( data );
if ( Extended.WM_TITLE.equals(field) && _title == null )
_title = info;
else if ( Extended.WM_AUTHOR.equals(field) && _artist == null )
_artist = info;
else if ( Extended.WM_ALBUMTITLE.equals(field) && _album == null )
_album = info;
else if ( Extended.WM_ALBUMARTIST.equals(field) && _albumArtist == null )
_albumArtist = info;
else if ( Extended.WM_TRACK_NUMBER.equals(field) && _track == -1 )
_track = toShort(info);
else if ( Extended.WM_YEAR.equals(field) && _year == null )
_year = info;
else if ( Extended.WM_GENRE.equals(field) && _genre == null )
_genre = info;
else if ( Extended.WM_DESCRIPTION.equals(field) && _comment == null )
_comment = info;
}
/**
* Parses a value from an extended tag, assuming the value is of the 'boolean' dataType.
*
*/
private void parseExtendedBoolean( final int size, final DataInputStream ds) throws IOException {
ensureSkip( ds, size );
}
/**
* Parses a value from an extended tag, assuming the value is of the 'int' dataType.
*
*/
private void parseExtendedInt( final String field, final int size, final DataInputStream ds ) throws IOException {
if ( size != 4 ) {
ensureSkip( ds, size );
return;
}
final int value = ByteOrder.leb2int(ds);
if ( Extended.WM_TRACK_NUMBER.equals(field) && _track == -1 ) {
final short shortValue = (short)value;
if ( shortValue < 0 )
throw new IOException( "ASF file reports negative track number " +shortValue );
_track = shortValue;
}
}
/**
* Parses a value from an extended tag, assuming the value is of the 'binary' dataType.
*
*/
private void parseExtendedBinary( final int size, final DataInputStream ds ) throws IOException {
ensureSkip( ds, size );
}
/**
* Converts a String to a short, if it can.
*
*/
private short toShort( final String x ) {
try {
return Short.parseShort( x );
}
catch( final NumberFormatException nfe ) {
return -1;
}
}
/**
* Returns a String uses ASF's encoding (WCHAR: UTF-16 little endian).
* If we don't support that encoding for whatever, hack out the zeros.
*
*/
private String string( final byte[] x ) throws IOException {
if ( x == null )
return null;
try {
return new String( x, "UTF-16LE" ).trim();
}
catch( final UnsupportedEncodingException uee ) {
// hack.
int pos = 0;
for ( int i=0; i<x.length; i++ ) {
if ( x[i] != 0 )
x[pos++] = x[i];
}
return new String( x, 0, pos, "UTF-8" );
}
}
private static class IDs {
private static final byte HEADER_ID[] =
{ (byte)0x30, (byte)0x26, (byte)0xB2, (byte)0x75, (byte)0x8E, (byte)0x66, (byte)0xCF, (byte)0x11,
(byte)0xA6, (byte)0xD9, (byte)0x00, (byte)0xAA, (byte)0x00, (byte)0x62, (byte)0xCE, (byte)0x6C };
private static final byte FILE_PROPERTIES_ID[] =
{ (byte)0xA1, (byte)0xDC, (byte)0xAB, (byte)0x8C, (byte)0x47, (byte)0xA9, (byte)0xCF, (byte)0x11,
(byte)0x8E, (byte)0xE4, (byte)0x00, (byte)0xC0, (byte)0x0C, (byte)0x20, (byte)0x53, (byte)0x65 };
private static final byte STREAM_PROPERTIES_ID[] =
{ (byte)0x91, (byte)0x07, (byte)0xDC, (byte)0xB7, (byte)0xB7, (byte)0xA9, (byte)0xCF, (byte)0x11,
(byte)0x8E, (byte)0xE6, (byte)0x00, (byte)0xC0, (byte)0x0C, (byte)0x20, (byte)0x53, (byte)0x65 };
private static final byte EXTENDED_STREAM_PROPERTIES_ID[] =
{ (byte)0xCB, (byte)0xA5, (byte)0xE6, (byte)0x14, (byte)0x72, (byte)0xC6, (byte)0x32, (byte)0x43,
(byte)0x83, (byte)0x99, (byte)0xA9, (byte)0x69, (byte)0x52, (byte)0x06, (byte)0x5B, (byte)0x5A };
private static final byte CONTENT_DESCRIPTION_ID[] =
{ (byte)0x33, (byte)0x26, (byte)0xB2, (byte)0x75, (byte)0x8E, (byte)0x66, (byte)0xCF, (byte)0x11,
(byte)0xA6, (byte)0xD9, (byte)0x00, (byte)0xAA, (byte)0x00, (byte)0x62, (byte)0xCE, (byte)0x6C };
private static final byte EXTENDED_CONTENT_DESCRIPTION_ID[] =
{ (byte)0x40, (byte)0xA4, (byte)0xD0, (byte)0xD2, (byte)0x07, (byte)0xE3, (byte)0xD2, (byte)0x11,
(byte)0x97, (byte)0xF0, (byte)0x00, (byte)0xA0, (byte)0xC9, (byte)0x5E, (byte)0xA8, (byte)0x50 };
private static final byte CONTENT_ENCRYPTION_ID[] =
{ (byte)0xFB, (byte)0xB3, (byte)0x11, (byte)0x22, (byte)0x23, (byte)0xBD, (byte)0xD2, (byte)0x11,
(byte)0xB4, (byte)0xB7, (byte)0x00, (byte)0xA0, (byte)0xC9, (byte)0x55, (byte)0xFC, (byte)0x6E };
@SuppressWarnings("unused")
private static final byte EXTENDED_CONTENT_ENCRYPTION_ID[] =
{ (byte)0x14, (byte)0xE6, (byte)0x8A, (byte)0x29, (byte)0x22, (byte)0x26, (byte)0x17, (byte)0x4C,
(byte)0xB9, (byte)0x35, (byte)0xDA, (byte)0xE0, (byte)0x7E, (byte)0xE9, (byte)0x28, (byte)0x9C };
@SuppressWarnings("unused")
private static final byte CODEC_LIST_ID[] =
{ (byte)0x40, (byte)0x52, (byte)0xD1, (byte)0x86, (byte)0x1D, (byte)0x31, (byte)0xD0, (byte)0x11,
(byte)0xA3, (byte)0xA4, (byte)0x00, (byte)0xA0, (byte)0xC9, (byte)0x03, (byte)0x48, (byte)0xF6 };
private static final byte AUDIO_STREAM_ID[] =
{ (byte)0x40, (byte)0x9E, (byte)0x69, (byte)0xF8, (byte)0x4D, (byte)0x5B, (byte)0xCF, (byte)0x11,
(byte)0xA8, (byte)0xFD, (byte)0x00, (byte)0x80, (byte)0x5F, (byte)0x5C, (byte)0x44, (byte)0x2B };
private static final byte VIDEO_STREAM_ID[] =
{ (byte)0xC0, (byte)0xEF, (byte)0x19, (byte)0xBC, (byte)0x4D, (byte)0x5B, (byte)0xCF, (byte)0x11,
(byte)0xA8, (byte)0xFD, (byte)0x00, (byte)0x80, (byte)0x5F, (byte)0x5C, (byte)0x44, (byte)0x2B };
}
private static class Extended {
/** the title of the file */
private static final String WM_TITLE = "WM/Title";
/** the author of the file */
private static final String WM_AUTHOR = "WM/Author";
/** the title of the album the file is on */
private static final String WM_ALBUMTITLE = "WM/AlbumTitle";
/** the zero-based track of the song */
@SuppressWarnings("unused")
private static final String WM_TRACK = "WM/Track";
/** the one-based track of the song */
private static final String WM_TRACK_NUMBER = "WM/TrackNumber";
/** the year the song was made */
private static final String WM_YEAR = "WM/Year";
/** the genre of the song */
private static final String WM_GENRE = "WM/Genre";
/** the description of the song */
private static final String WM_DESCRIPTION = "WM/Description";
/** the lyrics of the song */
@SuppressWarnings("unused")
private static final String WM_LYRICS = "WM/Lyrics";
/** whether or not this is encoded in VBR */
@SuppressWarnings("unused")
private static final String VBR = "IsVBR";
/** the unique file identifier of this song */
@SuppressWarnings("unused")
private static final String WM_UNIQUE_FILE_IDENTIFIER = "WM/UniqueFileIdentifier";
/** the artist of the album as a whole */
@SuppressWarnings("unused")
private static final String WM_ALBUMARTIST = "WM/AlbumArtist";
/** the encapsulated ID3 info */
@SuppressWarnings("unused")
private static final String ID3 = "ID3";
/** the provider of the song */
@SuppressWarnings("unused")
private static final String WM_PROVIDER = "WM/Provider";
/** the rating the provider gave this song */
@SuppressWarnings("unused")
private static final String WM_PROVIDER_RATING = "WM/ProviderRating";
/** the publisher */
@SuppressWarnings("unused")
private static final String WM_PUBLISHER = "WM/Publisher";
/** the composer */
@SuppressWarnings("unused")
private static final String WM_COMPOSER = "WM/Composer";
/** the time the song was encoded */
@SuppressWarnings("unused")
private static final String WM_ENCODING_TIME = "WM/EncodingTime";
}
}
class CountingInputStream extends FilterInputStream {
private int _count = 0;
public CountingInputStream( final InputStream in ) {
super(in);
}
@Override
public int read() throws IOException {
final int read = super.read();
if ( read != -1 ) {
_count++;
}
return read;
}
@Override
public int read( final byte[] b, final int off, final int len ) throws IOException {
int read;
try {
read = super.read(b, off, len);
}
catch( final ArrayIndexOutOfBoundsException aioob ) {
// happens.
throw new IOException();
}
if ( read != -1 )
_count += read;
return read;
}
@Override
public long skip( final long n ) throws IOException {
final long skipped = super.skip(n);
_count += (int) skipped;
return skipped;
}
@Override
public void close() throws IOException {
in.close();
}
public int getAmountRead() {
return _count;
}
public void clearAmountRead() {
_count = 0;
}
}