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