Add metadata stream parser and test case.
authorDavid ‘Bombe’ Roden <bombe@pterodactylus.net>
Sat, 25 May 2013 07:52:26 +0000 (09:52 +0200)
committerDavid ‘Bombe’ Roden <bombe@pterodactylus.net>
Mon, 27 May 2013 20:54:37 +0000 (22:54 +0200)
src/main/java/net/pterodactylus/sonitus/io/MetadataStream.java [new file with mode: 0644]
src/test/java/net/pterodactylus/sonitus/io/MetadataStreamTest.java [new file with mode: 0644]

diff --git a/src/main/java/net/pterodactylus/sonitus/io/MetadataStream.java b/src/main/java/net/pterodactylus/sonitus/io/MetadataStream.java
new file mode 100644 (file)
index 0000000..baee88e
--- /dev/null
@@ -0,0 +1,219 @@
+/*
+ * Sonitus - MetadataStream.java - Copyright © 2013 David Roden
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program 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 for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package net.pterodactylus.sonitus.io;
+
+import java.io.BufferedInputStream;
+import java.io.FilterInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.UnsupportedEncodingException;
+import java.nio.ByteBuffer;
+import java.nio.CharBuffer;
+import java.nio.charset.Charset;
+import java.nio.charset.CharsetDecoder;
+import java.nio.charset.CoderResult;
+import java.nio.charset.CodingErrorAction;
+import java.util.Map;
+
+import net.pterodactylus.sonitus.data.ContentMetadata;
+
+import com.google.common.base.Optional;
+import com.google.common.collect.Maps;
+
+/**
+ * Wrapper around an {@link InputStream} that can separate metadata out of
+ * icecast audio streams.
+ * <p/>
+ * {@link #read(byte[])} and {@link #read(byte[], int, int)} are implemented
+ * using {@link #read()} so wrapping the underlying stream into a {@link
+ * BufferedInputStream} is highly recommended.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public class MetadataStream extends FilterInputStream {
+
+       /** The UTF-8 charset. */
+       private static final Charset utf8Charset = Charset.forName("UTF-8");
+
+       /** The interval of the metadata blocks. */
+       private final int metadataInterval;
+
+       /** How many bytes of stream are left before a metadata block is expected. */
+       private int streamRemaining;
+
+       /** The last parsed metadata. */
+       private Optional<ContentMetadata> contentMetadata = Optional.absent();
+
+       /**
+        * Creates a new metadata stream.
+        *
+        * @param inputStream
+        *              The input stream to parse metadata out of
+        * @param metadataInterval
+        *              The interval at which metadata blocks are weaved into the stream
+        */
+       public MetadataStream(InputStream inputStream, int metadataInterval) {
+               super(inputStream);
+               this.metadataInterval = metadataInterval;
+               this.streamRemaining = metadataInterval;
+       }
+
+       //
+       // ACCESSORS
+       //
+
+       /**
+        * Returns the last parsed content metadata of this stream.
+        *
+        * @return The last parsed content metadata
+        */
+       public Optional<ContentMetadata> getContentMetadata() {
+               return contentMetadata;
+       }
+
+       //
+       // PRIVATE METHODS
+       //
+
+       /**
+        * Parses the metadata from the given byte array.
+        *
+        * @param metadataBuffer
+        * @return The parsed metadata, or {@link Optional#absent()} if the metadata
+        *         could not be parsed
+        */
+       private static Optional<ContentMetadata> parseMetadata(byte[] metadataBuffer) {
+
+               /* the byte array may be padded with NULs. */
+               int realLength = metadataBuffer.length;
+               while ((realLength > -1) && (metadataBuffer[realLength - 1] == 0)) {
+                       realLength--;
+               }
+
+               try {
+
+                       /* decode the byte array as a UTF-8 string. */
+                       CharsetDecoder utf8Decoder = utf8Charset.newDecoder();
+                       utf8Decoder.onMalformedInput(CodingErrorAction.REPORT);
+                       CharBuffer decodedBuffer = CharBuffer.allocate(realLength);
+                       CoderResult utf8Result = utf8Decoder.decode(ByteBuffer.wrap(metadataBuffer, 0, realLength), decodedBuffer, true);
+                       utf8Decoder.flush(decodedBuffer);
+
+                       /* use latin-1 as fallback if decoding as UTF-8 failed. */
+                       String metadataString;
+                       if (utf8Result.isMalformed()) {
+                               metadataString = new String(metadataBuffer, 0, realLength, "ISO8859-1");
+                       } else {
+                               metadataString = decodedBuffer.flip().toString();
+                       }
+                       int currentOffset = 0;
+
+                       /* metadata has the form of key='value'[;key='value'[…]] */
+                       Map<String, String> metadataAttributes = Maps.newHashMap();
+                       while (currentOffset < metadataString.length()) {
+                               int equalSign = metadataString.indexOf('=', currentOffset);
+                               if (equalSign == -1) {
+                                       break;
+                               }
+                               String key = metadataString.substring(currentOffset, equalSign);
+                               int semicolon = metadataString.indexOf(';', equalSign);
+                               if (semicolon == -1) {
+                                       break;
+                               }
+                               String value = metadataString.substring(equalSign + 1, semicolon);
+                               if ((value.startsWith("'") && value.endsWith("'")) || (value.startsWith("\"") && value.endsWith("\""))) {
+                                       value = value.substring(1, value.length() - 1);
+                               }
+                               metadataAttributes.put(key, value);
+                               currentOffset = semicolon + 1;
+                       }
+
+                       if (!metadataAttributes.containsKey("StreamTitle")) {
+                               return Optional.absent();
+                       }
+
+                       return Optional.of(new ContentMetadata(metadataAttributes.get("StreamTitle")));
+
+               } catch (UnsupportedEncodingException uee1) {
+                       /* should never happen. */
+                       throw new RuntimeException("UTF-8 not supported");
+               }
+       }
+
+       //
+       // INPUTSTREAM METHODS
+       //
+
+       @Override
+       public int read() throws IOException {
+               int data = super.read();
+               if (data == -1) {
+                       return -1;
+               }
+               if (streamRemaining > 0) {
+                       --streamRemaining;
+               } else if (data == 0) {
+                       /* 0-byte metadata follows, ignore. */
+                       streamRemaining = metadataInterval - 1;
+                       data = super.read();
+               } else {
+                       /* loop until we’ve read all metadata. */
+                       byte[] metadataBuffer = new byte[data * 16];
+                       int metadataPosition = 0;
+                       do {
+                               int metadataByte = super.read();
+                               if (metadataByte == -1) {
+                                       return -1;
+                               }
+                               metadataBuffer[metadataPosition++] = (byte) metadataByte;
+                               if (metadataPosition == metadataBuffer.length) {
+                                       /* parse metadata. */
+                                       Optional<ContentMetadata> parsedMetadata = parseMetadata(metadataBuffer);
+                                       if (parsedMetadata.isPresent()) {
+                                               contentMetadata = parseMetadata(metadataBuffer);
+                                       }
+                                       /* reset metadata buffer and position. */
+                                       metadataBuffer = null;
+                                       metadataPosition = 0;
+                                       /* we read one more byte after the loop. */
+                                       streamRemaining = metadataInterval - 1;
+                               }
+                       } while (metadataBuffer != null);
+                       data = super.read();
+               }
+               return data;
+       }
+
+       @Override
+       public int read(byte[] buffer) throws IOException {
+               return read(buffer, 0, buffer.length);
+       }
+
+       @Override
+       public int read(byte[] buffer, int offset, int length) throws IOException {
+               for (int index = offset; index < (offset + length); ++index) {
+                       int data = read();
+                       if (data == -1) {
+                               return (index > offset) ? (index - offset) : -1;
+                       }
+                       buffer[index] = (byte) data;
+               }
+               return length;
+       }
+
+}
diff --git a/src/test/java/net/pterodactylus/sonitus/io/MetadataStreamTest.java b/src/test/java/net/pterodactylus/sonitus/io/MetadataStreamTest.java
new file mode 100644 (file)
index 0000000..7550bae
--- /dev/null
@@ -0,0 +1,193 @@
+/*
+ * Sonitus - MetadataStreamTest.java - Copyright © 2013 David Roden
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program 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 for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package net.pterodactylus.sonitus.io;
+
+import static org.hamcrest.CoreMatchers.is;
+import static org.junit.Assert.assertThat;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Arrays;
+import java.util.Iterator;
+import java.util.Random;
+
+import com.google.common.collect.FluentIterable;
+import com.google.common.io.ByteStreams;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Test;
+
+/**
+ * Test for {@link MetadataStream}.
+ *
+ * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+ */
+public class MetadataStreamTest {
+
+       /**
+        * Returns test data for {@link #testMetadataStream(int, int, String[])}.
+        *
+        * @return Test data for {@link #testMetadataStream(int, int, String[])}
+        */
+       @DataProvider(name = "testData")
+       public Object[][] getStreamTestParameters() {
+               return new Object[][] {
+                                                                         { 5, 18, new String[] { "Test 1", "Test 2", "Test 3" } },
+                                                                         { 1024, 10240, new String[] { "Test 1", "Test 2" } },
+                                                                         { 1024, 10240, new String[] { "Test 1", "", "", "", "Test 2" } },
+                                                                         { 8192, 10240, new String[] { "Test 1" } },
+                                                                         { 8192, 262144, new String[] { "Test 1", "Test 2" } }
+               };
+       }
+
+       /**
+        * Returns test data for {@link #testMetadataEncoding(String, String)}.
+        *
+        * @return Test data for {@link #testMetadataEncoding(String, String)}
+        */
+       @DataProvider(name = "encodingTestData")
+       public Object[][] getEncodingTestParameters() {
+               return new Object[][] {
+                                                                         { "Metadata mit Ümläute!", "UTF-8" },
+                                                                         { "Metadata mit Ümläute!", "ISO8859-1" }
+               };
+       }
+
+       /**
+        * Tests that the {@link MetadataStream} can successfully separate the payload
+        * from the metadata, and that the stream returns the expected bytes.
+        *
+        * @param metadataInterval
+        *              The interval of the metadata
+        * @param length
+        *              The length of the stream to test
+        * @param metadatas
+        *              The metadata strings to write (empty strings allowed to signify “no
+        *              metadata change”)
+        * @throws IOException
+        *              if an I/O error occurs
+        */
+       @Test(dataProvider = "testData")
+       public void testMetadataStream(int metadataInterval, int length, String[] metadatas) throws IOException {
+               byte[] randomData = generateData(length);
+               InputStream testInputStream = generateInputStream(metadataInterval, randomData, "UTF-8", metadatas);
+               MetadataStream metadataStream = new MetadataStream(testInputStream, metadataInterval);
+               ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
+
+               ByteStreams.copy(metadataStream, outputStream);
+               assertThat(outputStream.toByteArray().length, is(length));
+               assertThat(outputStream.toByteArray(), is(randomData));
+       }
+
+       /**
+        * Tests that the metadata is decoded correcty.
+        *
+        * @param metadata
+        *              The string to encode
+        * @param charset
+        *              The charset in which to encode the string
+        * @throws IOException
+        *              if an I/O error occurs
+        */
+       @Test(dataProvider = "encodingTestData")
+       public void testMetadataEncoding(String metadata, String charset) throws IOException {
+               InputStream testInputStream = generateInputStream(8192, 10240, charset, metadata);
+               MetadataStream metadataStream = new MetadataStream(testInputStream, 8192);
+
+               ByteStreams.copy(metadataStream, new ByteArrayOutputStream());
+               assertThat(metadataStream.getContentMetadata().isPresent(), is(true));
+               assertThat(metadataStream.getContentMetadata().get().title(), is(metadata));
+       }
+
+       //
+       // PRIVATE METHODS
+       //
+
+       /**
+        * Generates a random amount of data.
+        *
+        * @param length
+        *              The length of the data
+        * @return The generated random data
+        */
+       private static byte[] generateData(int length) {
+               Random random = new Random();
+               byte[] buffer = new byte[length];
+               random.nextBytes(buffer);
+               return buffer;
+       }
+
+       /**
+        * Generates an input stream of the given length that has the given metadata
+        * strings (cycled) embedded in the given intervals.
+        *
+        * @param metadataInterval
+        *              The interval of the embedded metadata
+        * @param length
+        *              The length of the stream to generate
+        * @param charset
+        *              The charset with which to encode the metadata strings
+        * @param metadatas
+        *              The metadata strings which will be cycled
+        * @return The generated input stream
+        * @throws IOException
+        *              if an I/O error occurs
+        */
+       private static InputStream generateInputStream(int metadataInterval, int length, String charset, String... metadatas) throws IOException {
+               return generateInputStream(metadataInterval, generateData(length), charset, metadatas);
+       }
+
+       /**
+        * Generates an input stream of the given buffer that has the given metadata
+        * strings (cycled) embedded in the given intervals.
+        *
+        * @param metadataInterval
+        *              The interval of the embedded metadata
+        * @param buffer
+        *              The data to embed the metadata into
+        * @param charset
+        *              The charset with which to encode the metadata strings
+        * @param metadatas
+        *              The metadata strings which will be cycled
+        * @return The generated input stream
+        * @throws IOException
+        *              if an I/O error occurs
+        */
+       private static InputStream generateInputStream(int metadataInterval, byte[] buffer, String charset, String... metadatas) throws IOException {
+               ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
+               Iterator<String> metadataIterator = FluentIterable.from(Arrays.asList(metadatas)).cycle().iterator();
+               int bufferPosition = 0;
+               int remaining = buffer.length;
+               while (remaining > 0) {
+                       int bytesToWrite = Math.min(remaining, metadataInterval);
+                       outputStream.write(buffer, bufferPosition, bytesToWrite);
+                       remaining -= bytesToWrite;
+                       bufferPosition += bytesToWrite;
+                       if (remaining > 0) {
+                               String nextMetadata = "StreamTitle='" + metadataIterator.next() + "';";
+                               byte[] metadata = nextMetadata.getBytes(charset);
+                               outputStream.write((metadata.length + 15) / 16);
+                               outputStream.write(metadata);
+                               outputStream.write(new byte[(16 - metadata.length % 16) % 16]);
+                       }
+               }
+               return new ByteArrayInputStream(outputStream.toByteArray());
+       }
+
+}