Merge branch 'master' of https://github.com/airsonic/airsonic into transifex_update

master
jo 6 years ago
commit b8b5dc59a0
  1. 4
      .travis.yml
  2. 2
      CHANGELOG.md
  3. 2
      README.md
  4. 5
      airsonic-main/cve-suppressed.xml
  5. 25
      airsonic-main/pom.xml
  6. 13
      airsonic-main/src/main/java/org/airsonic/player/Application.java
  7. 6
      airsonic-main/src/main/java/org/airsonic/player/TomcatApplication.java
  8. 4
      airsonic-main/src/main/java/org/airsonic/player/controller/SubsonicRESTController.java
  9. 6
      airsonic-main/src/main/java/org/airsonic/player/dao/PlayerDao.java
  10. 12
      airsonic-main/src/main/java/org/airsonic/player/dao/PlayerDaoPlayQueueFactory.java
  11. 58
      airsonic-main/src/main/java/org/airsonic/player/service/JukeboxJavaService.java
  12. 5
      airsonic-main/src/main/java/org/airsonic/player/service/JukeboxLegacySubsonicService.java
  13. 2
      airsonic-main/src/main/java/org/airsonic/player/service/TranscodingService.java
  14. 14
      airsonic-main/src/main/java/org/airsonic/player/service/jukebox/AudioPlayerFactory.java
  15. 17
      airsonic-main/src/main/java/org/airsonic/player/service/jukebox/JavaPlayerFactory.java
  16. 3
      airsonic-main/src/main/resources/application.properties
  17. 2
      airsonic-main/src/main/resources/org/airsonic/player/i18n/ResourceBundle_en.properties
  18. 2
      airsonic-main/src/main/resources/org/airsonic/player/i18n/ResourceBundle_zh_TW.properties
  19. 9
      airsonic-main/src/test/java/org/airsonic/player/TestCaseUtils.java
  20. 59
      airsonic-main/src/test/java/org/airsonic/player/api/AirsonicRestApiIntTest.java
  21. 279
      airsonic-main/src/test/java/org/airsonic/player/api/jukebox/AbstractAirsonicRestApiJukeboxIntTest.java
  22. 38
      airsonic-main/src/test/java/org/airsonic/player/api/jukebox/AirsonicRestApiJukeboxIntTest.java
  23. 62
      airsonic-main/src/test/java/org/airsonic/player/api/jukebox/AirsonicRestApiJukeboxLegacyIntTest.java
  24. 41
      airsonic-main/src/test/java/org/airsonic/player/service/TranscodingServiceIntTest.java
  25. 13
      airsonic-main/src/test/resources/application.properties
  26. 3
      install/docker/Dockerfile
  27. 205
      integration-test/pom.xml
  28. 12
      integration-test/src/test/java/org/airsonic/test/SpringContext.java
  29. 13
      integration-test/src/test/java/org/airsonic/test/cucumber/RunCukesTest.java
  30. 20
      integration-test/src/test/java/org/airsonic/test/cucumber/server/AirsonicServer.java
  31. 50
      integration-test/src/test/java/org/airsonic/test/cucumber/steps/api/PingStepDef.java
  32. 61
      integration-test/src/test/java/org/airsonic/test/cucumber/steps/api/ScanStepDef.java
  33. 20
      integration-test/src/test/java/org/airsonic/test/cucumber/steps/api/SpringStepDef.java
  34. 69
      integration-test/src/test/java/org/airsonic/test/cucumber/steps/api/StreamStepDef.java
  35. 158
      integration-test/src/test/java/org/airsonic/test/cucumber_hooks/docker/DynamicDockerHook.java
  36. 98
      integration-test/src/test/java/org/airsonic/test/cucumber_hooks/docker/ExistingDockerHook.java
  37. 12
      integration-test/src/test/resources/application.properties
  38. 4
      integration-test/src/test/resources/blobs/ping/missing-auth.xml
  39. 2
      integration-test/src/test/resources/blobs/ping/ok.xml
  40. BIN
      integration-test/src/test/resources/blobs/stream/piano/piano.mp3
  41. 5
      integration-test/src/test/resources/features/api/ping.feature
  42. 11
      integration-test/src/test/resources/features/api/stream-mp3.feature
  43. 14
      integration-test/src/test/resources/logback.xml
  44. 24
      pom.xml

@ -2,6 +2,8 @@ language: java
sudo: required
jdk:
- oraclejdk8
services:
- docker
cache:
directories:
- $HOME/.m2
@ -13,4 +15,4 @@ before_script:
- export M2_HOME=$PWD/apache-maven-3.5.4
- export PATH=$PWD/apache-maven-3.5.4/bin:$PATH
script:
- mvn package install
- mvn verify -P integration-test

@ -66,7 +66,7 @@ Note that with this release, the jdbc-extra flavored war is now the default and
* New login page
* Added additional war with builtin support for external databases
* Improved playlist handling
* DLNA browsing improvments
* DLNA browsing improvements
* Small fixes and improvements
## v6.2

@ -8,7 +8,7 @@ Airsonic
What is Airsonic?
-----------------
Airsonic is a free, web-based media streamer, providing ubiquitious access to your music. Use it to share your music with friends, or to listen to your own music while at work. You can stream to multiple players simultaneously, for instance to one player in your kitchen and another in your living room.
Airsonic is a free, web-based media streamer, providing ubiquitous access to your music. Use it to share your music with friends, or to listen to your own music while at work. You can stream to multiple players simultaneously, for instance to one player in your kitchen and another in your living room.
Airsonic is designed to handle very large music collections (hundreds of gigabytes). Although optimized for MP3 streaming, it works for any audio or video format that can stream over HTTP, for instance AAC and OGG. By using transcoder plug-ins, Airsonic supports on-the-fly conversion and streaming of virtually any audio format, including WMA, FLAC, APE, Musepack, WavPack and Shorten.

@ -124,6 +124,11 @@
<gav regex="true">^org\.postgresql:postgresql:.*$</gav>
<cve>CVE-2018-1115</cve>
</suppress>
<suppress>
<notes>Does not affect the postgres client</notes>
<gav regex="true">^org\.postgresql:postgresql:.*$</gav>
<cve>CVE-2016-7048</cve>
</suppress>
<suppress>
<notes>This is for nodejs</notes>
<gav regex="true">^org\.mariadb\.jdbc:mariadb-java-client:.*$</gav>

@ -15,6 +15,7 @@
<properties>
<metrics.version>3.1.0</metrics.version>
<chameleon.version>1.2.1-RELEASE</chameleon.version>
<tomcat.server.scope>provided</tomcat.server.scope>
</properties>
<dependencies>
@ -86,6 +87,16 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-ldap</artifactId>
@ -303,6 +314,10 @@
<version>1.10.19</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
</dependency>
<!-- UPNP / DLNA -->
<dependency>
@ -445,12 +460,12 @@
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-tomcat</artifactId>
<scope>provided</scope>
<scope>${tomcat.server.scope}</scope>
</dependency>
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-jasper</artifactId>
<scope>provided</scope>
<scope>${tomcat.server.scope}</scope>
</dependency>
<!-- Embedded Jetty -->
<dependency>
@ -599,5 +614,11 @@
<boot.artifact.excludes>spring-boot-starter-tomcat</boot.artifact.excludes>
</properties>
</profile>
<profile>
<id>ide-tomcat-embed</id>
<properties>
<tomcat.server.scope>compile</tomcat.server.scope>
</properties>
</profile>
</profiles>
</project>

