From b3a371bbaee012ad475d31a78993cace4299175b Mon Sep 17 00:00:00 2001 From: =?utf8?q?David=20=E2=80=98Bombe=E2=80=99=20Roden?= Date: Sat, 25 May 2013 09:52:26 +0200 Subject: [PATCH] Add metadata stream parser and test case. --- .../pterodactylus/sonitus/io/MetadataStream.java | 219 +++++++++++++++++++++ .../sonitus/io/MetadataStreamTest.java | 193 ++++++++++++++++++ 2 files changed, 412 insertions(+) create mode 100644 src/main/java/net/pterodactylus/sonitus/io/MetadataStream.java create mode 100644 src/test/java/net/pterodactylus/sonitus/io/MetadataStreamTest.java 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 index 0000000..baee88e --- /dev/null +++ b/src/main/java/net/pterodactylus/sonitus/io/MetadataStream.java @@ -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 . + */ + +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. + *

+ * {@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 David ‘Bombe’ Roden + */ +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 = 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 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 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 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 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 index 0000000..7550bae --- /dev/null +++ b/src/test/java/net/pterodactylus/sonitus/io/MetadataStreamTest.java @@ -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 . + */ + +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 David ‘Bombe’ Roden + */ +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 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()); + } + +} -- 2.7.4