diff --git a/.gitignore b/.gitignore index 98205b1..4210a3a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ -.idea +.classpath .gradle - +.project +.settings +bin build - -application.yml diff --git a/build.gradle b/build.gradle index 2d38c1a..96ccb5b 100644 --- a/build.gradle +++ b/build.gradle @@ -4,16 +4,16 @@ buildscript { } dependencies { - classpath 'com.netflix.nebula:gradle-ospackage-plugin:1.12.2' + classpath 'com.netflix.nebula:gradle-ospackage-plugin:8.3.0' } } plugins { - id "com.github.johnrengelman.shadow" version "5.0.0" + id "com.github.johnrengelman.shadow" version "5.2.0" id "application" id "java" id "net.ltgt.apt-eclipse" version "0.21" - id "nebula.ospackage" version "4.8.0" + id "nebula.ospackage" version "8.3.0" } apply plugin: 'nebula.deb' @@ -22,12 +22,10 @@ description "jbert control application" version "0.3.0" group "ch.jbert" -sourceCompatibility = "1.8" -targetCompatibility = "1.8" - repositories { mavenCentral() maven { url "https://jcenter.bintray.com" } + maven { url 'https://jitpack.io' } } configurations { @@ -41,51 +39,66 @@ dependencies { annotationProcessor "io.micronaut:micronaut-graal" annotationProcessor "io.micronaut:micronaut-inject-java" annotationProcessor "io.micronaut:micronaut-validation" + annotationProcessor "io.micronaut.configuration:micronaut-openapi" compileOnly "org.graalvm.nativeimage:svm" implementation platform("io.micronaut:micronaut-bom:$micronautVersion") - implementation "io.micronaut:micronaut-http-client" implementation "io.micronaut:micronaut-inject" implementation "io.micronaut:micronaut-validation" implementation "io.micronaut:micronaut-runtime" implementation "io.micronaut:micronaut-http-server-netty" + implementation "io.micronaut:micronaut-http-client" + implementation "io.micronaut:micronaut-management" + implementation "io.swagger.core.v3:swagger-annotations" + implementation "com.github.goxr3plus:jaudiotagger:2.2.7" + implementation 'com.google.guava:guava:28.2-jre' runtimeOnly "ch.qos.logback:logback-classic:1.2.3" testAnnotationProcessor platform("io.micronaut:micronaut-bom:$micronautVersion") testAnnotationProcessor "io.micronaut:micronaut-inject-java" testImplementation platform("io.micronaut:micronaut-bom:$micronautVersion") testImplementation "org.junit.jupiter:junit-jupiter-api" testImplementation "io.micronaut.test:micronaut-test-junit5" + testImplementation "org.mockito:mockito-junit-jupiter:3.3.3" testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine" // Sub-Project dependencies - compile project(':core') + implementation project(':core') } test.classpath += configurations.developmentOnly mainClassName = "ch.jbert.Application" + // use JUnit 5 platform test { useJUnitPlatform() } -shadowJar { - mergeServiceFiles() +java { + sourceCompatibility = JavaVersion.toVersion('1.8') + targetCompatibility = JavaVersion.toVersion('1.8') } run.classpath += configurations.developmentOnly -run.jvmArgs("-noverify", - "-XX:TieredStopAtLevel=1", - "-Dcom.sun.management.jmxremote", - "-Dcom.sun.management.jmxremote.port=7777", - "-Dcom.sun.management.jmxremote.local.only=false", - "-Dcom.sun.management.jmxremote.authenticate=false", - "-Dcom.sun.management.jmxremote.ssl=false") +run.jvmArgs( + "-noverify", + "-XX:TieredStopAtLevel=1", + "-Dcom.sun.management.jmxremote", + "-Dcom.sun.management.jmxremote.port=7777", + "-Dcom.sun.management.jmxremote.local.only=false", + "-Dcom.sun.management.jmxremote.authenticate=false", + "-Dcom.sun.management.jmxremote.ssl=false") tasks.withType(JavaCompile) { options.encoding = "UTF-8" options.compilerArgs.add("-parameters") options.compilerArgs.add("-deprecation") options.compilerArgs.add("-Werror") + options.fork = true + options.forkOptions.jvmArgs << '-Dmicronaut.openapi.views.spec=rapidoc.enabled=true,redoc.enabled=true,swagger-ui.enabled=true,swagger-ui.theme=flattop' +} + +shadowJar { + mergeServiceFiles() } task jbertDebPackage(type: Deb) { diff --git a/core/build.gradle b/core/build.gradle index e561601..077f627 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -2,20 +2,19 @@ plugins { id "java" } -sourceCompatibility = "1.8" -targetCompatibility = "1.8" +sourceCompatibility = JavaVersion.toVersion('1.8') +targetCompatibility = JavaVersion.toVersion('1.8') repositories { mavenCentral() maven { url "https://jcenter.bintray.com" } - } dependencies { // https://mvnrepository.com/artifact/com.pi4j/pi4j-core - compile group: "com.pi4j", name: "pi4j-core", version: "1.2" + implementation group: "com.pi4j", name: "pi4j-core", version: "1.2" // https://mvnrepository.com/artifact/org.hihn/javampd - compile group: "org.hihn", name: "javampd", version: "6.1.13" + implementation group: "org.hihn", name: "javampd", version: "6.1.13" } // use JUnit 5 platform @@ -29,4 +28,3 @@ tasks.withType(JavaCompile) { options.compilerArgs.add('-deprecation') options.compilerArgs.add('-Werror') } - diff --git a/core/src/main/java/ch/jbert/mpd/MpdServiceImpl.java b/core/src/main/java/ch/jbert/mpd/MpdServiceImpl.java index ca1de95..285cdde 100644 --- a/core/src/main/java/ch/jbert/mpd/MpdServiceImpl.java +++ b/core/src/main/java/ch/jbert/mpd/MpdServiceImpl.java @@ -49,15 +49,19 @@ public void ensureConnection() { @Override public void configure() { - if (PLAYER_REPEAT) { - logger.info("Enable repeat playing"); - mpd.getPlayer().setRepeat(true); + try { + if (PLAYER_REPEAT) { + logger.info("Enable repeat playing"); + mpd.getPlayer().setRepeat(true); + } + if (X_FADE) { + mpd.getPlayer().setXFade((int) X_FADE_DURATION.getSeconds()); + } + + mpd.getPlayer().setVolume(DEFAULT_VOLUME_IN_PERCENT); + } catch (Exception e) { + logger.warn("Failed configuring mpd", e); } - if (X_FADE) { - mpd.getPlayer().setXFade((int) X_FADE_DURATION.getSeconds()); - } - - mpd.getPlayer().setVolume(DEFAULT_VOLUME_IN_PERCENT); } @Override diff --git a/gradle.properties b/gradle.properties index d4fd0a0..c4a2b83 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1 +1 @@ -micronautVersion=1.2.9 \ No newline at end of file +micronautVersion=1.3.6 \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 91ca28c..5c2d1cf 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 838e6bc..622ab64 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-5.3-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-6.5-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index cccdd3d..b0d6d0a 100755 --- a/gradlew +++ b/gradlew @@ -1,5 +1,21 @@ #!/usr/bin/env sh +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + ############################################################################## ## ## Gradle start up script for UN*X @@ -28,7 +44,7 @@ APP_NAME="Gradle" APP_BASE_NAME=`basename "$0"` # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS="" +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD="maximum" diff --git a/gradlew.bat b/gradlew.bat index e95643d..15e1ee3 100755 --- a/gradlew.bat +++ b/gradlew.bat @@ -1,3 +1,19 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem http://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + @if "%DEBUG%" == "" @echo off @rem ########################################################################## @rem @@ -14,7 +30,7 @@ set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS= +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" @rem Find java.exe if defined JAVA_HOME goto findJavaFromJavaHome diff --git a/openapi.properties b/openapi.properties new file mode 100644 index 0000000..d3f1153 --- /dev/null +++ b/openapi.properties @@ -0,0 +1,10 @@ +micronaut.openapi.expand.api.version=0.1.0 +micronaut.openapi.expand.api.title=jBert REST API +micronaut.openapi.expand.api.description=jBert control application REST interface specification +micronaut.openapi.expand.api.license.name=MIT +micronaut.openapi.expand.api.tags.system.name=System +micronaut.openapi.expand.api.tags.system.description=System Endpoints +micronaut.openapi.expand.api.tags.playlists.name=Playlists +micronaut.openapi.expand.api.tags.playlists.description=Playlist Endpoints +micronaut.openapi.expand.api.tags.tracks.name=Tracks +micronaut.openapi.expand.api.tags.tracks.description=Track Endpoints diff --git a/runlocal.sh b/runlocal.sh new file mode 100755 index 0000000..ae352d8 --- /dev/null +++ b/runlocal.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash + +MICRONAUT_ENVIRONMENTS=local gradle run diff --git a/src/main/java/ch/jbert/Application.java b/src/main/java/ch/jbert/Application.java index cee31f0..0439a00 100644 --- a/src/main/java/ch/jbert/Application.java +++ b/src/main/java/ch/jbert/Application.java @@ -1,7 +1,23 @@ package ch.jbert; import io.micronaut.runtime.Micronaut; +import io.swagger.v3.oas.annotations.OpenAPIDefinition; +import io.swagger.v3.oas.annotations.info.Info; +import io.swagger.v3.oas.annotations.info.License; +import io.swagger.v3.oas.annotations.tags.Tag; +@OpenAPIDefinition( + info = @Info( + title = "${api.title}", + version = "${api.version}", + description = "${api.description}", + license = @License(name = "${api.license.name}") + ), + tags = { + @Tag(name = "${api.tags.playlists.name}", description = "${api.tags.playlists.description}"), + @Tag(name = "${api.tags.tracks.name}", description = "${api.tags.tracks.description}") + } +) public class Application { public static void main(String[] args) { Micronaut.run(Application.class); diff --git a/src/main/java/ch/jbert/controllers/api/GenericExceptionHandler.java b/src/main/java/ch/jbert/controllers/api/GenericExceptionHandler.java new file mode 100644 index 0000000..ed53cd0 --- /dev/null +++ b/src/main/java/ch/jbert/controllers/api/GenericExceptionHandler.java @@ -0,0 +1,23 @@ +package ch.jbert.controllers.api; + +import javax.inject.Singleton; + +import ch.jbert.models.Error; +import io.micronaut.context.annotation.Requires; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.HttpResponse; +import io.micronaut.http.HttpStatus; +import io.micronaut.http.annotation.Produces; +import io.micronaut.http.server.exceptions.ExceptionHandler; + +@Produces +@Singleton +@Requires(classes = { Exception.class, ExceptionHandler.class }) +public class GenericExceptionHandler implements ExceptionHandler { + + @Override + public HttpResponse handle(HttpRequest request, Exception exception) { + return HttpResponse.serverError(new Error(HttpStatus.INTERNAL_SERVER_ERROR.getCode(), exception.getMessage())); + } + +} \ No newline at end of file diff --git a/src/main/java/ch/jbert/controllers/api/IllegalArgumentExceptionHandler.java b/src/main/java/ch/jbert/controllers/api/IllegalArgumentExceptionHandler.java new file mode 100644 index 0000000..30f2bfc --- /dev/null +++ b/src/main/java/ch/jbert/controllers/api/IllegalArgumentExceptionHandler.java @@ -0,0 +1,23 @@ +package ch.jbert.controllers.api; + +import javax.inject.Singleton; + +import ch.jbert.models.Error; +import io.micronaut.context.annotation.Requires; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.HttpResponse; +import io.micronaut.http.HttpStatus; +import io.micronaut.http.annotation.Produces; +import io.micronaut.http.server.exceptions.ExceptionHandler; + +@Produces +@Singleton +@Requires(classes = { IllegalArgumentException.class, ExceptionHandler.class }) +public class IllegalArgumentExceptionHandler implements ExceptionHandler { + + @Override + public HttpResponse handle(HttpRequest request, IllegalArgumentException exception) { + return HttpResponse.badRequest(new Error(HttpStatus.BAD_REQUEST.getCode(), exception.getMessage())); + } + +} \ No newline at end of file diff --git a/src/main/java/ch/jbert/controllers/api/PlaylistController.java b/src/main/java/ch/jbert/controllers/api/PlaylistController.java new file mode 100644 index 0000000..67caf67 --- /dev/null +++ b/src/main/java/ch/jbert/controllers/api/PlaylistController.java @@ -0,0 +1,177 @@ +package ch.jbert.controllers.api; + +import ch.jbert.models.Error; +import ch.jbert.models.Playlist; +import ch.jbert.models.Track; +import ch.jbert.services.PlaylistService; +import ch.jbert.services.TrackService; +import ch.jbert.utils.ThrowingFunction; +import ch.jbert.utils.ThrowingSupplier; +import io.micronaut.http.HttpResponse; +import io.micronaut.http.annotation.Body; +import io.micronaut.http.annotation.Controller; +import io.micronaut.http.annotation.Delete; +import io.micronaut.http.annotation.Get; +import io.micronaut.http.annotation.Post; +import io.micronaut.http.annotation.Put; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.parameters.RequestBody; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; + +import java.io.IOException; +import java.util.List; +import java.util.Optional; + +import javax.inject.Inject; + +import static io.micronaut.http.HttpResponse.created; +import static io.micronaut.http.HttpResponse.notFound; +import static io.micronaut.http.HttpResponse.ok; + +@Controller("/api/playlists") +@Tag(name = "${api.tags.playlists.name}") +public class PlaylistController { + + @Inject + private PlaylistService playlistService; + + /** + * List all available Playlists + */ + @Get("{?q}") + @ApiResponse(responseCode = "200", content = @Content(array = @ArraySchema(schema = @Schema(implementation = Playlist.class)))) + @ApiResponse(responseCode = "500", description = "Server Error", content = @Content(schema = @Schema(implementation = Error.class))) + public HttpResponse list( + @Parameter(description = "Filter playlists by name", required = false) Optional q) { + final List playlists = q + .map(ThrowingFunction.of(n -> playlistService.findAllByName(n))) + .orElseGet(ThrowingSupplier.of(() -> playlistService.getAll())); + return ok(playlists); + } + + /** + * Create a new Playlist + */ + @Post + @RequestBody(description = "The playlist data") + @ApiResponse(responseCode = "201", description = "New playlist successfully created", content = @Content(schema = @Schema(implementation = Playlist.class))) + @ApiResponse(responseCode = "400", description = "Bad Request", content = @Content(schema = @Schema(implementation = Error.class))) + @ApiResponse(responseCode = "500", description = "Server Error", content = @Content(schema = @Schema(implementation = Error.class))) + public HttpResponse create(@Body Playlist playlist) throws IOException { + return created(playlistService.create(playlist)); + } + + /** + * Find a Playlist by name + */ + @Get("/{name}") + @ApiResponse(responseCode = "200", description = "Existing playlist successfully returned", content = @Content(schema = @Schema(implementation = Playlist.class))) + @ApiResponse(responseCode = "404", description = "Playlist not found", content = @Content(schema = @Schema(implementation = Error.class))) + public HttpResponse find(@Parameter(description = "The name of the playlist") String name) { + return playlistService.findOneByName(name) + .map(HttpResponse::ok) + .orElse(notFound()); + } + + /** + * Update an existing Playlist + */ + @Put("/{name}") + @RequestBody(description = "The playlist data") + @ApiResponse(responseCode = "200", description = "Existing playlist successfully updated", content = @Content(schema = @Schema(implementation = Playlist.class))) + @ApiResponse(responseCode = "404", description = "Playlist not found", content = @Content(schema = @Schema(implementation = Error.class))) + @ApiResponse(responseCode = "500", description = "Server Error", content = @Content(schema = @Schema(implementation = Error.class))) + public HttpResponse update( + @Parameter(description = "The name of the playlist") String name, @Body Playlist playlist) { + return playlistService.findOneByName(name) + .map(ThrowingFunction.of(original -> playlistService.update(original, playlist))) + .map(HttpResponse::ok) + .orElse(notFound()); + } + + /** + * Delete a Playlist + */ + @Delete("/{name}") + @ApiResponse(responseCode = "200", description = "Existing playlist successfully deleted", content = @Content(schema = @Schema(implementation = Playlist.class))) + @ApiResponse(responseCode = "404", description = "Playlist not found", content = @Content(schema = @Schema(implementation = Error.class))) + @ApiResponse(responseCode = "500", description = "Server Error", content = @Content(schema = @Schema(implementation = Error.class))) + public HttpResponse delete(@Parameter(description = "The name of the playlist") String name) { + return playlistService.findOneByName(name) + .map(ThrowingFunction.of(playlistService::delete)) + .map(HttpResponse::ok) + .orElse(notFound()); + } + + /** + * List Tracks of a Playlist + */ + @Get("/{name}/tracks{?q}") + @ApiResponse(responseCode = "200", description = "Tracks of playlist successfully returned", content = @Content(array = @ArraySchema(schema = @Schema(implementation = Track.class)))) + @ApiResponse(responseCode = "400", description = "Bad Request", content = @Content(schema = @Schema(implementation = Error.class))) + @ApiResponse(responseCode = "404", description = "Playlist not found", content = @Content(schema = @Schema(implementation = Error.class))) + @ApiResponse(responseCode = "500", description = "Server Error", content = @Content(schema = @Schema(implementation = Error.class))) + public HttpResponse listTracks(@Parameter(description = "The name of the playlist") String name, + @Parameter(description = "Filter tracks by name", required = false) Optional q) { + return playlistService.findOneByName(name) + .map(playlist -> q + .map(query -> TrackService.filterByName(playlist.getTracks(), query)) + .orElse(playlist.getTracks())) + .map(HttpResponse::ok) + .orElse(notFound()); + } + + /** + * Create a new Track and add it to the Playlist + */ + @Post("/{name}/tracks") + @RequestBody(description = "The track data") + @ApiResponse(responseCode = "201", description = "New track successfully created and added to the playlist", content = @Content(schema = @Schema(implementation = Playlist.class))) + @ApiResponse(responseCode = "400", description = "Bad Request", content = @Content(schema = @Schema(implementation = Error.class))) + @ApiResponse(responseCode = "404", description = "Playlist not found", content = @Content(schema = @Schema(implementation = Error.class))) + @ApiResponse(responseCode = "500", description = "Server Error", content = @Content(schema = @Schema(implementation = Error.class))) + public HttpResponse createTrack(@Parameter(description = "The name of the playlist") String name, + @Body Track track) { + return playlistService.findOneByName(name) + .map(ThrowingFunction.of(p -> playlistService.addTrack(p, track))) + .map(HttpResponse::created) + .orElse(notFound()); + } + + /** + * Get a Track by Index + */ + @Get("/{name}/tracks/{index}") + @ApiResponse(responseCode = "200", description = "Track of playlist successfully returned", content = @Content(schema = @Schema(implementation = Track.class))) + @ApiResponse(responseCode = "400", description = "Bad Request", content = @Content(schema = @Schema(implementation = Error.class))) + @ApiResponse(responseCode = "404", description = "Track not found", content = @Content(schema = @Schema(implementation = Error.class))) + @ApiResponse(responseCode = "500", description = "Server Error", content = @Content(schema = @Schema(implementation = Error.class))) + public HttpResponse getTrackByIndex(@Parameter(description = "The name of the playlist") String name, + @Parameter(description = "The position of the track (starting from 0)") int index) { + return playlistService.findOneByName(name) + .map(Playlist::getTracks) + .map(tracks -> tracks.get(index)) + .map(HttpResponse::ok) + .orElse(notFound()); + } + + /** + * Remove a Track from a Playlist by Index + */ + @Delete("/{name}/tracks/{index}") + @ApiResponse(responseCode = "200", description = "Track successfully deleted", content = @Content(schema = @Schema(implementation = Track.class))) + @ApiResponse(responseCode = "400", description = "Bad Request", content = @Content(schema = @Schema(implementation = Error.class))) + @ApiResponse(responseCode = "404", description = "Track not found", content = @Content(schema = @Schema(implementation = Error.class))) + @ApiResponse(responseCode = "500", description = "Server Error", content = @Content(schema = @Schema(implementation = Error.class))) + public HttpResponse deleteTrackByIndex(@Parameter(description = "The name of the playlist") String name, + @Parameter(description = "The position of the track (starting from 0)") int index) throws IOException { + return playlistService.findOneByName(name) + .map(ThrowingFunction.of(playlist -> playlistService.deleteTrackByIndex(playlist, index))) + .map(HttpResponse::ok) + .orElse(notFound()); + } +} diff --git a/src/main/java/ch/jbert/controllers/api/TrackController.java b/src/main/java/ch/jbert/controllers/api/TrackController.java new file mode 100644 index 0000000..cab0b9f --- /dev/null +++ b/src/main/java/ch/jbert/controllers/api/TrackController.java @@ -0,0 +1,107 @@ +package ch.jbert.controllers.api; + +import ch.jbert.models.Error; +import ch.jbert.models.Track; +import ch.jbert.services.TrackService; +import ch.jbert.utils.ThrowingFunction; +import ch.jbert.utils.ThrowingSupplier; +import io.micronaut.http.HttpResponse; +import io.micronaut.http.annotation.Body; +import io.micronaut.http.annotation.Controller; +import io.micronaut.http.annotation.Delete; +import io.micronaut.http.annotation.Get; +import io.micronaut.http.annotation.Post; +import io.micronaut.http.annotation.Put; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.parameters.RequestBody; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; + +import javax.inject.Inject; + +import java.io.IOException; +import java.util.List; +import java.util.Optional; + +import static io.micronaut.http.HttpResponse.created; +import static io.micronaut.http.HttpResponse.notFound; + +@Controller("/api/tracks") +@Tag(name = "${api.tags.tracks.name}") +public class TrackController { + + @Inject + private TrackService trackService; + + /** + * List all available Tracks + */ + @Get("{?q}") + @ApiResponse(responseCode = "200", content = @Content(array = @ArraySchema(schema = @Schema(implementation = Track.class)))) + @ApiResponse(responseCode = "500", description = "Server Error", content = @Content(schema = @Schema(implementation = Error.class))) + public HttpResponse list(@Parameter(description = "Filter tracks by name", required = false) Optional q) { + final List playlists = q.map(ThrowingFunction.of(n -> trackService.findAllByName(n))) + .orElseGet(ThrowingSupplier.of(() -> trackService.getAll())); + return HttpResponse.ok(playlists); + } + + /** + * Create a new Track + */ + @Post + @RequestBody(description = "The track data") + @ApiResponse(responseCode = "201", description = "New track successfully created", content = @Content(schema = @Schema(implementation = Track.class))) + @ApiResponse(responseCode = "400", description = "Bad Request", content = @Content(schema = @Schema(implementation = Error.class))) + @ApiResponse(responseCode = "500", description = "Server Error", content = @Content(schema = @Schema(implementation = Error.class))) + public HttpResponse create(@Body Track track) throws IOException { + return created(trackService.create(track)); + } + + /** + * Find a Track by hash + */ + @Get("/{hash}") + @ApiResponse(responseCode = "200", description = "Existing track successfully returned", content = @Content(schema = @Schema(implementation = Track.class))) + @ApiResponse(responseCode = "400", description = "Bad Request", content = @Content(schema = @Schema(implementation = Error.class))) + @ApiResponse(responseCode = "404", description = "Track not found", content = @Content(schema = @Schema(implementation = Error.class))) + @ApiResponse(responseCode = "500", description = "Server Error", content = @Content(schema = @Schema(implementation = Error.class))) + public HttpResponse find(@Parameter(description = "The hash of the track") String hash) throws IOException { + return trackService.findOneByHash(hash) + .map(HttpResponse::ok) + .orElse(notFound()); + } + + /** + * Update a Track + */ + @Put("/{hash}") + @ApiResponse(responseCode = "200", description = "Existing track successfully updated", content = @Content(schema = @Schema(implementation = Track.class))) + @ApiResponse(responseCode = "400", description = "Bad Request", content = @Content(schema = @Schema(implementation = Error.class))) + @ApiResponse(responseCode = "404", description = "Track not found", content = @Content(schema = @Schema(implementation = Error.class))) + @ApiResponse(responseCode = "500", description = "Server Error", content = @Content(schema = @Schema(implementation = Error.class))) + public HttpResponse update(@Parameter(description = "The hash of the track") String hash, @Body Track track) + throws IOException { + return trackService.findOneByHash(hash) + .map(ThrowingFunction.of(original -> trackService.update(original, track))) + .map(HttpResponse::ok) + .orElse(notFound()); + } + + /** + * Delete a Track + */ + @Delete("/{hash}") + @ApiResponse(responseCode = "200", description = "Existing track successfully deleted", content = @Content(schema = @Schema(implementation = Track.class))) + @ApiResponse(responseCode = "400", description = "Bad Request", content = @Content(schema = @Schema(implementation = Error.class))) + @ApiResponse(responseCode = "404", description = "Track not found", content = @Content(schema = @Schema(implementation = Error.class))) + @ApiResponse(responseCode = "500", description = "Server Error", content = @Content(schema = @Schema(implementation = Error.class))) + public HttpResponse delete(@Parameter(description = "The hash of the track") String hash) throws IOException { + return trackService.findOneByHash(hash) + .map(ThrowingFunction.of(trackService::delete)) + .map(HttpResponse::ok) + .orElse(notFound()); + } +} diff --git a/src/main/java/ch/jbert/controllers/api/UncheckedExceptionHandler.java b/src/main/java/ch/jbert/controllers/api/UncheckedExceptionHandler.java new file mode 100644 index 0000000..32a1aab --- /dev/null +++ b/src/main/java/ch/jbert/controllers/api/UncheckedExceptionHandler.java @@ -0,0 +1,26 @@ +package ch.jbert.controllers.api; + +import javax.inject.Singleton; + +import ch.jbert.models.Error; +import ch.jbert.utils.Throwables; +import ch.jbert.utils.UncheckedException; +import io.micronaut.context.annotation.Requires; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.HttpResponse; +import io.micronaut.http.HttpStatus; +import io.micronaut.http.annotation.Produces; +import io.micronaut.http.server.exceptions.ExceptionHandler; + +@Produces +@Singleton +@Requires(classes = { UncheckedException.class, ExceptionHandler.class }) +public class UncheckedExceptionHandler implements ExceptionHandler { + + @Override + public HttpResponse handle(HttpRequest request, UncheckedException exception) { + Throwables.propagateIfPossible(exception.getCause(), IllegalArgumentException.class); + return HttpResponse.serverError(new Error(HttpStatus.INTERNAL_SERVER_ERROR.getCode(), exception.getCause().getMessage())); + } + +} \ No newline at end of file diff --git a/src/main/java/ch/jbert/models/Error.java b/src/main/java/ch/jbert/models/Error.java new file mode 100644 index 0000000..bf5b3fc --- /dev/null +++ b/src/main/java/ch/jbert/models/Error.java @@ -0,0 +1,67 @@ +package ch.jbert.models; + +import java.util.Objects; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; + +@JsonDeserialize(builder = Error.Builder.class) +public final class Error { + + private final int code; + private final String message; + + public Error(int code, String message) { + this.code = code; + this.message = message; + } + + public int getCode() { + return this.code; + } + + public String getMessage() { + return this.message; + } + + @Override + public boolean equals(Object o) { + if (o == this) return true; + if (o == null || getClass() != o.getClass()) return false; + final Error error = (Error) o; + return Objects.equals(code, error.code) + && Objects.equals(message, error.message); + } + + @Override + public int hashCode() { + return Objects.hash(code, message); + } + + @Override + public String toString() { + return String.format("Error[code=%s, message=%s]", code, message); + } + + public static Builder newBuilder() { + return new Builder(); + } + + public static final class Builder { + private int code; + private String message; + + public Error build() { + return new Error(code, message); + } + + public Builder withCode(int code) { + this.code = code; + return this; + } + + public Builder withMessage(String message) { + this.message = message; + return this; + } + } +} diff --git a/src/main/java/ch/jbert/models/Metadata.java b/src/main/java/ch/jbert/models/Metadata.java new file mode 100644 index 0000000..f049a9b --- /dev/null +++ b/src/main/java/ch/jbert/models/Metadata.java @@ -0,0 +1,170 @@ +package ch.jbert.models; + +import java.util.Objects; +import java.util.Optional; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; + +@JsonDeserialize(builder = Metadata.Builder.class) +public final class Metadata { + + private final String title; + private final String artist; + private final String album; + private final Integer year; + private final String genre; + private final String comment; + private final Integer duration; + + public Metadata(String title, String artist, String album, Integer year, String genre, String comment, + Integer duration) { + this.title = title; + this.artist = artist; + this.album = album; + this.year = year; + this.genre = genre; + this.comment = comment; + this.duration = duration; + } + + public static Metadata merge(Metadata m1, Metadata m2) { + final Builder builder = newBuilder(); + m1.getTitle().ifPresent(builder::withTitle); + m1.getArtist().ifPresent(builder::withArtist); + m1.getYear().ifPresent(builder::withYear); + m1.getGenre().ifPresent(builder::withGenre); + m1.getComment().ifPresent(builder::withComment); + m1.getDuration().ifPresent(builder::withDuration); + m1.getAlbum().ifPresent(builder::withAlbum); + m2.getTitle().ifPresent(builder::withTitle); + m2.getArtist().ifPresent(builder::withArtist); + m2.getYear().ifPresent(builder::withYear); + m2.getGenre().ifPresent(builder::withGenre); + m2.getComment().ifPresent(builder::withComment); + m2.getDuration().ifPresent(builder::withDuration); + m2.getAlbum().ifPresent(builder::withAlbum); + return builder.build(); + } + + public Optional getTitle() { + return Optional.ofNullable(this.title); + } + + public Optional getArtist() { + return Optional.ofNullable(this.artist); + } + + public Optional getAlbum() { + return Optional.ofNullable(this.album); + } + + public Optional getYear() { + return Optional.ofNullable(this.year); + } + + public Optional getGenre() { + return Optional.ofNullable(genre); + } + + public Optional getComment() { + return Optional.ofNullable(comment); + } + + public Optional getDuration() { + return Optional.ofNullable(this.duration); + } + + @Override + public boolean equals(Object other) { + if (other == this) return true; + if (other == null || getClass() != other.getClass()) return false; + final Metadata v = (Metadata) other; + return Objects.equals(title, v.title) + && Objects.equals(artist, v.artist) + && Objects.equals(album, v.album) + && Objects.equals(year, v.year) + && Objects.equals(genre, v.genre) + && Objects.equals(comment, v.comment) + && Objects.equals(duration, v.duration); + } + + @Override + public int hashCode() { + return Objects.hash(title, artist, album, year, genre, comment, duration); + } + + @Override + public String toString() { + return String.format("Metadata[title=%s, artist=%s, album=%s, year=%s, genre=%s, comment=%s, duration=%s]", + title, artist, album, year, genre, comment, duration); + } + + public static Builder newBuilder() { + return new Builder(); + } + + public Builder getBuilder() { + final Builder builder = new Builder(); + getTitle().ifPresent(builder::withTitle); + getArtist().ifPresent(builder::withArtist); + getAlbum().ifPresent(builder::withAlbum); + getYear().ifPresent(builder::withYear); + getGenre().ifPresent(builder::withGenre); + getComment().ifPresent(builder::withComment); + getDuration().ifPresent(builder::withDuration); + return builder; + } + + public static final class Builder { + + private String title; + private String artist; + private String album; + private Integer year; + private String genre; + private String comment; + private Integer duration; + + public Metadata build() { + return new Metadata(title, artist, album, year, genre, comment, duration); + } + + public Builder withTitle(String title) { + this.title = title; + return this; + } + + public Builder withArtist(String artist) { + this.artist = artist; + return this; + } + + public Builder withAlbum(String album) { + this.album = album; + return this; + } + + public Builder withYear(Integer year) { + this.year = year; + return this; + } + + public Builder withGenre(String genre) { + this.genre = genre; + return this; + } + + public Builder withComment(String comment) { + this.comment = comment; + return this; + } + + /** + * Duration in seconds. + */ + public Builder withDuration(Integer duration) { + this.duration = duration; + return this; + } + } +} diff --git a/src/main/java/ch/jbert/models/Playlist.java b/src/main/java/ch/jbert/models/Playlist.java new file mode 100644 index 0000000..eae1271 --- /dev/null +++ b/src/main/java/ch/jbert/models/Playlist.java @@ -0,0 +1,87 @@ +package ch.jbert.models; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; + +@JsonDeserialize(builder = Playlist.Builder.class) +public final class Playlist implements Comparable { + + private final String name; + private final List tracks; + + public Playlist(String name, List tracks) { + this.name = name; + this.tracks = Objects.requireNonNull(tracks); + } + + public Optional getName() { + return Optional.ofNullable(name); + } + + public List getTracks() { + return new ArrayList<>(tracks); + } + + @Override + public boolean equals(Object o) { + if (o == this) return true; + if (o == null || getClass() != o.getClass()) return false; + final Playlist playlist = (Playlist) o; + return Objects.equals(name, playlist.name) + && Objects.equals(tracks, playlist.tracks); + } + + @Override + public int hashCode() { + return Objects.hash(name, tracks); + } + + @Override + public String toString() { + return String.format("Playlist[name=%s, tracks=%s]", name, tracks); + } + + public static Builder newBuilder() { + return new Builder(); + } + + public Builder getBuilder() { + final Builder builder = new Builder(); + getName().ifPresent(builder::withName); + builder.withTracks(getTracks()); + return builder; + } + + public static final class Builder { + private String name; + private List tracks; + + public Builder() { + tracks = Collections.emptyList(); + } + + public Playlist build() { + return new Playlist(name, tracks); + } + + public Builder withName(String name) { + this.name = name; + return this; + } + + public Builder withTracks(List tracks) { + this.tracks = Objects.requireNonNull(tracks); + return this; + } + } + + @Override + public int compareTo(Playlist o) { + return name.compareTo(o.name); + } +} diff --git a/src/main/java/ch/jbert/models/Track.java b/src/main/java/ch/jbert/models/Track.java new file mode 100644 index 0000000..12ffa65 --- /dev/null +++ b/src/main/java/ch/jbert/models/Track.java @@ -0,0 +1,138 @@ +package ch.jbert.models; + +import java.io.IOException; +import java.util.Base64; +import java.util.Comparator; +import java.util.Objects; +import java.util.Optional; + + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.google.common.collect.Comparators; +import com.google.common.hash.Hashing; +import com.google.common.io.ByteSource; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@JsonDeserialize(builder = Track.Builder.class) +public final class Track implements Comparable { + + private static final Logger LOG = LoggerFactory.getLogger(Track.class); + + private final Metadata metadata; + private final String data; + + public Track(Metadata metadata, String data) { + this.metadata = metadata; + this.data = data; + } + + public Track(Metadata metadata) { + this(metadata, null); + } + + public Optional getMetadata() { + return Optional.ofNullable(metadata); + } + + /** + * Base64 encoded data + */ + public Optional getData() { + return Optional.ofNullable(data); + } + + /** + * Calculates the SHA256 hash of the Base64 encoded data + */ + public String calculateSha256() throws IOException { + LOG.trace("Calculating SHA256 hash from track data: {}", data); + // final MessageDigest md = MessageDigest.getInstance("MD5"); + // String hash = (new HexBinaryAdapter()).marshal(md.digest(data.getBytes())); + final ByteSource byteSource = getData() + .map(data -> ByteSource.wrap(Base64.getDecoder().decode(data))) + .orElse(ByteSource.empty()); + final String hash = byteSource.hash(Hashing.sha256()).toString(); + LOG.debug("Calculated SHA256 hash: {}", hash); + return hash; + } + + @Override + public boolean equals(Object o) { + if (o == this) return true; + if (o == null || getClass() != o.getClass()) return false; + final Track track = (Track) o; + return Objects.equals(metadata, track.metadata); + } + + @Override + public int hashCode() { + return Objects.hash(metadata); + } + + @Override + public String toString() { + return String.format("Track[metadata=%s]", metadata); + } + + @Override + public int compareTo(Track o) { + + final Comparator> comparator = Comparators.emptiesFirst(Comparator.naturalOrder()); + final int comparingArtist = comparator.compare(metadata.getArtist(), o.metadata.getArtist()); + if (comparingArtist != 0) { + return comparingArtist; + } + + final int comparingAlbum = comparator.compare(metadata.getAlbum(), o.metadata.getAlbum()); + if (comparingAlbum != 0) { + return comparingAlbum; + } + + final int comparingTitle = comparator.compare(metadata.getTitle(), o.metadata.getTitle()); + if (comparingTitle != 0) { + return comparingTitle; + } + + return 0; + } + + public static Builder newBuilder() { + return new Builder(); + } + + public Builder getBuilder() { + final Builder builder = new Builder(); + getMetadata().ifPresent(builder::withMetadata); + getData().ifPresent(builder::withData); + return builder; + } + + public static final class Builder { + private Metadata metadata; + private String data; + + public Track build() { + return new Track(metadata, data); + } + + public Builder withMetadata(Metadata metadata) { + this.metadata = metadata; + return this; + } + + /** + * Base64 encoded data + */ + public Builder withData(String data) { + this.data = data; + return this; + } + + public Builder withData(byte[] data) { + this.data = Base64.getEncoder().encodeToString(data); + return this; + } + } +} \ No newline at end of file diff --git a/src/main/java/ch/jbert/services/DataService.java b/src/main/java/ch/jbert/services/DataService.java new file mode 100644 index 0000000..07b453a --- /dev/null +++ b/src/main/java/ch/jbert/services/DataService.java @@ -0,0 +1,27 @@ +package ch.jbert.services; + +import java.io.IOException; +import java.util.List; + +/** + * CRUD Service + */ +public interface DataService { + + T create(T entity) throws IOException; + + List getAll() throws IOException; + + List findAllByName(String name) throws IOException; + + T update(T original, T update) throws IOException; + + T delete(T entity) throws IOException; + + boolean exists(T entity); + + default boolean notExists(T entity) { + return !exists(entity); + } + +} diff --git a/src/main/java/ch/jbert/services/PlaylistService.java b/src/main/java/ch/jbert/services/PlaylistService.java new file mode 100644 index 0000000..935c35e --- /dev/null +++ b/src/main/java/ch/jbert/services/PlaylistService.java @@ -0,0 +1,190 @@ +package ch.jbert.services; + +import ch.jbert.models.Playlist; +import ch.jbert.models.Track; +import ch.jbert.utils.ThrowingConsumer; +import io.micronaut.context.annotation.Value; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.stream.Collectors; + +import javax.inject.Inject; +import javax.inject.Singleton; + +@Singleton +public class PlaylistService implements DataService { + + private static final Logger LOG = LoggerFactory.getLogger(PlaylistService.class); + private static final String FILE_SUFFIX = ".m3u"; + + @Value("${restapi.playlists.path}") + private String basePath; + + @Inject + private TrackService trackService; + + @Override + public Playlist create(Playlist playlist) throws IOException { + + Objects.requireNonNull(playlist); + + if (exists(playlist)) { + throw new IllegalArgumentException(String.format("Playlist '%s' already exists", + playlist.getName().orElse(null))); + } + return addTracks(playlist); + } + + @Override + public List getAll() { + + LOG.debug("Reading playlists from folder {}", basePath); + + final String[] filenames = new File(basePath).list((dir, name) -> name.endsWith(FILE_SUFFIX)); + if (filenames == null) { + throw new IllegalStateException(String.format("Given playlists path is not accessible: '%s'", + basePath)); + } + return Arrays.stream(filenames) + .map(filename -> filename.substring(0, filename.length() - FILE_SUFFIX.length())) + .map(name -> new Playlist(name, getTracks(name + FILE_SUFFIX))) + .sorted() + .collect(Collectors.toList()); + } + + @Override + public List findAllByName(String name) { + + if (name == null || name.isEmpty()) { + return getAll(); + } + + return getAll().stream() + .filter(playlist -> playlist.getName().map(n -> n.matches("(?i:.*" + name + ".*)")) + .orElse(false)) + .collect(Collectors.toList()); + } + + public Optional findOneByName(String name) { + return getAll().stream() + .filter(playlist -> playlist.getName().map(n -> n.equals(name)).orElse(false)) + .findFirst(); + } + + private List getTracks(String filename) { + final Path file = Paths.get(basePath, filename); + if (Files.notExists(file) || !Files.isReadable(file)) { + throw new IllegalStateException(String.format("Cannot read tracks from playlist file '%s'", filename)); + } + + try (final BufferedReader reader = Files.newBufferedReader(file)) { + return reader.lines() + .map(trackService::readId3Tags) + .map(metadata -> Track.newBuilder().withMetadata(metadata).build()) + .collect(Collectors.toList()); + } catch (IOException e) { + throw new IllegalStateException(String.format("Cannot read tracks from playlist file '%s'", filename)); + } + } + + @Override + public Playlist update(Playlist original, Playlist update) throws IOException { + + Objects.requireNonNull(original); + Objects.requireNonNull(update); + + if (!update.getName().isPresent()) { + String originalName = original.getName() + .orElseThrow(() -> new IllegalStateException("Original playlist does not have a name")); + update = new Playlist(originalName, update.getTracks()); + } + if (!original.getName().equals(update.getName()) && exists(update)) { + throw new IllegalArgumentException(String.format("Playlist '%s' already exists", + update.getName().orElse(null))); + } else if (notExists(original)) { + throw new IllegalArgumentException(String.format("Playlist '%s' does not exist", update)); + } + delete(original); + return addTracks(update); + } + + @Override + public Playlist delete(Playlist playlist) throws IOException { + + Objects.requireNonNull(playlist); + + if (notExists(playlist)) { + throw new IllegalArgumentException(String.format("Playlist '%s' does not exist", playlist)); + } + + getFilePath(playlist).ifPresent(ThrowingConsumer.of(Files::delete)); + return playlist; + } + + public Playlist addTrack(Playlist playlist, Track track) throws IOException { + + Objects.requireNonNull(playlist); + Objects.requireNonNull(track); + + if (notExists(playlist)) { + throw new IllegalArgumentException(String.format("Playlist '%s' does not exist", playlist)); + } + + final List tracks = playlist.getTracks(); + tracks.add(track); + return addTracks(playlist.getBuilder().withTracks(tracks).build()); + } + + public Playlist deleteTrackByIndex(Playlist playlist, int index) throws IOException { + + Objects.requireNonNull(playlist); + + final String playlistName = playlist.getName().orElseThrow( + () -> new IllegalArgumentException(String.format("Could not get name from playlist '%s'", playlist))); + + final List tracks = findOneByName(playlistName).map(Playlist::getTracks).orElseThrow( + () -> new IllegalArgumentException(String.format("Playlist '%s' does not exist", playlist))); + tracks.remove(index); + + return update(playlist, new Playlist(playlistName, tracks)); + } + + @Override + public boolean exists(Playlist playlist) { + return getFilePath(playlist).map(Files::exists).orElse(false); + } + + private Playlist addTracks(Playlist playlist) throws IOException { + + final Path file = getFilePath(playlist).orElseThrow( + () -> new IllegalArgumentException(String.format("Cannot get file path of playlist '%s'", playlist))); + + try (final BufferedWriter writer = Files.newBufferedWriter(file)) { + for (Track track : playlist.getTracks()) { + if (trackService.notExists(track)) { + trackService.create(track); + } + writer.write(trackService.getRelativeFilePath(track)); + writer.newLine(); + } + } + return playlist; + } + + private Optional getFilePath(Playlist playlist) { + return playlist.getName().map(name -> Paths.get(basePath, name + FILE_SUFFIX)); + } +} diff --git a/src/main/java/ch/jbert/services/TrackService.java b/src/main/java/ch/jbert/services/TrackService.java new file mode 100644 index 0000000..22f388b --- /dev/null +++ b/src/main/java/ch/jbert/services/TrackService.java @@ -0,0 +1,288 @@ +package ch.jbert.services; + +import com.google.common.base.Strings; +import com.google.common.io.ByteSource; +import ch.jbert.models.Metadata; +import ch.jbert.models.Track; +import ch.jbert.utils.ThrowingConsumer; +import ch.jbert.utils.ThrowingFunction; +import ch.jbert.utils.ThrowingPredicate; +import io.micronaut.context.annotation.Value; + +import org.jaudiotagger.audio.AudioFile; +import org.jaudiotagger.audio.AudioFileIO; +import org.jaudiotagger.audio.AudioHeader; +import org.jaudiotagger.tag.FieldKey; +import org.jaudiotagger.tag.Tag; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.io.OutputStream; +import java.nio.file.FileVisitOption; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.attribute.BasicFileAttributes; +import java.time.LocalDateTime; +import java.util.Base64; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.function.BiPredicate; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import javax.inject.Singleton; + +import static ch.jbert.utils.Strings.sanitizeFilename; + +@Singleton +public class TrackService implements DataService { + + private static final Logger LOG = LoggerFactory.getLogger(TrackService.class); + private static final String TEMP_DIR = "_tmp"; + private static final String FILE_SUFFIX = ".mp3"; + private static final String SHA256_PATTERN = "^[A-Fa-f0-9]{64}$"; + + private static final String UNKNOWN_ARTIST = "Unknown Artist"; + private static final String UNKNOWN_ALBUM = "Unknown Album"; + private static final String UNKNOWN_TITLE = "Unknown Title"; + + @Value("${restapi.tracks.path}") + private String basePath; + + @Override + public Track create(Track track) throws IOException { + + Objects.requireNonNull(track); + + if (exists(track)) { + throw new IllegalArgumentException(String.format("Track '%s' already exists, not going to overwrite it", track)); + } + if (track.getData().isPresent()) { + final ByteSource data = ByteSource.wrap(Base64.getDecoder().decode(track.getData().get())); + final Path file = getFilePath(track); + Files.createDirectories(file.getParent()); + try (final OutputStream out = Files.newOutputStream(file)) { + out.write(data.read()); + } + track.getMetadata().ifPresent(m -> writeId3Tags(file, m)); + } else { + throw new IllegalArgumentException( + String.format("Create track failed due to missing track data: '%s'", track)); + } + return Track.newBuilder() + .withMetadata(readMetadata(track)) + .withData(Files.readAllBytes(getFilePath(track))) + .build(); + } + + private Metadata readMetadata(Track track) { + return track.getMetadata() + .orElseGet(() -> readId3Tags(getFilePath(track))); + } + + @Override + public List getAll() throws IOException { + + LOG.debug("Reading tracks from folder {}", basePath); + + try (Stream files = Files.find(Paths.get(basePath), Integer.MAX_VALUE, trackFinder(), FileVisitOption.FOLLOW_LINKS)) { + return files + .map(this::readId3Tags) + .map(Track::new) + .sorted().collect(Collectors.toList()); + } + } + + private BiPredicate trackFinder() { + return (path, attr) -> !attr.isDirectory() && !path.startsWith(Paths.get(basePath, TEMP_DIR)) && + path.toString().endsWith(FILE_SUFFIX); + } + + @Override + public List findAllByName(String name) throws IOException { + return filterByName(getAll(), name); + } + + /** + * Returns a sublist of tracks which's names match the given string. + * + * @param tracks List of tracks to be filtered + * @param name Name query + * @return Sublist of the given tracks + */ + public static List filterByName(List tracks, String name) { + + Objects.requireNonNull(tracks); + + if (Strings.isNullOrEmpty(name)) { + return tracks; + } + + return tracks.stream() + .filter(track -> track.getMetadata() + .flatMap(Metadata::getTitle) + .map(title -> title.matches("(?i:.*" + name + ".*)")) + .orElse(false)) + .collect(Collectors.toList()); + } + + public Optional findOneByHash(String hash) throws IOException { + LOG.debug("Looking up tracks by hash: {}", hash); + if (!isValidSha256(hash)) { + throw new IllegalArgumentException(String.format("Provided hash is not a valid MD5 hash: '%s'", hash)); + } + return getAllWithData().stream() + .filter(ThrowingPredicate.of(t -> Objects.equals(t.calculateSha256(), hash))) + .findAny(); + } + + private boolean isValidSha256(String s) { + return s.matches(SHA256_PATTERN); + } + + private List getAllWithData() throws IOException { + return getAll().stream() + .map(ThrowingFunction.of(this::enrichTrackData)) + .collect(Collectors.toList()); + } + + private Track enrichTrackData(Track track) throws IOException { + final Track enriched = track.getBuilder().withData(Files.readAllBytes(getFilePath(track))).build(); + LOG.trace("Enriched track {} with data: {}", enriched, enriched.getData()); + return enriched; + } + + @Override + public Track update(Track original, Track update) throws IOException { + + Objects.requireNonNull(original); + Objects.requireNonNull(update); + + if (notExists(original)) { + throw new IllegalArgumentException(String.format("Track '%s' does not exist", original)); + } + + // FIXME: Cannot unset single metadata + final Metadata metadata; + if (original.getMetadata().isPresent() && update.getMetadata().isPresent()) { + metadata = Metadata.merge(original.getMetadata().get(), update.getMetadata().get()); + } else { + metadata = update.getMetadata() + .orElseGet(() -> original.getMetadata() + .orElse(null)); + } + final String data = update.getData() + .orElseGet(() -> original.getData() + .orElseThrow(() -> new IllegalStateException("Original track does not have data"))); + delete(original); + return create(new Track(metadata, data)); + } + + @Override + public Track delete(Track track) throws IOException { + + Objects.requireNonNull(track); + + if (notExists(track)) { + throw new IllegalArgumentException(String.format("Track '%s' does not exist", track)); + } + + Files.delete(getFilePath(track)); + return track; + } + + @Override + public boolean exists(Track track) { + return Files.exists(getFilePath(track)); + } + + private Path getFilePath(Track track) { + return Paths.get(basePath, getRelativeFilePath(track)); + } + + String getRelativeFilePath(Track track) { + final Metadata fromId3 = readId3Tags(track); + return sanitizeFilename(track.getMetadata().flatMap(Metadata::getArtist) + .orElseGet(() -> fromId3.getArtist().orElse(UNKNOWN_ARTIST))) + '/' + + sanitizeFilename(track.getMetadata().flatMap(Metadata::getAlbum) + .orElseGet(() -> fromId3.getAlbum().orElse(UNKNOWN_ALBUM))) + '/' + + sanitizeFilename(track.getMetadata().flatMap(Metadata::getTitle) + .orElseGet(() -> fromId3.getTitle().orElse(UNKNOWN_TITLE)) + FILE_SUFFIX); + } + + private Metadata readId3Tags(Track track) { + return track.getData() + .map(ThrowingFunction.of(d -> { + final ByteSource data = ByteSource.wrap(Base64.getDecoder().decode(d)); + final Path tmpDir = Files.createDirectories(Paths.get(basePath, TEMP_DIR)); + final Path tmpFile = tmpDir.resolve(track.calculateSha256() + FILE_SUFFIX); + try (final OutputStream out = Files.newOutputStream(tmpFile)) { + out.write(data.read()); + } + return readId3Tags(tmpFile); + })) + .orElse(Metadata.newBuilder().build()); + } + + Metadata readId3Tags(String file) { + return readId3Tags(Paths.get(basePath, file)); + } + + private Metadata readId3Tags(Path file) { + + LOG.debug("Attempting to read ID3 tags from file '{}'...", file); + + try { + final AudioFile audioFile = AudioFileIO.read(file.toFile()); + final AudioHeader audioHeader = audioFile.getAudioHeader(); + final Tag tag = audioFile.getTag(); + final Metadata.Builder metadataBuilder = Metadata.newBuilder() + .withDuration(audioHeader.getTrackLength()) + .withArtist(tag.getFirst(FieldKey.ARTIST)) + .withAlbum(tag.getFirst(FieldKey.ALBUM)) + .withTitle(tag.getFirst(FieldKey.TITLE)) + .withComment(tag.getFirst(FieldKey.COMMENT)) + .withGenre(tag.getFirst(FieldKey.GENRE)); + + final String year = tag.getFirst(FieldKey.YEAR); + try { + metadataBuilder.withYear(Integer.parseInt(year)); + } catch (Exception e) { + LOG.info("Could not parse year '{}' as number: {}", year, e.getMessage()); + } + try { + metadataBuilder.withYear(LocalDateTime.parse(year).getYear()); + } catch (Exception e) { + LOG.info("Could not parse year '{}' as date: {}", year, e.getMessage()); + } + + return metadataBuilder.build(); + } catch (Exception e) { + LOG.info("Could not read ID3 tags from file '{}', falling back to recovering metadata from file path: '{}'", file, e.getMessage()); + return Metadata.newBuilder() + .withArtist(file.getName(0).toString()) + .withAlbum(file.getName(1).toString()) + .withTitle(file.getName(2).toString()) + .build(); + } + } + + private void writeId3Tags(Path file, Metadata metadata) { + try { + final AudioFile audioFile = AudioFileIO.read(file.toFile()); + final Tag tag = audioFile.getTag(); + metadata.getArtist().ifPresent(ThrowingConsumer.of(v -> tag.setField(FieldKey.ARTIST, v))); + metadata.getAlbum().ifPresent(ThrowingConsumer.of(v -> tag.setField(FieldKey.ALBUM, v))); + metadata.getTitle().ifPresent(ThrowingConsumer.of(v -> tag.setField(FieldKey.TITLE, v))); + metadata.getComment().ifPresent(ThrowingConsumer.of(v -> tag.setField(FieldKey.COMMENT, v))); + metadata.getGenre().ifPresent(ThrowingConsumer.of(v -> tag.setField(FieldKey.GENRE, v))); + audioFile.setTag(tag); + audioFile.commit(); + } catch (Exception e) { + LOG.warn("Could not write ID3 tags from track '{}'", file); + } + } +} diff --git a/src/main/java/ch/jbert/utils/FileUtils.java b/src/main/java/ch/jbert/utils/FileUtils.java new file mode 100644 index 0000000..d357c47 --- /dev/null +++ b/src/main/java/ch/jbert/utils/FileUtils.java @@ -0,0 +1,22 @@ +package ch.jbert.utils; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; +import java.util.stream.Stream; + +public class FileUtils { + + public static void deleteRecursively(Path path) throws IOException { + + if (!Files.exists(path)) { + return; + } + + try (Stream files = Files.walk(path)) { + files.sorted(Comparator.reverseOrder()) + .forEach(ThrowingConsumer.of(Files::delete)); + } + } +} diff --git a/src/main/java/ch/jbert/utils/Strings.java b/src/main/java/ch/jbert/utils/Strings.java new file mode 100644 index 0000000..5232f4e --- /dev/null +++ b/src/main/java/ch/jbert/utils/Strings.java @@ -0,0 +1,10 @@ +package ch.jbert.utils; + +public class Strings { + + private Strings() {} + + public static String sanitizeFilename(String filename) { + return filename.replaceAll("[^a-zA-Z0-9\\.\\- ]", "_"); + } +} diff --git a/src/main/java/ch/jbert/utils/TagFields.java b/src/main/java/ch/jbert/utils/TagFields.java new file mode 100644 index 0000000..1cda7af --- /dev/null +++ b/src/main/java/ch/jbert/utils/TagFields.java @@ -0,0 +1,27 @@ +package ch.jbert.utils; + +import org.jaudiotagger.tag.TagField; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +public class TagFields { + + private static final Logger LOG = LoggerFactory.getLogger(TagFields.class); + + public static List toString(List tagFields) { + try { + return tagFields.stream() + .map(ThrowingFunction.of(TagField::getRawContent)) + .map(String::new) + .collect(Collectors.toList()); + } catch (Exception e) { + LOG.info("Could not read tag field {}", e.getMessage()); + return Collections.emptyList(); + } + } + +} diff --git a/src/main/java/ch/jbert/utils/Throwables.java b/src/main/java/ch/jbert/utils/Throwables.java new file mode 100644 index 0000000..a4e858d --- /dev/null +++ b/src/main/java/ch/jbert/utils/Throwables.java @@ -0,0 +1,30 @@ +package ch.jbert.utils; + +public final class Throwables { + private Throwables() {} + + /** + * Rethrows the given throwable if it is an {@link Error} or {@link + * RuntimeException}. + */ + public static void propagateUnchecked(Throwable error) { + if (error instanceof Error) { + throw (Error) error; + } + if (error instanceof RuntimeException) { + throw (RuntimeException) error; + } + } + + /** + * Rethrows the given throwable if it is an {@link Error} or {@link + * RuntimeException} or an instance of {@code E}. + */ + @SuppressWarnings("unchecked") + public static void propagateIfPossible(Throwable error, Class eclass) throws E { + propagateUnchecked(error); + if (eclass.isInstance(error)) { + throw (E) error; + } + } +} diff --git a/src/main/java/ch/jbert/utils/ThrowingConsumer.java b/src/main/java/ch/jbert/utils/ThrowingConsumer.java new file mode 100644 index 0000000..feeff7f --- /dev/null +++ b/src/main/java/ch/jbert/utils/ThrowingConsumer.java @@ -0,0 +1,64 @@ +package ch.jbert.utils; + +import java.util.Objects; +import java.util.function.Consumer; + +/** + * A special consumer interface that allows throwing {@link Exception}s. + * + *

It implements the default {@code accept} method by catching all exceptions and rethrowing them + * wrapped in the {@link UncheckedException}. It will ever only throw this exception, carrying the + * actual exception as its {@link Exception#getCause() cause}. + * + *

This can be useful when dealing with checked exceptions inside functional interfaces, like + * {@link java.util.stream.Stream}. Example: + * + *

+ *     try {
+ *         stream.foreach(ThrowingConsumer.of(param -> doSomethingThatThrows(param)));
+ *     } catch (UncheckedException e) {
+ *         Throwables.propagateIfPossible(e.getCause(), IOException.class);
+ *         throw e;
+ *     }
+ * 
+ * + * This would rethrow the checked {@code IOException} and all unchecked exceptions. If {@code + * doSomethingThatHrows} would throw another checked exception, then this would be rethrown in an + * {@link UncheckedException}. + * + *

The advantage is, that the catching code is not required to show up in every lambda. The + * downside is, that the compiler cannot detect whether all checked exceptions are rethrown + * properly. + */ +@FunctionalInterface +public interface ThrowingConsumer extends Consumer { + void unsafeAccept(A a) throws Exception; + + default void accept(A a) { + UncheckedException.wrap( + () -> { + unsafeAccept(a); + return null; + }); + } + + default ThrowingConsumer next(ThrowingConsumer next) { + Objects.requireNonNull(next); + return a -> { + unsafeAccept(a); + next.unsafeAccept(a); + }; + } + + default ThrowingConsumer contramap(ThrowingFunction f) { + return b -> unsafeAccept(f.unsafeApply(b)); + } + + static ThrowingConsumer of(ThrowingConsumer c) { + return c; + } + + static ThrowingConsumer doNothing() { + return a -> {}; + } +} diff --git a/src/main/java/ch/jbert/utils/ThrowingFunction.java b/src/main/java/ch/jbert/utils/ThrowingFunction.java new file mode 100644 index 0000000..ebea7b6 --- /dev/null +++ b/src/main/java/ch/jbert/utils/ThrowingFunction.java @@ -0,0 +1,50 @@ +package ch.jbert.utils; + +import java.util.function.Function; + +/** + * A special function interface that allows throwing {@link Exception}s.

+ * + * It implements the default {@code apply} method by catching all exceptions and + * rethrowing them wrapped in the {@link UncheckedException}. It will ever only + * throw this exception, carrying the actual exception as its {@link + * Exception#getCause() cause}.

+ * + * This can be useful when dealing with checked exceptions inside functional + * interfaces, like {@link java.util.stream.Stream}. Example: + * + *

+ *     try {
+ *         stream.map(ThrowingConsumer.of(param -> doSomethingThatThrows(param)));
+ *     } catch (UncheckedException e) {
+ *         Throwables.propagateIfPossible(e.getCause(), IOException.class);
+ *         throw e;
+ *     }
+ * 
+ * + * This would rethrow the checked {@code IOException} and all unchecked + * exceptions. If {@code doSomethingThatHrows} would throw another checked + * exception, then this would be rethrown in an {@link UncheckedException}.

+ * + * The advantage is, that the catching code is not required to show up in every + * lambda. The downside is, that the compiler cannot detect whether all checked + * exceptions are rethrown properly. + */ +@FunctionalInterface +public interface ThrowingFunction extends Function { + B unsafeApply(A a) throws Exception; + + default B apply(A a) { + return UncheckedException.wrap(() -> unsafeApply(a)); + } + + /** + * This is useful when you want to use a {@link ThrowingFunction} for your + * lambda in a place that expects a {@link Function}: + * + * {@code Stream.of(1,2,3).map(ThrowingFunction.of(n -> 2 / n))} + */ + static ThrowingFunction of(ThrowingFunction f) { + return f; + } +} diff --git a/src/main/java/ch/jbert/utils/ThrowingPredicate.java b/src/main/java/ch/jbert/utils/ThrowingPredicate.java new file mode 100644 index 0000000..771045d --- /dev/null +++ b/src/main/java/ch/jbert/utils/ThrowingPredicate.java @@ -0,0 +1,50 @@ +package ch.jbert.utils; + +import java.util.function.Predicate; + +/** + * A special function interface that allows throwing {@link Exception}s.

+ * + * It implements the default {@code test} method by catching all exceptions and + * rethrowing them wrapped in the {@link UncheckedException}. It will ever only + * throw this exception, carrying the actual exception as its {@link + * Exception#getCause() cause}.

+ * + * This can be useful when dealing with checked exceptions inside functional + * interfaces, like {@link java.util.stream.Stream}. Example: + * + *

+ *     try {
+ *         stream.filter(ThrowingPredicate.of(param -> doSomethingThatThrows(param)));
+ *     } catch (UncheckedException e) {
+ *         Throwables.propagateIfPossible(e.getCause(), IOException.class);
+ *         throw e;
+ *     }
+ * 
+ * + * This would rethrow the checked {@code IOException} and all unchecked + * exceptions. If {@code doSomethingThatThrows} would throw another checked + * exception, then this would be rethrown in an {@link UncheckedException}.

+ * + * The advantage is, that the catching code is not required to show up in every + * lambda. The downside is, that the compiler cannot detect whether all checked + * exceptions are rethrown properly. + */ +@FunctionalInterface +public interface ThrowingPredicate extends Predicate { + boolean unsafeTest(A a) throws Exception; + + default boolean test(A a) { + return UncheckedException.wrap(() -> unsafeTest(a)); + } + + /** + * This is useful when you want to use a {@link ThrowingPredicate} for your + * lambda in a place that expects a {@link Predicate}: + * + * {@code Stream.of(1,2,3).filter(ThrowingPredicate.of(n -> n % 2 == 0))} + */ + static ThrowingPredicate of(ThrowingPredicate p) { + return p; + } +} diff --git a/src/main/java/ch/jbert/utils/ThrowingSupplier.java b/src/main/java/ch/jbert/utils/ThrowingSupplier.java new file mode 100644 index 0000000..7d0a888 --- /dev/null +++ b/src/main/java/ch/jbert/utils/ThrowingSupplier.java @@ -0,0 +1,59 @@ +package ch.jbert.utils; + +import java.util.concurrent.Callable; +import java.util.function.Supplier; + +/** + * A special supplier interface that allows throwing {@link Exception}s. + *

+ * + * It implements the default {@code get} method by catching all exceptions and + * rethrowing them wrapped in the {@link UncheckedException}. It will ever only + * throw this exception, carrying the actual exception as its {@link + * Exception#getCause() cause}.

+ * + * This can be useful when dealing with checked exceptions inside functional + * interfaces, like {@link java.util.stream.Stream}. Example: + * + *

+ *     try {
+ *         Stream.generate(ThrowingSupplier.of(() -> somethingThatThrows())).collect(Collectors.toList());
+ *     } catch (UncheckedException e) {
+ *         Throwables.propagateIfPossible(e.getCause(), IOException.class);
+ *         throw e;
+ *     }
+ * 
+ * + * This would rethrow the checked {@code IOException} and all unchecked + * exceptions. If {@code somethingThatThrows} would throw another checked + * exception, then this would be rethrown in an {@link UncheckedException}.

+ * + * The advantage is, that the catching code is not required to show up in every + * lambda. The downside is, that the compiler cannot detect whether all checked + * exceptions are rethrown properly. + */ +@FunctionalInterface +public interface ThrowingSupplier extends Supplier, Callable { + + A unsafeGet() throws Exception; + + default A get() { + return UncheckedException.wrap(this); + } + + default A call() throws Exception { + return unsafeGet(); + } + + default ThrowingSupplier map(ThrowingFunction f) { + return () -> f.unsafeApply(this.unsafeGet()); + } + + default ThrowingSupplier flatMap(ThrowingFunction> f) { + return () -> f.unsafeApply(this.unsafeGet()).unsafeGet(); + } + + static ThrowingSupplier of(ThrowingSupplier s) { + return s; + } +} diff --git a/src/main/java/ch/jbert/utils/UncheckedException.java b/src/main/java/ch/jbert/utils/UncheckedException.java new file mode 100644 index 0000000..9aeb731 --- /dev/null +++ b/src/main/java/ch/jbert/utils/UncheckedException.java @@ -0,0 +1,81 @@ +package ch.jbert.utils; + +import java.util.concurrent.Callable; + +public final class UncheckedException extends RuntimeException { + + + private static final long serialVersionUID = 8303457839849480553L; + + public UncheckedException(Throwable cause) { + super(cause instanceof UncheckedException ? cause.getCause() : cause); + } + + public static UncheckedException wrapThrowable(Throwable cause) { + return cause instanceof UncheckedException + ? (UncheckedException) cause + : new UncheckedException(cause); + } + + /** + * Avoid gathering stack frames for performance reasons, since this + * exception is only a holder for a “real” cause. + */ + @Override + public synchronized Throwable fillInStackTrace() { + return this; + } + + /** + * Runs the given code while catching all exceptions and rethrows it inside + * an {@link UncheckedException}. + *

+ * This method will either return an {@code A} or throw an {@link + * UncheckedException}. + *

+ * + * {@link InterruptedException} are also wrapped, but the current thread's + * interrupt status is set back to true. + */ + public static A wrap(Callable code) { + try { + return code.call(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new UncheckedException(e); + } catch (Exception e) { + throw wrapThrowable(e); + } + } + + /** + * Run the given {@code code} while checking {@link UncheckedException}. + * It's cause is rethrown if it is an instance of {@code e1Class}. Otherwise + * the {@link UncheckedException} is rethrown. + */ + public static A rethrow(Class e1Class, ThrowingSupplier code) throws E1 { + try { + return code.get(); + } catch (UncheckedException e) { + Throwables.propagateIfPossible(e.getCause(), e1Class); + throw e; + } + } + + /** + * Run the given {@code code} while checking {@link UncheckedException}. + * It's cause is rethrown if it is an instance of {@code e1Class} or {@code + * e2Class}. Otherwise the {@link UncheckedException} is rethrown. + */ + public static A rethrow(Class e1Class + , Class e2Class + , ThrowingSupplier code) throws E1, E2 { + try { + return code.get(); + } catch (UncheckedException e) { + Throwables.propagateIfPossible(e.getCause(), e1Class); + Throwables.propagateIfPossible(e.getCause(), e2Class); + throw e; + } + } +} diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml new file mode 100644 index 0000000..44fd7af --- /dev/null +++ b/src/main/resources/application-local.yml @@ -0,0 +1,23 @@ +--- +micronaut: + environment: local + +--- +restapi: + playlists: + path: "/tmp/jbert/playlists" + tracks: + path: "/tmp/jbert/music" + +core: + hal: + mock: + enabled: true + +endpoints: + all: + port: 8085 + sensitive: false + +application: + event-router-name: SimplePlaylistLoad diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml new file mode 100644 index 0000000..0729e0f --- /dev/null +++ b/src/main/resources/application-test.yml @@ -0,0 +1,23 @@ +--- +micronaut: + environment: test + +--- +restapi: + playlists: + path: "build/test/playlists" + tracks: + path: "build/test/music" + +core: + hal: + mock: + enabled: true + +endpoints: + all: + port: 8085 + sensitive: false + +application: + event-router-name: SimplePlaylistLoad diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index f88887c..a655c32 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,7 +1,31 @@ +--- micronaut: application: name: jbert +--- +micronaut: + router: + static-resources: + swagger: + paths: classpath:META-INF/swagger + mapping: /swagger/** + redoc: + paths: classpath:META-INF/swagger/views/redoc + mapping: /redoc/** + rapidoc: + paths: classpath:META-INF/swagger/views/rapidoc + mapping: /rapidoc/** + swagger-ui: + paths: classpath:META-INF/swagger/views/swagger-ui + mapping: /swagger-ui/** + +restapi: + playlists: + path: "/var/lib/mpd/playlists" + tracks: + path: "/var/lib/mpd/music" + core: hal: mock: diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml index 0432a9f..43c24e0 100644 --- a/src/main/resources/logback.xml +++ b/src/main/resources/logback.xml @@ -14,14 +14,16 @@ + + - + diff --git a/src/main/resources/openapi.yaml b/src/main/resources/openapi.yaml deleted file mode 100644 index 2624b7e..0000000 --- a/src/main/resources/openapi.yaml +++ /dev/null @@ -1,339 +0,0 @@ -openapi: "3.0.0" -info: - version: 0.1.0 - title: jBert - description: jBert control application REST interface specification - license: - name: MIT - -paths: - /api/config: - get: - summary: Returns the current system configuration - responses: - '200': - description: JSON system configuration - content: - application/json: - schema: - $ref: "#/components/schemas/Config" - default: - description: Unexpected error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - - /api/status: - get: - summary: Returns general system status / information - responses: - '200': - description: JSON structured system information - content: - application/json: - schema: - $ref: "#/components/schemas/Status" - default: - description: Unexpected error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - - /api/playlists: - get: - summary: List all playlists - responses: - '200': - description: Expected response to a valid request - content: - application/json: - schema: - $ref: "#/components/schemas/Playlists" - default: - description: unexpected error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - post: - summary: Create a new playlist - responses: - '201': - description: New playlist successfully created - content: - application/json: - schema: - $ref: "#/components/schemas/Playlist" - default: - description: unexpected error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - - /api/playlists/{playlistId}: - get: - summary: List all playlists - parameters: - - name: playlistId - in: path - required: true - description: The id of the playlist - schema: - type: integer - responses: - '200': - description: Expected response to a valid request - content: - application/json: - schema: - $ref: "#/components/schemas/Playlist" - default: - description: unexpected error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - put: - summary: Update an existing playlist - parameters: - - name: playlistId - in: path - required: true - description: The id of the playlist to update - schema: - type: integer - - name: playlist - in: query - required: true - description: The playlist data - schema: - $ref: "#/components/schemas/Playlist" - responses: - '200': - description: Expected response to a valid request - content: - application/json: - schema: - $ref: "#/components/schemas/Playlist" - default: - description: unexpected error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - delete: - summary: Delete an existing playlist - parameters: - - name: playlistId - in: path - required: true - description: The id of the playlist to update - schema: - type: integer - responses: - '200': - description: Expected response to a valid request - default: - description: unexpected error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - - /api/playlists/{playlistId}/listTracks: - get: - summary: List all tracks in the playlist - parameters: - - name: playlistId - in: path - required: true - description: The id of the playlist - schema: - type: integer - responses: - '200': - description: Expected response to a valid request - content: - application/json: - schema: - $ref: "#/components/schemas/Tracks" - default: - description: unexpected error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - - /api/playlists/{playlistId}/addTrack: - post: - summary: Add a track to an existing playlist - parameters: - - name: playlistId - in: path - required: true - description: The id of the playlist to update - schema: - type: integer - - name: track - in: query - description: The track meta information - schema: - $ref: "#/components/schemas/Track" - - name: trackData - in: query - description: The track audio data - schema: - type: string - format: binary - responses: - '200': - description: Expected response to a valid request - content: - application/json: - schema: - $ref: "#/components/schemas/Playlist" - default: - description: unexpected error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - - /api/playlists/{playlistId}/removeTrack: - delete: - summary: Remove a track from an existing playlist - parameters: - - name: playlistId - in: path - required: true - description: The id of the playlist to update - schema: - type: integer - - name: track - in: query - description: The track meta information - schema: - $ref: "#/components/schemas/Track" - responses: - '200': - description: Expected response to a valid request - content: - application/json: - schema: - $ref: "#/components/schemas/Playlist" - default: - description: unexpected error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - -components: - schemas: - Status: - properties: - systemStatus: - $ref: "#/components/schemas/SystemStatus" - playerStatus: - $ref: "#/components/schemas/PlayerStatus" - rfidStatus: - $ref: "#/components/schemas/RfidStatus" - - SystemStatus: - properties: - systemLoad: - type: number - format: float - memUsage: - type: number - format: float - uptime: - type: integer - format: int32 - - PlayerStatus: - properties: - volume: - type: number - format: float - numberOfPlaylists: - type: integer - format: int32 - numberOfTracks: - type: integer - format: int32 - - RfidStatus: - properties: - enabled: - type: boolean - - Config: - properties: - systemLoad: - type: number - format: float - memUsage: - type: number - format: float - - Playlist: - properties: - id: - type: integer - format: int32 - name: - type: string - numberOfTracks: - type: integer - format: int32 - - Playlists: - type: array - items: - $ref: "#/components/schemas/Playlist" - - Track: - properties: - uuid: - type: string - format: uuid - title: - type: string - maxLength: 30 - artist: - type: string - maxLength: 30 - album: - type: string - maxLength: 30 - year: - type: integer - format: int32 - genre: - type: string - comment: - type: string - maxLength: 30 - duration: - type: integer - format: int32 - - Tracks: - type: array - items: - $ref: "#/components/schemas/Track" - - Error: - required: - - code - - message - properties: - code: - type: integer - format: int32 - message: - type: string diff --git a/src/test/java/ch/jbert/controllers/api/ExceptionHandlingTest.java b/src/test/java/ch/jbert/controllers/api/ExceptionHandlingTest.java new file mode 100644 index 0000000..b9b7d8e --- /dev/null +++ b/src/test/java/ch/jbert/controllers/api/ExceptionHandlingTest.java @@ -0,0 +1,127 @@ +package ch.jbert.controllers.api; + +import java.io.IOException; + +import javax.inject.Inject; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.fail; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.Test; + +import ch.jbert.models.Error; +import ch.jbert.services.TrackService; +import ch.jbert.utils.UncheckedException; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.HttpResponse; +import io.micronaut.http.HttpStatus; +import io.micronaut.http.client.RxHttpClient; +import io.micronaut.http.client.annotation.Client; +import io.micronaut.http.client.exceptions.HttpClientResponseException; +import io.micronaut.test.annotation.MicronautTest; +import io.micronaut.test.annotation.MockBean; + +@MicronautTest +class ExceptionHandlingTest { + + @Inject + TrackService trackService; + + @Inject + @Client("/api") + RxHttpClient client; + + @MockBean(TrackService.class) + TrackService trackService() { + TrackService mock = mock(TrackService.class); + return mock; + } + + @Test + void handle_when_illegalArgumentException_then_badRequest() throws IOException { + + when( trackService.getAll() ) + .thenThrow(IllegalArgumentException.class); + + try { + client.toBlocking() + .exchange(HttpRequest.GET("/tracks"), Error.class); + fail(); + } catch (HttpClientResponseException e) { + final HttpResponse response = (HttpResponse) e.getResponse(); + assertEquals(HttpStatus.BAD_REQUEST.getCode(), response.code()); + assertFalse(response.getBody().isPresent()); + } + } + + @Test + void handle_when_uncheckedException_then_badRequest() throws IOException { + + when( trackService.getAll() ) + .thenThrow(UncheckedException.wrapThrowable(new IllegalArgumentException("foo"))); + + try { + client.toBlocking() + .exchange(HttpRequest.GET("/tracks"), Error.class); + fail(); + } catch (HttpClientResponseException e) { + final HttpResponse response = (HttpResponse) e.getResponse(); + assertEquals(HttpStatus.BAD_REQUEST.getCode(), response.code()); + assertFalse(response.getBody().isPresent()); + } + } + + @Test + void handle_when_uncheckedException_then_serverError() throws IOException { + + when( trackService.getAll() ) + .thenThrow(UncheckedException.wrapThrowable(new Exception("foo"))); + + try { + client.toBlocking() + .exchange(HttpRequest.GET("/tracks"), Error.class); + fail(); + } catch (HttpClientResponseException e) { + final HttpResponse response = (HttpResponse) e.getResponse(); + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR.getCode(), response.code()); + assertFalse(response.getBody().isPresent()); + } + } + + @Test + void handle_when_ioException_then_serverError() throws IOException { + + when( trackService.getAll() ) + .thenThrow(IOException.class); + + try { + client.toBlocking() + .exchange(HttpRequest.GET("/tracks"), Error.class); + fail(); + } catch (HttpClientResponseException e) { + final HttpResponse response = (HttpResponse) e.getResponse(); + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR.getCode(), response.code()); + assertFalse(response.getBody().isPresent()); + } + } + + @Test + void handle_when_genericException_then_serverError() throws IOException { + + when( trackService.getAll() ) + .thenThrow(RuntimeException.class); + + try { + client.toBlocking() + .exchange(HttpRequest.GET("/tracks"), Error.class); + fail(); + } catch (HttpClientResponseException e) { + final HttpResponse response = (HttpResponse) e.getResponse(); + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR.getCode(), response.code()); + assertFalse(response.getBody().isPresent()); + } + } +} diff --git a/src/test/java/ch/jbert/controllers/api/GenericExceptionHandlerTest.java b/src/test/java/ch/jbert/controllers/api/GenericExceptionHandlerTest.java new file mode 100644 index 0000000..c22a15a --- /dev/null +++ b/src/test/java/ch/jbert/controllers/api/GenericExceptionHandlerTest.java @@ -0,0 +1,28 @@ +package ch.jbert.controllers.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import javax.inject.Inject; + +import org.junit.jupiter.api.Test; + +import ch.jbert.models.Error; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.HttpResponse; +import io.micronaut.http.HttpStatus; +import io.micronaut.test.annotation.MicronautTest; + +@MicronautTest +public class GenericExceptionHandlerTest { + + @Inject + private GenericExceptionHandler genericExceptionHandler; + + @Test + void handle_when_ever_then_badRequest() { + final Exception e = new Exception("foo"); + final HttpResponse response = genericExceptionHandler.handle(HttpRequest.GET("/"), e); + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR.getCode(), response.code()); + assertEquals(new Error(HttpStatus.INTERNAL_SERVER_ERROR.getCode(), "foo"), response.body()); + } +} \ No newline at end of file diff --git a/src/test/java/ch/jbert/controllers/api/IllegalArgumentExceptionHandlerTest.java b/src/test/java/ch/jbert/controllers/api/IllegalArgumentExceptionHandlerTest.java new file mode 100644 index 0000000..41a3532 --- /dev/null +++ b/src/test/java/ch/jbert/controllers/api/IllegalArgumentExceptionHandlerTest.java @@ -0,0 +1,28 @@ +package ch.jbert.controllers.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import javax.inject.Inject; + +import org.junit.jupiter.api.Test; + +import ch.jbert.models.Error; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.HttpResponse; +import io.micronaut.http.HttpStatus; +import io.micronaut.test.annotation.MicronautTest; + +@MicronautTest +public class IllegalArgumentExceptionHandlerTest { + + @Inject + private IllegalArgumentExceptionHandler illegalArgumentExceptionHandler; + + @Test + void handle_when_ever_then_badRequest() { + final IllegalArgumentException e = new IllegalArgumentException("foo"); + final HttpResponse response = illegalArgumentExceptionHandler.handle(HttpRequest.GET("/"), e); + assertEquals(HttpStatus.BAD_REQUEST.getCode(), response.code()); + assertEquals(new Error(HttpStatus.BAD_REQUEST.getCode(), "foo"), response.body()); + } +} \ No newline at end of file diff --git a/src/test/java/ch/jbert/controllers/api/PlaylistControllerTest.java b/src/test/java/ch/jbert/controllers/api/PlaylistControllerTest.java new file mode 100644 index 0000000..91aef3e --- /dev/null +++ b/src/test/java/ch/jbert/controllers/api/PlaylistControllerTest.java @@ -0,0 +1,494 @@ +package ch.jbert.controllers.api; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import javax.inject.Inject; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.fail; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +import ch.jbert.models.Error; +import ch.jbert.models.Metadata; +import ch.jbert.models.Playlist; +import ch.jbert.models.Track; +import ch.jbert.services.PlaylistService; +import io.micronaut.core.type.Argument; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.HttpResponse; +import io.micronaut.http.HttpStatus; +import io.micronaut.http.client.RxHttpClient; +import io.micronaut.http.client.annotation.Client; +import io.micronaut.http.client.exceptions.HttpClientResponseException; +import io.micronaut.test.annotation.MicronautTest; +import io.micronaut.test.annotation.MockBean; + +@MicronautTest +class PlaylistControllerTest { + + private static final Track track1 = new Track(Metadata.newBuilder() + .withArtist("Alice Cooper") + .withAlbum("Trash") + .withTitle("Poison") + .build()); + private static final Track track2 = new Track(Metadata.newBuilder() + .withArtist("Bee Gees") + .withAlbum("Greatest") + .withTitle("Night Fever") + .build()); + private static final Playlist playlist1 = new Playlist("playlist1", Collections.singletonList(track1)); + private static final Playlist playlist2 = new Playlist("playlist2", Collections.singletonList(track2)); + private static final List ALL_PLAYLISTS = Arrays.asList(playlist1, playlist2); + + @Inject + PlaylistService playlistService; + + @Inject + @Client("/api/playlists") + RxHttpClient client; + + @MockBean(PlaylistService.class) + PlaylistService playlistService() { + PlaylistService mock = mock(PlaylistService.class); + return mock; + } + + @Test + void list_when_withoutQuery_then_returnAll() throws IOException { + + when( playlistService.getAll() ) + .then(invocation -> ALL_PLAYLISTS); + + final HttpResponse> response = client.toBlocking() + .exchange(HttpRequest.GET(""), Argument.listOf(Playlist.class)); + + assertEquals(HttpStatus.OK.getCode(), response.code()); + assertEquals(ALL_PLAYLISTS, response.body()); + + verify(playlistService).getAll(); + } + + @Test + void list_when_withQuery_then_findAllByName() throws IOException { + + final String query = "list1"; + + when( playlistService.findAllByName(query) ) + .then(invocation -> Arrays.asList(playlist1)); + + final HttpResponse> response = client.toBlocking() + .exchange(HttpRequest.GET("?q=" + query), Argument.listOf(Playlist.class)); + + assertEquals(HttpStatus.OK.getCode(), response.code()); + assertEquals(Collections.singletonList(playlist1), response.body()); + + verify(playlistService).findAllByName(query); + } + + @Test + void list_when_withQuery_then_findNone() throws IOException { + + final String query = "nonexisting"; + + when( playlistService.findAllByName(query) ) + .then(invocation -> Collections.EMPTY_LIST); + + final HttpResponse> response = client.toBlocking() + .exchange(HttpRequest.GET("?q=" + query), Argument.listOf(Playlist.class)); + + assertEquals(HttpStatus.OK.getCode(), response.code()); + assertEquals(Collections.EMPTY_LIST, response.body()); + + verify(playlistService).findAllByName(query); + } + + @Test + void create_when_successful_then_returnCreatedPlaylist() throws IOException { + + when( playlistService.create(any()) ) + .then(invocation -> playlist1); + + final HttpResponse response = client.toBlocking() + .exchange(HttpRequest.POST("", playlist1), Playlist.class); + + assertEquals(HttpStatus.CREATED.getCode(), response.code()); + assertEquals(playlist1, response.body()); + + verify(playlistService).create(any()); + } + + @Test + void find_when_existing_then_returnPlaylist() throws IOException { + + final String name = "playlist1"; + + when( playlistService.findOneByName(name) ) + .then(invocation -> Optional.of(playlist1)); + + final HttpResponse response = client.toBlocking() + .exchange(HttpRequest.GET("/" + name), Playlist.class); + + assertEquals(HttpStatus.OK.getCode(), response.code()); + assertEquals(playlist1, response.body()); + + verify(playlistService).findOneByName(name); + } + + @Test + void find_when_nonexisting_then_notFound() throws IOException { + + final String name = "nonexisting"; + + when( playlistService.findOneByName(name) ) + .then(invocation -> Optional.empty()); + + try { + client.toBlocking() + .exchange(HttpRequest.GET("/" + name), Playlist.class); + fail(); + } catch (HttpClientResponseException e) { + final HttpResponse response = (HttpResponse) e.getResponse(); + assertEquals(HttpStatus.NOT_FOUND.getCode(), response.code()); + assertFalse(response.getBody().isPresent()); + } + + verify(playlistService).findOneByName(name); + } + + @Test + void update_when_existing_then_returnUpdatedPlaylist() throws IOException { + + final String name = "playlist1"; + + when( playlistService.findOneByName(name) ) + .then(invocation -> Optional.of(playlist1)); + when( playlistService.update(playlist1, playlist2) ) + .then(invocation -> playlist2); + + final HttpResponse response = client.toBlocking() + .exchange(HttpRequest.PUT("/" + name, playlist2), Playlist.class); + + assertEquals(HttpStatus.OK.getCode(), response.code()); + assertEquals(playlist2, response.body()); + + verify(playlistService).findOneByName(name); + verify(playlistService).update(playlist1, playlist2); + } + + @Test + void update_when_nonexisting_then_notFound() throws IOException { + + final String name = "playlist1"; + + when( playlistService.findOneByName(name) ) + .then(invocation -> Optional.empty()); + + try { + client.toBlocking() + .exchange(HttpRequest.PUT("/" + name, playlist2), Track.class); + fail(); + } catch (HttpClientResponseException e) { + final HttpResponse response = (HttpResponse) e.getResponse(); + assertEquals(HttpStatus.NOT_FOUND.getCode(), response.code()); + assertFalse(response.getBody().isPresent()); + } + + verify(playlistService).findOneByName(name); + } + + @Test + void delete_when_existing_then_returnDeletedPlaylist() throws IOException { + + final String name = "playlist1"; + + when( playlistService.findOneByName(name) ) + .then(invocation -> Optional.of(playlist1)); + when( playlistService.delete(playlist1) ) + .then(invocation -> playlist1); + + final HttpResponse response = client.toBlocking() + .exchange(HttpRequest.DELETE("/" + name), Playlist.class); + + assertEquals(HttpStatus.OK.getCode(), response.code()); + assertEquals(playlist1, response.body()); + + verify(playlistService).findOneByName(name); + verify(playlistService).delete(playlist1); + } + + @Test + void delete_when_nonexisting_then_notFound() throws IOException { + + final String name = "playlist1"; + + when( playlistService.findOneByName(name) ) + .then(invocation -> Optional.empty()); + + try { + client.toBlocking() + .exchange(HttpRequest.DELETE("/" + name), Playlist.class); + fail(); + } catch (HttpClientResponseException e) { + final HttpResponse response = (HttpResponse) e.getResponse(); + assertEquals(HttpStatus.NOT_FOUND.getCode(), response.code()); + assertFalse(response.getBody().isPresent()); + } + + verify(playlistService).findOneByName(name); + } + + @Test + void listTracks_when_existingPlaylistWithoutQuery_then_returnAllTracksOfPlaylist() { + + final String name = "playlist1"; + + when( playlistService.findOneByName(name) ) + .then(invocation -> Optional.of(playlist1)); + + final HttpResponse> response = client.toBlocking() + .exchange(HttpRequest.GET("/" + name + "/tracks"), Argument.listOf(Track.class)); + + assertEquals(HttpStatus.OK.getCode(), response.code()); + assertEquals(playlist1.getTracks(), response.body()); + + verify(playlistService).findOneByName(name); + } + + @Test + void listTracks_when_existingPlaylistWithQuery_then_returnAllTracksOfPlaylistByName() throws IOException { + + final String name = "playlist1"; + final String query = "poison"; + + when( playlistService.findOneByName(name) ) + .then(invocation -> Optional.of(playlist1)); + + final HttpResponse> response = client.toBlocking() + .exchange(HttpRequest.GET("/" + name + "/tracks?q=" + query), Argument.listOf(Track.class)); + + assertEquals(HttpStatus.OK.getCode(), response.code()); + assertEquals(Collections.singletonList(track1), response.body()); + + verify(playlistService).findOneByName(name); + } + + @Test + void listTracks_when_existingPlaylistWithQuery_then_findNone() throws IOException { + + final String name = "playlist1"; + final String query = "nonexisting"; + + when( playlistService.findOneByName(name) ) + .then(invocation -> Optional.of(playlist1)); + + final HttpResponse> response = client.toBlocking() + .exchange(HttpRequest.GET("/" + name + "/tracks?q=" + query), Argument.listOf(Track.class)); + + assertEquals(HttpStatus.OK.getCode(), response.code()); + assertEquals(Collections.EMPTY_LIST, response.body()); + + verify(playlistService).findOneByName(name); + } + + @Test + void listTracks_when_nonexistingPlaylist_then_notFound() { + + final String name = "playlist1"; + + when( playlistService.findOneByName(name) ) + .then(invocation -> Optional.empty()); + + try { + client.toBlocking() + .exchange(HttpRequest.GET("/" + name), Playlist.class); + fail(); + } catch (HttpClientResponseException e) { + final HttpResponse response = (HttpResponse) e.getResponse(); + assertEquals(HttpStatus.NOT_FOUND.getCode(), response.code()); + assertFalse(response.getBody().isPresent()); + } + + verify(playlistService).findOneByName(name); + } + + @Test + void createTrack_when_existingPlaylist_then_returnUpdatedPlaylist() throws IOException { + + final String name = "playlist1"; + final Playlist updatedPlaylist = playlist1.getBuilder() + .withTracks(Arrays.asList(track1, track2)) + .build(); + + when( playlistService.findOneByName(name) ) + .then(invocation -> Optional.of(playlist1)); + when( playlistService.addTrack(playlist1, track2)) + .then(invocation -> updatedPlaylist); + + final HttpResponse response = client.toBlocking() + .exchange(HttpRequest.POST("/" + name + "/tracks", track2), Playlist.class); + + assertEquals(HttpStatus.CREATED.getCode(), response.code()); + assertEquals(updatedPlaylist, response.body()); + + verify(playlistService).findOneByName(name); + verify(playlistService).addTrack(playlist1, track2); + } + + @Test + void createTrack_when_nonexistingPlaylist_then_notFound() throws IOException { + + final String name = "nonexisting"; + + when( playlistService.findOneByName(name) ) + .then(invocation -> Optional.empty()); + + try { + client.toBlocking() + .exchange(HttpRequest.POST("/" + name + "/tracks", track2), Playlist.class); + fail(); + } catch (HttpClientResponseException e) { + final HttpResponse response = (HttpResponse) e.getResponse(); + assertEquals(HttpStatus.NOT_FOUND.getCode(), response.code()); + assertFalse(response.getBody().isPresent()); + } + + verify(playlistService).findOneByName(name); + } + + @Test + void getTrackByIndex_when_existing_then_returnTrack() { + + final String name = "playlist1"; + + when( playlistService.findOneByName(name) ) + .then(invocation -> Optional.of(playlist1)); + + // assertEquals(track1, playlist1.getTracks().get(0)); + + final HttpResponse response = client.toBlocking() + .exchange(HttpRequest.GET("/" + name + "/tracks/" + 0)); + + assertEquals(HttpStatus.OK.getCode(), response.code()); + assertEquals(track1, response.body()); + + verify(playlistService).findOneByName(name); + } + + @Test + void getTrackByIndex_when_nonexistingPlaylist_then_notFound() { + + final String name = "playlist1"; + + when( playlistService.findOneByName(name) ) + .then(invocation -> Optional.empty()); + + try { + client.toBlocking() + .exchange(HttpRequest.GET("/" + name + "/tracks/" + 0)); + fail(); + } catch (HttpClientResponseException e) { + final HttpResponse response = (HttpResponse) e.getResponse(); + assertEquals(HttpStatus.NOT_FOUND.getCode(), response.code()); + assertFalse(response.getBody().isPresent()); + } + + verify(playlistService).findOneByName(name); + } + + @Test + void getTrackByIndex_when_nonexistingTrack_then_notFound() { + + final String name = "playlist1"; + + when( playlistService.findOneByName(name) ) + .then(invocation -> Optional.empty()); + + try { + client.toBlocking() + .exchange(HttpRequest.GET("/" + name + "/tracks/" + playlist1.getTracks().size())); + fail(); + } catch (HttpClientResponseException e) { + final HttpResponse response = (HttpResponse) e.getResponse(); + assertEquals(HttpStatus.NOT_FOUND.getCode(), response.code()); + assertFalse(response.getBody().isPresent()); + } + + verify(playlistService).findOneByName(name); + } + + @Test + void deleteTrackByIndex_when_existing_then_returnUpdatedPlaylist() throws IOException { + + final String name = "playlist1"; + final Playlist updatedPlaylist = playlist1.getBuilder() + .withTracks(Collections.emptyList()) + .build(); + + when( playlistService.findOneByName(name) ) + .then(invocation -> Optional.of(playlist1)); + when( playlistService.deleteTrackByIndex(playlist1, 0)) + .then(invocation -> updatedPlaylist); + + final HttpResponse response = client.toBlocking() + .exchange(HttpRequest.DELETE("/" + name + "/tracks/" + 0)); + + assertEquals(HttpStatus.OK.getCode(), response.code()); + assertEquals(updatedPlaylist, response.body()); + + verify(playlistService).findOneByName(name); + verify(playlistService).deleteTrackByIndex(playlist1, 0); + } + + @Test + void deleteTrackByIndex_when_nonexistingPlaylist_then_notFound() { + + final String name = "playlist1"; + + when( playlistService.findOneByName(name) ) + .then(invocation -> Optional.empty()); + + try { + client.toBlocking() + .exchange(HttpRequest.DELETE("/" + name + "/tracks/" + 0)); + fail(); + } catch (HttpClientResponseException e) { + final HttpResponse response = (HttpResponse) e.getResponse(); + assertEquals(HttpStatus.NOT_FOUND.getCode(), response.code()); + assertFalse(response.getBody().isPresent()); + } + + verify(playlistService).findOneByName(name); + } + + @Test + void deleteTrackByIndex_when_nonexistingTrack_then_notFound() { + + final String name = "playlist1"; + + when( playlistService.findOneByName(name) ) + .then(invocation -> Optional.empty()); + + try { + client.toBlocking() + .exchange(HttpRequest.DELETE("/" + name + "/tracks/" + playlist1.getTracks().size())); + fail(); + } catch (HttpClientResponseException e) { + final HttpResponse response = (HttpResponse) e.getResponse(); + assertEquals(HttpStatus.NOT_FOUND.getCode(), response.code()); + assertFalse(response.getBody().isPresent()); + } + + verify(playlistService).findOneByName(name); + } +} diff --git a/src/test/java/ch/jbert/controllers/api/TrackControllerTest.java b/src/test/java/ch/jbert/controllers/api/TrackControllerTest.java new file mode 100644 index 0000000..3ff577c --- /dev/null +++ b/src/test/java/ch/jbert/controllers/api/TrackControllerTest.java @@ -0,0 +1,246 @@ +package ch.jbert.controllers.api; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import javax.inject.Inject; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.fail; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.Test; + +import ch.jbert.models.Error; +import ch.jbert.models.Metadata; +import ch.jbert.models.Track; +import ch.jbert.services.TrackService; +import io.micronaut.core.type.Argument; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.HttpResponse; +import io.micronaut.http.HttpStatus; +import io.micronaut.http.client.RxHttpClient; +import io.micronaut.http.client.annotation.Client; +import io.micronaut.http.client.exceptions.HttpClientResponseException; +import io.micronaut.test.annotation.MicronautTest; +import io.micronaut.test.annotation.MockBean; + +@MicronautTest +class TrackControllerTest { + + private static final Track track1 = new Track(Metadata.newBuilder() + .withArtist("Alice Cooper") + .withAlbum("Trash") + .withTitle("Poison") + .build()); + private static final Track track2 = new Track(Metadata.newBuilder() + .withArtist("Bee Gees") + .withAlbum("Greatest") + .withTitle("Night Fever") + .build()); + private static final List ALL_TRACKS = Arrays.asList(track1, track2); + + @Inject + TrackService trackService; + + @Inject + @Client("/api/tracks") + RxHttpClient client; + + @MockBean(TrackService.class) + TrackService trackService() { + TrackService mock = mock(TrackService.class); + return mock; + } + + @Test + void list_when_withoutQuery_then_returnAll() throws IOException { + + when( trackService.getAll() ) + .then(invocation -> ALL_TRACKS); + + final HttpResponse> response = client.toBlocking() + .exchange(HttpRequest.GET(""), Argument.listOf(Track.class)); + + assertEquals(HttpStatus.OK.getCode(), response.code()); + assertEquals(ALL_TRACKS, response.body()); + + verify(trackService).getAll(); + } + + @Test + void list_when_withQuery_then_findAllByName() throws IOException { + + final String query = "poison"; + + when( trackService.findAllByName(query) ) + .then(invocation -> Arrays.asList(track1)); + + final HttpResponse> response = client.toBlocking() + .exchange(HttpRequest.GET("?q=" + query), Argument.listOf(Track.class)); + + assertEquals(HttpStatus.OK.getCode(), response.code()); + assertEquals(Collections.singletonList(track1), response.body()); + + verify(trackService).findAllByName(query); + } + + @Test + void list_when_withQuery_then_findNone() throws IOException { + + final String query = "nonexisting"; + + when( trackService.findAllByName(query) ) + .then(invocation -> Collections.EMPTY_LIST); + + final HttpResponse> response = client.toBlocking() + .exchange(HttpRequest.GET("?q=" + query), Argument.listOf(Track.class)); + + assertEquals(HttpStatus.OK.getCode(), response.code()); + assertEquals(Collections.EMPTY_LIST, response.body()); + + verify(trackService).findAllByName(query); + } + + @Test + void create_when_successful_then_returnCreatedTrack() throws IOException { + + when( trackService.create(any()) ) + .then(invocation -> track1); + + final HttpResponse response = client.toBlocking() + .exchange(HttpRequest.POST("", track1), Track.class); + + assertEquals(HttpStatus.CREATED.getCode(), response.code()); + assertEquals(track1, response.body()); + + verify(trackService).create(any()); + } + + @Test + void find_when_existingHash_then_returnTrack() throws IOException { + + final String hash = track1.calculateSha256(); + + when( trackService.findOneByHash(hash) ) + .then(invocation -> Optional.of(track1)); + + final HttpResponse response = client.toBlocking() + .exchange(HttpRequest.GET("/" + hash), Track.class); + + assertEquals(HttpStatus.OK.getCode(), response.code()); + assertEquals(track1, response.body()); + + verify(trackService).findOneByHash(hash); + } + + @Test + void find_when_nonexistingHash_then_notFound() throws IOException { + + final String hash = track1.calculateSha256(); + + when( trackService.findOneByHash(hash) ) + .then(invocation -> Optional.empty()); + + try { + client.toBlocking() + .exchange(HttpRequest.GET("/" + hash), Track.class); + fail(); + } catch (HttpClientResponseException e) { + final HttpResponse response = (HttpResponse) e.getResponse(); + assertEquals(HttpStatus.NOT_FOUND.getCode(), response.code()); + assertFalse(response.getBody().isPresent()); + } + + verify(trackService).findOneByHash(hash); + } + + @Test + void update_when_existingHash_then_returnUpdatedTrack() throws IOException { + + final String hash = track1.calculateSha256(); + + when( trackService.findOneByHash(hash) ) + .then(invocation -> Optional.of(track1)); + when( trackService.update(track1, track2) ) + .then(invocation -> track2); + + final HttpResponse response = client.toBlocking() + .exchange(HttpRequest.PUT("/" + hash, track2), Track.class); + + assertEquals(HttpStatus.OK.getCode(), response.code()); + assertEquals(track2, response.body()); + + verify(trackService).findOneByHash(hash); + verify(trackService).update(track1, track2); + } + + @Test + void update_when_nonexistingHash_then_notFound() throws IOException { + + final String hash = track1.calculateSha256(); + + when( trackService.findOneByHash(hash) ) + .then(invocation -> Optional.empty()); + + try { + client.toBlocking() + .exchange(HttpRequest.PUT("/" + hash, track2), Track.class); + fail(); + } catch (HttpClientResponseException e) { + final HttpResponse response = (HttpResponse) e.getResponse(); + assertEquals(HttpStatus.NOT_FOUND.getCode(), response.code()); + assertFalse(response.getBody().isPresent()); + } + + verify(trackService).findOneByHash(hash); + } + + @Test + void delete_when_existingHash_then_returnDeletedTrack() throws IOException { + + final String hash = track1.calculateSha256(); + + when( trackService.findOneByHash(hash) ) + .then(invocation -> Optional.of(track1)); + when( trackService.delete(track1) ) + .then(invocation -> track1); + + final HttpResponse response = client.toBlocking() + .exchange(HttpRequest.DELETE("/" + hash), Track.class); + + assertEquals(HttpStatus.OK.getCode(), response.code()); + assertEquals(track1, response.body()); + + verify(trackService).findOneByHash(hash); + verify(trackService).delete(track1); + } + + @Test + void delete_when_nonexistingHash_then_notFound() throws IOException { + + final String hash = track1.calculateSha256(); + + when( trackService.findOneByHash(hash) ) + .then(invocation -> Optional.empty()); + + try { + client.toBlocking() + .exchange(HttpRequest.DELETE("/" + hash), Track.class); + fail(); + } catch (HttpClientResponseException e) { + final HttpResponse response = (HttpResponse) e.getResponse(); + assertEquals(HttpStatus.NOT_FOUND.getCode(), response.code()); + assertFalse(response.getBody().isPresent()); + } + + verify(trackService).findOneByHash(hash); + } +} diff --git a/src/test/java/ch/jbert/controllers/api/UncheckedExceptionHandlerTest.java b/src/test/java/ch/jbert/controllers/api/UncheckedExceptionHandlerTest.java new file mode 100644 index 0000000..941f1be --- /dev/null +++ b/src/test/java/ch/jbert/controllers/api/UncheckedExceptionHandlerTest.java @@ -0,0 +1,36 @@ +package ch.jbert.controllers.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import javax.inject.Inject; + +import org.junit.jupiter.api.Test; + +import ch.jbert.models.Error; +import ch.jbert.utils.UncheckedException; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.HttpResponse; +import io.micronaut.http.HttpStatus; +import io.micronaut.test.annotation.MicronautTest; + +@MicronautTest +public class UncheckedExceptionHandlerTest { + + @Inject + private UncheckedExceptionHandler uncheckedExceptionHandler; + + @Test + void handle_when_wrappedIllegalArgumentException_then_throwIllegalArgumentException() { + final UncheckedException e = UncheckedException.wrapThrowable(new IllegalArgumentException()); + assertThrows(IllegalArgumentException.class, () -> uncheckedExceptionHandler.handle(HttpRequest.GET("/"), e)); + } + + @Test + void handle_when_wrappedGenericException_then_serverError() { + final UncheckedException e = UncheckedException.wrapThrowable(new Exception("foo")); + final HttpResponse response = uncheckedExceptionHandler.handle(HttpRequest.GET("/"), e); + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR.getCode(), response.code()); + assertEquals(new Error(HttpStatus.INTERNAL_SERVER_ERROR.getCode(), "foo"), response.body()); + } +} \ No newline at end of file diff --git a/src/test/java/ch/jbert/services/PlaylistServiceTest.java b/src/test/java/ch/jbert/services/PlaylistServiceTest.java new file mode 100644 index 0000000..e2424fa --- /dev/null +++ b/src/test/java/ch/jbert/services/PlaylistServiceTest.java @@ -0,0 +1,266 @@ +package ch.jbert.services; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.net.URISyntaxException; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import javax.inject.Inject; + +import org.junit.jupiter.api.Test; + +import ch.jbert.models.Playlist; +import ch.jbert.models.Track; +import ch.jbert.utils.TestData; +import ch.jbert.utils.TestEnvironment; +import io.micronaut.test.annotation.MicronautTest; + +@MicronautTest +public class PlaylistServiceTest extends TestEnvironment { + + private final Playlist playlist; + + public PlaylistServiceTest() throws IOException, URISyntaxException { + playlist = TestData.getPlaylist(); + } + + @Inject + private PlaylistService playlistService; + + @Test + void create_when_newPlaylist_then_create() throws IOException { + playlistService.create(playlist); + assertEquals(Arrays.asList(playlist), playlistService.getAll()); + } + + @Test + void create_when_withoutName_then_throwIllegalArgumentException() throws IOException { + Playlist withoutName = playlist.getBuilder().withName(null).build(); + assertThrows(IllegalArgumentException.class, () -> playlistService.create(withoutName)); + } + + @Test + void create_when_null_then_throwNullPointerException() { + assertThrows(NullPointerException.class, () -> playlistService.create(null)); + } + + @Test + void create_when_empty_then_create() throws IOException { + Playlist empty = playlist.getBuilder().withTracks(Collections.emptyList()).build(); + playlistService.create(empty); + assertEquals(empty, playlistService.getAll().get(0)); + } + + @Test + void getAll_when_noPlaylists_then_empty() throws IOException { + assertTrue(playlistService.getAll().isEmpty()); + } + + @Test + void findAllByName_when_caseSensitiveMatch_then_findPlaylist() throws IOException { + final Playlist playlist2 = playlist.getBuilder().withName("foo bar baz").build(); + playlistService.create(playlist); + playlistService.create(playlist2); + final List result = playlistService.findAllByName("bar"); + assertEquals(Arrays.asList(playlist2), result); + } + + @Test + void findAllByName_when_caseInsensitiveMatch_then_findPlaylist() throws IOException { + final Playlist playlist2 = playlist.getBuilder().withName("foo bar baz").build(); + playlistService.create(playlist); + playlistService.create(playlist2); + final List result = playlistService.findAllByName("BAR"); + assertEquals(Arrays.asList(playlist2), result); + } + + @Test + void findAllByName_when_noMatch_then_empty() throws IOException { + playlistService.create(playlist); + assertTrue(playlistService.findAllByName("no match").isEmpty()); + } + + @Test + void findAllByName_when_null_then_getAll() throws IOException { + final Playlist playlist2 = playlist.getBuilder().withName("foo bar baz").build(); + playlistService.create(playlist); + playlistService.create(playlist2); + assertEquals(playlistService.getAll(), playlistService.findAllByName(null)); + } + + @Test + void findAllByName_when_empty_then_getAll() throws IOException { + final Playlist playlist2 = playlist.getBuilder().withName("foo bar baz").build(); + playlistService.create(playlist); + playlistService.create(playlist2); + assertEquals(playlistService.getAll(), playlistService.findAllByName("")); + } + + @Test + void findOneByName_when_caseSensitiveMatch_then_findPlaylist() throws IOException { + final Playlist playlist2 = playlist.getBuilder().withName("foo bar baz").build(); + playlistService.create(playlist); + playlistService.create(playlist2); + Optional result = playlistService.findOneByName("foo bar baz"); + assertTrue(result.isPresent()); + assertEquals(playlist2, result.get()); + } + + @Test + void findOneByName_when_caseInsensitiveMatch_then_noResult() throws IOException { + final Playlist playlist2 = playlist.getBuilder().withName("foo bar baz").build(); + playlistService.create(playlist); + playlistService.create(playlist2); + assertFalse(playlistService.findOneByName("FOO BAR BAZ").isPresent()); + } + + @Test + void findOneByName_when_noMatch_then_noResult() throws IOException { + playlistService.create(playlist); + assertFalse(playlistService.findOneByName("no match").isPresent()); + } + + @Test + void findOneByName_when_null_then_noResult() throws IOException { + final Playlist playlist2 = playlist.getBuilder().withName("foo bar baz").build(); + playlistService.create(playlist); + playlistService.create(playlist2); + assertFalse(playlistService.findOneByName(null).isPresent()); + } + + @Test + void findOneByName_when_empty_then_noResult() throws IOException { + final Playlist playlist2 = playlist.getBuilder().withName("foo bar baz").build(); + playlistService.create(playlist); + playlistService.create(playlist2); + assertFalse(playlistService.findOneByName("").isPresent()); + } + + @Test + void update_when_unchanged_then_replaceExisting() throws IOException { + playlistService.create(playlist); + playlistService.update(playlist, playlist); + final List result = playlistService.getAll(); + assertEquals(Arrays.asList(playlist), result); + } + + @Test + void update_when_changed_then_replaceExisting() throws Exception { + playlistService.create(playlist); + final Playlist update = playlist.getBuilder().withName("foo").build(); + playlistService.update(playlist, update); + final List result = playlistService.getAll(); + assertEquals(Arrays.asList(update), result); + } + + @Test + void update_when_originalNotExists_then_throwIllegalArgumentException() throws IOException { + playlistService.create(playlist); + final Playlist nonexisting = playlist.getBuilder().withName("foo").build(); + assertThrows(IllegalArgumentException.class, () -> playlistService.update(nonexisting, playlist)); + } + + @Test + void update_when_updateExists_then_throwIllegalArgumentException() throws IOException { + playlistService.create(playlist); + final Playlist existing = playlist.getBuilder().withName("foo").build(); + playlistService.create(existing); + assertThrows(IllegalArgumentException.class, () -> playlistService.update(playlist, existing)); + } + + @Test + void update_when_withoutName_then_keepOriginalName() throws IOException { + playlistService.create(playlist); + final Playlist withoutName = playlist.getBuilder().withName(null).build(); + playlistService.update(playlist, withoutName); + assertEquals(Arrays.asList(playlist), playlistService.getAll()); + } + + @Test + void delete_when_exists_then_delete() throws IOException { + playlistService.create(playlist); + playlistService.delete(playlist); + assertTrue(playlistService.getAll().isEmpty()); + } + + @Test + void delete_when_notExists_then_throwIllegalArgumentException() { + assertThrows(IllegalArgumentException.class, () -> playlistService.delete(playlist)); + } + + @Test + void addTrack_when_exists_then_addTrack() throws Exception { + playlistService.create(playlist); + playlistService.addTrack(playlist, TestData.getChangedTrackWithTitle("foo")); + final Playlist result = playlistService.findOneByName(playlist.getName().get()).get(); + assertEquals(playlist.getTracks().size() + 1, result.getTracks().size()); + } + + @Test + void addTrack_when_notExists_then_throwIllegalArgumentException() { + assertThrows(IllegalArgumentException.class, () -> playlistService.addTrack(playlist, TestData.getChangedTrackWithTitle("foo"))); + } + + @Test + void addTrack_when_playlistNull_then_throwNullPointerException() { + assertThrows(NullPointerException.class, () -> playlistService.addTrack(null, TestData.getChangedTrackWithTitle("foo"))); + } + + @Test + void addTrack_when_trackNull_then_throwNullPointerException() { + assertThrows(NullPointerException.class, () -> playlistService.addTrack(playlist, null)); + } + + @Test + void deleteTrackByIndex_when_exists_then_deleteTrack() throws Exception { + playlistService.create(playlist); + final Track track2 = TestData.getChangedTrackWithTitle("foo"); + playlistService.addTrack(playlist, track2); + assertEquals(Arrays.asList(track2), playlistService.deleteTrackByIndex(playlist, 0).getTracks()); + } + + @Test + void deleteTrackByIndex_when_playlistNotExists_then_throwIllegalArgumentException() { + assertThrows(IllegalArgumentException.class, () -> playlistService.deleteTrackByIndex(playlist, 0)); + } + + @Test + void deleteTrackByIndex_when_indexNotExists_then_throwIndexOutOfBoundsException() throws IOException { + playlistService.create(playlist); + assertThrows(IndexOutOfBoundsException.class, () -> playlistService.deleteTrackByIndex(playlist, 42)); + } + + @Test + void deleteTrackByIndex_when_PlaylistNull_then_throwNullPointerException() { + assertThrows(NullPointerException.class, () -> playlistService.deleteTrackByIndex(null, 0)); + } + + @Test + void exists_when_exists_then_true() throws IOException { + playlistService.create(playlist); + assertTrue(playlistService.exists(playlist)); + } + + @Test + void exists_when_notExists_then_false() { + assertFalse(playlistService.exists(playlist)); + } + + @Test + void notExists_when_exists_then_false() throws IOException { + playlistService.create(playlist); + assertFalse(playlistService.notExists(playlist)); + } + + @Test + void notExists_when_notExists_then_true() { + assertTrue(playlistService.notExists(playlist)); + } +} diff --git a/src/test/java/ch/jbert/services/TrackServiceTest.java b/src/test/java/ch/jbert/services/TrackServiceTest.java new file mode 100644 index 0000000..05b1a32 --- /dev/null +++ b/src/test/java/ch/jbert/services/TrackServiceTest.java @@ -0,0 +1,314 @@ +package ch.jbert.services; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.net.URISyntaxException; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import javax.inject.Inject; + +import org.junit.jupiter.api.Test; + +import ch.jbert.models.Metadata; +import ch.jbert.models.Track; +import ch.jbert.utils.TestData; +import ch.jbert.utils.TestEnvironment; +import io.micronaut.test.annotation.MicronautTest; + +@MicronautTest +class TrackServiceTest extends TestEnvironment { + + private final Track track; + + public TrackServiceTest() throws IOException, URISyntaxException { + track = TestData.getTrack(); + } + + @Inject + private TrackService trackService; + + @Test + void create_when_newTrack_then_create() throws IOException { + trackService.create(track); + assertEquals(Arrays.asList(track), trackService.getAll()); + } + + @Test + void create_when_withMetadata_then_keepMetadata() throws IOException { + final Metadata metadata = track.getMetadata().get().getBuilder().withComment("foo, bar").build(); + final Track withMetadata = track.getBuilder().withMetadata(metadata).build(); + assertEquals(withMetadata, trackService.create(withMetadata)); + } + + @Test + void create_when_withMetadata_then_changeHash() throws Exception { + final Metadata metadata = track.getMetadata().get().getBuilder().withComment("foo, bar").build(); + final Track withMetadata = track.getBuilder().withMetadata(metadata).build(); + final Track created = trackService.create(withMetadata); + assertFalse(withMetadata.calculateSha256().equals(created.calculateSha256())); + } + + @Test + void create_when_withoutMetadata_then_takeMetadataFromFile() throws IOException { + final Track withoutMetadata = track.getBuilder().withMetadata(null).build(); + assertEquals(track, trackService.create(withoutMetadata)); + } + + @Test + void create_when_withoutMetadata_then_doNotChangeHash() throws Exception { + final Track withoutMetadata = track.getBuilder().withMetadata(null).build(); + final Track created = trackService.create(withoutMetadata); + assertEquals(withoutMetadata.calculateSha256(), created.calculateSha256()); + } + + @Test + void create_when_duplicateTrack_then_throwIllegalArgumentException() throws IOException { + trackService.create(track); + assertThrows(IllegalArgumentException.class, () -> trackService.create(track)); + } + + @Test + void create_when_updatedTrack_then_throwIllegalArgumentException() throws IOException { + final Metadata metadata = track.getMetadata().get().getBuilder().withComment("foo, bar").build(); + final Track update = track.getBuilder().withMetadata(metadata).build(); + trackService.create(track); + assertThrows(IllegalArgumentException.class, () -> trackService.create(update)); + } + + @Test + void create_when_null_then_throwNullPointerException() { + assertThrows(NullPointerException.class, () -> trackService.create(null)); + } + + @Test + void create_when_withoutData_then_throwIllegalArgumentException() { + assertThrows(IllegalArgumentException.class, () -> trackService.create(new Track(track.getMetadata().get(), null))); + } + + @Test + void getAll_when_noTracks_then_empty() throws IOException { + assertTrue(trackService.getAll().isEmpty()); + } + + @Test + void findAllByName_when_caseSensitiveMatch_then_findTrack() throws Exception { + final Track track2 = TestData.getChangedTrackWithTitle("foo bar baz"); + trackService.create(track); + trackService.create(track2); + final List result = trackService.findAllByName("bar"); + assertEquals(Arrays.asList(track2), result); + } + + @Test + void findAllByName_when_caseInsensitiveMatch_then_findTrack() throws Exception { + final Track track2 = TestData.getChangedTrackWithTitle("foo bar baz"); + trackService.create(track); + trackService.create(track2); + final List result = trackService.findAllByName("BAR"); + assertEquals(Arrays.asList(track2), result); + } + + @Test + void findAllByName_when_noMatch_then_empty() throws IOException { + trackService.create(track); + assertTrue(trackService.findAllByName("no match").isEmpty()); + } + + @Test + void findAllByName_when_null_then_getAll() throws Exception { + final Track track2 = TestData.getChangedTrackWithTitle("foo bar baz"); + trackService.create(track); + trackService.create(track2); + assertEquals(trackService.getAll(), trackService.findAllByName(null)); + } + + @Test + void findAllByName_when_empty_then_getAll() throws Exception { + final Track track2 = TestData.getChangedTrackWithTitle("foo bar baz"); + trackService.create(track); + trackService.create(track2); + assertEquals(trackService.getAll(), trackService.findAllByName("")); + } + + @Test + void filterByName_when_caseSensitiveMatch_returnMatch() throws Exception { + final Track track2 = TestData.getChangedTrackWithTitle("foo bar baz"); + final List result = TrackService.filterByName(Arrays.asList(track, track2), "bar"); + assertEquals(Arrays.asList(track2), result); + } + + @Test + void filterByName_when_caseInsensitiveMatch_returnMatch() throws Exception { + final Track track2 = TestData.getChangedTrackWithTitle("foo bar baz"); + final List result = TrackService.filterByName(Arrays.asList(track, track2), "BAR"); + assertEquals(Arrays.asList(track2), result); + } + + @Test + void filterByName_when_filterNull_returnMatch() throws Exception { + final Track track2 = TestData.getChangedTrackWithTitle("foo bar baz"); + final List result = TrackService.filterByName(Arrays.asList(track, track2), null); + assertEquals(Arrays.asList(track, track2), result); + } + + @Test + void filterByName_when_filterEmpty_returnMatch() throws Exception { + final Track track2 = TestData.getChangedTrackWithTitle("foo bar baz"); + final List result = TrackService.filterByName(Arrays.asList(track, track2), ""); + assertEquals(Arrays.asList(track, track2), result); + } + + @Test + void filterByName_when_emptyList_then_empty() { + assertTrue(TrackService.filterByName(Collections.emptyList(), "foo").isEmpty()); + } + + @Test + void filterByName_when_emptyListAndFilterNull_then_empty() { + assertTrue(TrackService.filterByName(Collections.emptyList(), null).isEmpty()); + } + + @Test + void filterByName_when_emptyListAndFilterEmpty_then_empty() { + assertTrue(TrackService.filterByName(Collections.emptyList(), "").isEmpty()); + } + + @Test + void findOneByHash_when_match_then_returnMatch() throws Exception { + final Track created = trackService.create(track); + assertEquals(created, trackService.findOneByHash(created.calculateSha256()).get()); + } + + @Test + void findOneByHash_when_noMatch_then_empty() throws Exception { + trackService.create(track); + assertFalse(trackService.findOneByHash(track.calculateSha256()).isPresent()); + } + + @Test + void update_when_unchanged_then_replaceExisting() throws IOException { + trackService.create(track); + trackService.update(track, track); + final List result = trackService.getAll(); + assertEquals(Arrays.asList(track), result); + } + + @Test + void update_when_changed_then_replaceExisting() throws Exception { + trackService.create(track); + final Track update = TestData.getChangedTrackWithComment("foo, bar"); + trackService.update(track, update); + final List result = trackService.getAll(); + assertEquals(Arrays.asList(update), result); + } + + @Test + void update_when_withMetadata_then_keepMetadata() throws IOException { + trackService.create(track); + final Metadata metadata = track.getMetadata().get().getBuilder().withComment("foo, bar").build(); + final Track withMetadata = track.getBuilder().withMetadata(metadata).build(); + assertEquals(withMetadata, trackService.update(track, withMetadata)); + } + + @Test + void update_when_changed_then_changeHash() throws Exception { + trackService.create(track); + final Track withMetadata = TestData.getChangedTrackWithComment("foo, bar"); + final Track updated = trackService.update(track, withMetadata); + assertFalse(withMetadata.calculateSha256().equals(updated.calculateSha256())); + } + + @Test + void update_when_withoutMetadata_then_takeMetadataFromFile() throws IOException { + trackService.create(track); + final Track withoutMetadata = track.getBuilder().withMetadata(null).build(); + assertEquals(track, trackService.update(track, withoutMetadata)); + } + + @Test + void update_when_unchanged_then_doNotChangeHash() throws Exception { + final Track withoutMetadata = track.getBuilder().withMetadata(null).build(); + trackService.create(withoutMetadata); + Track updated = trackService.update(withoutMetadata, withoutMetadata); + assertEquals(withoutMetadata.calculateSha256(), updated.calculateSha256()); + } + + @Test + void update_when_notExists_then_throwIllegalArgumentException() { + assertThrows(IllegalArgumentException.class, () -> trackService.update(track, track)); + } + + @Test + void delete_when_exists_then_delete() throws IOException { + trackService.create(track); + trackService.delete(track); + assertTrue(trackService.getAll().isEmpty()); + } + + @Test + void delete_when_notExists_then_throwIllegalArgumentException() { + assertThrows(IllegalArgumentException.class, () -> trackService.delete(track)); + } + + @Test + void exists_when_exists_then_true() throws IOException { + trackService.create(track); + assertTrue(trackService.exists(track)); + } + + @Test + void exists_when_notExists_then_false() { + assertFalse(trackService.exists(track)); + } + + @Test + void notExists_when_exists_then_false() throws IOException { + trackService.create(track); + assertFalse(trackService.notExists(track)); + } + + @Test + void notExists_when_notExists_then_true() { + assertTrue(trackService.notExists(track)); + } + + @Test + void getRelativeFilePath_when_wihMetadata_then_readFromMetadata() throws IOException { + final Metadata metadata = track.getMetadata().get().getBuilder() + .withArtist("David Hasselhoff") + .withAlbum("Greatest Hits") + .withTitle("Crazy for You") + .build(); + final Track withMetadata = track.getBuilder().withMetadata(metadata).build(); + assertEquals("David Hasselhoff/Greatest Hits/Crazy for You.mp3", trackService.getRelativeFilePath(withMetadata)); + } + + @Test + void getRelativeFilePath_when_withoutMetadata_then_readFromFile() throws IOException { + final Track withoutMetadata = track.getBuilder().withMetadata(null).build(); + assertEquals("Silicon Transmitter/Additives/Virus.mp3", trackService.getRelativeFilePath(withoutMetadata)); + } + + @Test + void getRelativeFilePath_when_withoutData_then_readFromMetadata() throws IOException { + final Track withoutMetadata = track.getBuilder().withData((String) null).build(); + assertEquals("Silicon Transmitter/Additives/Virus.mp3", trackService.getRelativeFilePath(withoutMetadata)); + } + + @Test + void getRelativeFilePath_when_emptyTrack_then_dunno() throws IOException { + final Track emptyTrack = Track.newBuilder().build(); + assertEquals("Unknown Artist/Unknown Album/Unknown Title.mp3", trackService.getRelativeFilePath(emptyTrack)); + } + + @Test + void getRelativeFilePath_when_null_then_throwNullPointerException() { + assertThrows(NullPointerException.class, () -> trackService.getRelativeFilePath(null)); + } +} diff --git a/src/test/java/ch/jbert/utils/TestData.java b/src/test/java/ch/jbert/utils/TestData.java new file mode 100644 index 0000000..be0cfc7 --- /dev/null +++ b/src/test/java/ch/jbert/utils/TestData.java @@ -0,0 +1,70 @@ +package ch.jbert.utils; + +import java.io.IOException; +import java.net.URISyntaxException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Arrays; +import java.util.Base64; +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import ch.jbert.models.Metadata; +import ch.jbert.models.Playlist; +import ch.jbert.models.Track; + +public class TestData { + + private static final Logger LOG = LoggerFactory.getLogger(TestData.class); + + private TestData() {} + + public static Track getTrack() throws IOException, URISyntaxException { + final Metadata metadata = Metadata.newBuilder().withArtist("Silicon Transmitter").withAlbum("Additives") + .withTitle("Virus").withGenre("Krautrock").withYear(2018).withDuration(1) + .withComment("URL: http://freemusicarchive.org/music/Silicon_Transmitter/Additives/Virus_1339\r\n" + + "Comments: http://freemusicarchive.org/\r\n" + "Curator: Nul Tiel Records\r\n" + + "Copyright: Attribution-NonCommercial-ShareAlike: http://creativecommons.org/licenses/by-nc-sa/4.0/") + .build(); + return Track.newBuilder() + .withMetadata(metadata) + .withData(readFileDataBase64("Silicon_Transmitter_-_08_-_Virus_1s.mp3")) + .build(); + } + + public static Track getChangedTrackWithTitle(String title) throws IOException, URISyntaxException { + final Metadata metadata = getTrack().getMetadata().get().getBuilder().withTitle("foo bar baz").build(); + return getTrack().getBuilder().withMetadata(metadata).build(); + } + + public static Track getChangedTrackWithComment(String Comment) throws IOException, URISyntaxException { + final Metadata metadata = getTrack().getMetadata().get().getBuilder().withComment("foo, bar").build(); + return getTrack().getBuilder().withMetadata(metadata).build(); + } + + public static List getTracks() throws IOException, URISyntaxException { + return Arrays.asList(getTrack()); + } + + public static Playlist getPlaylist() throws IOException, URISyntaxException { + return Playlist.newBuilder() + .withName("Creative Commons") + .withTracks(getTracks()) + .build(); + } + + public static Playlist getChangedPlaylist() throws IOException, URISyntaxException { + return getPlaylist().getBuilder().withTracks(Arrays.asList(getChangedTrackWithTitle("foo"))).build(); + } + + public static String readFileDataBase64(String filename) throws IOException, URISyntaxException { + final Path filePath = Paths.get(ClassLoader.getSystemResource("data/" + filename).toURI()); + final String data = Base64.getEncoder().encodeToString(Files.readAllBytes(filePath)); + LOG.debug("Reading file data of file {}", filePath); + LOG.trace("File data read: {}", data); + return data; + } +} diff --git a/src/test/java/ch/jbert/utils/TestEnvironment.java b/src/test/java/ch/jbert/utils/TestEnvironment.java new file mode 100644 index 0000000..a432545 --- /dev/null +++ b/src/test/java/ch/jbert/utils/TestEnvironment.java @@ -0,0 +1,32 @@ +package ch.jbert.utils; + +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.Arrays; + +import org.junit.jupiter.api.BeforeEach; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.micronaut.context.annotation.Value; + +public class TestEnvironment { + + private static final Logger LOG = LoggerFactory.getLogger(TestEnvironment.class); + + @Value("${restapi.playlists.path}") + private String playlistsBasePath; + + @Value("${restapi.tracks.path}") + private String tracksBasePath; + + @BeforeEach + void clearFiles() { + Arrays.asList(playlistsBasePath, tracksBasePath).stream() + .peek(p -> LOG.info("Clearing directory {} ...", p)) + .map(Paths::get) + .peek(ThrowingConsumer.of(FileUtils::deleteRecursively)) + .forEach(ThrowingConsumer.of(Files::createDirectories)); + } + +} diff --git a/src/test/resources/data/Silicon_Transmitter_-_08_-_Virus_1s.mp3 b/src/test/resources/data/Silicon_Transmitter_-_08_-_Virus_1s.mp3 new file mode 100644 index 0000000..858f2ca Binary files /dev/null and b/src/test/resources/data/Silicon_Transmitter_-_08_-_Virus_1s.mp3 differ diff --git a/version.sbt b/version.sbt deleted file mode 100644 index 668b9d0..0000000 --- a/version.sbt +++ /dev/null @@ -1 +0,0 @@ -version in ThisBuild := version.value