@ -189,6 +189,7 @@ public class Application extends SpringBootServletInitializer implements Embedde
@Override
public void customize(ConfigurableEmbeddedServletContainer container) {
LOG.trace("Servlet container is {}", container.getClass().getCanonicalName());
// Yes, there is a good reason we do this.
// We cannot count on the tomcat classes being on the classpath which will
// happen if the war is deployed to another app server like Jetty. So, we
@ -197,6 +198,7 @@ public class Application extends SpringBootServletInitializer implements Embedde
try {
Class<?> tomcatESCF = Class.forName("org.springframework.boot.context.embedded.tomcat.TomcatEmbeddedServletContainerFactory");
if(tomcatESCF.isInstance(container)) {
LOG.info("Detected Tomcat web server");
LOG.debug("Attempting to optimize tomcat");
Object tomcatESCFInstance = tomcatESCF.cast(container);
Class<?> tomcatApplicationClass = Class.forName("org.airsonic.player.TomcatApplication");
@ -207,10 +209,19 @@ public class Application extends SpringBootServletInitializer implements Embedde
LOG.debug("Skipping tomcat optimization as we are not running on tomcat");
}
} catch (NoClassDefFoundError | ClassNotFoundException e) {
LOG.debug("Skipping tomcat optimization as the tomcat classes are not available");
LOG.debug("No tomcat classes found");
} catch (Exception e) {
LOG.warn("An error happened while trying to optimize tomcat", e);
}
try {
Class<?> jettyESCF = Class.forName("org.springframework.boot.context.embedded.jetty.JettyEmbeddedServletContainerFactory");
if(jettyESCF.isInstance(container)) {
LOG.warn("Detected Jetty web server. Here there be dragons.");
}
} catch (NoClassDefFoundError | ClassNotFoundException e) {
LOG.debug("No jetty classes found");
}
}
public static void main(String[] args) {

@ -3,6 +3,7 @@ package org.airsonic.player;
import org.apache.catalina.Container;
import org.apache.catalina.Wrapper;
import org.apache.catalina.webresources.StandardRoot;
import org.apache.tomcat.util.scan.StandardJarScanFilter;
import org.springframework.boot.context.embedded.tomcat.TomcatContextCustomizer;
import org.springframework.boot.context.embedded.tomcat.TomcatEmbeddedServletContainerFactory;
@ -12,6 +13,11 @@ public class TomcatApplication {
tomcatFactory.addContextCustomizers((TomcatContextCustomizer) context -> {
StandardJarScanFilter standardJarScanFilter = new StandardJarScanFilter();
standardJarScanFilter.setTldScan("dwr-*.jar,jstl-*.jar,spring-security-taglibs-*.jar,spring-web-*.jar,spring-webmvc-*.jar,string-*.jar,taglibs-standard-impl-*.jar,tomcat-annotations-api-*.jar,tomcat-embed-jasper-*.jar");
standardJarScanFilter.setTldSkip("*");
context.getJarScanner().setJarScanFilter(standardJarScanFilter);
boolean development = (System.getProperty("airsonic.development") != null);
// Increase the size and time before eviction of the Tomcat

@ -1274,7 +1274,7 @@ public class SubsonicRESTController {
child.setSuffix(suffix);
child.setContentType(StringUtil.getMimeType(suffix));
child.setIsVideo(mediaFile.isVideo());
child.setPath(getRelativePath(mediaFile));
child.setPath(getRelativePath(mediaFile, settingsService));
org.airsonic.player.domain.Bookmark bookmark = bookmarkCache.get(new BookmarkKey(username, mediaFile.getId()));
if (bookmark != null) {
@ -1329,7 +1329,7 @@ public class SubsonicRESTController {
return null;
}
private String getRelativePath(MediaFile musicFile) {
public static String getRelativePath(MediaFile musicFile, SettingsService settingsService) {
String filePath = musicFile.getPath();

@ -22,6 +22,7 @@ package org.airsonic.player.dao;
import org.airsonic.player.domain.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
@ -43,6 +44,9 @@ public class PlayerDao extends AbstractDao {
"last_seen, cover_art_scheme, transcode_scheme, dynamic_ip, technology, client_id, mixer";
private static final String QUERY_COLUMNS = "id, " + INSERT_COLUMNS;
@Autowired
private PlayerDaoPlayQueueFactory playerDaoPlayQueueFactory;
private PlayerRowMapper rowMapper = new PlayerRowMapper();
private Map<Integer, PlayQueue> playlists = Collections.synchronizedMap(new HashMap<Integer, PlayQueue>());
@ -166,7 +170,7 @@ public class PlayerDao extends AbstractDao {
private void addPlaylist(Player player) {
PlayQueue playQueue = playlists.get(player.getId());
if (playQueue == null) {
playQueue = new PlayQueue();
playQueue = playerDaoPlayQueueFactory.createPlayQueue();
playlists.put(player.getId(), playQueue);
}
player.setPlayQueue(playQueue);

@ -0,0 +1,12 @@
package org.airsonic.player.dao;
import org.airsonic.player.domain.PlayQueue;
import org.springframework.stereotype.Component;
@Component
public class PlayerDaoPlayQueueFactory {
public PlayQueue createPlayQueue() {
return new PlayQueue();
}
}

@ -1,9 +1,9 @@
package org.airsonic.player.service;
import com.github.biconou.AudioPlayer.JavaPlayer;
import com.github.biconou.AudioPlayer.api.*;
import com.github.biconou.AudioPlayer.api.PlayList;
import com.github.biconou.AudioPlayer.api.PlayerListener;
import org.airsonic.player.domain.*;
import org.airsonic.player.domain.Player;
import org.airsonic.player.service.jukebox.JavaPlayerFactory;
import org.airsonic.player.util.FileUtil;
import org.apache.commons.lang.StringUtils;
import org.slf4j.LoggerFactory;
@ -19,23 +19,25 @@ import java.util.Map;
/**
* @author R??mi Cocula
* @author Rémi Cocula
*/
@Service
public class JukeboxJavaService {
private static final org.slf4j.Logger log = LoggerFactory.getLogger(JukeboxJavaService.class);
private static final float DEFAULT_GAIN = 0.75f;
@Autowired
private AudioScrobblerService audioScrobblerService;
@Autowired
private StatusService statusService;
@Autowired
private SettingsService settingsService;
@Autowired
private SecurityService securityService;
@Autowired
private MediaFileService mediaFileService;
@Autowired
private JavaPlayerFactory javaPlayerFactory;
private TransferStatus status;
@ -45,20 +47,21 @@ public class JukeboxJavaService {
/**
* Finds the corresponding active audio player for a given airsonic player.
* If no player exists we create one.
* The JukeboxJavaService references all active audio players in a map indexed by airsonic player id.
*
* @param airsonicPlayer a given airsonic player.
* @return the corresponding active audio player of null if none exists.
* @return the corresponding active audio player.
*/
private com.github.biconou.AudioPlayer.api.Player retrieveAudioPlayerForAirsonicPlayer(Player airsonicPlayer) {
com.github.biconou.AudioPlayer.api.Player foundPlayer = activeAudioPlayers.get(airsonicPlayer.getId());
if (foundPlayer == null) {
synchronized (activeAudioPlayers) {
foundPlayer = initAudioPlayer(airsonicPlayer);
if (foundPlayer == null) {
com.github.biconou.AudioPlayer.api.Player newPlayer = initAudioPlayer(airsonicPlayer);
if (newPlayer == null) {
throw new RuntimeException("Did not initialized a player");
} else {
activeAudioPlayers.put(airsonicPlayer.getId(), foundPlayer);
}
activeAudioPlayers.put(airsonicPlayer.getId(), newPlayer);
String mixer = airsonicPlayer.getJavaJukeboxMixer();
if (StringUtils.isBlank(mixer)) {
mixer = DEFAULT_MIXER_ENTRY_KEY;
@ -68,8 +71,8 @@ public class JukeboxJavaService {
playersForMixer = new ArrayList<>();
activeAudioPlayersPerMixer.put(mixer, playersForMixer);
}
playersForMixer.add(foundPlayer);
}
playersForMixer.add(newPlayer);
foundPlayer = newPlayer;
}
}
return foundPlayer;
@ -88,11 +91,12 @@ public class JukeboxJavaService {
if (StringUtils.isNotBlank(airsonicPlayer.getJavaJukeboxMixer())) {
log.info("use mixer : {}", airsonicPlayer.getJavaJukeboxMixer());
audioPlayer = new JavaPlayer(airsonicPlayer.getJavaJukeboxMixer());
audioPlayer = javaPlayerFactory.createJavaPlayer(airsonicPlayer.getJavaJukeboxMixer());
} else {
log.info("use default mixer");
audioPlayer = new JavaPlayer();
audioPlayer = javaPlayerFactory.createJavaPlayer();
}
audioPlayer.setGain(DEFAULT_GAIN);
if (audioPlayer != null) {
audioPlayer.registerListener(new PlayerListener() {
@Override
@ -159,11 +163,8 @@ public class JukeboxJavaService {
throw new RuntimeException("The player " + airsonicPlayer.getName() + " is not a java jukebox player");
}
com.github.biconou.AudioPlayer.api.Player audioPlayer = retrieveAudioPlayerForAirsonicPlayer(airsonicPlayer);
if (audioPlayer != null) {
return audioPlayer.getGain();
}
return 0.5f;
}
public void setGain(final Player airsonicPlayer, final float gain) {
if (!airsonicPlayer.getTechnology().equals(PlayerTechnology.JAVA_JUKEBOX)) {
@ -271,20 +272,8 @@ public class JukeboxJavaService {
}
}
public void start(Player airsonicPlayer) throws Exception {
log.debug("begin start jukebox : player = id:{};name:{}", airsonicPlayer.getId(), airsonicPlayer.getName());
com.github.biconou.AudioPlayer.api.Player audioPlayer = retrieveAudioPlayerForAirsonicPlayer(airsonicPlayer);
// Control user authorizations
User user = securityService.getUserByName(airsonicPlayer.getUsername());
if (!user.isJukeboxRole()) {
log.warn("{} is not authorized for jukebox playback.", user.getUsername());
return;
}
log.debug("PlayQueue.Status is {}", airsonicPlayer.getPlayQueue().getStatus());
audioPlayer.play();
public void start(Player airsonicPlayer) {
play(airsonicPlayer);
}
public void stop(Player airsonicPlayer) throws Exception {
@ -332,7 +321,6 @@ public class JukeboxJavaService {
}
}
public void setAudioScrobblerService(AudioScrobblerService audioScrobblerService) {
this.audioScrobblerService = audioScrobblerService;
}
@ -341,10 +329,6 @@ public class JukeboxJavaService {
this.statusService = statusService;
}
public void setSettingsService(SettingsService settingsService) {
this.settingsService = settingsService;
}
public void setSecurityService(SecurityService securityService) {
this.securityService = securityService;
}

@ -21,6 +21,7 @@ package org.airsonic.player.service;
import org.airsonic.player.domain.*;
import org.airsonic.player.service.jukebox.AudioPlayer;
import org.airsonic.player.service.jukebox.AudioPlayerFactory;
import org.airsonic.player.util.FileUtil;
import org.apache.commons.io.IOUtils;
import org.slf4j.Logger;
@ -52,6 +53,8 @@ public class JukeboxLegacySubsonicService implements AudioPlayer.Listener {
private SecurityService securityService;
@Autowired
private MediaFileService mediaFileService;
@Autowired
private AudioPlayerFactory audioPlayerFactory;
private AudioPlayer audioPlayer;
private Player player;
@ -111,7 +114,7 @@ public class JukeboxLegacySubsonicService implements AudioPlayer.Listener {
String command = settingsService.getJukeboxCommand();
parameters.setTranscoding(new Transcoding(null, null, null, null, command, null, null, false));
in = transcodingService.getTranscodedInputStream(parameters);
audioPlayer = new AudioPlayer(in, this);
audioPlayer = audioPlayerFactory.createAudioPlayer(in, this);
audioPlayer.setGain(gain);
audioPlayer.play();
onSongStart(file);

@ -32,6 +32,7 @@ import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import java.io.File;
@ -61,6 +62,7 @@ public class TranscodingService {
@Autowired
private SettingsService settingsService;
@Autowired
@Lazy // used to deal with circular dependencies between PlayerService and TranscodingService
private PlayerService playerService;
/**

@ -0,0 +1,14 @@
package org.airsonic.player.service.jukebox;
import org.airsonic.player.service.JukeboxLegacySubsonicService;
import org.springframework.stereotype.Component;
import java.io.InputStream;
@Component
public class AudioPlayerFactory {
public AudioPlayer createAudioPlayer(InputStream in, JukeboxLegacySubsonicService jukeboxLegacySubsonicService) throws Exception {
return new AudioPlayer(in, jukeboxLegacySubsonicService);
}
}

@ -0,0 +1,17 @@
package org.airsonic.player.service.jukebox;
import com.github.biconou.AudioPlayer.JavaPlayer;
import com.github.biconou.AudioPlayer.api.Player;
import org.springframework.stereotype.Component;
@Component
public class JavaPlayerFactory {
public Player createJavaPlayer() {
return new JavaPlayer();
}
public Player createJavaPlayer(String mixerName) {
return new JavaPlayer(mixerName);
}
}

@ -6,3 +6,6 @@ logging.level.org.airsonic=INFO
logging.level.liquibase=INFO
logging.pattern.console=%clr(%d{yyyy-MM-dd HH:mm:ss.SSS}){faint} %clr(%5p){green} %clr(---){faint} %clr(%-40.40logger{32}){blue} %clr(:){faint} %m%n%wEx
logging.pattern.file=%d{yyyy-MM-dd HH:mm:ss.SSS} %5p --- %-40.40logger{32} : %m%n%wEx
# Helpful to debug which jars are scanned
#logging.level.org.apache.tomcat.util.scan=TRACE

@ -279,7 +279,7 @@ help.homepage.title=Homepage
help.forum.title=Forum
help.shop.title=Merchandise
help.contact.title=Contact
help.contact.text=Airsonic is a community project. You can find us in <a href="irc://chat.freenode.net/airsonic">#airsonic on Freenode</a>. Technical issues can be submitted to the <a href="https://github.com/airsonic/airsonic/issues">issue tracker on GitHub</a>.
help.contact.text=Airsonic is a community project. You can find us in <a href="irc://chat.freenode.net/airsonic">#airsonic on Freenode</a>. Technical issues can be submitted to the <a href="https://github.com/airsonic/airsonic/issues" target="_blank">issue tracker on GitHub</a>.
help.log=Log
help.logfile=The complete log is saved in {0}.
# settingsHeader.jsp

@ -279,7 +279,7 @@ help.homepage.title=\u9996\u9801
help.forum.title=\u8AD6\u58C7
help.shop.title=\u5546\u54C1
help.contact.title=\u806F\u7E6B
help.contact.text=Airsonic \u662F\u793E\u7FA4\u5C08\u6848\u3002 \u60A8\u53EF\u4EE5\u5728 <a href="irc://chat.freenode.net/airsonic">Freenode \u4E0A\u7684 #airsonic</a> \u627E\u5230\u6211\u5011\u3002\u6280\u8853\u554F\u984C\u53EF\u4EE5\u63D0\u4EA4\u5230 <a href="https://github.com/airsonic/airsonic/issues">Github \u4E0A\u7684\u554F\u984C\u8FFD\u8E64\u5668</a>\u3002
help.contact.text=Airsonic \u662F\u793E\u7FA4\u5C08\u6848\u3002 \u60A8\u53EF\u4EE5\u5728 <a href="irc://chat.freenode.net/airsonic">Freenode \u4E0A\u7684 #airsonic</a> \u627E\u5230\u6211\u5011\u3002\u6280\u8853\u554F\u984C\u53EF\u4EE5\u63D0\u4EA4\u5230 <a href="https://github.com/airsonic/airsonic/issues" target="_blank">Github \u4E0A\u7684\u554F\u984C\u8FFD\u8E64\u5668</a>\u3002
help.log=\u8A18\u9304
help.logfile=\u5B8C\u6574\u7684\u7D00\u9304\u5B58\u653E\u5728 {0}\u3002
# settingsHeader.jsp

@ -1,5 +1,6 @@
package org.airsonic.player;
import org.airsonic.player.controller.JAXBWriter;
import org.airsonic.player.dao.DaoHelper;
import org.airsonic.player.service.MediaScannerService;
import org.apache.commons.io.FileUtils;
@ -37,6 +38,13 @@ public class TestCaseUtils {
return airsonicHomeDirForTest.getAbsolutePath();
}
/**
*
* @return current REST api version.
*/
public static String restApiVersion() {
return new JAXBWriter().getRestProtocolVersion();
}
/**
* Cleans the AIRSONIC_HOME directory used for tests.
@ -106,6 +114,7 @@ public class TestCaseUtils {
* Scans the music library * @param mediaScannerService
*/
public static void execScan(MediaScannerService mediaScannerService) {
// TODO create a synchronous scan
mediaScannerService.scanLibrary();
while (mediaScannerService.isScanning()) {

@ -0,0 +1,59 @@
package org.airsonic.player.api;
import org.airsonic.player.TestCaseUtils;
import org.airsonic.player.util.HomeRule;
import org.junit.BeforeClass;
import org.junit.ClassRule;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@RunWith(SpringRunner.class)
@SpringBootTest
@AutoConfigureMockMvc
public class AirsonicRestApiIntTest {
public static final String CLIENT_NAME = "airsonic";
public static final String AIRSONIC_USER = "admin";
public static final String AIRSONIC_PASSWORD = "admin";
public static final String EXPECTED_FORMAT = "json";
private static String AIRSONIC_API_VERSION;
@Autowired
private MockMvc mvc;
@ClassRule
public static final HomeRule classRule = new HomeRule(); // sets airsonic.home to a temporary dir
@BeforeClass
public static void setupClass() {
AIRSONIC_API_VERSION = TestCaseUtils.restApiVersion();
}
@Test
public void pingTest() throws Exception {
mvc.perform(get("/rest/ping")
.param("v", AIRSONIC_API_VERSION)
.param("c", CLIENT_NAME)
.param("u", AIRSONIC_USER)
.param("p", AIRSONIC_PASSWORD)
.param("f", EXPECTED_FORMAT)
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.subsonic-response.status").value("ok"))
.andExpect(jsonPath("$.subsonic-response.version").value(AIRSONIC_API_VERSION))
.andDo(print());
}
}

@ -0,0 +1,279 @@
package org.airsonic.player.api.jukebox;
import org.airsonic.player.TestCaseUtils;
import org.airsonic.player.dao.*;
import org.airsonic.player.domain.*;
import org.airsonic.player.service.MediaScannerService;
import org.airsonic.player.service.PlayerService;
import org.airsonic.player.service.SettingsService;
import org.airsonic.player.util.HomeRule;
import org.junit.*;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.http.MediaType;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import org.springframework.test.web.servlet.ResultMatcher;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.TimeZone;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@RunWith(SpringRunner.class)
@SpringBootTest
@AutoConfigureMockMvc
@DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_EACH_TEST_METHOD)
public abstract class AbstractAirsonicRestApiJukeboxIntTest {
@ClassRule
public static final HomeRule classRule = new HomeRule(); // sets airsonic.home to a temporary dir
@TestConfiguration
static class Config {
private static class SpiedPlayerDaoPlayQueueFactory extends PlayerDaoPlayQueueFactory {
@Override
public PlayQueue createPlayQueue() {
return spy(super.createPlayQueue());
}
}
@Bean
public PlayerDaoPlayQueueFactory playerDaoPlayQueueFactory() {
return new SpiedPlayerDaoPlayQueueFactory();
}
}
protected static final String CLIENT_NAME = "airsonic";
protected static final String JUKEBOX_PLAYER_NAME = CLIENT_NAME + "-jukebox";
private static final String EXPECTED_FORMAT = "json";
private static String AIRSONIC_API_VERSION;
private static boolean dataBasePopulated;
private static DaoHelper staticDaoHelper;
@Autowired
protected PlayerService playerService;
@Autowired
private MockMvc mvc;
@Autowired
private MusicFolderDao musicFolderDao;
@Autowired
private SettingsService settingsService;
@Autowired
private MediaScannerService mediaScannerService;
@Autowired
private PlayerDao playerDao;
@Autowired
private MediaFileDao mediaFileDao;
@Autowired
private DaoHelper daoHelper;
@Autowired
private AlbumDao albumDao;
@Autowired
private ArtistDao artistDao;
private Player testJukeboxPlayer;
@BeforeClass
public static void setupClass() {
AIRSONIC_API_VERSION = TestCaseUtils.restApiVersion();
dataBasePopulated = false;
}
@AfterClass
public static void cleanDataBase() {
staticDaoHelper.getJdbcTemplate().execute("DROP SCHEMA PUBLIC CASCADE");
staticDaoHelper = null;
dataBasePopulated = false;
}
/**
* Populate test datas in the database only once.
*
* <ul>
* <li>Creates 2 music folder</li>
* <li>Scans the music folders</li>
* <li>Creates a test jukebox player</li>
* </ul>
*/
private void populateDatabase() {
if (!dataBasePopulated) {
staticDaoHelper = daoHelper;
assertThat(musicFolderDao.getAllMusicFolders().size()).isEqualTo(1);
MusicFolderTestData.getTestMusicFolders().forEach(musicFolderDao::createMusicFolder);
settingsService.clearMusicFolderCache();
TestCaseUtils.execScan(mediaScannerService);
assertThat(playerDao.getAllPlayers().size()).isEqualTo(0);
createTestPlayer();
assertThat(playerDao.getAllPlayers().size()).isEqualTo(1);
dataBasePopulated = true;
}
}
@Before
public void setup() throws Exception {
populateDatabase();
testJukeboxPlayer = findTestJukeboxPlayer();
assertThat(testJukeboxPlayer).isNotNull();
reset(testJukeboxPlayer.getPlayQueue());
testJukeboxPlayer.getPlayQueue().clear();
assertThat(testJukeboxPlayer.getPlayQueue().size()).isEqualTo(0);
testJukeboxPlayer.getPlayQueue().addFiles(true,
mediaFileDao.getSongsForAlbum("_DIR_ Ravel", "Complete Piano Works"));
assertThat(testJukeboxPlayer.getPlayQueue().size()).isEqualTo(2);
}
protected abstract void createTestPlayer();
private Player findTestJukeboxPlayer() {
return playerDao.getAllPlayers().stream().filter(player -> player.getName().equals(JUKEBOX_PLAYER_NAME))
.findFirst().orElseThrow(() -> new RuntimeException("No player found in database"));
}
private String convertDateToString(Date date) {
SimpleDateFormat formatter = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.000'Z'");
formatter.setTimeZone(TimeZone.getTimeZone("UTC"));
return formatter.format(date);
}
private ResultMatcher playListItem1isCorrect() {
MediaFile mediaFile = testJukeboxPlayer.getPlayQueue().getFile(0);
MediaFile parent = mediaFileDao.getMediaFile(mediaFile.getParentPath());
Album album = albumDao.getAlbum(mediaFile.getArtist(), mediaFile.getAlbumName());
Artist artist = artistDao.getArtist(mediaFile.getArtist());
assertThat(album).isNotNull();
return result -> {
jsonPath("$.subsonic-response.jukeboxPlaylist.entry[0].id").value(mediaFile.getId()).match(result);
jsonPath("$.subsonic-response.jukeboxPlaylist.entry[0].parent").value(parent.getId()).match(result);
jsonPath("$.subsonic-response.jukeboxPlaylist.entry[0].isDir").value(false).match(result);
jsonPath("$.subsonic-response.jukeboxPlaylist.entry[0].title").value("01 - Gaspard de la Nuit - i. Ondine").match(result);
jsonPath("$.subsonic-response.jukeboxPlaylist.entry[0].album").value("Complete Piano Works").match(result);
jsonPath("$.subsonic-response.jukeboxPlaylist.entry[0].artist").value("_DIR_ Ravel").match(result);
jsonPath("$.subsonic-response.jukeboxPlaylist.entry[0].coverArt").value(parent.getId()).match(result);
jsonPath("$.subsonic-response.jukeboxPlaylist.entry[0].size").value(45138).match(result);
jsonPath("$.subsonic-response.jukeboxPlaylist.entry[0].contentType").value("audio/mpeg").match(result);
jsonPath("$.subsonic-response.jukeboxPlaylist.entry[0].suffix").value("mp3").match(result);
jsonPath("$.subsonic-response.jukeboxPlaylist.entry[0].duration").value(2).match(result);
jsonPath("$.subsonic-response.jukeboxPlaylist.entry[0].bitRate").value(128).match(result);
jsonPath("$.subsonic-response.jukeboxPlaylist.entry[0].path").value("_DIR_ Ravel/_DIR_ Ravel - Complete Piano Works/01 - Gaspard de la Nuit - i. Ondine.mp3").match(result);
jsonPath("$.subsonic-response.jukeboxPlaylist.entry[0].isVideo").value(false).match(result);
jsonPath("$.subsonic-response.jukeboxPlaylist.entry[0].playCount").isNumber().match(result);
jsonPath("$.subsonic-response.jukeboxPlaylist.entry[0].created").value(convertDateToString(mediaFile.getCreated())).match(result);
jsonPath("$.subsonic-response.jukeboxPlaylist.entry[0].albumId").value(album.getId()).match(result);
jsonPath("$.subsonic-response.jukeboxPlaylist.entry[0].artistId").value(artist.getId()).match(result);
jsonPath("$.subsonic-response.jukeboxPlaylist.entry[0].type").value("music").match(result);
};
}
@Test
@WithMockUser(username = "admin")
public void jukeboxStartActionTest() throws Exception {
// Given
// When and Then
performStartAction();
performStatusAction("true");
performGetAction()
.andExpect(jsonPath("$.subsonic-response.jukeboxPlaylist.currentIndex").value("0"))
.andExpect(jsonPath("$.subsonic-response.jukeboxPlaylist.playing").value("true"))
.andExpect(jsonPath("$.subsonic-response.jukeboxPlaylist.gain").value("0.75"))
.andExpect(jsonPath("$.subsonic-response.jukeboxPlaylist.position").value("0"))
.andExpect(jsonPath("$.subsonic-response.jukeboxPlaylist.entry").isArray())
.andExpect(jsonPath("$.subsonic-response.jukeboxPlaylist.entry.length()").value(2))
.andExpect(playListItem1isCorrect())
.andDo(print());
verify(testJukeboxPlayer.getPlayQueue(), times(2)).setStatus(PlayQueue.Status.PLAYING);
assertThat(testJukeboxPlayer.getPlayQueue().getIndex()).isEqualTo(0);
assertThat(testJukeboxPlayer.getPlayQueue().getStatus()).isEqualTo(PlayQueue.Status.PLAYING);
}
@Test
@WithMockUser(username = "admin")
public void jukeboxStopActionTest() throws Exception {
// Given
// When and Then
performStartAction();
performStatusAction("true");
performStopAction();
performStatusAction("false");
verify(testJukeboxPlayer.getPlayQueue(), times(2)).setStatus(PlayQueue.Status.PLAYING);
verify(testJukeboxPlayer.getPlayQueue(), times(1)).setStatus(PlayQueue.Status.STOPPED);
assertThat(testJukeboxPlayer.getPlayQueue().getIndex()).isEqualTo(0);
assertThat(testJukeboxPlayer.getPlayQueue().getStatus()).isEqualTo(PlayQueue.Status.STOPPED);
}
private void performStatusAction(String expectedPlayingValue) throws Exception {
mvc.perform(get("/rest/jukeboxControl.view")
.param("v", AIRSONIC_API_VERSION)
.param("c", CLIENT_NAME)
.param("f", EXPECTED_FORMAT)
.param("action", "status")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.subsonic-response.status").value("ok"))
.andExpect(jsonPath("$.subsonic-response.jukeboxStatus.currentIndex").value("0"))
.andExpect(jsonPath("$.subsonic-response.jukeboxStatus.playing").value(expectedPlayingValue))
.andExpect(jsonPath("$.subsonic-response.jukeboxStatus.position").value("0"));
}
private ResultActions performGetAction() throws Exception {
return mvc.perform(get("/rest/jukeboxControl.view")
.param("v", AIRSONIC_API_VERSION)
.param("c", CLIENT_NAME)
.param("f", EXPECTED_FORMAT)
.param("action", "get")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.subsonic-response.status").value("ok"));
}
private void performStopAction() throws Exception {
mvc.perform(get("/rest/jukeboxControl.view")
.param("v", AIRSONIC_API_VERSION)
.param("c", CLIENT_NAME)
.param("f", EXPECTED_FORMAT)
.param("action", "stop")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.subsonic-response.status").value("ok"))
.andExpect(jsonPath("$.subsonic-response.jukeboxStatus.currentIndex").value("0"))
.andExpect(jsonPath("$.subsonic-response.jukeboxStatus.playing").value("false"))
.andExpect(jsonPath("$.subsonic-response.jukeboxStatus.position").value("0"));
}
private void performStartAction() throws Exception {
mvc.perform(get("/rest/jukeboxControl.view")
.param("v", AIRSONIC_API_VERSION)
.param("c", CLIENT_NAME)
.param("f", EXPECTED_FORMAT)
.param("action", "start")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.subsonic-response.status").value("ok"))
.andExpect(jsonPath("$.subsonic-response.jukeboxStatus.currentIndex").value("0"))
.andExpect(jsonPath("$.subsonic-response.jukeboxStatus.playing").value("true"))
.andExpect(jsonPath("$.subsonic-response.jukeboxStatus.position").value("0"));
}
}

@ -0,0 +1,38 @@
package org.airsonic.player.api.jukebox;
import com.github.biconou.AudioPlayer.JavaPlayer;
import org.airsonic.player.domain.Player;
import org.airsonic.player.domain.PlayerTechnology;
import org.airsonic.player.service.jukebox.JavaPlayerFactory;
import org.junit.Before;
import org.springframework.boot.test.mock.mockito.MockBean;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
public class AirsonicRestApiJukeboxIntTest extends AbstractAirsonicRestApiJukeboxIntTest {
@MockBean
protected JavaPlayerFactory javaPlayerFactory;
@Before
@Override
public void setup() throws Exception {
super.setup();
JavaPlayer mockJavaPlayer = mock(JavaPlayer.class);
when(mockJavaPlayer.getPlayingInfos()).thenReturn( () -> 0 );
when(mockJavaPlayer.getGain()).thenReturn(0.75f);
when(javaPlayerFactory.createJavaPlayer()).thenReturn(mockJavaPlayer);
}
@Override
protected void createTestPlayer() {
Player jukeBoxPlayer = new Player();
jukeBoxPlayer.setName(JUKEBOX_PLAYER_NAME);
jukeBoxPlayer.setUsername("admin");
jukeBoxPlayer.setClientId(CLIENT_NAME + "-jukebox");
jukeBoxPlayer.setTechnology(PlayerTechnology.JAVA_JUKEBOX);
playerService.createPlayer(jukeBoxPlayer);
}
}

@ -0,0 +1,62 @@
package org.airsonic.player.api.jukebox;
import org.airsonic.player.domain.Player;
import org.airsonic.player.domain.PlayerTechnology;
import org.airsonic.player.service.TranscodingService;
import org.airsonic.player.service.jukebox.AudioPlayer;
import org.airsonic.player.service.jukebox.AudioPlayerFactory;
import org.junit.Before;
import org.junit.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.boot.test.mock.mockito.SpyBean;
import org.springframework.security.test.context.support.WithMockUser;
import static org.mockito.Matchers.any;
import static org.mockito.Mockito.*;
public class AirsonicRestApiJukeboxLegacyIntTest extends AirsonicRestApiJukeboxIntTest {
@SpyBean
private TranscodingService transcodingService;
@MockBean
protected AudioPlayerFactory audioPlayerFactory;
private AudioPlayer mockAudioPlayer;
@Before
@Override
public void setup() throws Exception {
super.setup();
mockAudioPlayer = mock(AudioPlayer.class);
when(audioPlayerFactory.createAudioPlayer(any(), any())).thenReturn(mockAudioPlayer);
doReturn(null).when(transcodingService).getTranscodedInputStream(any());
}
@Override
protected final void createTestPlayer() {
Player jukeBoxPlayer = new Player();
jukeBoxPlayer.setName(JUKEBOX_PLAYER_NAME);
jukeBoxPlayer.setUsername("admin");
jukeBoxPlayer.setClientId(CLIENT_NAME + "-jukebox");
jukeBoxPlayer.setTechnology(PlayerTechnology.JUKEBOX);
playerService.createPlayer(jukeBoxPlayer);
}
@Test
@WithMockUser(username = "admin")
@Override
public void jukeboxStartActionTest() throws Exception {
super.jukeboxStartActionTest();
verify(mockAudioPlayer).play();
}
@Test
@WithMockUser(username = "admin")
@Override
public void jukeboxStopActionTest() throws Exception {
super.jukeboxStopActionTest();
verify(mockAudioPlayer).play();
verify(mockAudioPlayer).pause();
}
}

@ -0,0 +1,41 @@
package org.airsonic.player.service;
import org.airsonic.player.domain.Transcoding;
import org.airsonic.player.util.HomeRule;
import org.junit.ClassRule;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.SpyBean;
import org.springframework.test.context.junit4.SpringRunner;
import static org.mockito.Mockito.*;
@RunWith(SpringRunner.class)
@SpringBootTest
public class TranscodingServiceIntTest {
@Autowired
private TranscodingService transcodingService;
@SpyBean
private PlayerService playerService;
@ClassRule
public static final HomeRule classRule = new HomeRule(); // sets airsonic.home to a temporary dir
@Test
public void createTranscodingTest() {
// Given
Transcoding transcoding = new Transcoding(null,
"test-transcoding",
"mp3",
"wav",
"step1",
"step2",
"step3",
true);
transcodingService.createTranscoding(transcoding);
verify(playerService).getAllPlayers();
}
}

@ -0,0 +1,13 @@
spring.mvc.view.prefix: /WEB-INF/jsp/
spring.mvc.view.suffix: .jsp
server.error.includeStacktrace: ALWAYS
logging.level.root=WARN
logging.level.org.airsonic=INFO
logging.level.liquibase=INFO
logging.pattern.console=%clr(%d{yyyy-MM-dd HH:mm:ss.SSS}){faint} %clr(%5p){green} %clr(---){faint} %clr(%-40.40logger{32}){blue} %clr(:){faint} %m%n%wEx
logging.pattern.file=%d{yyyy-MM-dd HH:mm:ss.SSS} %5p --- %-40.40logger{32} : %m%n%wEx
DatabaseConfigType=embed
DatabaseConfigEmbedDriver=org.hsqldb.jdbcDriver
DatabaseConfigEmbedUrl=jdbc:hsqldb:mem:airsonic
DatabaseConfigEmbedUsername=sa
DatabaseConfigEmbedPassword=

@ -16,6 +16,7 @@ RUN apk --no-cache add \
ttf-dejavu \
ca-certificates \
tini \
curl \
openjdk8-jre
COPY run.sh /usr/local/bin/run.sh
@ -28,6 +29,6 @@ EXPOSE $AIRSONIC_PORT
VOLUME $AIRSONIC_DIR/data $AIRSONIC_DIR/music $AIRSONIC_DIR/playlists $AIRSONIC_DIR/podcasts
HEALTHCHECK CMD wget -q http://localhost:"$AIRSONIC_PORT""$CONTEXT_PATH"/rest/ping -O /dev/null || exit 1
HEALTHCHECK --interval=15s --timeout=3s CMD wget -q http://localhost:"$AIRSONIC_PORT""$CONTEXT_PATH"rest/ping -O /dev/null || exit 1
ENTRYPOINT ["tini", "--", "run.sh"]

@ -0,0 +1,205 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<modelVersion>4.0.0</modelVersion>
<artifactId>airsonic-integration-test</artifactId>
<name>Airsonic Integration Test</name>
<parent>
<groupId>org.airsonic.player</groupId>
<artifactId>airsonic</artifactId>
<version>10.2.0-SNAPSHOT</version>
</parent>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<cucumber.version>2.3.1</cucumber.version>
</properties>
<dependencies>
<dependency>
<groupId>io.cucumber</groupId>
<artifactId>cucumber-core</artifactId>
<version>${cucumber.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.cucumber</groupId>
<artifactId>cucumber-junit</artifactId>
<version>${cucumber.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.cucumber</groupId>
<artifactId>cucumber-java</artifactId>
<version>${cucumber.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.cucumber</groupId>
<artifactId>cucumber-java8</artifactId>
<version>${cucumber.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.cucumber</groupId>
<artifactId>cucumber-spring</artifactId>
<version>${cucumber.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-test</artifactId>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.12</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>commons-codec</groupId>
<artifactId>commons-codec</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpcore</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.spotify</groupId>
<artifactId>docker-client</artifactId>
<version>8.13.1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.xmlunit</groupId>
<artifactId>xmlunit-core</artifactId>
<version>2.6.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.xmlunit</groupId>
<artifactId>xmlunit-matchers</artifactId>
<version>2.6.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.airsonic.player</groupId>
<artifactId>subsonic-rest-api</artifactId>
<version>${project.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<testResources>
<testResource>
<directory>src/test/resources</directory>
<filtering>true</filtering>
</testResource>
</testResources>
<plugins>
<plugin>
<groupId>com.github.temyers</groupId>
<artifactId>cucumber-jvm-parallel-plugin</artifactId>
<version>5.0.0</version>
<executions>
<execution>
<id>generateRunners</id>
<phase>generate-test-sources</phase>
<goals>
<goal>generateRunners</goal>
</goals>
<configuration>
<featuresDirectory>src/test/resources/features</featuresDirectory>
<format>pretty</format>
<glue>org.airsonic.test.cucumber.steps</glue>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.5.1</version>
<configuration>
<source>1.8</source>
<target>1.8</target>
<compilerVersion>1.8</compilerVersion>
<showWarnings>true</showWarnings>
<encoding>UTF-8</encoding>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.1.0</version>
<executions>
<execution>
<id>default-jar</id>
<phase/>
<configuration>
<finalName>unwanted</finalName>
<classifier>unwanted</classifier>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>2.21.0</version>
<configuration>
<parallel>all</parallel>
<threadCount>2</threadCount>
<includes>
<include>**/Parallel*IT.class</include>
</includes>
</configuration>
</plugin>
</plugins>
</build>
</project>

@ -0,0 +1,12 @@
package org.airsonic.test;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.PropertySource;
@Configuration
@ComponentScan("org.airsonic.test")
@PropertySource("classpath:application.properties")
public class SpringContext {
}

@ -0,0 +1,13 @@
package org.airsonic.test.cucumber;
import cucumber.api.CucumberOptions;
import cucumber.api.junit.Cucumber;
import org.junit.runner.RunWith;
@RunWith(Cucumber.class)
@CucumberOptions(plugin = {"pretty"},
features = "classpath:features/api/stream-mp3.feature",
glue = "org.airsonic.test.cucumber.steps")
public class RunCukesTest {
}

@ -0,0 +1,20 @@
package org.airsonic.test.cucumber.server;
import org.apache.commons.codec.digest.DigestUtils;
import org.apache.http.client.methods.RequestBuilder;
import java.nio.file.Path;
public interface AirsonicServer {
String getBaseUri();
void uploadToDefaultMusicFolder(Path directoryPath, String relativePath);
default void addRestParameters(RequestBuilder builder) {
builder.addParameter("c", "inttest");
builder.addParameter("v", "1.15.0");
builder.addParameter("u", "admin");
builder.addParameter("s", "int");
builder.addParameter("t", DigestUtils.md5Hex("admin" + "int"));
}
}

@ -0,0 +1,50 @@
package org.airsonic.test.cucumber.steps.api;
import cucumber.api.java8.En;
import org.airsonic.test.cucumber.server.AirsonicServer;
import org.apache.http.HttpEntity;
import org.apache.http.StatusLine;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClientBuilder;
import org.apache.http.util.EntityUtils;
import org.xmlunit.builder.Input;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertThat;
import static org.xmlunit.matchers.CompareMatcher.isIdenticalTo;
public class PingStepDef implements En {
private CloseableHttpResponse response;
private CloseableHttpClient client;
public PingStepDef(AirsonicServer server) {
this.client = HttpClientBuilder.create().build();
When("^A ping request is sent$", () -> {
HttpGet httpGet = new HttpGet(server.getBaseUri() + "/rest/ping");
this.response = client.execute(httpGet);
});
Then("^A required parameter response is received$", () -> {
if(response == null) {
throw new IllegalStateException();
}
try {
StatusLine statusLine = response.getStatusLine();
assertEquals(statusLine.getStatusCode(), 200);
HttpEntity entity = response.getEntity();
String actual = EntityUtils.toString(entity);
assertThat(
actual,
isIdenticalTo(
Input.fromStream(
getClass().getResourceAsStream("/blobs/ping/missing-auth.xml")))
.ignoreWhitespace());
} finally {
response.close();
}
});
}
}

@ -0,0 +1,61 @@
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.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.RequestBuilder;
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.Response;
import java.io.IOException;
public class ScanStepDef implements En {
private final AirsonicServer server;
private CloseableHttpResponse response;
private CloseableHttpClient client;
private ObjectMapper mapper = new ObjectMapper();
public ScanStepDef(AirsonicServer server) {
mapper.enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS);
this.client = HttpClientBuilder.create().build();
this.server = server;
Given("a scan is done", () -> {
Assert.assertFalse(isScanning());
RequestBuilder builder = RequestBuilder.create("GET").setUri(server.getBaseUri() + "/rest/startScan");
server.addRestParameters(builder);
response = client.execute(builder.build());
System.out.println(EntityUtils.toString(response.getEntity()));
Long waitTime = 30000L;
Long sleepTime = 1000L;
while(waitTime > 0 && isScanning()) {
waitTime -= sleepTime;
Thread.sleep(sleepTime);
}
Assert.assertFalse(isScanning());
});
}
private boolean isScanning() throws IOException {
RequestBuilder builder = RequestBuilder.create("GET").setUri(server.getBaseUri() + "/rest/getScanStatus");
builder.addParameter("f", "json");
server.addRestParameters(builder);
response = client.execute(builder.build());
String responseAsString = EntityUtils.toString(response.getEntity());
JsonNode jsonNode = mapper.readTree(responseAsString).get("subsonic-response");
Response response = mapper.treeToValue(jsonNode, Response.class);
return response.getScanStatus().isScanning();
}
}

@ -0,0 +1,20 @@
package org.airsonic.test.cucumber.steps.api;
import cucumber.api.java.Before;
import org.airsonic.test.SpringContext;
import org.airsonic.test.cucumber.server.AirsonicServer;
import org.springframework.test.context.ContextConfiguration;
@ContextConfiguration(classes = SpringContext.class)
public class SpringStepDef {
public SpringStepDef(AirsonicServer server) {
}
@Before
public void setup_cucumber_spring_context(){
// Dummy method so cucumber will recognize this class as glue
// and use its context configuration.
}
}

@ -0,0 +1,69 @@
package org.airsonic.test.cucumber.steps.api;
import cucumber.api.java8.En;
import org.airsonic.test.cucumber.server.AirsonicServer;
import org.apache.commons.io.FileUtils;
import org.apache.commons.io.HexDump;
import org.apache.commons.io.IOUtils;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.RequestBuilder;
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 java.io.File;
import java.io.IOException;
import java.nio.file.Paths;
public class StreamStepDef implements En {
private CloseableHttpResponse response;
private CloseableHttpClient client;
private boolean closed = false;
private byte[] body;
public StreamStepDef(AirsonicServer server) {
this.client = HttpClientBuilder.create().build();
Given("Media file (.*) is added", (String mediaFile) -> {
// TODO fix this
server.uploadToDefaultMusicFolder(
Paths.get(this.getClass().getResource("/blobs/stream/piano").toURI()),
"");
});
When("A stream request is sent", () -> {
RequestBuilder builder = RequestBuilder.create("GET").setUri(server.getBaseUri() + "/rest/stream");
builder.addParameter("id", "2");
builder.addHeader("Range", "bytes=0-");
builder.addHeader("Accept", "audio/webm,audio/ogg,audio/wav,audio/*;");
server.addRestParameters(builder);
response = client.execute(builder.build());
});
Then("The response bytes are equal", () -> {
ensureBodyRead();
FileUtils.writeByteArrayToFile(new File("/tmp/bytearray"), body);
byte[] expected = IOUtils.toByteArray(this.getClass().getResource("/blobs/stream/piano/piano.mp3").toURI());
//
// HexDump.dump(expected, 0, System.out, 0);
Assert.assertArrayEquals(expected, body);
});
}
void ensureBodyRead() throws IOException {
if(closed) {
return;
} else {
this.body = EntityUtils.toByteArray(response.getEntity());
closed = true;
response.close();
}
}
}

@ -0,0 +1,158 @@
package org.airsonic.test.cucumber_hooks.docker;
import com.spotify.docker.client.DefaultDockerClient;
import com.spotify.docker.client.DockerClient;
import com.spotify.docker.client.exceptions.DockerException;
import com.spotify.docker.client.messages.*;
import org.airsonic.test.cucumber.server.AirsonicServer;
import org.apache.commons.lang3.RandomStringUtils;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.DisposableBean;
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.util.Map;
import static com.spotify.docker.client.DockerClient.RemoveContainerParam.*;
@Component
@Profile("dynamic")
public class DynamicDockerHook implements AirsonicServer, EnvironmentAware, InitializingBean, DisposableBean {
private static final Logger logger = LoggerFactory.getLogger(DynamicDockerHook.class);
public static final String AIRSONIC_DOCKER_IMAGE = "airsonic.docker.image";
public static final String AIRSONIC_DOCKER_PORT = "airsonic.docker.port";
public static final String AIRSONIC_READY_MAX_WAIT = "airsonic.ready.max_wait";
public static final String AIRSONIC_READY_SLEEP_TIME = "airsonic.ready.sleep_time";
private String serverUri = null;
private final DockerClient docker;
private String containerId;
private String dockerImage;
private Integer dockerPort;
private Long readyMaxWaitTime;
private Long readySleepTime;
public DynamicDockerHook() {
logger.debug("Using hook for dynamically creating containers");
docker = new DefaultDockerClient("unix:///var/run/docker.sock");
testDockerIsAvail();
}
private void testDockerIsAvail() {
try {
logger.trace("Trying to ping docker daemon");
docker.ping();
} catch (Exception e) {
throw new RuntimeException(e);
}
}
@Override
public String getBaseUri() {
if (serverUri == null) {
throw new IllegalStateException("Server is not yet ready");
} else {
return serverUri;
}
}
@Override
public void uploadToDefaultMusicFolder(Path localDir, String relativePath) {
try {
// TODO ensure localDir is a directory
docker.copyToContainer(localDir, containerId, "/airsonic/music/" + relativePath);
} catch (DockerException | IOException | InterruptedException e) {
throw new RuntimeException(e);
}
}
public void startServer() {
logger.debug("Starting server");
final ContainerConfig config = ContainerConfig.builder()
.image(dockerImage)
.build();
final String name = "airsonic-it-" + RandomStringUtils.randomAlphabetic(10);
try {
final ContainerCreation containerCreate = docker.createContainer(config, name);
containerId = containerCreate.id();
docker.startContainer(containerId);
Long waitTime = readyMaxWaitTime;
while(true) {
ContainerInfo containerInfo = docker.inspectContainer(containerId);
ContainerState.Health health = containerInfo.state().health();
if (health != null && StringUtils.equalsIgnoreCase(health.status(), "healthy")) {
logger.trace("Container started early. Yay!");
break;
} else if(waitTime > readySleepTime) {
if(logger.isTraceEnabled()) {
String message;
if(health != null) {
message = "Container ("+name+") not yet ready. State was: " + health.status();
} else {
message = "Container ("+name+") state unknown. Waiting";
}
logger.trace(message);
}
waitTime -= readySleepTime;
Thread.sleep(readySleepTime);
} else if(health == null) {
logger.trace("Max wait time with unknown container state. Hoping container is ready");
break;
} else {
logger.trace("Container ("+name+") never became ready within max wait time");
throw new RuntimeException("Container ("+name+") not ready");
}
}
ContainerInfo containerInfo = docker.inspectContainer(containerId);
try {
Map.Entry<String, AttachedNetwork> next = containerInfo.networkSettings().networks().entrySet().iterator().next();
String ipAddress = next.getValue().ipAddress();
serverUri = "http://" + ipAddress + ":" + dockerPort;
} catch(Exception e) {
throw new RuntimeException("Could not determine container ("+name+") address", e);
}
} catch (DockerException | InterruptedException e) {
throw new RuntimeException(e);
}
}
public void stopServer() {
if(containerId != null) {
try {
docker.removeContainer(containerId, forceKill(), removeVolumes());
} catch (DockerException | InterruptedException e) {
throw new RuntimeException("Could not remove container", e);
}
}
}
@Override
public void setEnvironment(Environment environment) {
dockerImage = environment.getRequiredProperty(AIRSONIC_DOCKER_IMAGE);
dockerPort = Integer.parseInt(environment.getRequiredProperty(AIRSONIC_DOCKER_PORT));
readyMaxWaitTime = Long.parseLong(environment.getRequiredProperty(AIRSONIC_READY_MAX_WAIT));
readySleepTime = Long.parseLong(environment.getRequiredProperty(AIRSONIC_READY_SLEEP_TIME));
if(readyMaxWaitTime <= 0L || readySleepTime <= 0L) {
throw new IllegalArgumentException("Max wait time and sleep time must be greater than 0");
}
}
@Override
public void destroy() throws Exception {
stopServer();
}
@Override
public void afterPropertiesSet() throws Exception {
startServer();
}
}

@ -0,0 +1,98 @@
package org.airsonic.test.cucumber_hooks.docker;
import com.spotify.docker.client.DefaultDockerClient;
import com.spotify.docker.client.DockerClient;
import com.spotify.docker.client.exceptions.DockerException;
import com.spotify.docker.client.messages.AttachedNetwork;
import com.spotify.docker.client.messages.ContainerInfo;
import org.airsonic.test.cucumber.server.AirsonicServer;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
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.util.Map;
@Component
@Profile("existing")
public class ExistingDockerHook implements AirsonicServer, EnvironmentAware, InitializingBean {
private static final Logger logger = LoggerFactory.getLogger(ExistingDockerHook.class);
public static final String AIRSONIC_DOCKER_CONTAINER = "airsonic.docker.container";
public static final String AIRSONIC_DOCKER_PORT = "airsonic.docker.port";
private String serverUri = null;
private final DockerClient docker;
private String containerId;
private Integer dockerPort;
public ExistingDockerHook() {
logger.debug("Using hook for existing docker container");
docker = new DefaultDockerClient("unix:///var/run/docker.sock");
testDockerIsAvail();
}
private void testDockerIsAvail() {
try {
logger.trace("Trying to ping docker daemon");
docker.ping();
} catch (Exception e) {
throw new RuntimeException(e);
}
}
@Override
public String getBaseUri() {
if (serverUri == null) {
throw new IllegalStateException("Server is not yet ready");
} else {
return serverUri;
}
}
@Override
public void uploadToDefaultMusicFolder(Path localDir, String relativePath) {
try {
// TODO ensure localDir is a directory
docker.copyToContainer(localDir, containerId, "/airsonic/music/" + relativePath);
} catch (DockerException | IOException | InterruptedException e) {
throw new RuntimeException(e);
}
}
public void connectToServer() {
logger.debug("Connecting to server");
try {
ContainerInfo containerInfo = docker.inspectContainer(containerId);
if(!containerInfo.state().running()) {
throw new IllegalStateException("Container is not running " + containerId);
}
Map.Entry<String, AttachedNetwork> next = containerInfo.networkSettings().networks().entrySet().iterator().next();
String ipAddress = next.getValue().ipAddress();
if(StringUtils.isBlank(ipAddress)) {
throw new IllegalStateException("No address found for container " + containerId);
}
serverUri = "http://" + ipAddress + ":" + dockerPort;
} catch (DockerException | InterruptedException e) {
throw new RuntimeException(e);
}
}
@Override
public void setEnvironment(Environment environment) {
containerId = environment.getRequiredProperty(AIRSONIC_DOCKER_CONTAINER);
dockerPort = Integer.parseInt(environment.getRequiredProperty(AIRSONIC_DOCKER_PORT));
}
@Override
public void afterPropertiesSet() throws Exception {
connectToServer();
}
}

@ -0,0 +1,12 @@
airsonic.docker.port=4040
airsonic.ready.max_wait=80000
airsonic.ready.sleep_time=5000
# Use for dynamically generating a container as needed
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

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<subsonic-response xmlns="http://subsonic.org/restapi" status="failed" version="1.15.0">
<error code="10" message="Required parameter is missing."/>
</subsonic-response>

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?>
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.15.0"/>

@ -0,0 +1,5 @@
Feature: Ping API
Scenario: Airsonic responds to ping requests
When A ping request is sent
Then A required parameter response is received

@ -0,0 +1,11 @@
Feature: Stream API for MP3
Background:
Given Media file stream/piano/piano.mp3 is added
And a scan is done
Scenario: Airsonic sends stream data
When A stream request is sent
Then The response bytes are equal
# TODO check length

@ -0,0 +1,14 @@
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<logger level="trace" name="org.airsonic.test.cucumber_hooks.docker.DockerHook" />
<root level="info">
<appender-ref ref="STDOUT" />
</root>
</configuration>

@ -116,12 +116,22 @@
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-core</artifactId>
<version>2.8.11</version>
<version>2.9.6</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.8.11.1</version>
<version>2.9.6</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-annotations</artifactId>
<version>2.9.0</version>
</dependency>
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>20.0</version>
</dependency>
</dependencies>
</dependencyManagement>
@ -303,6 +313,16 @@
<module>install/docker</module>
</modules>
</profile>
<profile>
<id>integration-test</id>
<modules>
<module>subsonic-rest-api</module>
<module>airsonic-sonos-api</module>
<module>airsonic-main</module>
<module>install/docker</module>
<module>integration-test</module>
</modules>
</profile>
<profile>
<id>sign</id>
<build>

Loading…
Cancel
Save