diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 00000000..df5a3a90
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1 @@
+/integration-test/src/test/resources/blobs/stream/** filter=lfs diff=lfs merge=lfs -text
diff --git a/integration-test/pom.xml b/integration-test/pom.xml
index a0f91123..136b8a64 100644
--- a/integration-test/pom.xml
+++ b/integration-test/pom.xml
@@ -137,6 +137,13 @@
src/test/resources
true
+
+ **/application.properties
+
+
+
+ src/test/resources
+ false
@@ -199,6 +206,7 @@
org.apache.maven.plugins
maven-gpg-plugin
+ 1.6
none
diff --git a/integration-test/src/test/java/org/airsonic/test/cucumber/steps/api/StreamStepDef.java b/integration-test/src/test/java/org/airsonic/test/cucumber/steps/api/StreamStepDef.java
index e7683215..6e526f20 100644
--- a/integration-test/src/test/java/org/airsonic/test/cucumber/steps/api/StreamStepDef.java
+++ b/integration-test/src/test/java/org/airsonic/test/cucumber/steps/api/StreamStepDef.java
@@ -1,5 +1,8 @@
package org.airsonic.test.cucumber.steps.api;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.MapperFeature;
+import com.fasterxml.jackson.databind.ObjectMapper;
import cucumber.api.java8.En;
import org.airsonic.test.cucumber.server.AirsonicServer;
import org.airsonic.test.domain.SavedHttpResponse;
@@ -14,6 +17,9 @@ import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClientBuilder;
import org.apache.http.util.EntityUtils;
import org.junit.Assert;
+import org.subsonic.restapi.Child;
+import org.subsonic.restapi.MusicFolder;
+import org.subsonic.restapi.Response;
import java.io.File;
import java.io.IOException;
@@ -22,6 +28,7 @@ import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
+import java.util.Objects;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@@ -30,13 +37,16 @@ public class StreamStepDef implements En {
private CloseableHttpClient client;
private List responses = new ArrayList<>();
private String streamName;
+ private String mediaFileId;
+ private ObjectMapper mapper = new ObjectMapper();
public StreamStepDef(AirsonicServer server) {
+ mapper.enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS);
this.client = HttpClientBuilder.create().build();
Given("Media file (.*) is added", (String streamName) -> {
this.streamName = streamName;
server.uploadToDefaultMusicFolder(
- Paths.get(this.getClass().getResource("/blobs/stream/" + streamName).toURI()),
+ Paths.get(this.getClass().getResource("/blobs/stream/" + streamName + "/input").toURI()),
"");
});
@@ -54,25 +64,70 @@ public class StreamStepDef implements En {
Then("^The length headers are correct$", () -> {
responses.forEach(this::checkLengths);
});
+ Then("^The length headers are absent$", () -> {
+ responses.forEach(this::noLengths);
+ });
When("^A stream is consumed$", () -> {
while(shouldDoRequest()) {
responses.add(consumeResponse(doRequest(server)));
}
});
+ Then("^Save response bodies to files$", () -> {
+ for (int i = 0; i < responses.size(); i++) {
+ saveBody(responses.get(i), i);
+ }
+ });
+ And("^The media file id is found$", () -> {
+ mediaFileId = getMediaFilesInMusicFolder(server).get(0).getId();
+ });
}
+ private List getMediaFilesInMusicFolder(AirsonicServer server) throws IOException {
+ RequestBuilder builder = RequestBuilder.create("GET").setUri(server.getBaseUri() + "/rest/getMusicFolders");
+ builder.addParameter("f", "json");
+ server.addRestParameters(builder);
+ CloseableHttpResponse response = client.execute(builder.build());
+
+ String responseAsString = EntityUtils.toString(response.getEntity());
+ JsonNode jsonNode = mapper.readTree(responseAsString).get("subsonic-response");
+ Response subsonicResponse = mapper.treeToValue(jsonNode, Response.class);
+ List musicFolder = subsonicResponse.getMusicFolders().getMusicFolder();
+ MusicFolder music = musicFolder
+ .stream()
+ .filter(folder -> Objects.equals(folder.getName(), "Music"))
+ .findFirst()
+ .orElseThrow(() -> new RuntimeException("No top level folder named Music"));
+
+ return getMediaFiles(server, music.getId());
+ }
+
+ private List getMediaFiles(AirsonicServer server, int folderId) throws IOException {
+ RequestBuilder builder = RequestBuilder.create("GET").setUri(server.getBaseUri() + "/rest/getIndexes");
+ builder.addParameter("f", "json");
+ builder.addParameter("musicFolderId", String.valueOf(folderId));
+ server.addRestParameters(builder);
+ CloseableHttpResponse response = client.execute(builder.build());
+
+ String responseAsString = EntityUtils.toString(response.getEntity());
+ JsonNode jsonNode = mapper.readTree(responseAsString).get("subsonic-response");
+ Response subsonicResponse = mapper.treeToValue(jsonNode, Response.class);
+ return subsonicResponse.getIndexes().getChild();
+ }
+
+ private void saveBody(SavedHttpResponse savedHttpResponse, int iter) throws IOException {
+ FileUtils.writeByteArrayToFile(
+ new File(String.format("/tmp/bytearray-%d", iter+1)),
+ savedHttpResponse.getBody());
+ // TODO if debug...
+// HexDump.dump(expected, 0, System.out, 0);
+ }
+
private void checkBody(SavedHttpResponse savedHttpResponse, int iter) throws URISyntaxException, IOException {
String expectedBodyResource = String.format("/blobs/stream/"+streamName+"/responses/%d.dat", iter+1);
byte[] expected = IOUtils.toByteArray(
this.getClass()
- .getResource(expectedBodyResource)
- .toURI());
- // TODO if debug...
-// FileUtils.writeByteArrayToFile(
-// new File(String.format("/tmp/bytearray-%d", iter+1)),
-// savedHttpResponse.getBody());
-// HexDump.dump(expected, 0, System.out, 0);
+ .getResourceAsStream(expectedBodyResource));
Assert.assertArrayEquals(expected, savedHttpResponse.getBody());
@@ -97,13 +152,29 @@ public class StreamStepDef implements En {
Assert.assertEquals(response.getBody().length, Integer.parseInt(header.getValue()));
}
+ private void noLengths(SavedHttpResponse response) {
+ Header header = response.getHeader("Content-Length");
+ Assert.assertNull(header);
+ }
+
private boolean shouldDoRequest() {
return responses.isEmpty() || isUnconsumedContent(responses.get(responses.size() - 1));
}
private Pattern CONTENT_RANGE_PATTERN = Pattern.compile("^bytes (\\d+)-(\\d+)/(\\d+)$");
private boolean isUnconsumedContent(SavedHttpResponse savedHttpResponse) {
+ ContentRange contentRange = getContentRange(savedHttpResponse);
+ if(contentRange == null) {
+ return false;
+ }
+ return (contentRange.getTotal() - 1) > contentRange.getEnd();
+ }
+
+ private ContentRange getContentRange(SavedHttpResponse savedHttpResponse) {
Header header = savedHttpResponse.getHeader("Content-Range");
+ if(header == null) {
+ return null;
+ }
Matcher matcher = CONTENT_RANGE_PATTERN.matcher(header.getValue());
if(!matcher.matches()) {
throw new RuntimeException("Unexpected Content-Range format");
@@ -111,23 +182,62 @@ public class StreamStepDef implements En {
int start = Integer.parseInt(matcher.group(1));
int end = Integer.parseInt(matcher.group(2));
int total = Integer.parseInt(matcher.group(3));
- return (total - 1) > end;
+ return new ContentRange(start, end, total);
+ }
+
+ private String calculateRange() {
+ Integer start = null;
+ Integer end = null;
+ if(responses.isEmpty()) {
+ start = 0;
+ } else {
+ SavedHttpResponse lastResponse = responses.get(responses.size() - 1);
+ ContentRange contentRange = getContentRange(lastResponse);
+ start = contentRange.getEnd();
+ }
+ return start + "-" + (end == null ? "" : end);
}
private CloseableHttpResponse doRequest(AirsonicServer server) throws IOException {
RequestBuilder builder = RequestBuilder.create("GET").setUri(server.getBaseUri() + "/rest/stream");
- builder.addParameter("id", "2"); // TODO abstract this out
- builder.addHeader("Range", "bytes=0-");
+ builder.addParameter("id", mediaFileId);
+
+ String range = calculateRange();
+ System.out.println("In request "+ (responses.size() + 1) +" asking for range " + range);
+ builder.addHeader("Range", "bytes=" + range);
builder.addHeader("Accept", "audio/webm,audio/ogg,audio/wav,audio/*;");
server.addRestParameters(builder);
return client.execute(builder.build());
}
- SavedHttpResponse consumeResponse(CloseableHttpResponse response) throws IOException {
+ private SavedHttpResponse consumeResponse(CloseableHttpResponse response) throws IOException {
byte[] body = EntityUtils.toByteArray(response.getEntity());
List headers = Arrays.asList(response.getAllHeaders());
response.close();
return new SavedHttpResponse(headers, body);
}
+ private class ContentRange {
+ private final int start;
+ private final int end;
+ private final int total;
+
+ public ContentRange(int start, int end, int total) {
+ this.start = start;
+ this.end = end;
+ this.total = total;
+ }
+
+ public int getStart() {
+ return start;
+ }
+
+ public int getEnd() {
+ return end;
+ }
+
+ public int getTotal() {
+ return total;
+ }
+ }
}
diff --git a/integration-test/src/test/java/org/airsonic/test/cucumber_hooks/local/LocalServer.java b/integration-test/src/test/java/org/airsonic/test/cucumber_hooks/local/LocalServer.java
new file mode 100644
index 00000000..545a9d18
--- /dev/null
+++ b/integration-test/src/test/java/org/airsonic/test/cucumber_hooks/local/LocalServer.java
@@ -0,0 +1,49 @@
+package org.airsonic.test.cucumber_hooks.local;
+
+import org.airsonic.test.cucumber.server.AirsonicServer;
+import org.apache.commons.io.FileUtils;
+import org.springframework.beans.factory.InitializingBean;
+import org.springframework.context.EnvironmentAware;
+import org.springframework.context.annotation.Profile;
+import org.springframework.core.env.Environment;
+import org.springframework.stereotype.Component;
+
+import java.io.IOException;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+
+@Component
+@Profile("local")
+public class LocalServer implements AirsonicServer, EnvironmentAware, InitializingBean {
+ private static final String AIRSONIC_SERVER_PORT = "airsonic.server.port";
+ private static final String AIRSONIC_SERVER_DEFAULT_MUSIC_DIR = "airsonic.server.default.music.dir";
+
+ private int port;
+ private String defaultMusicDir;
+
+ @Override
+ public String getBaseUri() {
+ return "http://localhost:" + port;
+ }
+
+ @Override
+ public void uploadToDefaultMusicFolder(Path directoryPath, String relativePath) {
+ Path dest = Paths.get(defaultMusicDir, relativePath);
+ try {
+ FileUtils.copyDirectory(directoryPath.toFile(), dest.toFile(), false);
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ @Override
+ public void afterPropertiesSet() throws Exception {
+
+ }
+
+ @Override
+ public void setEnvironment(Environment environment) {
+ port = Integer.parseInt(environment.getRequiredProperty(AIRSONIC_SERVER_PORT));
+ defaultMusicDir = environment.getRequiredProperty(AIRSONIC_SERVER_DEFAULT_MUSIC_DIR);
+ }
+}
diff --git a/integration-test/src/test/java/org/airsonic/test/domain/SavedHttpResponse.java b/integration-test/src/test/java/org/airsonic/test/domain/SavedHttpResponse.java
index 8fe66551..1a92612f 100644
--- a/integration-test/src/test/java/org/airsonic/test/domain/SavedHttpResponse.java
+++ b/integration-test/src/test/java/org/airsonic/test/domain/SavedHttpResponse.java
@@ -28,7 +28,7 @@ public class SavedHttpResponse {
Objects.equals(header.getName(), name))
.collect(Collectors.toList());
if(matchingHeaders.size() != 1) {
- throw new RuntimeException("Did not find one matching header with name " + name);
+ return null;
}
return matchingHeaders.iterator().next();
}
diff --git a/integration-test/src/test/resources/application.properties b/integration-test/src/test/resources/application.properties
index 95c1423a..f45c63b5 100644
--- a/integration-test/src/test/resources/application.properties
+++ b/integration-test/src/test/resources/application.properties
@@ -7,6 +7,10 @@ spring.profiles.active=dynamic
airsonic.docker.image=airsonic/airsonic:${project.version}
# Use for testing against an existing/running container
-#airsonic.docker.container=1212be8a94e0
#spring.profiles.active=existing
+#airsonic.docker.container=1212be8a94e0
+# Use for testing against a local instance
+#spring.profiles.active=local
+airsonic.server.port=8080
+airsonic.server.default.music.dir=/tmp/airsonic/music
diff --git a/integration-test/src/test/resources/blobs/stream/dance/input/11 Dance, Dance Christa P├дffgen.m4a b/integration-test/src/test/resources/blobs/stream/dance/input/11 Dance, Dance Christa P├дffgen.m4a
new file mode 100644
index 00000000..3f85aa3e
--- /dev/null
+++ b/integration-test/src/test/resources/blobs/stream/dance/input/11 Dance, Dance Christa P├дffgen.m4a
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:7142dd05c288514fd1150cc9494fa1e0e074fd02f6a414afe14963d6a41d5e80
+size 14564681
diff --git a/integration-test/src/test/resources/blobs/stream/dance/responses/1.dat b/integration-test/src/test/resources/blobs/stream/dance/responses/1.dat
new file mode 100644
index 00000000..6a89b6bf
--- /dev/null
+++ b/integration-test/src/test/resources/blobs/stream/dance/responses/1.dat
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:d197053e46e2ddf49f0230885d6fbc224a9d634be6462d965be256729e7fbdd2
+size 10238118
diff --git a/integration-test/src/test/resources/blobs/stream/dead/input/dead.flac b/integration-test/src/test/resources/blobs/stream/dead/input/dead.flac
new file mode 100644
index 00000000..58a81a4a
--- /dev/null
+++ b/integration-test/src/test/resources/blobs/stream/dead/input/dead.flac
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:ed62be06ad1df3afcc2931b7a0e2ad4ccb704faa71ea96ee04bd6c2d9fecea2b
+size 22340651
diff --git a/integration-test/src/test/resources/blobs/stream/dead/responses/1.dat b/integration-test/src/test/resources/blobs/stream/dead/responses/1.dat
new file mode 100644
index 00000000..48eafcf2
--- /dev/null
+++ b/integration-test/src/test/resources/blobs/stream/dead/responses/1.dat
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:685a5981d4098ff310c6a9b15587cbcb3878194014699d7f9b60013385ef9aed
+size 5951995
diff --git a/integration-test/src/test/resources/blobs/stream/piano/input/piano.mp3 b/integration-test/src/test/resources/blobs/stream/piano/input/piano.mp3
index dd253d5d..60f368f3 100644
Binary files a/integration-test/src/test/resources/blobs/stream/piano/input/piano.mp3 and b/integration-test/src/test/resources/blobs/stream/piano/input/piano.mp3 differ
diff --git a/integration-test/src/test/resources/blobs/stream/piano/responses/1.dat b/integration-test/src/test/resources/blobs/stream/piano/responses/1.dat
index dd253d5d..60f368f3 100644
Binary files a/integration-test/src/test/resources/blobs/stream/piano/responses/1.dat and b/integration-test/src/test/resources/blobs/stream/piano/responses/1.dat differ
diff --git a/integration-test/src/test/resources/features/api/stream-flac.feature b/integration-test/src/test/resources/features/api/stream-flac.feature
new file mode 100644
index 00000000..1045915b
--- /dev/null
+++ b/integration-test/src/test/resources/features/api/stream-flac.feature
@@ -0,0 +1,13 @@
+Feature: Stream API for FLAC
+
+ Background:
+ Given Media file dead is added
+ And a scan is done
+ And The media file id is found
+
+ Scenario: Airsonic sends stream data
+ When A stream is consumed
+ Then Print debug output
+ Then The response bytes are equal
+ Then The length headers are absent
+
diff --git a/integration-test/src/test/resources/features/api/stream-m4a-vbr.feature b/integration-test/src/test/resources/features/api/stream-m4a-vbr.feature
new file mode 100644
index 00000000..2c863ad0
--- /dev/null
+++ b/integration-test/src/test/resources/features/api/stream-m4a-vbr.feature
@@ -0,0 +1,13 @@
+Feature: Stream API for VBR M4A
+
+ Background:
+ Given Media file dance is added
+ And a scan is done
+ And The media file id is found
+
+ Scenario: Airsonic sends stream data
+ When A stream is consumed
+ Then Print debug output
+ Then The response bytes are equal
+ Then The length headers are absent
+
diff --git a/integration-test/src/test/resources/features/api/stream-mp3.feature b/integration-test/src/test/resources/features/api/stream-mp3.feature
index 977ccb49..0ea5d88f 100644
--- a/integration-test/src/test/resources/features/api/stream-mp3.feature
+++ b/integration-test/src/test/resources/features/api/stream-mp3.feature
@@ -3,6 +3,7 @@ Feature: Stream API for MP3
Background:
Given Media file piano is added
And a scan is done
+ And The media file id is found
Scenario: Airsonic sends stream data
When A stream is consumed