mirror of
https://github.com/airsonic/airsonic.git
synced 2025-10-03 09:49:17 +02:00
Test m4a and flac streaming
Signed-off-by: Andrew DeMaria <lostonamountain@gmail.com>
This commit is contained in:
parent
16fb6d6134
commit
68723db2e9
15 changed files with 225 additions and 14 deletions
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
/integration-test/src/test/resources/blobs/stream/** filter=lfs diff=lfs merge=lfs -text
|
|
@ -137,6 +137,13 @@
|
||||||
<testResource>
|
<testResource>
|
||||||
<directory>src/test/resources</directory>
|
<directory>src/test/resources</directory>
|
||||||
<filtering>true</filtering>
|
<filtering>true</filtering>
|
||||||
|
<includes>
|
||||||
|
<include>**/application.properties</include>
|
||||||
|
</includes>
|
||||||
|
</testResource>
|
||||||
|
<testResource>
|
||||||
|
<directory>src/test/resources</directory>
|
||||||
|
<filtering>false</filtering>
|
||||||
</testResource>
|
</testResource>
|
||||||
</testResources>
|
</testResources>
|
||||||
<plugins>
|
<plugins>
|
||||||
|
@ -199,6 +206,7 @@
|
||||||
<plugin>
|
<plugin>
|
||||||
<groupId>org.apache.maven.plugins</groupId>
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
<artifactId>maven-gpg-plugin</artifactId>
|
<artifactId>maven-gpg-plugin</artifactId>
|
||||||
|
<version>1.6</version>
|
||||||
<executions>
|
<executions>
|
||||||
<execution>
|
<execution>
|
||||||
<phase>none</phase>
|
<phase>none</phase>
|
||||||
|
|
|
@ -1,5 +1,8 @@
|
||||||
package org.airsonic.test.cucumber.steps.api;
|
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 cucumber.api.java8.En;
|
||||||
import org.airsonic.test.cucumber.server.AirsonicServer;
|
import org.airsonic.test.cucumber.server.AirsonicServer;
|
||||||
import org.airsonic.test.domain.SavedHttpResponse;
|
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.impl.client.HttpClientBuilder;
|
||||||
import org.apache.http.util.EntityUtils;
|
import org.apache.http.util.EntityUtils;
|
||||||
import org.junit.Assert;
|
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.File;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
@ -22,6 +28,7 @@ import java.nio.file.Paths;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
import java.util.regex.Matcher;
|
import java.util.regex.Matcher;
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
@ -30,13 +37,16 @@ public class StreamStepDef implements En {
|
||||||
private CloseableHttpClient client;
|
private CloseableHttpClient client;
|
||||||
private List<SavedHttpResponse> responses = new ArrayList<>();
|
private List<SavedHttpResponse> responses = new ArrayList<>();
|
||||||
private String streamName;
|
private String streamName;
|
||||||
|
private String mediaFileId;
|
||||||
|
private ObjectMapper mapper = new ObjectMapper();
|
||||||
|
|
||||||
public StreamStepDef(AirsonicServer server) {
|
public StreamStepDef(AirsonicServer server) {
|
||||||
|
mapper.enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS);
|
||||||
this.client = HttpClientBuilder.create().build();
|
this.client = HttpClientBuilder.create().build();
|
||||||
Given("Media file (.*) is added", (String streamName) -> {
|
Given("Media file (.*) is added", (String streamName) -> {
|
||||||
this.streamName = streamName;
|
this.streamName = streamName;
|
||||||
server.uploadToDefaultMusicFolder(
|
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$", () -> {
|
Then("^The length headers are correct$", () -> {
|
||||||
responses.forEach(this::checkLengths);
|
responses.forEach(this::checkLengths);
|
||||||
});
|
});
|
||||||
|
Then("^The length headers are absent$", () -> {
|
||||||
|
responses.forEach(this::noLengths);
|
||||||
|
});
|
||||||
When("^A stream is consumed$", () -> {
|
When("^A stream is consumed$", () -> {
|
||||||
while(shouldDoRequest()) {
|
while(shouldDoRequest()) {
|
||||||
responses.add(consumeResponse(doRequest(server)));
|
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<Child> 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> 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<Child> 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 {
|
private void checkBody(SavedHttpResponse savedHttpResponse, int iter) throws URISyntaxException, IOException {
|
||||||
String expectedBodyResource = String.format("/blobs/stream/"+streamName+"/responses/%d.dat", iter+1);
|
String expectedBodyResource = String.format("/blobs/stream/"+streamName+"/responses/%d.dat", iter+1);
|
||||||
byte[] expected = IOUtils.toByteArray(
|
byte[] expected = IOUtils.toByteArray(
|
||||||
this.getClass()
|
this.getClass()
|
||||||
.getResource(expectedBodyResource)
|
.getResourceAsStream(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);
|
|
||||||
|
|
||||||
Assert.assertArrayEquals(expected, savedHttpResponse.getBody());
|
Assert.assertArrayEquals(expected, savedHttpResponse.getBody());
|
||||||
|
|
||||||
|
@ -97,13 +152,29 @@ public class StreamStepDef implements En {
|
||||||
Assert.assertEquals(response.getBody().length, Integer.parseInt(header.getValue()));
|
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() {
|
private boolean shouldDoRequest() {
|
||||||
return responses.isEmpty() || isUnconsumedContent(responses.get(responses.size() - 1));
|
return responses.isEmpty() || isUnconsumedContent(responses.get(responses.size() - 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
private Pattern CONTENT_RANGE_PATTERN = Pattern.compile("^bytes (\\d+)-(\\d+)/(\\d+)$");
|
private Pattern CONTENT_RANGE_PATTERN = Pattern.compile("^bytes (\\d+)-(\\d+)/(\\d+)$");
|
||||||
private boolean isUnconsumedContent(SavedHttpResponse savedHttpResponse) {
|
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");
|
Header header = savedHttpResponse.getHeader("Content-Range");
|
||||||
|
if(header == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
Matcher matcher = CONTENT_RANGE_PATTERN.matcher(header.getValue());
|
Matcher matcher = CONTENT_RANGE_PATTERN.matcher(header.getValue());
|
||||||
if(!matcher.matches()) {
|
if(!matcher.matches()) {
|
||||||
throw new RuntimeException("Unexpected Content-Range format");
|
throw new RuntimeException("Unexpected Content-Range format");
|
||||||
|
@ -111,23 +182,62 @@ public class StreamStepDef implements En {
|
||||||
int start = Integer.parseInt(matcher.group(1));
|
int start = Integer.parseInt(matcher.group(1));
|
||||||
int end = Integer.parseInt(matcher.group(2));
|
int end = Integer.parseInt(matcher.group(2));
|
||||||
int total = Integer.parseInt(matcher.group(3));
|
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 {
|
private CloseableHttpResponse doRequest(AirsonicServer server) throws IOException {
|
||||||
RequestBuilder builder = RequestBuilder.create("GET").setUri(server.getBaseUri() + "/rest/stream");
|
RequestBuilder builder = RequestBuilder.create("GET").setUri(server.getBaseUri() + "/rest/stream");
|
||||||
builder.addParameter("id", "2"); // TODO abstract this out
|
builder.addParameter("id", mediaFileId);
|
||||||
builder.addHeader("Range", "bytes=0-");
|
|
||||||
|
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/*;");
|
builder.addHeader("Accept", "audio/webm,audio/ogg,audio/wav,audio/*;");
|
||||||
server.addRestParameters(builder);
|
server.addRestParameters(builder);
|
||||||
return client.execute(builder.build());
|
return client.execute(builder.build());
|
||||||
}
|
}
|
||||||
|
|
||||||
SavedHttpResponse consumeResponse(CloseableHttpResponse response) throws IOException {
|
private SavedHttpResponse consumeResponse(CloseableHttpResponse response) throws IOException {
|
||||||
byte[] body = EntityUtils.toByteArray(response.getEntity());
|
byte[] body = EntityUtils.toByteArray(response.getEntity());
|
||||||
List<Header> headers = Arrays.asList(response.getAllHeaders());
|
List<Header> headers = Arrays.asList(response.getAllHeaders());
|
||||||
response.close();
|
response.close();
|
||||||
return new SavedHttpResponse(headers, body);
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -28,7 +28,7 @@ public class SavedHttpResponse {
|
||||||
Objects.equals(header.getName(), name))
|
Objects.equals(header.getName(), name))
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
if(matchingHeaders.size() != 1) {
|
if(matchingHeaders.size() != 1) {
|
||||||
throw new RuntimeException("Did not find one matching header with name " + name);
|
return null;
|
||||||
}
|
}
|
||||||
return matchingHeaders.iterator().next();
|
return matchingHeaders.iterator().next();
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,6 +7,10 @@ spring.profiles.active=dynamic
|
||||||
airsonic.docker.image=airsonic/airsonic:${project.version}
|
airsonic.docker.image=airsonic/airsonic:${project.version}
|
||||||
|
|
||||||
# Use for testing against an existing/running container
|
# Use for testing against an existing/running container
|
||||||
#airsonic.docker.container=1212be8a94e0
|
|
||||||
#spring.profiles.active=existing
|
#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
|
||||||
|
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:7142dd05c288514fd1150cc9494fa1e0e074fd02f6a414afe14963d6a41d5e80
|
||||||
|
size 14564681
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:d197053e46e2ddf49f0230885d6fbc224a9d634be6462d965be256729e7fbdd2
|
||||||
|
size 10238118
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:ed62be06ad1df3afcc2931b7a0e2ad4ccb704faa71ea96ee04bd6c2d9fecea2b
|
||||||
|
size 22340651
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:685a5981d4098ff310c6a9b15587cbcb3878194014699d7f9b60013385ef9aed
|
||||||
|
size 5951995
|
Binary file not shown.
Binary file not shown.
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -3,6 +3,7 @@ Feature: Stream API for MP3
|
||||||
Background:
|
Background:
|
||||||
Given Media file piano is added
|
Given Media file piano is added
|
||||||
And a scan is done
|
And a scan is done
|
||||||
|
And The media file id is found
|
||||||
|
|
||||||
Scenario: Airsonic sends stream data
|
Scenario: Airsonic sends stream data
|
||||||
When A stream is consumed
|
When A stream is consumed
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue