From 5ff8c511d8003dce35d91bda479b497e2719bf79 Mon Sep 17 00:00:00 2001 From: Christian Harke Date: Tue, 14 Apr 2020 01:21:35 +0200 Subject: [PATCH 01/14] Rebase on Micronaut setup --- application.yml.template | 26 + runlocal.sh | 3 + .../controllers/api/PlaylistController.java | 239 +++++++++ .../controllers/api/TrackController.java | 70 +++ src/main/java/ch/jbert/models/ConfigDto.java | 95 ++++ src/main/java/ch/jbert/models/ErrorDto.java | 87 +++ .../java/ch/jbert/models/MetadataDto.java | 195 +++++++ .../java/ch/jbert/models/PlayerStatusDto.java | 115 ++++ src/main/java/ch/jbert/models/Playlist.java | 45 ++ .../java/ch/jbert/models/PlaylistDto.java | 96 ++++ .../java/ch/jbert/models/RfidStatusDto.java | 75 +++ src/main/java/ch/jbert/models/StatusDto.java | 115 ++++ .../java/ch/jbert/models/SystemStatusDto.java | 115 ++++ src/main/java/ch/jbert/models/Track.java | 70 +++ src/main/java/ch/jbert/models/TrackDto.java | 104 ++++ .../java/ch/jbert/services/DataService.java | 24 + .../ch/jbert/services/PlaylistService.java | 174 ++++++ .../java/ch/jbert/services/TrackService.java | 199 +++++++ src/main/java/ch/jbert/utils/Strings.java | 10 + src/main/java/ch/jbert/utils/Throwables.java | 30 ++ .../java/ch/jbert/utils/ThrowingFunction.java | 50 ++ .../java/ch/jbert/utils/ThrowingSupplier.java | 59 +++ .../ch/jbert/utils/UncheckedException.java | 78 +++ src/main/resources/openapi.yml | 498 ++++++++++++++++++ 24 files changed, 2572 insertions(+) create mode 100644 application.yml.template create mode 100755 runlocal.sh create mode 100644 src/main/java/ch/jbert/controllers/api/PlaylistController.java create mode 100644 src/main/java/ch/jbert/controllers/api/TrackController.java create mode 100644 src/main/java/ch/jbert/models/ConfigDto.java create mode 100644 src/main/java/ch/jbert/models/ErrorDto.java create mode 100644 src/main/java/ch/jbert/models/MetadataDto.java create mode 100644 src/main/java/ch/jbert/models/PlayerStatusDto.java create mode 100644 src/main/java/ch/jbert/models/Playlist.java create mode 100644 src/main/java/ch/jbert/models/PlaylistDto.java create mode 100644 src/main/java/ch/jbert/models/RfidStatusDto.java create mode 100644 src/main/java/ch/jbert/models/StatusDto.java create mode 100644 src/main/java/ch/jbert/models/SystemStatusDto.java create mode 100644 src/main/java/ch/jbert/models/Track.java create mode 100644 src/main/java/ch/jbert/models/TrackDto.java create mode 100644 src/main/java/ch/jbert/services/DataService.java create mode 100644 src/main/java/ch/jbert/services/PlaylistService.java create mode 100644 src/main/java/ch/jbert/services/TrackService.java create mode 100644 src/main/java/ch/jbert/utils/Strings.java create mode 100644 src/main/java/ch/jbert/utils/Throwables.java create mode 100644 src/main/java/ch/jbert/utils/ThrowingFunction.java create mode 100644 src/main/java/ch/jbert/utils/ThrowingSupplier.java create mode 100644 src/main/java/ch/jbert/utils/UncheckedException.java create mode 100644 src/main/resources/openapi.yml diff --git a/application.yml.template b/application.yml.template new file mode 100644 index 0000000..bc0cf49 --- /dev/null +++ b/application.yml.template @@ -0,0 +1,26 @@ +micronaut: + application: + name: jbert + +restapi: + playlists: + path: "/path/to/your/playlists" + tracks: + path: "/path/to/your/music" + +endpoints: + info: + enabled: true + +application: + event-router-name: SimplePlaylistLoad + +core: + hal: + mock: + enabled: true + rfid: + scan-interval: 2s + mpd: + server: "localhost" + port: 6600 diff --git a/runlocal.sh b/runlocal.sh new file mode 100755 index 0000000..3a551e2 --- /dev/null +++ b/runlocal.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash + +MICRONAUT_CONFIG_FILES=application.yml gradle run 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..7f7bd77 --- /dev/null +++ b/src/main/java/ch/jbert/controllers/api/PlaylistController.java @@ -0,0 +1,239 @@ +package ch.jbert.controllers.api; + +import ch.jbert.models.ErrorDto; +import ch.jbert.models.MetadataDto; +import ch.jbert.models.PlaylistDto; +import ch.jbert.services.PlaylistService; +import ch.jbert.utils.ThrowingFunction; +import ch.jbert.utils.ThrowingSupplier; +import io.micronaut.http.HttpResponse; +import io.micronaut.http.HttpStatus; +import io.micronaut.http.annotation.Body; +import io.micronaut.http.annotation.Controller; +import io.micronaut.http.annotation.Get; +import io.micronaut.http.annotation.Post; +import io.micronaut.http.annotation.Put; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.oracle.svm.core.annotate.Delete; + +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +import javax.inject.Inject; + +import static io.micronaut.http.HttpResponse.badRequest; +import static io.micronaut.http.HttpResponse.created; +import static io.micronaut.http.HttpResponse.notFound; +import static io.micronaut.http.HttpResponse.ok; +import static io.micronaut.http.HttpResponse.serverError; + +@Controller("/api/playlists") +public class PlaylistController { + + private static final Logger LOG = LoggerFactory.getLogger(PlaylistController.class); + + @Inject + private PlaylistService playlistService; + + /** + * List all available Playlists + */ + @Get("{?q}") + public HttpResponse list(Optional q) { + final List playlists = q + .map(ThrowingFunction.of(n -> playlistService.findAllByName(n))) + .orElseGet(ThrowingSupplier.of(() -> playlistService.getAll())); + return ok(playlists); + } + + /** + * Find a Playlist by name + */ + @Get("/{name}") + public HttpResponse find(String name) { + try { + return playlistService.findOneByName(name) + .map(HttpResponse::ok) + .orElse(notFound()); + } catch (Exception e) { + // TODO: Move into ErrorHandler + LOG.info("Find Playlist failed: '{}'", name, e); + return serverError( + new ErrorDto(HttpStatus.INTERNAL_SERVER_ERROR.getCode(), String.format("Find Playlist failed: '%s'", + name))); + } + } + + /** + * Create a new Playlist + */ + @Post + public HttpResponse create(@Body PlaylistDto playlist) { + + try { + return created(playlistService.create(playlist)); + } catch (IllegalArgumentException e) { + // TODO: Move into ErrorHandler + return badRequest(new ErrorDto(HttpStatus.BAD_REQUEST.getCode(), e.getMessage())); + } catch (Exception e) { + // TODO: Move into ErrorHandler + LOG.info("Create Playlist failed: {}", playlist, e); + return serverError( + new ErrorDto(HttpStatus.INTERNAL_SERVER_ERROR.getCode(), String.format("Create Playlist failed: '%s'", + playlist))); + } + } + + /** + * Update an existing Playlist + */ + @Put("/{name}") + public HttpResponse update(String name, @Body PlaylistDto playlist) { + + final Optional original; + try { + original = playlistService.findOneByName(name); + if (!original.isPresent()) { + return notFound(); + } + } catch (Exception e) { + // TODO: Move into ErrorHandler + LOG.info("Update Playlist failed: {}", playlist, e); + return serverError( + new ErrorDto(HttpStatus.INTERNAL_SERVER_ERROR.getCode(), String.format("Update Playlist failed: '%s'", + playlist))); + } + + try { + return ok(playlistService.update(original.get(), playlist.getNameOptional().isPresent() + ? playlist + : new PlaylistDto(original.get().getNameOptional().get(), playlist.getTracksOptional().get()))); + } catch (IllegalArgumentException e) { + // TODO: Move into ErrorHandler + return badRequest(new ErrorDto(HttpStatus.BAD_REQUEST.getCode(), e.getMessage())); + } catch (Exception e) { + // TODO: Move into ErrorHandler + LOG.info("Update Playlist failed: {}", playlist, e); + return serverError( + new ErrorDto(HttpStatus.INTERNAL_SERVER_ERROR.getCode(), String.format("Update Playlist failed: '%s'", + playlist))); + } + } + + /** + * Delete a Playlist + */ + @Delete("/{name}") + public HttpResponse delete(String name) { + + final Optional playlist; + try { + playlist = playlistService.findOneByName(name); + if (!playlist.isPresent()) { + return notFound(); + } + } catch (Exception e) { + // TODO: Move into ErrorHandler + LOG.info("Delete Playlist failed: '{}'", name, e); + return serverError( + new ErrorDto(HttpStatus.INTERNAL_SERVER_ERROR.getCode(), String.format("Delete Playlist failed: '%s'", + name))); + } + + try { + return ok(playlistService.delete(playlist.get())); + } catch (Exception e) { + // TODO: Move into ErrorHandler + LOG.info("Delete Playlist failed: {}", playlist.get(), e); + return serverError( + new ErrorDto(HttpStatus.INTERNAL_SERVER_ERROR.getCode(), String.format("Delete Playlist failed: '%s'", + playlist.get()))); + } + } + + /** + * List Tracks of a Playlist + */ + @Get("/{name}/tracks{?q}") + public HttpResponse listTracks(String name, Optional q) { + try { + final Optional playlist = playlistService.findOneByName(name); + return playlist.isPresent() + ? ok(playlist.flatMap(PlaylistDto::getTracksOptional) + .map(tracks -> q.isPresent() + ? tracks.stream() + .filter(track -> track.getMetadataOptional() + .flatMap(MetadataDto::getTitleOptional) + .map(title -> title.matches("(?i:.*" + q.get() + ".*)")) + .orElse(false) + ).collect(Collectors.toList()) + : tracks + ).orElse(Collections.emptyList())) + : notFound(); + } catch (IllegalArgumentException e) { + // TODO: Move into ErrorHandler + return badRequest(new ErrorDto(HttpStatus.BAD_REQUEST.getCode(), e.getMessage())); + } catch (Exception e) { + // TODO: Move into ErrorHandler + LOG.info("Find Playlist failed: '{}'", name, e); + return serverError( + new ErrorDto(HttpStatus.INTERNAL_SERVER_ERROR.getCode(), String.format("Find Playlist failed: '%s'", + name))); + } + } + + /** + * Get a Track by Index + */ + @Get("/{name}/tracks/{index}") + public HttpResponse getTrackByIndex(String name, int index) { + try { + final Optional playlist = playlistService.findOneByName(name); + final boolean hasIndex = playlist.flatMap(PlaylistDto::getTracksOptional) + .map(tracks -> tracks.size() > index) + .orElse(false); + return playlist.isPresent() && hasIndex + ? ok(playlist.flatMap(PlaylistDto::getTracksOptional).map(tracks -> tracks.get(index))) + : notFound(); + } catch (IllegalArgumentException e) { + // TODO: Move into ErrorHandler + return badRequest(new ErrorDto(HttpStatus.BAD_REQUEST.getCode(), e.getMessage())); + } catch (Exception e) { + // TODO: Move into ErrorHandler + LOG.info("Find Playlist failed: '{}'", name, e); + return serverError( + new ErrorDto(HttpStatus.INTERNAL_SERVER_ERROR.getCode(), String.format("Get Track #%d from Playlist failed: '%s'", + index, name))); + } + } + + /** + * Delete a Track by Index + */ + @Delete("/{name}/tracks/{index}") + public HttpResponse deleteTrackByIndex(String name, int index) { + try { + final Optional playlist = playlistService.findOneByName(name); + final boolean hasIndex = playlist.flatMap(PlaylistDto::getTracksOptional) + .map(tracks -> tracks.size() > index) + .orElse(false); + return playlist.isPresent() && hasIndex + ? ok(playlistService.deleteTrackByIndex(playlist.get(), index)) + : notFound(); + } catch (IllegalArgumentException e) { + // TODO: Move into ErrorHandler + return badRequest(new ErrorDto(HttpStatus.BAD_REQUEST.getCode(), e.getMessage())); + } catch (Exception e) { + // TODO: Move into ErrorHandler + LOG.info("Find Playlist failed: '{}'", name, e); + return serverError( + new ErrorDto(HttpStatus.INTERNAL_SERVER_ERROR.getCode(), String.format("Delete Track #%d Playlist failed: '%s'", + index, name))); + } + } +} 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..b61d836 --- /dev/null +++ b/src/main/java/ch/jbert/controllers/api/TrackController.java @@ -0,0 +1,70 @@ +package ch.jbert.controllers.api; + +import ch.jbert.models.ErrorDto; +import ch.jbert.models.TrackDto; +import ch.jbert.services.TrackService; +import ch.jbert.utils.ThrowingFunction; +import ch.jbert.utils.ThrowingSupplier; +import io.micronaut.http.HttpResponse; +import io.micronaut.http.HttpStatus; +import io.micronaut.http.annotation.Body; +import io.micronaut.http.annotation.Controller; +import io.micronaut.http.annotation.Get; +import io.micronaut.http.annotation.Post; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.inject.Inject; +import java.util.List; +import java.util.Optional; + +import static io.micronaut.http.HttpResponse.badRequest; +import static io.micronaut.http.HttpResponse.created; +import static io.micronaut.http.HttpResponse.serverError; + +@Controller("/api/tracks") +public class TrackController { + + private static final Logger LOG = LoggerFactory.getLogger(TrackController.class); + + @Inject + private TrackService trackService; + + /** + * List all available Tracks + */ + @Get("{?q}") + public HttpResponse list(Optional q) { + try { + final List playlists = q + .map(ThrowingFunction.of(n -> trackService.findAllByName(n))) + .orElseGet(ThrowingSupplier.of(() -> trackService.getAll())); + return HttpResponse.ok(playlists); + } catch (Exception e) { + // TODO: Move into ErrorHandler + LOG.info("List Tracks failed", e); + return serverError(new ErrorDto(HttpStatus.INTERNAL_SERVER_ERROR.getCode(), "List Tracks failed")); + } + } + + /** + * Create a new Track + */ + @Post + public HttpResponse create(@Body TrackDto track) { + + try { + return created(trackService.create(track)); + } catch (IllegalArgumentException e) { + // TODO: Move into ErrorHandler + return badRequest(new ErrorDto(HttpStatus.BAD_REQUEST.getCode(), e.getMessage())); + } catch (Exception e) { + // TODO: Move into ErrorHandler + LOG.info("Create Track failed: {}", track, e); + return serverError( + new ErrorDto(HttpStatus.INTERNAL_SERVER_ERROR.getCode(), String.format("Create Track failed: '%s'", + track))); + } + } +} diff --git a/src/main/java/ch/jbert/models/ConfigDto.java b/src/main/java/ch/jbert/models/ConfigDto.java new file mode 100644 index 0000000..712b2d8 --- /dev/null +++ b/src/main/java/ch/jbert/models/ConfigDto.java @@ -0,0 +1,95 @@ +package ch.jbert.models; + +import java.util.Objects; +import java.util.Optional; +import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.JsonAutoDetect.Visibility; + +// This is a generated file. Do not edit. + +@JsonAutoDetect(getterVisibility = Visibility.NON_PRIVATE) +@JsonIgnoreProperties({ "systemLoadOptional", "memUsageOptional" }) +@JsonDeserialize(builder = ConfigDto.Builder.class) +public final class ConfigDto { + private final Float systemLoad; + private final Float memUsage; + + public ConfigDto(Float systemLoad, Float memUsage) { + this.systemLoad = systemLoad; + this.memUsage = memUsage; + } + + Float getSystemLoad() { + return this.systemLoad; + } + + public Optional getSystemLoadOptional() { + return Optional.ofNullable(this.systemLoad); + } + + Float getMemUsage() { + return this.memUsage; + } + + public Optional getMemUsageOptional() { + return Optional.ofNullable(this.memUsage); + } + + public ConfigDto withSystemLoad(Float systemLoad) { + return new ConfigDto(systemLoad, memUsage); + } + + public ConfigDto withMemUsage(Float memUsage) { + return new ConfigDto(systemLoad, memUsage); + } + + @Override + public boolean equals(Object other) { + if (other == this) { + return true; + } + if (other == null || getClass() != other.getClass()) { + return false; + } + ConfigDto v = (ConfigDto) other; + return Objects.equals(systemLoad, v.systemLoad) && Objects.equals(memUsage, v.memUsage); + } + + @Override + public int hashCode() { + return Objects.hash(systemLoad, memUsage); + } + + @Override + public String toString() { + return String.format("ConfigDto[systemLoad=%s, memUsage=%s]", + systemLoad, memUsage); + } + + public static Builder newBuilder() { + return new Builder(); + } + + @JsonPOJOBuilder(withPrefix = "set") + public static final class Builder { + private Float systemLoad; + private Float memUsage; + + public ConfigDto build() { + return new ConfigDto(systemLoad, memUsage); + } + + public Builder setSystemLoad(Float value) { + this.systemLoad = value; + return this; + } + + public Builder setMemUsage(Float value) { + this.memUsage = value; + return this; + } + } +} \ No newline at end of file diff --git a/src/main/java/ch/jbert/models/ErrorDto.java b/src/main/java/ch/jbert/models/ErrorDto.java new file mode 100644 index 0000000..afceda6 --- /dev/null +++ b/src/main/java/ch/jbert/models/ErrorDto.java @@ -0,0 +1,87 @@ +package ch.jbert.models; + +import java.util.Objects; +import java.util.Optional; +import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.JsonAutoDetect.Visibility; + +// This is a generated file. Do not edit. + +@JsonAutoDetect(getterVisibility = Visibility.NON_PRIVATE) +@JsonIgnoreProperties({ }) +@JsonDeserialize(builder = ErrorDto.Builder.class) +public final class ErrorDto { + private final int code; + private final String message; + + public ErrorDto(int code, String message) { + this.code = code; + this.message = message; + } + + public int getCode() { + return this.code; + } + + public String getMessage() { + return this.message; + } + + public ErrorDto withCode(int code) { + return new ErrorDto(code, message); + } + + public ErrorDto withMessage(String message) { + return new ErrorDto(code, message); + } + + @Override + public boolean equals(Object other) { + if (other == this) { + return true; + } + if (other == null || getClass() != other.getClass()) { + return false; + } + ErrorDto v = (ErrorDto) other; + return Objects.equals(code, v.code) && Objects.equals(message, v.message); + } + + @Override + public int hashCode() { + return Objects.hash(code, message); + } + + @Override + public String toString() { + return String.format("ErrorDto[code=%s, message=%s]", + code, message); + } + + public static Builder newBuilder() { + return new Builder(); + } + + @JsonPOJOBuilder(withPrefix = "set") + public static final class Builder { + private int code; + private String message; + + public ErrorDto build() { + return new ErrorDto(code, message); + } + + public Builder setCode(int value) { + this.code = value; + return this; + } + + public Builder setMessage(String value) { + this.message = value; + return this; + } + } +} \ No newline at end of file diff --git a/src/main/java/ch/jbert/models/MetadataDto.java b/src/main/java/ch/jbert/models/MetadataDto.java new file mode 100644 index 0000000..221a869 --- /dev/null +++ b/src/main/java/ch/jbert/models/MetadataDto.java @@ -0,0 +1,195 @@ +package ch.jbert.models; + +import java.util.Objects; +import java.util.Optional; +import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.JsonAutoDetect.Visibility; + +// This is a generated file. Do not edit. + +@JsonAutoDetect(getterVisibility = Visibility.NON_PRIVATE) +@JsonIgnoreProperties({ "titleOptional", "artistOptional", "albumOptional", "yearOptional", "genreOptional", "commentOptional", "durationOptional" }) +@JsonDeserialize(builder = MetadataDto.Builder.class) +public final class MetadataDto { + 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 MetadataDto(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; + } + + String getTitle() { + return this.title; + } + + public Optional getTitleOptional() { + return Optional.ofNullable(this.title); + } + + String getArtist() { + return this.artist; + } + + public Optional getArtistOptional() { + return Optional.ofNullable(this.artist); + } + + String getAlbum() { + return this.album; + } + + public Optional getAlbumOptional() { + return Optional.ofNullable(this.album); + } + + Integer getYear() { + return this.year; + } + + public Optional getYearOptional() { + return Optional.ofNullable(this.year); + } + + String getGenre() { + return this.genre; + } + + public Optional getGenreOptional() { + return Optional.ofNullable(this.genre); + } + + String getComment() { + return this.comment; + } + + public Optional getCommentOptional() { + return Optional.ofNullable(this.comment); + } + + Integer getDuration() { + return this.duration; + } + + public Optional getDurationOptional() { + return Optional.ofNullable(this.duration); + } + + public MetadataDto withTitle(String title) { + return new MetadataDto(title, artist, album, year, genre, comment, duration); + } + + public MetadataDto withArtist(String artist) { + return new MetadataDto(title, artist, album, year, genre, comment, duration); + } + + public MetadataDto withAlbum(String album) { + return new MetadataDto(title, artist, album, year, genre, comment, duration); + } + + public MetadataDto withYear(Integer year) { + return new MetadataDto(title, artist, album, year, genre, comment, duration); + } + + public MetadataDto withGenre(String genre) { + return new MetadataDto(title, artist, album, year, genre, comment, duration); + } + + public MetadataDto withComment(String comment) { + return new MetadataDto(title, artist, album, year, genre, comment, duration); + } + + public MetadataDto withDuration(Integer duration) { + return new MetadataDto(title, artist, album, year, genre, comment, duration); + } + + @Override + public boolean equals(Object other) { + if (other == this) { + return true; + } + if (other == null || getClass() != other.getClass()) { + return false; + } + MetadataDto v = (MetadataDto) 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("MetadataDto[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(); + } + + @JsonPOJOBuilder(withPrefix = "set") + 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 MetadataDto build() { + return new MetadataDto(title, artist, album, year, genre, comment, duration); + } + + public Builder setTitle(String value) { + this.title = value; + return this; + } + + public Builder setArtist(String value) { + this.artist = value; + return this; + } + + public Builder setAlbum(String value) { + this.album = value; + return this; + } + + public Builder setYear(Integer value) { + this.year = value; + return this; + } + + public Builder setGenre(String value) { + this.genre = value; + return this; + } + + public Builder setComment(String value) { + this.comment = value; + return this; + } + + public Builder setDuration(Integer value) { + this.duration = value; + return this; + } + } +} \ No newline at end of file diff --git a/src/main/java/ch/jbert/models/PlayerStatusDto.java b/src/main/java/ch/jbert/models/PlayerStatusDto.java new file mode 100644 index 0000000..0762e24 --- /dev/null +++ b/src/main/java/ch/jbert/models/PlayerStatusDto.java @@ -0,0 +1,115 @@ +package ch.jbert.models; + +import java.util.Objects; +import java.util.Optional; +import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.JsonAutoDetect.Visibility; + +// This is a generated file. Do not edit. + +@JsonAutoDetect(getterVisibility = Visibility.NON_PRIVATE) +@JsonIgnoreProperties({ "volumeOptional", "numberOfPlaylistsOptional", "numberOfTracksOptional" }) +@JsonDeserialize(builder = PlayerStatusDto.Builder.class) +public final class PlayerStatusDto { + private final Float volume; + private final Integer numberOfPlaylists; + private final Integer numberOfTracks; + + public PlayerStatusDto(Float volume, Integer numberOfPlaylists, Integer numberOfTracks) { + this.volume = volume; + this.numberOfPlaylists = numberOfPlaylists; + this.numberOfTracks = numberOfTracks; + } + + Float getVolume() { + return this.volume; + } + + public Optional getVolumeOptional() { + return Optional.ofNullable(this.volume); + } + + Integer getNumberOfPlaylists() { + return this.numberOfPlaylists; + } + + public Optional getNumberOfPlaylistsOptional() { + return Optional.ofNullable(this.numberOfPlaylists); + } + + Integer getNumberOfTracks() { + return this.numberOfTracks; + } + + public Optional getNumberOfTracksOptional() { + return Optional.ofNullable(this.numberOfTracks); + } + + public PlayerStatusDto withVolume(Float volume) { + return new PlayerStatusDto(volume, numberOfPlaylists, numberOfTracks); + } + + public PlayerStatusDto withNumberOfPlaylists(Integer numberOfPlaylists) { + return new PlayerStatusDto(volume, numberOfPlaylists, numberOfTracks); + } + + public PlayerStatusDto withNumberOfTracks(Integer numberOfTracks) { + return new PlayerStatusDto(volume, numberOfPlaylists, numberOfTracks); + } + + @Override + public boolean equals(Object other) { + if (other == this) { + return true; + } + if (other == null || getClass() != other.getClass()) { + return false; + } + PlayerStatusDto v = (PlayerStatusDto) other; + return Objects.equals(volume, v.volume) && Objects.equals(numberOfPlaylists, v.numberOfPlaylists) && Objects.equals(numberOfTracks, v.numberOfTracks); + } + + @Override + public int hashCode() { + return Objects.hash(volume, numberOfPlaylists, numberOfTracks); + } + + @Override + public String toString() { + return String.format("PlayerStatusDto[volume=%s, numberOfPlaylists=%s, numberOfTracks=%s]", + volume, numberOfPlaylists, numberOfTracks); + } + + public static Builder newBuilder() { + return new Builder(); + } + + @JsonPOJOBuilder(withPrefix = "set") + public static final class Builder { + private Float volume; + private Integer numberOfPlaylists; + private Integer numberOfTracks; + + public PlayerStatusDto build() { + return new PlayerStatusDto(volume, numberOfPlaylists, numberOfTracks); + } + + public Builder setVolume(Float value) { + this.volume = value; + return this; + } + + public Builder setNumberOfPlaylists(Integer value) { + this.numberOfPlaylists = value; + return this; + } + + public Builder setNumberOfTracks(Integer value) { + this.numberOfTracks = value; + return this; + } + } +} \ No newline at end of file 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..0a35e89 --- /dev/null +++ b/src/main/java/ch/jbert/models/Playlist.java @@ -0,0 +1,45 @@ +package ch.jbert.models; + +import java.util.Objects; + +public class Playlist implements Comparable { + + private PlaylistDto wrapped; + + private Playlist(PlaylistDto wrapped) { + this.wrapped = wrapped; + } + + public static Playlist wrap(PlaylistDto dto) { + return new Playlist(dto); + } + + public PlaylistDto unwrap() { + return wrapped; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Playlist playlist = (Playlist) o; + return Objects.equals(wrapped.getName(), playlist.wrapped.getName()); + } + + @Override + public int hashCode() { + return Objects.hash(wrapped.getName()); + } + + @Override + public String toString() { + return "Playlist{" + + "name='" + wrapped.getName() + '\'' + + '}'; + } + + @Override + public int compareTo(Playlist o) { + return this.wrapped.getName().compareTo(o.unwrap().getName()); + } +} diff --git a/src/main/java/ch/jbert/models/PlaylistDto.java b/src/main/java/ch/jbert/models/PlaylistDto.java new file mode 100644 index 0000000..e7c1848 --- /dev/null +++ b/src/main/java/ch/jbert/models/PlaylistDto.java @@ -0,0 +1,96 @@ +package ch.jbert.models; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.JsonAutoDetect.Visibility; + +// This is a generated file. Do not edit. + +@JsonAutoDetect(getterVisibility = Visibility.NON_PRIVATE) +@JsonIgnoreProperties({ "nameOptional", "tracksOptional" }) +@JsonDeserialize(builder = PlaylistDto.Builder.class) +public final class PlaylistDto { + private final String name; + private final List tracks; + + public PlaylistDto(String name, List tracks) { + this.name = name; + this.tracks = tracks; + } + + String getName() { + return this.name; + } + + public Optional getNameOptional() { + return Optional.ofNullable(this.name); + } + + List getTracks() { + return this.tracks; + } + + public Optional> getTracksOptional() { + return Optional.ofNullable(this.tracks); + } + + public PlaylistDto withName(String name) { + return new PlaylistDto(name, tracks); + } + + public PlaylistDto withTracks(List tracks) { + return new PlaylistDto(name, tracks); + } + + @Override + public boolean equals(Object other) { + if (other == this) { + return true; + } + if (other == null || getClass() != other.getClass()) { + return false; + } + PlaylistDto v = (PlaylistDto) other; + return Objects.equals(name, v.name) && Objects.equals(tracks, v.tracks); + } + + @Override + public int hashCode() { + return Objects.hash(name, tracks); + } + + @Override + public String toString() { + return String.format("PlaylistDto[name=%s, tracks=%s]", + name, tracks); + } + + public static Builder newBuilder() { + return new Builder(); + } + + @JsonPOJOBuilder(withPrefix = "set") + public static final class Builder { + private String name; + private List tracks; + + public PlaylistDto build() { + return new PlaylistDto(name, tracks); + } + + public Builder setName(String value) { + this.name = value; + return this; + } + + public Builder setTracks(List value) { + this.tracks = value; + return this; + } + } +} \ No newline at end of file diff --git a/src/main/java/ch/jbert/models/RfidStatusDto.java b/src/main/java/ch/jbert/models/RfidStatusDto.java new file mode 100644 index 0000000..d4bbb7c --- /dev/null +++ b/src/main/java/ch/jbert/models/RfidStatusDto.java @@ -0,0 +1,75 @@ +package ch.jbert.models; + +import java.util.Objects; +import java.util.Optional; +import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.JsonAutoDetect.Visibility; + +// This is a generated file. Do not edit. + +@JsonAutoDetect(getterVisibility = Visibility.NON_PRIVATE) +@JsonIgnoreProperties({ "enabledOptional" }) +@JsonDeserialize(builder = RfidStatusDto.Builder.class) +public final class RfidStatusDto { + private final Boolean enabled; + + public RfidStatusDto(Boolean enabled) { + this.enabled = enabled; + } + + Boolean isEnabled() { + return this.enabled; + } + + public Optional isEnabledOptional() { + return Optional.ofNullable(this.enabled); + } + + public RfidStatusDto withEnabled(Boolean enabled) { + return new RfidStatusDto(enabled); + } + + @Override + public boolean equals(Object other) { + if (other == this) { + return true; + } + if (other == null || getClass() != other.getClass()) { + return false; + } + RfidStatusDto v = (RfidStatusDto) other; + return Objects.equals(enabled, v.enabled); + } + + @Override + public int hashCode() { + return Objects.hash(enabled); + } + + @Override + public String toString() { + return String.format("RfidStatusDto[enabled=%s]", + enabled); + } + + public static Builder newBuilder() { + return new Builder(); + } + + @JsonPOJOBuilder(withPrefix = "set") + public static final class Builder { + private Boolean enabled; + + public RfidStatusDto build() { + return new RfidStatusDto(enabled); + } + + public Builder setEnabled(Boolean value) { + this.enabled = value; + return this; + } + } +} \ No newline at end of file diff --git a/src/main/java/ch/jbert/models/StatusDto.java b/src/main/java/ch/jbert/models/StatusDto.java new file mode 100644 index 0000000..44a7918 --- /dev/null +++ b/src/main/java/ch/jbert/models/StatusDto.java @@ -0,0 +1,115 @@ +package ch.jbert.models; + +import java.util.Objects; +import java.util.Optional; +import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.JsonAutoDetect.Visibility; + +// This is a generated file. Do not edit. + +@JsonAutoDetect(getterVisibility = Visibility.NON_PRIVATE) +@JsonIgnoreProperties({ "systemStatusOptional", "playerStatusOptional", "rfidStatusOptional" }) +@JsonDeserialize(builder = StatusDto.Builder.class) +public final class StatusDto { + private final SystemStatusDto systemStatus; + private final PlayerStatusDto playerStatus; + private final RfidStatusDto rfidStatus; + + public StatusDto(SystemStatusDto systemStatus, PlayerStatusDto playerStatus, RfidStatusDto rfidStatus) { + this.systemStatus = systemStatus; + this.playerStatus = playerStatus; + this.rfidStatus = rfidStatus; + } + + SystemStatusDto getSystemStatus() { + return this.systemStatus; + } + + public Optional getSystemStatusOptional() { + return Optional.ofNullable(this.systemStatus); + } + + PlayerStatusDto getPlayerStatus() { + return this.playerStatus; + } + + public Optional getPlayerStatusOptional() { + return Optional.ofNullable(this.playerStatus); + } + + RfidStatusDto getRfidStatus() { + return this.rfidStatus; + } + + public Optional getRfidStatusOptional() { + return Optional.ofNullable(this.rfidStatus); + } + + public StatusDto withSystemStatus(SystemStatusDto systemStatus) { + return new StatusDto(systemStatus, playerStatus, rfidStatus); + } + + public StatusDto withPlayerStatus(PlayerStatusDto playerStatus) { + return new StatusDto(systemStatus, playerStatus, rfidStatus); + } + + public StatusDto withRfidStatus(RfidStatusDto rfidStatus) { + return new StatusDto(systemStatus, playerStatus, rfidStatus); + } + + @Override + public boolean equals(Object other) { + if (other == this) { + return true; + } + if (other == null || getClass() != other.getClass()) { + return false; + } + StatusDto v = (StatusDto) other; + return Objects.equals(systemStatus, v.systemStatus) && Objects.equals(playerStatus, v.playerStatus) && Objects.equals(rfidStatus, v.rfidStatus); + } + + @Override + public int hashCode() { + return Objects.hash(systemStatus, playerStatus, rfidStatus); + } + + @Override + public String toString() { + return String.format("StatusDto[systemStatus=%s, playerStatus=%s, rfidStatus=%s]", + systemStatus, playerStatus, rfidStatus); + } + + public static Builder newBuilder() { + return new Builder(); + } + + @JsonPOJOBuilder(withPrefix = "set") + public static final class Builder { + private SystemStatusDto systemStatus; + private PlayerStatusDto playerStatus; + private RfidStatusDto rfidStatus; + + public StatusDto build() { + return new StatusDto(systemStatus, playerStatus, rfidStatus); + } + + public Builder setSystemStatus(SystemStatusDto value) { + this.systemStatus = value; + return this; + } + + public Builder setPlayerStatus(PlayerStatusDto value) { + this.playerStatus = value; + return this; + } + + public Builder setRfidStatus(RfidStatusDto value) { + this.rfidStatus = value; + return this; + } + } +} \ No newline at end of file diff --git a/src/main/java/ch/jbert/models/SystemStatusDto.java b/src/main/java/ch/jbert/models/SystemStatusDto.java new file mode 100644 index 0000000..ba2fa67 --- /dev/null +++ b/src/main/java/ch/jbert/models/SystemStatusDto.java @@ -0,0 +1,115 @@ +package ch.jbert.models; + +import java.util.Objects; +import java.util.Optional; +import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.JsonAutoDetect.Visibility; + +// This is a generated file. Do not edit. + +@JsonAutoDetect(getterVisibility = Visibility.NON_PRIVATE) +@JsonIgnoreProperties({ "systemLoadOptional", "memUsageOptional", "uptimeOptional" }) +@JsonDeserialize(builder = SystemStatusDto.Builder.class) +public final class SystemStatusDto { + private final Float systemLoad; + private final Float memUsage; + private final Integer uptime; + + public SystemStatusDto(Float systemLoad, Float memUsage, Integer uptime) { + this.systemLoad = systemLoad; + this.memUsage = memUsage; + this.uptime = uptime; + } + + Float getSystemLoad() { + return this.systemLoad; + } + + public Optional getSystemLoadOptional() { + return Optional.ofNullable(this.systemLoad); + } + + Float getMemUsage() { + return this.memUsage; + } + + public Optional getMemUsageOptional() { + return Optional.ofNullable(this.memUsage); + } + + Integer getUptime() { + return this.uptime; + } + + public Optional getUptimeOptional() { + return Optional.ofNullable(this.uptime); + } + + public SystemStatusDto withSystemLoad(Float systemLoad) { + return new SystemStatusDto(systemLoad, memUsage, uptime); + } + + public SystemStatusDto withMemUsage(Float memUsage) { + return new SystemStatusDto(systemLoad, memUsage, uptime); + } + + public SystemStatusDto withUptime(Integer uptime) { + return new SystemStatusDto(systemLoad, memUsage, uptime); + } + + @Override + public boolean equals(Object other) { + if (other == this) { + return true; + } + if (other == null || getClass() != other.getClass()) { + return false; + } + SystemStatusDto v = (SystemStatusDto) other; + return Objects.equals(systemLoad, v.systemLoad) && Objects.equals(memUsage, v.memUsage) && Objects.equals(uptime, v.uptime); + } + + @Override + public int hashCode() { + return Objects.hash(systemLoad, memUsage, uptime); + } + + @Override + public String toString() { + return String.format("SystemStatusDto[systemLoad=%s, memUsage=%s, uptime=%s]", + systemLoad, memUsage, uptime); + } + + public static Builder newBuilder() { + return new Builder(); + } + + @JsonPOJOBuilder(withPrefix = "set") + public static final class Builder { + private Float systemLoad; + private Float memUsage; + private Integer uptime; + + public SystemStatusDto build() { + return new SystemStatusDto(systemLoad, memUsage, uptime); + } + + public Builder setSystemLoad(Float value) { + this.systemLoad = value; + return this; + } + + public Builder setMemUsage(Float value) { + this.memUsage = value; + return this; + } + + public Builder setUptime(Integer value) { + this.uptime = value; + return this; + } + } +} \ No newline at end of file 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..9d80226 --- /dev/null +++ b/src/main/java/ch/jbert/models/Track.java @@ -0,0 +1,70 @@ +package ch.jbert.models; + +import com.google.common.collect.Comparators; + +import java.util.Comparator; +import java.util.Objects; +import java.util.Optional; + +public class Track implements Comparable { + + private TrackDto wrapped; + + private Track(TrackDto wrapped) { + this.wrapped = wrapped; + } + + public static Track wrap(TrackDto dto) { + return new Track(dto); + } + + public TrackDto unwrap() { + return wrapped; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Track track = (Track) o; + return Objects.equals(wrapped.getMetadata(), track.wrapped.getMetadata()); + } + + @Override + public int hashCode() { + return Objects.hash(wrapped.getMetadata()); + } + + @Override + public String toString() { + return "Playlist{" + + "metadata='" + wrapped.getMetadata() + '\'' + + '}'; + } + + @Override + public int compareTo(Track o) { + + final MetadataDto metadata = this.wrapped.getMetadata(); + final MetadataDto otherMetadata = o.unwrap().getMetadata(); + + final Optional artist = metadata.getArtistOptional(); + Comparator> comparator = Comparators.emptiesFirst(Comparator.naturalOrder()); + final int comparingArtist = comparator.compare(metadata.getArtistOptional(), otherMetadata.getArtistOptional()); + if (comparingArtist != 0) { + return comparingArtist; + } + + final int comparingAlbum = comparator.compare(metadata.getAlbumOptional(), otherMetadata.getAlbumOptional()); + if (comparingAlbum != 0) { + return comparingAlbum; + } + + final int comparingTitle = comparator.compare(metadata.getTitleOptional(), otherMetadata.getTitleOptional()); + if (comparingTitle != 0) { + return comparingTitle; + } + + return 0; + } +} diff --git a/src/main/java/ch/jbert/models/TrackDto.java b/src/main/java/ch/jbert/models/TrackDto.java new file mode 100644 index 0000000..0acdfee --- /dev/null +++ b/src/main/java/ch/jbert/models/TrackDto.java @@ -0,0 +1,104 @@ +package ch.jbert.models; + +import java.util.Objects; +import java.util.Optional; +import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.JsonAutoDetect.Visibility; + +// This is a generated file. Do not edit. + +@JsonAutoDetect(getterVisibility = Visibility.NON_PRIVATE) +@JsonIgnoreProperties({ "metadataOptional", "dataOptional" }) +@JsonDeserialize(builder = TrackDto.Builder.class) +public final class TrackDto { + private final MetadataDto metadata; + private final String data; + + public TrackDto(MetadataDto metadata, String data) { + this.metadata = metadata; + this.data = data; + } + + MetadataDto getMetadata() { + return this.metadata; + } + + public Optional getMetadataOptional() { + return Optional.ofNullable(this.metadata); + } + + String getData() { + return this.data; + } + + /** + * Base64 encoded data + */ + public Optional getDataOptional() { + return Optional.ofNullable(this.data); + } + + public TrackDto withMetadata(MetadataDto metadata) { + return new TrackDto(metadata, data); + } + + /** + * Base64 encoded data + */ + public TrackDto withData(String data) { + return new TrackDto(metadata, data); + } + + @Override + public boolean equals(Object other) { + if (other == this) { + return true; + } + if (other == null || getClass() != other.getClass()) { + return false; + } + TrackDto v = (TrackDto) other; + return Objects.equals(metadata, v.metadata) && Objects.equals(data, v.data); + } + + @Override + public int hashCode() { + return Objects.hash(metadata, data); + } + + @Override + public String toString() { + return String.format("TrackDto[metadata=%s, data=%s]", + metadata, data); + } + + public static Builder newBuilder() { + return new Builder(); + } + + @JsonPOJOBuilder(withPrefix = "set") + public static final class Builder { + private MetadataDto metadata; + private String data; + + public TrackDto build() { + return new TrackDto(metadata, data); + } + + public Builder setMetadata(MetadataDto value) { + this.metadata = value; + return this; + } + + /** + * Base64 encoded data + */ + public Builder setData(String value) { + this.data = value; + 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..e45aadf --- /dev/null +++ b/src/main/java/ch/jbert/services/DataService.java @@ -0,0 +1,24 @@ +package ch.jbert.services; + +import java.io.IOException; +import java.util.List; + +import javax.inject.Singleton; + +/** + * CRUD Service template + */ +@Singleton +public abstract class DataService { + + public abstract T create(T dto) throws IOException; + + public abstract List getAll() throws IOException; + + public abstract List findAllByName(String name) throws IOException; + + public abstract T update(T originalDto, T updateDto) throws IOException; + + public abstract T delete(T dto) throws IOException; + +} 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..e896b43 --- /dev/null +++ b/src/main/java/ch/jbert/services/PlaylistService.java @@ -0,0 +1,174 @@ +package ch.jbert.services; + +import ch.jbert.models.MetadataDto; +import ch.jbert.models.Playlist; +import ch.jbert.models.PlaylistDto; +import ch.jbert.models.TrackDto; +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.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +import javax.inject.Inject; + +public class PlaylistService extends 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 PlaylistDto create(PlaylistDto playlist) throws IOException { + + if (exists(playlist)) { + throw new IllegalArgumentException(String.format("Playlist '%s' already exists", + playlist.getNameOptional().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 PlaylistDto(name, getTracks(name + FILE_SUFFIX))) + .map(Playlist::wrap) + .sorted() + .map(Playlist::unwrap) + .collect(Collectors.toList()); + } + + @Override + public List findAllByName(String name) { + + if (name == null || name.isEmpty()) { + return getAll(); + } + + return getAll().stream() + .filter(playlist -> playlist.getNameOptional().map(n -> n.matches("(?i:.*" + name + ".*)")) + .orElse(false)) + .collect(Collectors.toList()); + } + + public Optional findOneByName(String name) { + return getAll().stream() + .filter(playlist -> playlist.getNameOptional().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(line -> { + try { + return trackService.readId3Tags(line); + } catch (Exception e) { + LOG.info("Could not read ID3 tags from file {}: {}", line, e.getMessage()); + final List entry = Arrays.asList(line.split("/")); + return MetadataDto.newBuilder() + .setArtist(entry.get(0)) + .setAlbum(entry.get(1)) + .setTitle(entry.get(2)) + .build(); + } + }) + .map(metadata -> TrackDto.newBuilder().setMetadata(metadata).build()) + .collect(Collectors.toList()); + } catch (IOException e) { + throw new IllegalStateException(String.format("Cannot read tracks from playlist file '%s'", filename)); + } + } + + @Override + public PlaylistDto update(PlaylistDto original, PlaylistDto update) throws IOException { + + if (!original.getNameOptional().equals(update.getNameOptional()) && exists(update)) { + throw new IllegalArgumentException(String.format("Playlist '%s' already exists", + update.getNameOptional().orElse(null))); + } + delete(original); + return addTracks(update); + } + + @Override + public PlaylistDto delete(PlaylistDto playlist) throws IOException { + final Optional filePath = getFilePath(playlist); + if (filePath.isPresent()) { + Files.deleteIfExists(filePath.get()); + } + return playlist; + } + + public PlaylistDto deleteTrackByIndex(PlaylistDto playlist, int index) throws IOException { + + final List copiedTracks = playlist.getTracksOptional().map(ArrayList::new).orElseThrow( + () -> new IllegalArgumentException(String.format("Could not get tracks from playlist '%s'", playlist))); + + // Delete track from copied tracks list + copiedTracks.remove(index); + + final PlaylistDto update = playlist.getNameOptional().map(n -> new PlaylistDto(n, copiedTracks)).orElseThrow( + () -> new IllegalArgumentException(String.format("Could not get name from playlist '%s'", playlist))); + + return update(playlist, update); + } + + private boolean exists(PlaylistDto playlist) { + return getFilePath(playlist).map(Files::exists).orElse(false); + } + + private boolean notExists(PlaylistDto playlist) { + return !exists(playlist); + } + + private PlaylistDto addTracks(PlaylistDto 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 (TrackDto track : playlist.getTracksOptional().orElse(Collections.emptyList())) { + writer.write(trackService.getRelativeFilePath(trackService.create(track))); + writer.newLine(); + } + } + return playlist; + } + + private Optional getFilePath(PlaylistDto playlist) { + return playlist.getNameOptional().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..bebd49b --- /dev/null +++ b/src/main/java/ch/jbert/services/TrackService.java @@ -0,0 +1,199 @@ +package ch.jbert.services; + +import com.google.common.hash.Hashing; +import com.google.common.io.ByteSource; +import ch.jbert.models.MetadataDto; +import ch.jbert.models.Track; +import ch.jbert.models.TrackDto; +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.File; +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.util.Base64; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static java.nio.file.StandardOpenOption.CREATE; +import static ch.jbert.utils.Strings.sanitizeFilename; + +public class TrackService extends DataService { + + private static final Logger LOG = LoggerFactory.getLogger(TrackService.class); + private static final String FILE_SUFFIX = ".mp3"; + + 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 TrackDto create(TrackDto track) throws IOException { + + if (track.getDataOptional().isPresent()) { + if (exists(track)) { + LOG.info("Track '{}' already exists, attempting to overwrite it", track); + } + final ByteSource data = ByteSource.wrap(Base64.getDecoder().decode(track.getDataOptional().get())); + final Optional file = getFilePath(track); + if (file.isPresent()) { + Files.createDirectories(file.get().getParent()); + try (final OutputStream out = Files.newOutputStream(file.get())) { + // FIXME: Write ID3 tags based on provided TrackDto.MetadataDto + out.write(data.read()); + } + } + } else if (notExists(track)) { + throw new IllegalArgumentException(String.format("Create track failed due to missing track data: '%s'", + track)); + } + return track; + } + + @Override + public List getAll() throws IOException { + + LOG.debug("Reading playlists from folder {}", basePath); + + try (Stream files = Files.walk(Paths.get(basePath), FileVisitOption.FOLLOW_LINKS) + .filter(p -> p.toString().endsWith(FILE_SUFFIX))) { + return files + .map(file -> { + try { + // FIXME: Create MetadataDto by file structure alternatively + return readId3Tags(file.toFile()); + } catch (Exception e) { + LOG.warn("Could not read ID3 tags from track '{}', returning empty metadata", file); + return MetadataDto.newBuilder().build(); + } + }) + .map(metadata -> new TrackDto(metadata, null)) + .map(Track::wrap) + .sorted() + .map(Track::unwrap) + .collect(Collectors.toList()); + } + } + + @Override + public List findAllByName(String name) throws IOException { + + if (name == null || name.isEmpty()) { + return getAll(); + } + + return getAll().stream() + .filter(track -> { + final String pattern = "(?i:.*" + name + ".*)"; + final Optional metadataOptional = track.getMetadataOptional(); + final String artist = metadataOptional.flatMap(MetadataDto::getArtistOptional).orElse(""); + final String album = metadataOptional.flatMap(MetadataDto::getAlbumOptional).orElse(""); + final String title = metadataOptional.flatMap(MetadataDto::getTitleOptional).orElse(""); + return Stream.of(artist, album, title).anyMatch(s -> s.matches(pattern)); + }) + .collect(Collectors.toList()); + } + + public Optional findOneByHash(String hash) { + return Optional.empty(); + } + + @Override + public TrackDto update(TrackDto originalDto, TrackDto updateDto) { + return null; + } + + @Override + public TrackDto delete(TrackDto dto) { + return null; + } + + private boolean exists(TrackDto track) { + return getFilePath(track).map(Files::exists).orElse(false); + } + + private boolean notExists(TrackDto track) { + return !exists(track); + } + + private Optional getFilePath(TrackDto track) { + try { + return Optional.of(Paths.get(basePath, getRelativeFilePath(track))); + } catch (IOException e) { + return Optional.empty(); + } + } + + String getRelativeFilePath(TrackDto track) throws IOException { + final ByteSource byteSource = track.getDataOptional() + .map(data -> ByteSource.wrap(Base64.getDecoder().decode(data))) + .orElse(ByteSource.empty()); + final MetadataDto fromId3 = readId3Tags(byteSource); + return sanitizeFilename(track.getMetadataOptional().flatMap(MetadataDto::getArtistOptional) + .orElseGet(() -> fromId3.getArtistOptional().orElse(UNKNOWN_ARTIST))) + '/' + + sanitizeFilename(track.getMetadataOptional().flatMap(MetadataDto::getAlbumOptional) + .orElseGet(() -> fromId3.getAlbumOptional().orElse(UNKNOWN_ALBUM))) + '/' + + sanitizeFilename(track.getMetadataOptional().flatMap(MetadataDto::getTitleOptional) + .orElseGet(() -> fromId3.getTitleOptional().orElse(UNKNOWN_TITLE)) + FILE_SUFFIX); + } + + private MetadataDto readId3Tags(ByteSource data) throws IOException { + + final Path tmpDir = Files.createDirectories(Paths.get(basePath, "_tmp")); + final Path tmpFile = Paths.get(tmpDir.toString(), data.hash(Hashing.sha256()).toString() + FILE_SUFFIX); + try (final OutputStream out = Files.newOutputStream(tmpFile, CREATE)) { + out.write(data.read()); + } + + try { + final MetadataDto fromId3 = readId3Tags(tmpFile.toFile()); + Files.deleteIfExists(tmpDir); + return fromId3; + } catch (Exception e) { + LOG.info("Could not read ID3 tags from file '{}': {}", tmpFile, e.getMessage()); + } + return MetadataDto.newBuilder().build(); + } + + MetadataDto readId3Tags(String file) throws Exception { + return readId3Tags(Paths.get(basePath, file).toFile()); + } + + private MetadataDto readId3Tags(File file) throws Exception { + final AudioFile audioFile = AudioFileIO.read(file); + final AudioHeader audioHeader = audioFile.getAudioHeader(); + final Tag tag = audioFile.getTag(); + final MetadataDto.Builder trackBuilder = MetadataDto.newBuilder() + .setDuration(audioHeader.getTrackLength()) + .setArtist(tag.getFirst(FieldKey.ARTIST)) + .setAlbum(tag.getFirst(FieldKey.ALBUM)) + .setTitle(tag.getFirst(FieldKey.TITLE)) + .setComment(tag.getFirst(FieldKey.COMMENT)) + .setGenre(tag.getFirst(FieldKey.GENRE)); + + final String year = tag.getFirst(FieldKey.YEAR); + try { + trackBuilder.setYear(Integer.parseInt(year)); + } catch (Exception e) { + LOG.info("Could not parse year '{}' as number: {}", year, e.getMessage()); + } + + return trackBuilder.build(); + } +} 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/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/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/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..21f2659 --- /dev/null +++ b/src/main/java/ch/jbert/utils/UncheckedException.java @@ -0,0 +1,78 @@ +package ch.jbert.utils; + +import java.util.concurrent.Callable; + +public final class UncheckedException extends RuntimeException { + + 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/openapi.yml b/src/main/resources/openapi.yml new file mode 100644 index 0000000..e52c6d7 --- /dev/null +++ b/src/main/resources/openapi.yml @@ -0,0 +1,498 @@ +openapi: "3.0.0" +info: + version: 0.1.0 + title: jBert + description: jBert control application REST interface specification + license: + name: MIT + +servers: + - url: http://0.0.0.0:9000/api + +tags: [System, Playlists] + +paths: + /config: + get: + summary: Returns the current system configuration + tags: [System] + 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" + + /status: + get: + summary: Returns general system status / information + tags: [System] + 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" + + /playlists: + get: + summary: List all playlists + tags: [Playlists] + parameters: + - name: q + in: query + required: false + description: Filter playlists by name + schema: + type: string + responses: + "200": + description: Expected response to a valid request + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Playlist" + default: + description: unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + post: + summary: Create a new playlist + tags: [Playlists] + requestBody: + description: The playlist data + content: + application/json: + schema: + $ref: "#/components/schemas/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" + + /playlists/{name}: + get: + summary: Get a playlist + tags: [Playlists] + parameters: + - name: name + in: path + required: true + description: The name of the playlist + schema: + type: string + responses: + "200": + description: Expected response to a valid request + content: + application/json: + schema: + $ref: "#/components/schemas/Playlist" + "404": + description: Playlist not found + default: + description: unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + put: + summary: Update an existing playlist + tags: [Playlists] + parameters: + - name: name + in: path + required: true + description: The name of the playlist + schema: + type: string + requestBody: + description: The playlist data + content: + application/json: + schema: + $ref: "#/components/schemas/Playlist" + responses: + "200": + description: Expected response to a valid request + content: + application/json: + schema: + $ref: "#/components/schemas/Playlist" + "404": + description: Playlist not found + default: + description: unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + delete: + summary: Delete an existing playlist + tags: [Playlists] + parameters: + - name: name + in: path + required: true + description: The name of the playlist + schema: + type: string + responses: + "200": + description: Expected response to a valid request + "404": + description: Playlist not found + default: + description: unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /playlists/{name}/tracks: + get: + summary: List all tracks in the playlist + tags: [Playlists] + parameters: + - name: name + in: path + required: true + description: The name of the playlist + schema: + type: string + - name: q + in: query + required: false + description: Filter tracks by name + schema: + type: string + responses: + "200": + description: Expected response to a valid request + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Track" + default: + description: unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /playlists/{name}/tracks/{index}: + get: + summary: Get a track + tags: [Playlists] + parameters: + - name: name + in: path + required: true + description: The name of the playlist + schema: + type: string + - name: index + in: path + required: true + description: The position of the track (starting from 0) + schema: + type: integer + responses: + "200": + description: Expected response to a valid request + content: + application/json: + schema: + $ref: "#/components/schemas/Track" + default: + description: unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + delete: + summary: Remove a track from a playlist + tags: [Playlists] + parameters: + - name: name + in: path + required: true + description: The name of the playlist + schema: + type: string + - name: index + in: path + required: true + description: The position of the track (starting from 0) + schema: + type: integer + responses: + "200": + description: Expected response to a valid request + content: + application/json: + schema: + $ref: "#/components/schemas/Track" + default: + description: unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /tracks: + get: + summary: List all tracks + tags: [Tracks] + parameters: + - name: q + in: query + required: false + description: Filter tracks by metadata attributes + schema: + type: string + responses: + "200": + description: Expected response to a valid request + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Track" + default: + description: unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + post: + summary: Upload a track + tags: [Tracks] + requestBody: + description: The track data + content: + application/json: + schema: + $ref: "#/components/schemas/Track" + responses: + "201": + description: New track successfully created + content: + application/json: + schema: + $ref: "#/components/schemas/Track" + default: + description: unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /tracks/{hash}: + get: + summary: Get a track by its hash + tags: [Tracks] + parameters: + - name: hash + in: path + required: true + description: The hash of the track + schema: + type: string + responses: + "200": + description: Expected response to a valid request + content: + application/json: + schema: + $ref: "#/components/schemas/Track" + "404": + description: Track not found + default: + description: unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + put: + summary: Update a track + tags: [Tracks] + parameters: + - name: hash + in: path + required: true + description: The hash of the track + schema: + type: string + requestBody: + description: The track data + content: + application/json: + schema: + $ref: "#/components/schemas/Track" + responses: + "200": + description: Expected response to a valid request + content: + application/json: + schema: + $ref: "#/components/schemas/Track" + "404": + description: Track not found + default: + description: unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + delete: + summary: Delete a track + tags: [Tracks] + parameters: + - name: hash + in: path + required: true + description: The hash of the track + schema: + type: string + responses: + "200": + description: Expected response to a valid request + "404": + description: Track not found + 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: + name: + type: string + tracks: + type: array + items: + $ref: "#/components/schemas/Track" + + Track: + properties: + metadata: + $ref: "#/components/schemas/Metadata" + data: + description: Base64 encoded data + type: string + + Metadata: + properties: + 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 + + Error: + required: + - code + - message + properties: + code: + type: integer + format: int32 + message: + type: string From dd0e5d1475c33f0452c467c23ce075d2f33bbc34 Mon Sep 17 00:00:00 2001 From: Christian Harke Date: Mon, 11 May 2020 21:19:09 +0200 Subject: [PATCH 02/14] Remove sbt leftovers --- version.sbt | 1 - 1 file changed, 1 deletion(-) delete mode 100644 version.sbt 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 From efd215828d98e6650988d80e8fe96ad7d429cd88 Mon Sep 17 00:00:00 2001 From: Christian Harke Date: Sat, 16 May 2020 02:04:08 +0200 Subject: [PATCH 03/14] Switch from OpenAPI generator to micronaut swagger plugin --- build.gradle | 36 +- core/build.gradle | 6 +- gradle.properties | 2 +- openapi.properties | 10 + src/main/java/ch/jbert/Application.java | 17 + .../controllers/api/PlaylistController.java | 326 +++++++++++++---- .../controllers/api/TrackController.java | 63 +++- src/main/java/ch/jbert/models/ConfigDto.java | 95 ----- src/main/java/ch/jbert/models/Error.java | 66 ++++ src/main/java/ch/jbert/models/ErrorDto.java | 87 ----- src/main/java/ch/jbert/models/Metadata.java | 136 +++++++ .../java/ch/jbert/models/MetadataDto.java | 195 ---------- .../java/ch/jbert/models/PlayerStatusDto.java | 115 ------ src/main/java/ch/jbert/models/Playlist.java | 61 +++- .../java/ch/jbert/models/PlaylistDto.java | 96 ----- .../java/ch/jbert/models/RfidStatusDto.java | 75 ---- src/main/java/ch/jbert/models/StatusDto.java | 115 ------ .../java/ch/jbert/models/SystemStatusDto.java | 115 ------ src/main/java/ch/jbert/models/Track.java | 83 +++-- src/main/java/ch/jbert/models/TrackDto.java | 104 ------ .../java/ch/jbert/services/DataService.java | 6 +- .../ch/jbert/services/PlaylistService.java | 66 ++-- .../java/ch/jbert/services/TrackService.java | 89 +++-- src/main/resources/application.yml | 15 +- src/main/resources/logback.xml | 4 +- src/main/resources/openapi.yaml | 339 ------------------ 26 files changed, 763 insertions(+), 1559 deletions(-) create mode 100644 openapi.properties delete mode 100644 src/main/java/ch/jbert/models/ConfigDto.java create mode 100644 src/main/java/ch/jbert/models/Error.java delete mode 100644 src/main/java/ch/jbert/models/ErrorDto.java create mode 100644 src/main/java/ch/jbert/models/Metadata.java delete mode 100644 src/main/java/ch/jbert/models/MetadataDto.java delete mode 100644 src/main/java/ch/jbert/models/PlayerStatusDto.java delete mode 100644 src/main/java/ch/jbert/models/PlaylistDto.java delete mode 100644 src/main/java/ch/jbert/models/RfidStatusDto.java delete mode 100644 src/main/java/ch/jbert/models/StatusDto.java delete mode 100644 src/main/java/ch/jbert/models/SystemStatusDto.java delete mode 100644 src/main/java/ch/jbert/models/TrackDto.java delete mode 100644 src/main/resources/openapi.yaml diff --git a/build.gradle b/build.gradle index 2d38c1a..b935fe1 100644 --- a/build.gradle +++ b/build.gradle @@ -22,9 +22,6 @@ 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" } @@ -41,13 +38,17 @@ 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.swagger.core.v3:swagger-annotations" + implementation "org:jaudiotagger:2.0.3" + 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" @@ -63,29 +64,38 @@ dependencies { 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..c8b7634 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -2,13 +2,12 @@ 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 { @@ -29,4 +28,3 @@ tasks.withType(JavaCompile) { options.compilerArgs.add('-deprecation') options.compilerArgs.add('-Werror') } - diff --git a/gradle.properties b/gradle.properties index d4fd0a0..e95c26f 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1 +1 @@ -micronautVersion=1.2.9 \ No newline at end of file +micronautVersion=1.3.5 \ No newline at end of file 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/src/main/java/ch/jbert/Application.java b/src/main/java/ch/jbert/Application.java index cee31f0..c64cb3e 100644 --- a/src/main/java/ch/jbert/Application.java +++ b/src/main/java/ch/jbert/Application.java @@ -1,7 +1,24 @@ 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.system.name}", description = "${api.tags.system.description}"), + @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/PlaylistController.java b/src/main/java/ch/jbert/controllers/api/PlaylistController.java index 7f7bd77..050ea80 100644 --- a/src/main/java/ch/jbert/controllers/api/PlaylistController.java +++ b/src/main/java/ch/jbert/controllers/api/PlaylistController.java @@ -1,8 +1,9 @@ package ch.jbert.controllers.api; -import ch.jbert.models.ErrorDto; -import ch.jbert.models.MetadataDto; -import ch.jbert.models.PlaylistDto; +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 ch.jbert.utils.ThrowingFunction; import ch.jbert.utils.ThrowingSupplier; @@ -10,15 +11,21 @@ import io.micronaut.http.HttpStatus; 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 org.slf4j.Logger; import org.slf4j.LoggerFactory; -import com.oracle.svm.core.annotate.Delete; - import java.util.Collections; import java.util.List; import java.util.Optional; @@ -33,6 +40,7 @@ import static io.micronaut.http.HttpResponse.serverError; @Controller("/api/playlists") +@Tag(name = "${api.tags.playlists.name}") public class PlaylistController { private static final Logger LOG = LoggerFactory.getLogger(PlaylistController.class); @@ -44,28 +52,32 @@ public class PlaylistController { * List all available Playlists */ @Get("{?q}") - public HttpResponse list(Optional q) { - final List playlists = 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) { + try { + final List playlists = q .map(ThrowingFunction.of(n -> playlistService.findAllByName(n))) .orElseGet(ThrowingSupplier.of(() -> playlistService.getAll())); - return ok(playlists); - } - - /** - * Find a Playlist by name - */ - @Get("/{name}") - public HttpResponse find(String name) { - try { - return playlistService.findOneByName(name) - .map(HttpResponse::ok) - .orElse(notFound()); + return ok(playlists); } catch (Exception e) { // TODO: Move into ErrorHandler - LOG.info("Find Playlist failed: '{}'", name, e); - return serverError( - new ErrorDto(HttpStatus.INTERNAL_SERVER_ERROR.getCode(), String.format("Find Playlist failed: '%s'", - name))); + LOG.info("List Tracks failed", e); + return serverError(new Error(HttpStatus.INTERNAL_SERVER_ERROR.getCode(), "List Playlists failed")); } } @@ -73,29 +85,104 @@ public HttpResponse find(String name) { * Create a new Playlist */ @Post - public HttpResponse create(@Body PlaylistDto playlist) { - + @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) { try { return created(playlistService.create(playlist)); } catch (IllegalArgumentException e) { // TODO: Move into ErrorHandler - return badRequest(new ErrorDto(HttpStatus.BAD_REQUEST.getCode(), e.getMessage())); + return badRequest(new Error(HttpStatus.BAD_REQUEST.getCode(), e.getMessage())); } catch (Exception e) { // TODO: Move into ErrorHandler LOG.info("Create Playlist failed: {}", playlist, e); return serverError( - new ErrorDto(HttpStatus.INTERNAL_SERVER_ERROR.getCode(), String.format("Create Playlist failed: '%s'", + new Error(HttpStatus.INTERNAL_SERVER_ERROR.getCode(), String.format("Create Playlist failed: '%s'", 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) { + try { + return playlistService.findOneByName(name) + .map(HttpResponse::ok) + .orElse(notFound()); + } catch (Exception e) { + // TODO: Move into ErrorHandler + LOG.info("Find Playlist failed: '{}'", name, e); + return serverError( + new Error(HttpStatus.INTERNAL_SERVER_ERROR.getCode(), String.format("Find Playlist failed: '%s'", + name))); + } + } + /** * Update an existing Playlist */ @Put("/{name}") - public HttpResponse update(String name, @Body PlaylistDto playlist) { - - final Optional original; + @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) { + final Optional original; try { original = playlistService.findOneByName(name); if (!original.isPresent()) { @@ -105,22 +192,22 @@ public HttpResponse update(String name, @Body PlaylistDto playlist) { // TODO: Move into ErrorHandler LOG.info("Update Playlist failed: {}", playlist, e); return serverError( - new ErrorDto(HttpStatus.INTERNAL_SERVER_ERROR.getCode(), String.format("Update Playlist failed: '%s'", + new Error(HttpStatus.INTERNAL_SERVER_ERROR.getCode(), String.format("Update Playlist failed: '%s'", playlist))); } try { - return ok(playlistService.update(original.get(), playlist.getNameOptional().isPresent() + return ok(playlistService.update(original.get(), playlist.getName().isPresent() ? playlist - : new PlaylistDto(original.get().getNameOptional().get(), playlist.getTracksOptional().get()))); + : new Playlist(original.get().getName().get(), playlist.getTracks()))); } catch (IllegalArgumentException e) { // TODO: Move into ErrorHandler - return badRequest(new ErrorDto(HttpStatus.BAD_REQUEST.getCode(), e.getMessage())); + return badRequest(new Error(HttpStatus.BAD_REQUEST.getCode(), e.getMessage())); } catch (Exception e) { // TODO: Move into ErrorHandler LOG.info("Update Playlist failed: {}", playlist, e); return serverError( - new ErrorDto(HttpStatus.INTERNAL_SERVER_ERROR.getCode(), String.format("Update Playlist failed: '%s'", + new Error(HttpStatus.INTERNAL_SERVER_ERROR.getCode(), String.format("Update Playlist failed: '%s'", playlist))); } } @@ -129,46 +216,89 @@ public HttpResponse update(String name, @Body PlaylistDto playlist) { * Delete a Playlist */ @Delete("/{name}") - public HttpResponse delete(String name) { - - final Optional playlist; + @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) { + final Optional playlist; try { playlist = playlistService.findOneByName(name); if (!playlist.isPresent()) { return notFound(); } + return ok(playlistService.delete(playlist.get())); } catch (Exception e) { // TODO: Move into ErrorHandler LOG.info("Delete Playlist failed: '{}'", name, e); return serverError( - new ErrorDto(HttpStatus.INTERNAL_SERVER_ERROR.getCode(), String.format("Delete Playlist failed: '%s'", + new Error(HttpStatus.INTERNAL_SERVER_ERROR.getCode(), String.format("Delete Playlist failed: '%s'", name))); } - - try { - return ok(playlistService.delete(playlist.get())); - } catch (Exception e) { - // TODO: Move into ErrorHandler - LOG.info("Delete Playlist failed: {}", playlist.get(), e); - return serverError( - new ErrorDto(HttpStatus.INTERNAL_SERVER_ERROR.getCode(), String.format("Delete Playlist failed: '%s'", - playlist.get()))); - } } /** * List Tracks of a Playlist */ @Get("/{name}/tracks{?q}") - public HttpResponse listTracks(String name, Optional 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) { try { - final Optional playlist = playlistService.findOneByName(name); + final Optional playlist = playlistService.findOneByName(name); return playlist.isPresent() - ? ok(playlist.flatMap(PlaylistDto::getTracksOptional) + ? ok(playlist.map(Playlist::getTracks) .map(tracks -> q.isPresent() ? tracks.stream() - .filter(track -> track.getMetadataOptional() - .flatMap(MetadataDto::getTitleOptional) + .filter(track -> track.getMetadata() + .flatMap(Metadata::getTitle) .map(title -> title.matches("(?i:.*" + q.get() + ".*)")) .orElse(false) ).collect(Collectors.toList()) @@ -177,12 +307,12 @@ public HttpResponse listTracks(String name, Optional q) { : notFound(); } catch (IllegalArgumentException e) { // TODO: Move into ErrorHandler - return badRequest(new ErrorDto(HttpStatus.BAD_REQUEST.getCode(), e.getMessage())); + return badRequest(new Error(HttpStatus.BAD_REQUEST.getCode(), e.getMessage())); } catch (Exception e) { // TODO: Move into ErrorHandler LOG.info("Find Playlist failed: '{}'", name, e); return serverError( - new ErrorDto(HttpStatus.INTERNAL_SERVER_ERROR.getCode(), String.format("Find Playlist failed: '%s'", + new Error(HttpStatus.INTERNAL_SERVER_ERROR.getCode(), String.format("Find Playlist failed: '%s'", name))); } } @@ -191,35 +321,95 @@ public HttpResponse listTracks(String name, Optional q) { * Get a Track by Index */ @Get("/{name}/tracks/{index}") - public HttpResponse getTrackByIndex(String name, int 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) { try { - final Optional playlist = playlistService.findOneByName(name); - final boolean hasIndex = playlist.flatMap(PlaylistDto::getTracksOptional) + final Optional playlist = playlistService.findOneByName(name); + final boolean hasIndex = playlist.map(Playlist::getTracks) .map(tracks -> tracks.size() > index) .orElse(false); return playlist.isPresent() && hasIndex - ? ok(playlist.flatMap(PlaylistDto::getTracksOptional).map(tracks -> tracks.get(index))) + ? ok(playlist.map(Playlist::getTracks).map(tracks -> tracks.get(index))) : notFound(); } catch (IllegalArgumentException e) { // TODO: Move into ErrorHandler - return badRequest(new ErrorDto(HttpStatus.BAD_REQUEST.getCode(), e.getMessage())); + return badRequest(new Error(HttpStatus.BAD_REQUEST.getCode(), e.getMessage())); } catch (Exception e) { // TODO: Move into ErrorHandler LOG.info("Find Playlist failed: '{}'", name, e); return serverError( - new ErrorDto(HttpStatus.INTERNAL_SERVER_ERROR.getCode(), String.format("Get Track #%d from Playlist failed: '%s'", + new Error(HttpStatus.INTERNAL_SERVER_ERROR.getCode(), String.format("Get Track #%d from Playlist failed: '%s'", index, name))); } } /** - * Delete a Track by Index + * Remove a Track from a Playlist by Index */ @Delete("/{name}/tracks/{index}") - public HttpResponse deleteTrackByIndex(String name, int 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) { try { - final Optional playlist = playlistService.findOneByName(name); - final boolean hasIndex = playlist.flatMap(PlaylistDto::getTracksOptional) + final Optional playlist = playlistService.findOneByName(name); + final boolean hasIndex = playlist.map(Playlist::getTracks) .map(tracks -> tracks.size() > index) .orElse(false); return playlist.isPresent() && hasIndex @@ -227,12 +417,12 @@ public HttpResponse deleteTrackByIndex(String name, int index) { : notFound(); } catch (IllegalArgumentException e) { // TODO: Move into ErrorHandler - return badRequest(new ErrorDto(HttpStatus.BAD_REQUEST.getCode(), e.getMessage())); + return badRequest(new Error(HttpStatus.BAD_REQUEST.getCode(), e.getMessage())); } catch (Exception e) { // TODO: Move into ErrorHandler LOG.info("Find Playlist failed: '{}'", name, e); return serverError( - new ErrorDto(HttpStatus.INTERNAL_SERVER_ERROR.getCode(), String.format("Delete Track #%d Playlist failed: '%s'", + new Error(HttpStatus.INTERNAL_SERVER_ERROR.getCode(), String.format("Delete Track #%d Playlist failed: '%s'", index, name))); } } diff --git a/src/main/java/ch/jbert/controllers/api/TrackController.java b/src/main/java/ch/jbert/controllers/api/TrackController.java index b61d836..fe7ee99 100644 --- a/src/main/java/ch/jbert/controllers/api/TrackController.java +++ b/src/main/java/ch/jbert/controllers/api/TrackController.java @@ -1,7 +1,7 @@ package ch.jbert.controllers.api; -import ch.jbert.models.ErrorDto; -import ch.jbert.models.TrackDto; +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; @@ -11,6 +11,13 @@ import io.micronaut.http.annotation.Controller; import io.micronaut.http.annotation.Get; import io.micronaut.http.annotation.Post; +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 org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -24,6 +31,7 @@ import static io.micronaut.http.HttpResponse.serverError; @Controller("/api/tracks") +@Tag(name = "${api.tags.tracks.name}") public class TrackController { private static final Logger LOG = LoggerFactory.getLogger(TrackController.class); @@ -35,16 +43,32 @@ public class TrackController { * List all available Tracks */ @Get("{?q}") - public HttpResponse list(Optional 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 metadata attributes", required = false) Optional q) { try { - final List playlists = q + final List playlists = q .map(ThrowingFunction.of(n -> trackService.findAllByName(n))) .orElseGet(ThrowingSupplier.of(() -> trackService.getAll())); return HttpResponse.ok(playlists); } catch (Exception e) { // TODO: Move into ErrorHandler LOG.info("List Tracks failed", e); - return serverError(new ErrorDto(HttpStatus.INTERNAL_SERVER_ERROR.getCode(), "List Tracks failed")); + return serverError(new Error(HttpStatus.INTERNAL_SERVER_ERROR.getCode(), "List Tracks failed")); } } @@ -52,18 +76,39 @@ public HttpResponse list(Optional q) { * Create a new Track */ @Post - public HttpResponse create(@Body TrackDto track) { - + @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) { try { return created(trackService.create(track)); } catch (IllegalArgumentException e) { // TODO: Move into ErrorHandler - return badRequest(new ErrorDto(HttpStatus.BAD_REQUEST.getCode(), e.getMessage())); + return badRequest(new Error(HttpStatus.BAD_REQUEST.getCode(), e.getMessage())); } catch (Exception e) { // TODO: Move into ErrorHandler LOG.info("Create Track failed: {}", track, e); return serverError( - new ErrorDto(HttpStatus.INTERNAL_SERVER_ERROR.getCode(), String.format("Create Track failed: '%s'", + new Error(HttpStatus.INTERNAL_SERVER_ERROR.getCode(), String.format("Create Track failed: '%s'", track))); } } diff --git a/src/main/java/ch/jbert/models/ConfigDto.java b/src/main/java/ch/jbert/models/ConfigDto.java deleted file mode 100644 index 712b2d8..0000000 --- a/src/main/java/ch/jbert/models/ConfigDto.java +++ /dev/null @@ -1,95 +0,0 @@ -package ch.jbert.models; - -import java.util.Objects; -import java.util.Optional; -import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder; -import com.fasterxml.jackson.databind.annotation.JsonDeserialize; -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import com.fasterxml.jackson.annotation.JsonAutoDetect; -import com.fasterxml.jackson.annotation.JsonAutoDetect.Visibility; - -// This is a generated file. Do not edit. - -@JsonAutoDetect(getterVisibility = Visibility.NON_PRIVATE) -@JsonIgnoreProperties({ "systemLoadOptional", "memUsageOptional" }) -@JsonDeserialize(builder = ConfigDto.Builder.class) -public final class ConfigDto { - private final Float systemLoad; - private final Float memUsage; - - public ConfigDto(Float systemLoad, Float memUsage) { - this.systemLoad = systemLoad; - this.memUsage = memUsage; - } - - Float getSystemLoad() { - return this.systemLoad; - } - - public Optional getSystemLoadOptional() { - return Optional.ofNullable(this.systemLoad); - } - - Float getMemUsage() { - return this.memUsage; - } - - public Optional getMemUsageOptional() { - return Optional.ofNullable(this.memUsage); - } - - public ConfigDto withSystemLoad(Float systemLoad) { - return new ConfigDto(systemLoad, memUsage); - } - - public ConfigDto withMemUsage(Float memUsage) { - return new ConfigDto(systemLoad, memUsage); - } - - @Override - public boolean equals(Object other) { - if (other == this) { - return true; - } - if (other == null || getClass() != other.getClass()) { - return false; - } - ConfigDto v = (ConfigDto) other; - return Objects.equals(systemLoad, v.systemLoad) && Objects.equals(memUsage, v.memUsage); - } - - @Override - public int hashCode() { - return Objects.hash(systemLoad, memUsage); - } - - @Override - public String toString() { - return String.format("ConfigDto[systemLoad=%s, memUsage=%s]", - systemLoad, memUsage); - } - - public static Builder newBuilder() { - return new Builder(); - } - - @JsonPOJOBuilder(withPrefix = "set") - public static final class Builder { - private Float systemLoad; - private Float memUsage; - - public ConfigDto build() { - return new ConfigDto(systemLoad, memUsage); - } - - public Builder setSystemLoad(Float value) { - this.systemLoad = value; - return this; - } - - public Builder setMemUsage(Float value) { - this.memUsage = value; - return this; - } - } -} \ 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..6f3b2d6 --- /dev/null +++ b/src/main/java/ch/jbert/models/Error.java @@ -0,0 +1,66 @@ +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 value) { + this.code = value; + return this; + } + + public Builder withMessage(String value) { + this.message = value; + return this; + } + } +} diff --git a/src/main/java/ch/jbert/models/ErrorDto.java b/src/main/java/ch/jbert/models/ErrorDto.java deleted file mode 100644 index afceda6..0000000 --- a/src/main/java/ch/jbert/models/ErrorDto.java +++ /dev/null @@ -1,87 +0,0 @@ -package ch.jbert.models; - -import java.util.Objects; -import java.util.Optional; -import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder; -import com.fasterxml.jackson.databind.annotation.JsonDeserialize; -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import com.fasterxml.jackson.annotation.JsonAutoDetect; -import com.fasterxml.jackson.annotation.JsonAutoDetect.Visibility; - -// This is a generated file. Do not edit. - -@JsonAutoDetect(getterVisibility = Visibility.NON_PRIVATE) -@JsonIgnoreProperties({ }) -@JsonDeserialize(builder = ErrorDto.Builder.class) -public final class ErrorDto { - private final int code; - private final String message; - - public ErrorDto(int code, String message) { - this.code = code; - this.message = message; - } - - public int getCode() { - return this.code; - } - - public String getMessage() { - return this.message; - } - - public ErrorDto withCode(int code) { - return new ErrorDto(code, message); - } - - public ErrorDto withMessage(String message) { - return new ErrorDto(code, message); - } - - @Override - public boolean equals(Object other) { - if (other == this) { - return true; - } - if (other == null || getClass() != other.getClass()) { - return false; - } - ErrorDto v = (ErrorDto) other; - return Objects.equals(code, v.code) && Objects.equals(message, v.message); - } - - @Override - public int hashCode() { - return Objects.hash(code, message); - } - - @Override - public String toString() { - return String.format("ErrorDto[code=%s, message=%s]", - code, message); - } - - public static Builder newBuilder() { - return new Builder(); - } - - @JsonPOJOBuilder(withPrefix = "set") - public static final class Builder { - private int code; - private String message; - - public ErrorDto build() { - return new ErrorDto(code, message); - } - - public Builder setCode(int value) { - this.code = value; - return this; - } - - public Builder setMessage(String value) { - this.message = value; - return this; - } - } -} \ No newline at end of file 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..8eacfcf --- /dev/null +++ b/src/main/java/ch/jbert/models/Metadata.java @@ -0,0 +1,136 @@ +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 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(this.genre); + } + + public Optional getComment() { + return Optional.ofNullable(this.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 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 value) { + this.title = value; + return this; + } + + public Builder withArtist(String value) { + this.artist = value; + return this; + } + + public Builder withAlbum(String value) { + this.album = value; + return this; + } + + public Builder withYear(Integer value) { + this.year = value; + return this; + } + + public Builder withGenre(String value) { + this.genre = value; + return this; + } + + public Builder withComment(String value) { + this.comment = value; + return this; + } + + public Builder withDuration(Integer value) { + this.duration = value; + return this; + } + } +} diff --git a/src/main/java/ch/jbert/models/MetadataDto.java b/src/main/java/ch/jbert/models/MetadataDto.java deleted file mode 100644 index 221a869..0000000 --- a/src/main/java/ch/jbert/models/MetadataDto.java +++ /dev/null @@ -1,195 +0,0 @@ -package ch.jbert.models; - -import java.util.Objects; -import java.util.Optional; -import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder; -import com.fasterxml.jackson.databind.annotation.JsonDeserialize; -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import com.fasterxml.jackson.annotation.JsonAutoDetect; -import com.fasterxml.jackson.annotation.JsonAutoDetect.Visibility; - -// This is a generated file. Do not edit. - -@JsonAutoDetect(getterVisibility = Visibility.NON_PRIVATE) -@JsonIgnoreProperties({ "titleOptional", "artistOptional", "albumOptional", "yearOptional", "genreOptional", "commentOptional", "durationOptional" }) -@JsonDeserialize(builder = MetadataDto.Builder.class) -public final class MetadataDto { - 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 MetadataDto(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; - } - - String getTitle() { - return this.title; - } - - public Optional getTitleOptional() { - return Optional.ofNullable(this.title); - } - - String getArtist() { - return this.artist; - } - - public Optional getArtistOptional() { - return Optional.ofNullable(this.artist); - } - - String getAlbum() { - return this.album; - } - - public Optional getAlbumOptional() { - return Optional.ofNullable(this.album); - } - - Integer getYear() { - return this.year; - } - - public Optional getYearOptional() { - return Optional.ofNullable(this.year); - } - - String getGenre() { - return this.genre; - } - - public Optional getGenreOptional() { - return Optional.ofNullable(this.genre); - } - - String getComment() { - return this.comment; - } - - public Optional getCommentOptional() { - return Optional.ofNullable(this.comment); - } - - Integer getDuration() { - return this.duration; - } - - public Optional getDurationOptional() { - return Optional.ofNullable(this.duration); - } - - public MetadataDto withTitle(String title) { - return new MetadataDto(title, artist, album, year, genre, comment, duration); - } - - public MetadataDto withArtist(String artist) { - return new MetadataDto(title, artist, album, year, genre, comment, duration); - } - - public MetadataDto withAlbum(String album) { - return new MetadataDto(title, artist, album, year, genre, comment, duration); - } - - public MetadataDto withYear(Integer year) { - return new MetadataDto(title, artist, album, year, genre, comment, duration); - } - - public MetadataDto withGenre(String genre) { - return new MetadataDto(title, artist, album, year, genre, comment, duration); - } - - public MetadataDto withComment(String comment) { - return new MetadataDto(title, artist, album, year, genre, comment, duration); - } - - public MetadataDto withDuration(Integer duration) { - return new MetadataDto(title, artist, album, year, genre, comment, duration); - } - - @Override - public boolean equals(Object other) { - if (other == this) { - return true; - } - if (other == null || getClass() != other.getClass()) { - return false; - } - MetadataDto v = (MetadataDto) 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("MetadataDto[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(); - } - - @JsonPOJOBuilder(withPrefix = "set") - 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 MetadataDto build() { - return new MetadataDto(title, artist, album, year, genre, comment, duration); - } - - public Builder setTitle(String value) { - this.title = value; - return this; - } - - public Builder setArtist(String value) { - this.artist = value; - return this; - } - - public Builder setAlbum(String value) { - this.album = value; - return this; - } - - public Builder setYear(Integer value) { - this.year = value; - return this; - } - - public Builder setGenre(String value) { - this.genre = value; - return this; - } - - public Builder setComment(String value) { - this.comment = value; - return this; - } - - public Builder setDuration(Integer value) { - this.duration = value; - return this; - } - } -} \ No newline at end of file diff --git a/src/main/java/ch/jbert/models/PlayerStatusDto.java b/src/main/java/ch/jbert/models/PlayerStatusDto.java deleted file mode 100644 index 0762e24..0000000 --- a/src/main/java/ch/jbert/models/PlayerStatusDto.java +++ /dev/null @@ -1,115 +0,0 @@ -package ch.jbert.models; - -import java.util.Objects; -import java.util.Optional; -import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder; -import com.fasterxml.jackson.databind.annotation.JsonDeserialize; -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import com.fasterxml.jackson.annotation.JsonAutoDetect; -import com.fasterxml.jackson.annotation.JsonAutoDetect.Visibility; - -// This is a generated file. Do not edit. - -@JsonAutoDetect(getterVisibility = Visibility.NON_PRIVATE) -@JsonIgnoreProperties({ "volumeOptional", "numberOfPlaylistsOptional", "numberOfTracksOptional" }) -@JsonDeserialize(builder = PlayerStatusDto.Builder.class) -public final class PlayerStatusDto { - private final Float volume; - private final Integer numberOfPlaylists; - private final Integer numberOfTracks; - - public PlayerStatusDto(Float volume, Integer numberOfPlaylists, Integer numberOfTracks) { - this.volume = volume; - this.numberOfPlaylists = numberOfPlaylists; - this.numberOfTracks = numberOfTracks; - } - - Float getVolume() { - return this.volume; - } - - public Optional getVolumeOptional() { - return Optional.ofNullable(this.volume); - } - - Integer getNumberOfPlaylists() { - return this.numberOfPlaylists; - } - - public Optional getNumberOfPlaylistsOptional() { - return Optional.ofNullable(this.numberOfPlaylists); - } - - Integer getNumberOfTracks() { - return this.numberOfTracks; - } - - public Optional getNumberOfTracksOptional() { - return Optional.ofNullable(this.numberOfTracks); - } - - public PlayerStatusDto withVolume(Float volume) { - return new PlayerStatusDto(volume, numberOfPlaylists, numberOfTracks); - } - - public PlayerStatusDto withNumberOfPlaylists(Integer numberOfPlaylists) { - return new PlayerStatusDto(volume, numberOfPlaylists, numberOfTracks); - } - - public PlayerStatusDto withNumberOfTracks(Integer numberOfTracks) { - return new PlayerStatusDto(volume, numberOfPlaylists, numberOfTracks); - } - - @Override - public boolean equals(Object other) { - if (other == this) { - return true; - } - if (other == null || getClass() != other.getClass()) { - return false; - } - PlayerStatusDto v = (PlayerStatusDto) other; - return Objects.equals(volume, v.volume) && Objects.equals(numberOfPlaylists, v.numberOfPlaylists) && Objects.equals(numberOfTracks, v.numberOfTracks); - } - - @Override - public int hashCode() { - return Objects.hash(volume, numberOfPlaylists, numberOfTracks); - } - - @Override - public String toString() { - return String.format("PlayerStatusDto[volume=%s, numberOfPlaylists=%s, numberOfTracks=%s]", - volume, numberOfPlaylists, numberOfTracks); - } - - public static Builder newBuilder() { - return new Builder(); - } - - @JsonPOJOBuilder(withPrefix = "set") - public static final class Builder { - private Float volume; - private Integer numberOfPlaylists; - private Integer numberOfTracks; - - public PlayerStatusDto build() { - return new PlayerStatusDto(volume, numberOfPlaylists, numberOfTracks); - } - - public Builder setVolume(Float value) { - this.volume = value; - return this; - } - - public Builder setNumberOfPlaylists(Integer value) { - this.numberOfPlaylists = value; - return this; - } - - public Builder setNumberOfTracks(Integer value) { - this.numberOfTracks = value; - return this; - } - } -} \ No newline at end of file diff --git a/src/main/java/ch/jbert/models/Playlist.java b/src/main/java/ch/jbert/models/Playlist.java index 0a35e89..1035d68 100644 --- a/src/main/java/ch/jbert/models/Playlist.java +++ b/src/main/java/ch/jbert/models/Playlist.java @@ -1,45 +1,74 @@ package ch.jbert.models; +import java.util.List; import java.util.Objects; +import java.util.Optional; -public class Playlist implements Comparable { +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; - private PlaylistDto wrapped; +@JsonDeserialize(builder = Playlist.Builder.class) +public final class Playlist implements Comparable { - private Playlist(PlaylistDto wrapped) { - this.wrapped = wrapped; + private final String name; + private final List tracks; + + public Playlist(String name, List tracks) { + this.name = name; + this.tracks = tracks; } - public static Playlist wrap(PlaylistDto dto) { - return new Playlist(dto); + public Optional getName() { + return Optional.ofNullable(this.name); } - public PlaylistDto unwrap() { - return wrapped; + public List getTracks() { + return this.tracks; } @Override public boolean equals(Object o) { - if (this == o) return true; + if (o == this) return true; if (o == null || getClass() != o.getClass()) return false; - Playlist playlist = (Playlist) o; - return Objects.equals(wrapped.getName(), playlist.wrapped.getName()); + final Playlist playlist = (Playlist) o; + return Objects.equals(name, playlist.name) + && Objects.equals(tracks, playlist.tracks); } @Override public int hashCode() { - return Objects.hash(wrapped.getName()); + return Objects.hash(name, tracks); } @Override public String toString() { - return "Playlist{" + - "name='" + wrapped.getName() + '\'' + - '}'; + return String.format("Playlist[name=%s, tracks=%s]", name, tracks); + } + + public static Builder newBuilder() { + return new Builder(); + } + + public static final class Builder { + private String name; + private List tracks; + + public Playlist build() { + return new Playlist(name, tracks); + } + + public Builder withName(String value) { + this.name = value; + return this; + } + + public Builder withTracks(List value) { + this.tracks = value; + return this; + } } @Override public int compareTo(Playlist o) { - return this.wrapped.getName().compareTo(o.unwrap().getName()); + return name.compareTo(o.name); } } diff --git a/src/main/java/ch/jbert/models/PlaylistDto.java b/src/main/java/ch/jbert/models/PlaylistDto.java deleted file mode 100644 index e7c1848..0000000 --- a/src/main/java/ch/jbert/models/PlaylistDto.java +++ /dev/null @@ -1,96 +0,0 @@ -package ch.jbert.models; - -import java.util.List; -import java.util.Objects; -import java.util.Optional; -import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder; -import com.fasterxml.jackson.databind.annotation.JsonDeserialize; -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import com.fasterxml.jackson.annotation.JsonAutoDetect; -import com.fasterxml.jackson.annotation.JsonAutoDetect.Visibility; - -// This is a generated file. Do not edit. - -@JsonAutoDetect(getterVisibility = Visibility.NON_PRIVATE) -@JsonIgnoreProperties({ "nameOptional", "tracksOptional" }) -@JsonDeserialize(builder = PlaylistDto.Builder.class) -public final class PlaylistDto { - private final String name; - private final List tracks; - - public PlaylistDto(String name, List tracks) { - this.name = name; - this.tracks = tracks; - } - - String getName() { - return this.name; - } - - public Optional getNameOptional() { - return Optional.ofNullable(this.name); - } - - List getTracks() { - return this.tracks; - } - - public Optional> getTracksOptional() { - return Optional.ofNullable(this.tracks); - } - - public PlaylistDto withName(String name) { - return new PlaylistDto(name, tracks); - } - - public PlaylistDto withTracks(List tracks) { - return new PlaylistDto(name, tracks); - } - - @Override - public boolean equals(Object other) { - if (other == this) { - return true; - } - if (other == null || getClass() != other.getClass()) { - return false; - } - PlaylistDto v = (PlaylistDto) other; - return Objects.equals(name, v.name) && Objects.equals(tracks, v.tracks); - } - - @Override - public int hashCode() { - return Objects.hash(name, tracks); - } - - @Override - public String toString() { - return String.format("PlaylistDto[name=%s, tracks=%s]", - name, tracks); - } - - public static Builder newBuilder() { - return new Builder(); - } - - @JsonPOJOBuilder(withPrefix = "set") - public static final class Builder { - private String name; - private List tracks; - - public PlaylistDto build() { - return new PlaylistDto(name, tracks); - } - - public Builder setName(String value) { - this.name = value; - return this; - } - - public Builder setTracks(List value) { - this.tracks = value; - return this; - } - } -} \ No newline at end of file diff --git a/src/main/java/ch/jbert/models/RfidStatusDto.java b/src/main/java/ch/jbert/models/RfidStatusDto.java deleted file mode 100644 index d4bbb7c..0000000 --- a/src/main/java/ch/jbert/models/RfidStatusDto.java +++ /dev/null @@ -1,75 +0,0 @@ -package ch.jbert.models; - -import java.util.Objects; -import java.util.Optional; -import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder; -import com.fasterxml.jackson.databind.annotation.JsonDeserialize; -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import com.fasterxml.jackson.annotation.JsonAutoDetect; -import com.fasterxml.jackson.annotation.JsonAutoDetect.Visibility; - -// This is a generated file. Do not edit. - -@JsonAutoDetect(getterVisibility = Visibility.NON_PRIVATE) -@JsonIgnoreProperties({ "enabledOptional" }) -@JsonDeserialize(builder = RfidStatusDto.Builder.class) -public final class RfidStatusDto { - private final Boolean enabled; - - public RfidStatusDto(Boolean enabled) { - this.enabled = enabled; - } - - Boolean isEnabled() { - return this.enabled; - } - - public Optional isEnabledOptional() { - return Optional.ofNullable(this.enabled); - } - - public RfidStatusDto withEnabled(Boolean enabled) { - return new RfidStatusDto(enabled); - } - - @Override - public boolean equals(Object other) { - if (other == this) { - return true; - } - if (other == null || getClass() != other.getClass()) { - return false; - } - RfidStatusDto v = (RfidStatusDto) other; - return Objects.equals(enabled, v.enabled); - } - - @Override - public int hashCode() { - return Objects.hash(enabled); - } - - @Override - public String toString() { - return String.format("RfidStatusDto[enabled=%s]", - enabled); - } - - public static Builder newBuilder() { - return new Builder(); - } - - @JsonPOJOBuilder(withPrefix = "set") - public static final class Builder { - private Boolean enabled; - - public RfidStatusDto build() { - return new RfidStatusDto(enabled); - } - - public Builder setEnabled(Boolean value) { - this.enabled = value; - return this; - } - } -} \ No newline at end of file diff --git a/src/main/java/ch/jbert/models/StatusDto.java b/src/main/java/ch/jbert/models/StatusDto.java deleted file mode 100644 index 44a7918..0000000 --- a/src/main/java/ch/jbert/models/StatusDto.java +++ /dev/null @@ -1,115 +0,0 @@ -package ch.jbert.models; - -import java.util.Objects; -import java.util.Optional; -import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder; -import com.fasterxml.jackson.databind.annotation.JsonDeserialize; -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import com.fasterxml.jackson.annotation.JsonAutoDetect; -import com.fasterxml.jackson.annotation.JsonAutoDetect.Visibility; - -// This is a generated file. Do not edit. - -@JsonAutoDetect(getterVisibility = Visibility.NON_PRIVATE) -@JsonIgnoreProperties({ "systemStatusOptional", "playerStatusOptional", "rfidStatusOptional" }) -@JsonDeserialize(builder = StatusDto.Builder.class) -public final class StatusDto { - private final SystemStatusDto systemStatus; - private final PlayerStatusDto playerStatus; - private final RfidStatusDto rfidStatus; - - public StatusDto(SystemStatusDto systemStatus, PlayerStatusDto playerStatus, RfidStatusDto rfidStatus) { - this.systemStatus = systemStatus; - this.playerStatus = playerStatus; - this.rfidStatus = rfidStatus; - } - - SystemStatusDto getSystemStatus() { - return this.systemStatus; - } - - public Optional getSystemStatusOptional() { - return Optional.ofNullable(this.systemStatus); - } - - PlayerStatusDto getPlayerStatus() { - return this.playerStatus; - } - - public Optional getPlayerStatusOptional() { - return Optional.ofNullable(this.playerStatus); - } - - RfidStatusDto getRfidStatus() { - return this.rfidStatus; - } - - public Optional getRfidStatusOptional() { - return Optional.ofNullable(this.rfidStatus); - } - - public StatusDto withSystemStatus(SystemStatusDto systemStatus) { - return new StatusDto(systemStatus, playerStatus, rfidStatus); - } - - public StatusDto withPlayerStatus(PlayerStatusDto playerStatus) { - return new StatusDto(systemStatus, playerStatus, rfidStatus); - } - - public StatusDto withRfidStatus(RfidStatusDto rfidStatus) { - return new StatusDto(systemStatus, playerStatus, rfidStatus); - } - - @Override - public boolean equals(Object other) { - if (other == this) { - return true; - } - if (other == null || getClass() != other.getClass()) { - return false; - } - StatusDto v = (StatusDto) other; - return Objects.equals(systemStatus, v.systemStatus) && Objects.equals(playerStatus, v.playerStatus) && Objects.equals(rfidStatus, v.rfidStatus); - } - - @Override - public int hashCode() { - return Objects.hash(systemStatus, playerStatus, rfidStatus); - } - - @Override - public String toString() { - return String.format("StatusDto[systemStatus=%s, playerStatus=%s, rfidStatus=%s]", - systemStatus, playerStatus, rfidStatus); - } - - public static Builder newBuilder() { - return new Builder(); - } - - @JsonPOJOBuilder(withPrefix = "set") - public static final class Builder { - private SystemStatusDto systemStatus; - private PlayerStatusDto playerStatus; - private RfidStatusDto rfidStatus; - - public StatusDto build() { - return new StatusDto(systemStatus, playerStatus, rfidStatus); - } - - public Builder setSystemStatus(SystemStatusDto value) { - this.systemStatus = value; - return this; - } - - public Builder setPlayerStatus(PlayerStatusDto value) { - this.playerStatus = value; - return this; - } - - public Builder setRfidStatus(RfidStatusDto value) { - this.rfidStatus = value; - return this; - } - } -} \ No newline at end of file diff --git a/src/main/java/ch/jbert/models/SystemStatusDto.java b/src/main/java/ch/jbert/models/SystemStatusDto.java deleted file mode 100644 index ba2fa67..0000000 --- a/src/main/java/ch/jbert/models/SystemStatusDto.java +++ /dev/null @@ -1,115 +0,0 @@ -package ch.jbert.models; - -import java.util.Objects; -import java.util.Optional; -import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder; -import com.fasterxml.jackson.databind.annotation.JsonDeserialize; -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import com.fasterxml.jackson.annotation.JsonAutoDetect; -import com.fasterxml.jackson.annotation.JsonAutoDetect.Visibility; - -// This is a generated file. Do not edit. - -@JsonAutoDetect(getterVisibility = Visibility.NON_PRIVATE) -@JsonIgnoreProperties({ "systemLoadOptional", "memUsageOptional", "uptimeOptional" }) -@JsonDeserialize(builder = SystemStatusDto.Builder.class) -public final class SystemStatusDto { - private final Float systemLoad; - private final Float memUsage; - private final Integer uptime; - - public SystemStatusDto(Float systemLoad, Float memUsage, Integer uptime) { - this.systemLoad = systemLoad; - this.memUsage = memUsage; - this.uptime = uptime; - } - - Float getSystemLoad() { - return this.systemLoad; - } - - public Optional getSystemLoadOptional() { - return Optional.ofNullable(this.systemLoad); - } - - Float getMemUsage() { - return this.memUsage; - } - - public Optional getMemUsageOptional() { - return Optional.ofNullable(this.memUsage); - } - - Integer getUptime() { - return this.uptime; - } - - public Optional getUptimeOptional() { - return Optional.ofNullable(this.uptime); - } - - public SystemStatusDto withSystemLoad(Float systemLoad) { - return new SystemStatusDto(systemLoad, memUsage, uptime); - } - - public SystemStatusDto withMemUsage(Float memUsage) { - return new SystemStatusDto(systemLoad, memUsage, uptime); - } - - public SystemStatusDto withUptime(Integer uptime) { - return new SystemStatusDto(systemLoad, memUsage, uptime); - } - - @Override - public boolean equals(Object other) { - if (other == this) { - return true; - } - if (other == null || getClass() != other.getClass()) { - return false; - } - SystemStatusDto v = (SystemStatusDto) other; - return Objects.equals(systemLoad, v.systemLoad) && Objects.equals(memUsage, v.memUsage) && Objects.equals(uptime, v.uptime); - } - - @Override - public int hashCode() { - return Objects.hash(systemLoad, memUsage, uptime); - } - - @Override - public String toString() { - return String.format("SystemStatusDto[systemLoad=%s, memUsage=%s, uptime=%s]", - systemLoad, memUsage, uptime); - } - - public static Builder newBuilder() { - return new Builder(); - } - - @JsonPOJOBuilder(withPrefix = "set") - public static final class Builder { - private Float systemLoad; - private Float memUsage; - private Integer uptime; - - public SystemStatusDto build() { - return new SystemStatusDto(systemLoad, memUsage, uptime); - } - - public Builder setSystemLoad(Float value) { - this.systemLoad = value; - return this; - } - - public Builder setMemUsage(Float value) { - this.memUsage = value; - return this; - } - - public Builder setUptime(Integer value) { - this.uptime = value; - return this; - } - } -} \ No newline at end of file diff --git a/src/main/java/ch/jbert/models/Track.java b/src/main/java/ch/jbert/models/Track.java index 9d80226..bc4b8b0 100644 --- a/src/main/java/ch/jbert/models/Track.java +++ b/src/main/java/ch/jbert/models/Track.java @@ -1,70 +1,101 @@ package ch.jbert.models; -import com.google.common.collect.Comparators; - import java.util.Comparator; import java.util.Objects; import java.util.Optional; -public class Track implements Comparable { +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.google.common.collect.Comparators; - private TrackDto wrapped; +@JsonDeserialize(builder = Track.Builder.class) +public final class Track implements Comparable { - private Track(TrackDto wrapped) { - this.wrapped = wrapped; + private final Metadata metadata; + private final String data; + + public Track(Metadata metadata, String data) { + this.metadata = metadata; + this.data = data; } - public static Track wrap(TrackDto dto) { - return new Track(dto); + public Track(Metadata metadata) { + this(metadata, null); } - public TrackDto unwrap() { - return wrapped; + public Optional getMetadata() { + return Optional.ofNullable(this.metadata); + } + + /** + * Base64 encoded data + */ + public Optional getData() { + return Optional.ofNullable(this.data); } @Override public boolean equals(Object o) { - if (this == o) return true; + if (o == this) return true; if (o == null || getClass() != o.getClass()) return false; - Track track = (Track) o; - return Objects.equals(wrapped.getMetadata(), track.wrapped.getMetadata()); + final Track track = (Track) o; + return Objects.equals(metadata, track.metadata); } @Override public int hashCode() { - return Objects.hash(wrapped.getMetadata()); + return Objects.hash(metadata); } @Override public String toString() { - return "Playlist{" + - "metadata='" + wrapped.getMetadata() + '\'' + - '}'; + return String.format("Track[metadata=%s]", metadata); } @Override public int compareTo(Track o) { - final MetadataDto metadata = this.wrapped.getMetadata(); - final MetadataDto otherMetadata = o.unwrap().getMetadata(); - - final Optional artist = metadata.getArtistOptional(); - Comparator> comparator = Comparators.emptiesFirst(Comparator.naturalOrder()); - final int comparingArtist = comparator.compare(metadata.getArtistOptional(), otherMetadata.getArtistOptional()); + 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.getAlbumOptional(), otherMetadata.getAlbumOptional()); + final int comparingAlbum = comparator.compare(metadata.getAlbum(), o.metadata.getAlbum()); if (comparingAlbum != 0) { return comparingAlbum; } - final int comparingTitle = comparator.compare(metadata.getTitleOptional(), otherMetadata.getTitleOptional()); + 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 static final class Builder { + private Metadata metadata; + private String data; + + public Track build() { + return new Track(metadata, data); + } + + public Builder withMetadata(Metadata value) { + this.metadata = value; + return this; + } + + /** + * Base64 encoded data + */ + public Builder withData(String value) { + this.data = value; + return this; + } + } +} \ No newline at end of file diff --git a/src/main/java/ch/jbert/models/TrackDto.java b/src/main/java/ch/jbert/models/TrackDto.java deleted file mode 100644 index 0acdfee..0000000 --- a/src/main/java/ch/jbert/models/TrackDto.java +++ /dev/null @@ -1,104 +0,0 @@ -package ch.jbert.models; - -import java.util.Objects; -import java.util.Optional; -import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder; -import com.fasterxml.jackson.databind.annotation.JsonDeserialize; -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import com.fasterxml.jackson.annotation.JsonAutoDetect; -import com.fasterxml.jackson.annotation.JsonAutoDetect.Visibility; - -// This is a generated file. Do not edit. - -@JsonAutoDetect(getterVisibility = Visibility.NON_PRIVATE) -@JsonIgnoreProperties({ "metadataOptional", "dataOptional" }) -@JsonDeserialize(builder = TrackDto.Builder.class) -public final class TrackDto { - private final MetadataDto metadata; - private final String data; - - public TrackDto(MetadataDto metadata, String data) { - this.metadata = metadata; - this.data = data; - } - - MetadataDto getMetadata() { - return this.metadata; - } - - public Optional getMetadataOptional() { - return Optional.ofNullable(this.metadata); - } - - String getData() { - return this.data; - } - - /** - * Base64 encoded data - */ - public Optional getDataOptional() { - return Optional.ofNullable(this.data); - } - - public TrackDto withMetadata(MetadataDto metadata) { - return new TrackDto(metadata, data); - } - - /** - * Base64 encoded data - */ - public TrackDto withData(String data) { - return new TrackDto(metadata, data); - } - - @Override - public boolean equals(Object other) { - if (other == this) { - return true; - } - if (other == null || getClass() != other.getClass()) { - return false; - } - TrackDto v = (TrackDto) other; - return Objects.equals(metadata, v.metadata) && Objects.equals(data, v.data); - } - - @Override - public int hashCode() { - return Objects.hash(metadata, data); - } - - @Override - public String toString() { - return String.format("TrackDto[metadata=%s, data=%s]", - metadata, data); - } - - public static Builder newBuilder() { - return new Builder(); - } - - @JsonPOJOBuilder(withPrefix = "set") - public static final class Builder { - private MetadataDto metadata; - private String data; - - public TrackDto build() { - return new TrackDto(metadata, data); - } - - public Builder setMetadata(MetadataDto value) { - this.metadata = value; - return this; - } - - /** - * Base64 encoded data - */ - public Builder setData(String value) { - this.data = value; - 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 index e45aadf..672a5cc 100644 --- a/src/main/java/ch/jbert/services/DataService.java +++ b/src/main/java/ch/jbert/services/DataService.java @@ -11,14 +11,14 @@ @Singleton public abstract class DataService { - public abstract T create(T dto) throws IOException; + public abstract T create(T entity) throws IOException; public abstract List getAll() throws IOException; public abstract List findAllByName(String name) throws IOException; - public abstract T update(T originalDto, T updateDto) throws IOException; + public abstract T update(T original, T update) throws IOException; - public abstract T delete(T dto) throws IOException; + public abstract T delete(T entity) throws IOException; } diff --git a/src/main/java/ch/jbert/services/PlaylistService.java b/src/main/java/ch/jbert/services/PlaylistService.java index e896b43..f3cd624 100644 --- a/src/main/java/ch/jbert/services/PlaylistService.java +++ b/src/main/java/ch/jbert/services/PlaylistService.java @@ -1,9 +1,8 @@ package ch.jbert.services; -import ch.jbert.models.MetadataDto; +import ch.jbert.models.Metadata; import ch.jbert.models.Playlist; -import ch.jbert.models.PlaylistDto; -import ch.jbert.models.TrackDto; +import ch.jbert.models.Track; import io.micronaut.context.annotation.Value; import org.slf4j.Logger; @@ -16,7 +15,6 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; -import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; @@ -25,7 +23,7 @@ import javax.inject.Inject; -public class PlaylistService extends DataService { +public class PlaylistService extends DataService { private static final Logger LOG = LoggerFactory.getLogger(PlaylistService.class); private static final String FILE_SUFFIX = ".m3u"; @@ -37,17 +35,17 @@ public class PlaylistService extends DataService { private TrackService trackService; @Override - public PlaylistDto create(PlaylistDto playlist) throws IOException { + public Playlist create(Playlist playlist) throws IOException { if (exists(playlist)) { throw new IllegalArgumentException(String.format("Playlist '%s' already exists", - playlist.getNameOptional().orElse(null))); + playlist.getName().orElse(null))); } return addTracks(playlist); } @Override - public List getAll() { + public List getAll() { LOG.debug("Reading playlists from folder {}", basePath); @@ -58,33 +56,31 @@ public List getAll() { } return Arrays.stream(filenames) .map(filename -> filename.substring(0, filename.length() - FILE_SUFFIX.length())) - .map(name -> new PlaylistDto(name, getTracks(name + FILE_SUFFIX))) - .map(Playlist::wrap) + .map(name -> new Playlist(name, getTracks(name + FILE_SUFFIX))) .sorted() - .map(Playlist::unwrap) .collect(Collectors.toList()); } @Override - public List findAllByName(String name) { + public List findAllByName(String name) { if (name == null || name.isEmpty()) { return getAll(); } return getAll().stream() - .filter(playlist -> playlist.getNameOptional().map(n -> n.matches("(?i:.*" + name + ".*)")) + .filter(playlist -> playlist.getName().map(n -> n.matches("(?i:.*" + name + ".*)")) .orElse(false)) .collect(Collectors.toList()); } - public Optional findOneByName(String name) { + public Optional findOneByName(String name) { return getAll().stream() - .filter(playlist -> playlist.getNameOptional().map(n -> n.equals(name)).orElse(false)) + .filter(playlist -> playlist.getName().map(n -> n.equals(name)).orElse(false)) .findFirst(); } - private List getTracks(String filename) { + 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)); @@ -98,14 +94,14 @@ private List getTracks(String filename) { } catch (Exception e) { LOG.info("Could not read ID3 tags from file {}: {}", line, e.getMessage()); final List entry = Arrays.asList(line.split("/")); - return MetadataDto.newBuilder() - .setArtist(entry.get(0)) - .setAlbum(entry.get(1)) - .setTitle(entry.get(2)) + return Metadata.newBuilder() + .withArtist(entry.get(0)) + .withAlbum(entry.get(1)) + .withTitle(entry.get(2)) .build(); } }) - .map(metadata -> TrackDto.newBuilder().setMetadata(metadata).build()) + .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)); @@ -113,18 +109,18 @@ private List getTracks(String filename) { } @Override - public PlaylistDto update(PlaylistDto original, PlaylistDto update) throws IOException { + public Playlist update(Playlist original, Playlist update) throws IOException { - if (!original.getNameOptional().equals(update.getNameOptional()) && exists(update)) { + if (!original.getName().equals(update.getName()) && exists(update)) { throw new IllegalArgumentException(String.format("Playlist '%s' already exists", - update.getNameOptional().orElse(null))); + update.getName().orElse(null))); } delete(original); return addTracks(update); } @Override - public PlaylistDto delete(PlaylistDto playlist) throws IOException { + public Playlist delete(Playlist playlist) throws IOException { final Optional filePath = getFilePath(playlist); if (filePath.isPresent()) { Files.deleteIfExists(filePath.get()); @@ -132,35 +128,35 @@ public PlaylistDto delete(PlaylistDto playlist) throws IOException { return playlist; } - public PlaylistDto deleteTrackByIndex(PlaylistDto playlist, int index) throws IOException { + public Playlist deleteTrackByIndex(Playlist playlist, int index) throws IOException { - final List copiedTracks = playlist.getTracksOptional().map(ArrayList::new).orElseThrow( - () -> new IllegalArgumentException(String.format("Could not get tracks from playlist '%s'", playlist))); + final List copiedTracks = Collections.emptyList(); + Collections.copy(copiedTracks, playlist.getTracks()); // Delete track from copied tracks list copiedTracks.remove(index); - final PlaylistDto update = playlist.getNameOptional().map(n -> new PlaylistDto(n, copiedTracks)).orElseThrow( + final Playlist update = playlist.getName().map(n -> new Playlist(n, copiedTracks)).orElseThrow( () -> new IllegalArgumentException(String.format("Could not get name from playlist '%s'", playlist))); return update(playlist, update); } - private boolean exists(PlaylistDto playlist) { + private boolean exists(Playlist playlist) { return getFilePath(playlist).map(Files::exists).orElse(false); } - private boolean notExists(PlaylistDto playlist) { + private boolean notExists(Playlist playlist) { return !exists(playlist); } - private PlaylistDto addTracks(PlaylistDto playlist) throws IOException { + 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 (TrackDto track : playlist.getTracksOptional().orElse(Collections.emptyList())) { + for (Track track : playlist.getTracks()) { writer.write(trackService.getRelativeFilePath(trackService.create(track))); writer.newLine(); } @@ -168,7 +164,7 @@ private PlaylistDto addTracks(PlaylistDto playlist) throws IOException { return playlist; } - private Optional getFilePath(PlaylistDto playlist) { - return playlist.getNameOptional().map(name -> Paths.get(basePath, name + FILE_SUFFIX)); + 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 index bebd49b..9e1453b 100644 --- a/src/main/java/ch/jbert/services/TrackService.java +++ b/src/main/java/ch/jbert/services/TrackService.java @@ -2,9 +2,8 @@ import com.google.common.hash.Hashing; import com.google.common.io.ByteSource; -import ch.jbert.models.MetadataDto; +import ch.jbert.models.Metadata; import ch.jbert.models.Track; -import ch.jbert.models.TrackDto; import io.micronaut.context.annotation.Value; import org.jaudiotagger.audio.AudioFile; @@ -31,7 +30,7 @@ import static java.nio.file.StandardOpenOption.CREATE; import static ch.jbert.utils.Strings.sanitizeFilename; -public class TrackService extends DataService { +public class TrackService extends DataService { private static final Logger LOG = LoggerFactory.getLogger(TrackService.class); private static final String FILE_SUFFIX = ".mp3"; @@ -44,18 +43,18 @@ public class TrackService extends DataService { private String basePath; @Override - public TrackDto create(TrackDto track) throws IOException { + public Track create(Track track) throws IOException { - if (track.getDataOptional().isPresent()) { + if (track.getData().isPresent()) { if (exists(track)) { LOG.info("Track '{}' already exists, attempting to overwrite it", track); } - final ByteSource data = ByteSource.wrap(Base64.getDecoder().decode(track.getDataOptional().get())); + final ByteSource data = ByteSource.wrap(Base64.getDecoder().decode(track.getData().get())); final Optional file = getFilePath(track); if (file.isPresent()) { Files.createDirectories(file.get().getParent()); try (final OutputStream out = Files.newOutputStream(file.get())) { - // FIXME: Write ID3 tags based on provided TrackDto.MetadataDto + // FIXME: Write ID3 tags based on provided track metadata out.write(data.read()); } } @@ -67,7 +66,7 @@ public TrackDto create(TrackDto track) throws IOException { } @Override - public List getAll() throws IOException { + public List getAll() throws IOException { LOG.debug("Reading playlists from folder {}", basePath); @@ -76,23 +75,21 @@ public List getAll() throws IOException { return files .map(file -> { try { - // FIXME: Create MetadataDto by file structure alternatively + // FIXME: Create Metadata by file structure alternatively return readId3Tags(file.toFile()); } catch (Exception e) { LOG.warn("Could not read ID3 tags from track '{}', returning empty metadata", file); - return MetadataDto.newBuilder().build(); + return Metadata.newBuilder().build(); } }) - .map(metadata -> new TrackDto(metadata, null)) - .map(Track::wrap) + .map(Track::new) .sorted() - .map(Track::unwrap) .collect(Collectors.toList()); } } @Override - public List findAllByName(String name) throws IOException { + public List findAllByName(String name) throws IOException { if (name == null || name.isEmpty()) { return getAll(); @@ -101,38 +98,38 @@ public List findAllByName(String name) throws IOException { return getAll().stream() .filter(track -> { final String pattern = "(?i:.*" + name + ".*)"; - final Optional metadataOptional = track.getMetadataOptional(); - final String artist = metadataOptional.flatMap(MetadataDto::getArtistOptional).orElse(""); - final String album = metadataOptional.flatMap(MetadataDto::getAlbumOptional).orElse(""); - final String title = metadataOptional.flatMap(MetadataDto::getTitleOptional).orElse(""); + final Optional metadataOptional = track.getMetadata(); + final String artist = metadataOptional.flatMap(Metadata::getArtist).orElse(""); + final String album = metadataOptional.flatMap(Metadata::getAlbum).orElse(""); + final String title = metadataOptional.flatMap(Metadata::getTitle).orElse(""); return Stream.of(artist, album, title).anyMatch(s -> s.matches(pattern)); }) .collect(Collectors.toList()); } - public Optional findOneByHash(String hash) { + public Optional findOneByHash(String hash) { return Optional.empty(); } @Override - public TrackDto update(TrackDto originalDto, TrackDto updateDto) { + public Track update(Track original, Track update) { return null; } @Override - public TrackDto delete(TrackDto dto) { + public Track delete(Track track) { return null; } - private boolean exists(TrackDto track) { + private boolean exists(Track track) { return getFilePath(track).map(Files::exists).orElse(false); } - private boolean notExists(TrackDto track) { + private boolean notExists(Track track) { return !exists(track); } - private Optional getFilePath(TrackDto track) { + private Optional getFilePath(Track track) { try { return Optional.of(Paths.get(basePath, getRelativeFilePath(track))); } catch (IOException e) { @@ -140,20 +137,20 @@ private Optional getFilePath(TrackDto track) { } } - String getRelativeFilePath(TrackDto track) throws IOException { - final ByteSource byteSource = track.getDataOptional() + String getRelativeFilePath(Track track) throws IOException { + final ByteSource byteSource = track.getData() .map(data -> ByteSource.wrap(Base64.getDecoder().decode(data))) .orElse(ByteSource.empty()); - final MetadataDto fromId3 = readId3Tags(byteSource); - return sanitizeFilename(track.getMetadataOptional().flatMap(MetadataDto::getArtistOptional) - .orElseGet(() -> fromId3.getArtistOptional().orElse(UNKNOWN_ARTIST))) + '/' + - sanitizeFilename(track.getMetadataOptional().flatMap(MetadataDto::getAlbumOptional) - .orElseGet(() -> fromId3.getAlbumOptional().orElse(UNKNOWN_ALBUM))) + '/' + - sanitizeFilename(track.getMetadataOptional().flatMap(MetadataDto::getTitleOptional) - .orElseGet(() -> fromId3.getTitleOptional().orElse(UNKNOWN_TITLE)) + FILE_SUFFIX); + final Metadata fromId3 = readId3Tags(byteSource); + 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 MetadataDto readId3Tags(ByteSource data) throws IOException { + private Metadata readId3Tags(ByteSource data) throws IOException { final Path tmpDir = Files.createDirectories(Paths.get(basePath, "_tmp")); final Path tmpFile = Paths.get(tmpDir.toString(), data.hash(Hashing.sha256()).toString() + FILE_SUFFIX); @@ -162,34 +159,34 @@ private MetadataDto readId3Tags(ByteSource data) throws IOException { } try { - final MetadataDto fromId3 = readId3Tags(tmpFile.toFile()); + final Metadata fromId3 = readId3Tags(tmpFile.toFile()); Files.deleteIfExists(tmpDir); return fromId3; } catch (Exception e) { LOG.info("Could not read ID3 tags from file '{}': {}", tmpFile, e.getMessage()); } - return MetadataDto.newBuilder().build(); + return Metadata.newBuilder().build(); } - MetadataDto readId3Tags(String file) throws Exception { + Metadata readId3Tags(String file) throws Exception { return readId3Tags(Paths.get(basePath, file).toFile()); } - private MetadataDto readId3Tags(File file) throws Exception { + private Metadata readId3Tags(File file) throws Exception { final AudioFile audioFile = AudioFileIO.read(file); final AudioHeader audioHeader = audioFile.getAudioHeader(); final Tag tag = audioFile.getTag(); - final MetadataDto.Builder trackBuilder = MetadataDto.newBuilder() - .setDuration(audioHeader.getTrackLength()) - .setArtist(tag.getFirst(FieldKey.ARTIST)) - .setAlbum(tag.getFirst(FieldKey.ALBUM)) - .setTitle(tag.getFirst(FieldKey.TITLE)) - .setComment(tag.getFirst(FieldKey.COMMENT)) - .setGenre(tag.getFirst(FieldKey.GENRE)); + final Metadata.Builder trackBuilder = 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 { - trackBuilder.setYear(Integer.parseInt(year)); + trackBuilder.withYear(Integer.parseInt(year)); } catch (Exception e) { LOG.info("Could not parse year '{}' as number: {}", year, e.getMessage()); } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index f88887c..8f51bfd 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,7 +1,20 @@ micronaut: application: name: jbert - + 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/** 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 From 27ab4debc1716d7b50b8baa56b4c044df28b8da5 Mon Sep 17 00:00:00 2001 From: Christian Harke Date: Sat, 16 May 2020 02:12:11 +0200 Subject: [PATCH 04/14] Add management endpoints and OpenAPI routes --- application.yml.template | 23 +++++++++++++++++++---- build.gradle | 1 + src/main/resources/application.yml | 7 +++++++ 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/application.yml.template b/application.yml.template index bc0cf49..158872d 100644 --- a/application.yml.template +++ b/application.yml.template @@ -1,6 +1,25 @@ micronaut: application: name: jbert + 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/** + +endpoints: + all: + port: 8085 + sensitive: false restapi: playlists: @@ -8,10 +27,6 @@ restapi: tracks: path: "/path/to/your/music" -endpoints: - info: - enabled: true - application: event-router-name: SimplePlaylistLoad diff --git a/build.gradle b/build.gradle index b935fe1..c9dce83 100644 --- a/build.gradle +++ b/build.gradle @@ -46,6 +46,7 @@ dependencies { 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 "org:jaudiotagger:2.0.3" implementation 'com.google.guava:guava:28.2-jre' diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 8f51bfd..54920ad 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -15,6 +15,13 @@ micronaut: 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: From 509aa7a99712f98a8cc8790a2d0cdf84ece1e9b3 Mon Sep 17 00:00:00 2001 From: Christian Harke Date: Sat, 16 May 2020 02:22:05 +0200 Subject: [PATCH 05/14] Remove openapi spec since it is generated now --- src/main/resources/openapi.yml | 498 --------------------------------- 1 file changed, 498 deletions(-) delete mode 100644 src/main/resources/openapi.yml diff --git a/src/main/resources/openapi.yml b/src/main/resources/openapi.yml deleted file mode 100644 index e52c6d7..0000000 --- a/src/main/resources/openapi.yml +++ /dev/null @@ -1,498 +0,0 @@ -openapi: "3.0.0" -info: - version: 0.1.0 - title: jBert - description: jBert control application REST interface specification - license: - name: MIT - -servers: - - url: http://0.0.0.0:9000/api - -tags: [System, Playlists] - -paths: - /config: - get: - summary: Returns the current system configuration - tags: [System] - 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" - - /status: - get: - summary: Returns general system status / information - tags: [System] - 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" - - /playlists: - get: - summary: List all playlists - tags: [Playlists] - parameters: - - name: q - in: query - required: false - description: Filter playlists by name - schema: - type: string - responses: - "200": - description: Expected response to a valid request - content: - application/json: - schema: - type: array - items: - $ref: "#/components/schemas/Playlist" - default: - description: unexpected error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - post: - summary: Create a new playlist - tags: [Playlists] - requestBody: - description: The playlist data - content: - application/json: - schema: - $ref: "#/components/schemas/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" - - /playlists/{name}: - get: - summary: Get a playlist - tags: [Playlists] - parameters: - - name: name - in: path - required: true - description: The name of the playlist - schema: - type: string - responses: - "200": - description: Expected response to a valid request - content: - application/json: - schema: - $ref: "#/components/schemas/Playlist" - "404": - description: Playlist not found - default: - description: unexpected error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - put: - summary: Update an existing playlist - tags: [Playlists] - parameters: - - name: name - in: path - required: true - description: The name of the playlist - schema: - type: string - requestBody: - description: The playlist data - content: - application/json: - schema: - $ref: "#/components/schemas/Playlist" - responses: - "200": - description: Expected response to a valid request - content: - application/json: - schema: - $ref: "#/components/schemas/Playlist" - "404": - description: Playlist not found - default: - description: unexpected error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - delete: - summary: Delete an existing playlist - tags: [Playlists] - parameters: - - name: name - in: path - required: true - description: The name of the playlist - schema: - type: string - responses: - "200": - description: Expected response to a valid request - "404": - description: Playlist not found - default: - description: unexpected error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - - /playlists/{name}/tracks: - get: - summary: List all tracks in the playlist - tags: [Playlists] - parameters: - - name: name - in: path - required: true - description: The name of the playlist - schema: - type: string - - name: q - in: query - required: false - description: Filter tracks by name - schema: - type: string - responses: - "200": - description: Expected response to a valid request - content: - application/json: - schema: - type: array - items: - $ref: "#/components/schemas/Track" - default: - description: unexpected error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - - /playlists/{name}/tracks/{index}: - get: - summary: Get a track - tags: [Playlists] - parameters: - - name: name - in: path - required: true - description: The name of the playlist - schema: - type: string - - name: index - in: path - required: true - description: The position of the track (starting from 0) - schema: - type: integer - responses: - "200": - description: Expected response to a valid request - content: - application/json: - schema: - $ref: "#/components/schemas/Track" - default: - description: unexpected error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - delete: - summary: Remove a track from a playlist - tags: [Playlists] - parameters: - - name: name - in: path - required: true - description: The name of the playlist - schema: - type: string - - name: index - in: path - required: true - description: The position of the track (starting from 0) - schema: - type: integer - responses: - "200": - description: Expected response to a valid request - content: - application/json: - schema: - $ref: "#/components/schemas/Track" - default: - description: unexpected error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - - /tracks: - get: - summary: List all tracks - tags: [Tracks] - parameters: - - name: q - in: query - required: false - description: Filter tracks by metadata attributes - schema: - type: string - responses: - "200": - description: Expected response to a valid request - content: - application/json: - schema: - type: array - items: - $ref: "#/components/schemas/Track" - default: - description: unexpected error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - post: - summary: Upload a track - tags: [Tracks] - requestBody: - description: The track data - content: - application/json: - schema: - $ref: "#/components/schemas/Track" - responses: - "201": - description: New track successfully created - content: - application/json: - schema: - $ref: "#/components/schemas/Track" - default: - description: unexpected error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - - /tracks/{hash}: - get: - summary: Get a track by its hash - tags: [Tracks] - parameters: - - name: hash - in: path - required: true - description: The hash of the track - schema: - type: string - responses: - "200": - description: Expected response to a valid request - content: - application/json: - schema: - $ref: "#/components/schemas/Track" - "404": - description: Track not found - default: - description: unexpected error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - put: - summary: Update a track - tags: [Tracks] - parameters: - - name: hash - in: path - required: true - description: The hash of the track - schema: - type: string - requestBody: - description: The track data - content: - application/json: - schema: - $ref: "#/components/schemas/Track" - responses: - "200": - description: Expected response to a valid request - content: - application/json: - schema: - $ref: "#/components/schemas/Track" - "404": - description: Track not found - default: - description: unexpected error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - delete: - summary: Delete a track - tags: [Tracks] - parameters: - - name: hash - in: path - required: true - description: The hash of the track - schema: - type: string - responses: - "200": - description: Expected response to a valid request - "404": - description: Track not found - 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: - name: - type: string - tracks: - type: array - items: - $ref: "#/components/schemas/Track" - - Track: - properties: - metadata: - $ref: "#/components/schemas/Metadata" - data: - description: Base64 encoded data - type: string - - Metadata: - properties: - 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 - - Error: - required: - - code - - message - properties: - code: - type: integer - format: int32 - message: - type: string From 6a16db5f7ecd3f0adef4c502f63ad7b8469910eb Mon Sep 17 00:00:00 2001 From: Christian Harke Date: Sun, 17 May 2020 02:26:14 +0200 Subject: [PATCH 06/14] Enhance API implementation --- src/main/java/ch/jbert/Application.java | 1 - .../controllers/api/PlaylistController.java | 151 +++++++++++++---- .../controllers/api/TrackController.java | 158 +++++++++++++++++- src/main/java/ch/jbert/models/Metadata.java | 19 +++ src/main/java/ch/jbert/models/Track.java | 22 ++- .../ch/jbert/services/PlaylistService.java | 19 ++- .../java/ch/jbert/services/TrackService.java | 115 +++++++++---- .../java/ch/jbert/utils/ThrowingConsumer.java | 64 +++++++ .../ch/jbert/utils/ThrowingPredicate.java | 50 ++++++ 9 files changed, 517 insertions(+), 82 deletions(-) create mode 100644 src/main/java/ch/jbert/utils/ThrowingConsumer.java create mode 100644 src/main/java/ch/jbert/utils/ThrowingPredicate.java diff --git a/src/main/java/ch/jbert/Application.java b/src/main/java/ch/jbert/Application.java index c64cb3e..0439a00 100644 --- a/src/main/java/ch/jbert/Application.java +++ b/src/main/java/ch/jbert/Application.java @@ -14,7 +14,6 @@ license = @License(name = "${api.license.name}") ), tags = { - @Tag(name = "${api.tags.system.name}", description = "${api.tags.system.description}"), @Tag(name = "${api.tags.playlists.name}", description = "${api.tags.playlists.description}"), @Tag(name = "${api.tags.tracks.name}", description = "${api.tags.tracks.description}") } diff --git a/src/main/java/ch/jbert/controllers/api/PlaylistController.java b/src/main/java/ch/jbert/controllers/api/PlaylistController.java index 050ea80..3f33539 100644 --- a/src/main/java/ch/jbert/controllers/api/PlaylistController.java +++ b/src/main/java/ch/jbert/controllers/api/PlaylistController.java @@ -1,12 +1,12 @@ package ch.jbert.controllers.api; 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 ch.jbert.utils.ThrowingFunction; import ch.jbert.utils.ThrowingSupplier; +import ch.jbert.utils.UncheckedException; import io.micronaut.http.HttpResponse; import io.micronaut.http.HttpStatus; import io.micronaut.http.annotation.Body; @@ -26,10 +26,8 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.util.Collections; import java.util.List; import java.util.Optional; -import java.util.stream.Collectors; import javax.inject.Inject; @@ -110,6 +108,15 @@ public HttpResponse list( public HttpResponse create(@Body Playlist playlist) { try { return created(playlistService.create(playlist)); + } catch (UncheckedException e) { + // TODO: Move into ErrorHandler + if (e.getCause() instanceof IllegalArgumentException) { + return badRequest(new Error(HttpStatus.BAD_REQUEST.getCode(), e.getMessage())); + } + LOG.info("Create Playlist failed: {}", playlist, e); + return serverError( + new Error(HttpStatus.INTERNAL_SERVER_ERROR.getCode(), String.format("Create Playlist failed: '%s'", + playlist))); } catch (IllegalArgumentException e) { // TODO: Move into ErrorHandler return badRequest(new Error(HttpStatus.BAD_REQUEST.getCode(), e.getMessage())); @@ -182,24 +189,20 @@ public HttpResponse find(@Parameter(description = "The name of the playlist") St ) public HttpResponse update( @Parameter(description = "The name of the playlist") String name, @Body Playlist playlist) { - final Optional original; try { - original = playlistService.findOneByName(name); - if (!original.isPresent()) { - return notFound(); - } - } catch (Exception e) { + return playlistService.findOneByName(name) + .map(ThrowingFunction.of(original -> playlistService.update(original, playlist))) + .map(HttpResponse::ok) + .orElse(notFound()); + } catch (UncheckedException e) { // TODO: Move into ErrorHandler + if (e.getCause() instanceof IllegalArgumentException) { + return badRequest(new Error(HttpStatus.BAD_REQUEST.getCode(), e.getMessage())); + } LOG.info("Update Playlist failed: {}", playlist, e); return serverError( new Error(HttpStatus.INTERNAL_SERVER_ERROR.getCode(), String.format("Update Playlist failed: '%s'", playlist))); - } - - try { - return ok(playlistService.update(original.get(), playlist.getName().isPresent() - ? playlist - : new Playlist(original.get().getName().get(), playlist.getTracks()))); } catch (IllegalArgumentException e) { // TODO: Move into ErrorHandler return badRequest(new Error(HttpStatus.BAD_REQUEST.getCode(), e.getMessage())); @@ -238,13 +241,11 @@ public HttpResponse update( ) ) public HttpResponse delete(@Parameter(description = "The name of the playlist") String name) { - final Optional playlist; try { - playlist = playlistService.findOneByName(name); - if (!playlist.isPresent()) { - return notFound(); - } - return ok(playlistService.delete(playlist.get())); + return playlistService.findOneByName(name) + .map(ThrowingFunction.of(playlistService::delete)) + .map(HttpResponse::ok) + .orElse(notFound()); } catch (Exception e) { // TODO: Move into ErrorHandler LOG.info("Delete Playlist failed: '{}'", name, e); @@ -292,19 +293,21 @@ public HttpResponse listTracks( @Parameter(description = "The name of the playlist") String name, @Parameter(description = "Filter tracks by name", required = false) Optional q) { try { - final Optional playlist = playlistService.findOneByName(name); - return playlist.isPresent() - ? ok(playlist.map(Playlist::getTracks) - .map(tracks -> q.isPresent() - ? tracks.stream() - .filter(track -> track.getMetadata() - .flatMap(Metadata::getTitle) - .map(title -> title.matches("(?i:.*" + q.get() + ".*)")) - .orElse(false) - ).collect(Collectors.toList()) - : tracks - ).orElse(Collections.emptyList())) - : notFound(); + return playlistService.findOneByName(name) + .map(playlist -> q + .map(query -> playlistService.findTracksByTitle(playlist, query)) + .orElse(playlist.getTracks())) + .map(HttpResponse::ok) + .orElse(notFound()); + } catch (UncheckedException e) { + // TODO: Move into ErrorHandler + if (e.getCause() instanceof IllegalArgumentException) { + return badRequest(new Error(HttpStatus.BAD_REQUEST.getCode(), e.getMessage())); + } + LOG.info("Find Playlist failed: '{}'", name, e); + return serverError( + new Error(HttpStatus.INTERNAL_SERVER_ERROR.getCode(), String.format("Find Playlist failed: '%s'", + name))); } catch (IllegalArgumentException e) { // TODO: Move into ErrorHandler return badRequest(new Error(HttpStatus.BAD_REQUEST.getCode(), e.getMessage())); @@ -317,6 +320,68 @@ public HttpResponse listTracks( } } + /** + * 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) { + try { + return playlistService.findOneByName(name) + .map(ThrowingFunction.of(p -> playlistService.addTrack(p, track))) + .map(HttpResponse::created) + .orElse(notFound()); + } catch (UncheckedException e) { + // TODO: Move into ErrorHandler + if (e.getCause() instanceof IllegalArgumentException) { + return badRequest(new Error(HttpStatus.BAD_REQUEST.getCode(), e.getMessage())); + } + LOG.info("Create Track failed: {}", track, e); + return serverError( + new Error(HttpStatus.INTERNAL_SERVER_ERROR.getCode(), String.format("Create Track failed: '%s'", + track))); + } catch (IllegalArgumentException e) { + // TODO: Move into ErrorHandler + return badRequest(new Error(HttpStatus.BAD_REQUEST.getCode(), e.getMessage())); + } catch (Exception e) { + // TODO: Move into ErrorHandler + LOG.info("Create Track failed: {}", track, e); + return serverError( + new Error(HttpStatus.INTERNAL_SERVER_ERROR.getCode(), String.format("Create Track failed: '%s'", + track))); + } + } + /** * Get a Track by Index */ @@ -360,6 +425,15 @@ public HttpResponse getTrackByIndex( return playlist.isPresent() && hasIndex ? ok(playlist.map(Playlist::getTracks).map(tracks -> tracks.get(index))) : notFound(); + } catch (UncheckedException e) { + // TODO: Move into ErrorHandler + if (e.getCause() instanceof IllegalArgumentException) { + return badRequest(new Error(HttpStatus.BAD_REQUEST.getCode(), e.getMessage())); + } + LOG.info("Find Playlist failed: '{}'", name, e); + return serverError( + new Error(HttpStatus.INTERNAL_SERVER_ERROR.getCode(), String.format("Get Track #%d from Playlist failed: '%s'", + index, name))); } catch (IllegalArgumentException e) { // TODO: Move into ErrorHandler return badRequest(new Error(HttpStatus.BAD_REQUEST.getCode(), e.getMessage())); @@ -415,6 +489,15 @@ public HttpResponse deleteTrackByIndex( return playlist.isPresent() && hasIndex ? ok(playlistService.deleteTrackByIndex(playlist.get(), index)) : notFound(); + } catch (UncheckedException e) { + // TODO: Move into ErrorHandler + if (e.getCause() instanceof IllegalArgumentException) { + return badRequest(new Error(HttpStatus.BAD_REQUEST.getCode(), e.getMessage())); + } + LOG.info("Find Playlist failed: '{}'", name, e); + return serverError( + new Error(HttpStatus.INTERNAL_SERVER_ERROR.getCode(), String.format("Delete Track #%d Playlist failed: '%s'", + index, name))); } catch (IllegalArgumentException e) { // TODO: Move into ErrorHandler return badRequest(new Error(HttpStatus.BAD_REQUEST.getCode(), e.getMessage())); diff --git a/src/main/java/ch/jbert/controllers/api/TrackController.java b/src/main/java/ch/jbert/controllers/api/TrackController.java index fe7ee99..ff80e3c 100644 --- a/src/main/java/ch/jbert/controllers/api/TrackController.java +++ b/src/main/java/ch/jbert/controllers/api/TrackController.java @@ -5,12 +5,15 @@ import ch.jbert.services.TrackService; import ch.jbert.utils.ThrowingFunction; import ch.jbert.utils.ThrowingSupplier; +import ch.jbert.utils.UncheckedException; import io.micronaut.http.HttpResponse; import io.micronaut.http.HttpStatus; 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; @@ -23,11 +26,13 @@ import org.slf4j.LoggerFactory; import javax.inject.Inject; + import java.util.List; import java.util.Optional; import static io.micronaut.http.HttpResponse.badRequest; import static io.micronaut.http.HttpResponse.created; +import static io.micronaut.http.HttpResponse.notFound; import static io.micronaut.http.HttpResponse.serverError; @Controller("/api/tracks") @@ -59,7 +64,7 @@ public class TrackController { ) ) public HttpResponse list( - @Parameter(description = "Filter tracks by metadata attributes", required = false) Optional q) { + @Parameter(description = "Filter tracks by name", required = false) Optional q) { try { final List playlists = q .map(ThrowingFunction.of(n -> trackService.findAllByName(n))) @@ -101,6 +106,15 @@ public HttpResponse list( public HttpResponse create(@Body Track track) { try { return created(trackService.create(track)); + } catch (UncheckedException e) { + // TODO: Move into ErrorHandler + if (e.getCause() instanceof IllegalArgumentException) { + return badRequest(new Error(HttpStatus.BAD_REQUEST.getCode(), e.getMessage())); + } + LOG.info("Create Track failed: {}", track, e); + return serverError( + new Error(HttpStatus.INTERNAL_SERVER_ERROR.getCode(), String.format("Create Track failed: '%s'", + track))); } catch (IllegalArgumentException e) { // TODO: Move into ErrorHandler return badRequest(new Error(HttpStatus.BAD_REQUEST.getCode(), e.getMessage())); @@ -112,4 +126,146 @@ public HttpResponse create(@Body Track track) { 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) + ) + ) + public HttpResponse find(@Parameter(description = "The hash of the track") String hash) { + try { + return trackService.findOneByHash(hash) + .map(HttpResponse::ok) + .orElse(notFound()); + } catch (IllegalArgumentException e) { + // TODO: Move into ErrorHandler + return badRequest(new Error(HttpStatus.BAD_REQUEST.getCode(), e.getMessage())); + } catch (Exception e) { + // TODO: Move into ErrorHandler + LOG.info("Find Track failed: '{}'", hash, e); + return serverError( + new Error(HttpStatus.INTERNAL_SERVER_ERROR.getCode(), String.format("Find Track failed: '%s'", + hash))); + } + } + + /** + * 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) { + try { + return trackService.findOneByHash(hash) + .map(original -> trackService.update(original, track)) + .map(HttpResponse::ok) + .orElse(notFound()); + } catch (IllegalArgumentException e) { + // TODO: Move into ErrorHandler + return badRequest(new Error(HttpStatus.BAD_REQUEST.getCode(), e.getMessage())); + } catch (Exception e) { + // TODO: Move into ErrorHandler + LOG.info("Find Track failed: '{}'", hash, e); + return serverError( + new Error(HttpStatus.INTERNAL_SERVER_ERROR.getCode(), String.format("Update Track failed: '%s'", + hash))); + } + } + + /** + * 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) { + try { + return trackService.findOneByHash(hash) + .map(trackService::delete) + .map(HttpResponse::ok) + .orElse(notFound()); + } catch (IllegalArgumentException e) { + // TODO: Move into ErrorHandler + return badRequest(new Error(HttpStatus.BAD_REQUEST.getCode(), e.getMessage())); + } catch (Exception e) { + // TODO: Move into ErrorHandler + LOG.info("Find Track failed: '{}'", hash, e); + return serverError( + new Error(HttpStatus.INTERNAL_SERVER_ERROR.getCode(), String.format("Delete Track failed: '%s'", + hash))); + } + } } diff --git a/src/main/java/ch/jbert/models/Metadata.java b/src/main/java/ch/jbert/models/Metadata.java index 8eacfcf..79666dc 100644 --- a/src/main/java/ch/jbert/models/Metadata.java +++ b/src/main/java/ch/jbert/models/Metadata.java @@ -27,6 +27,25 @@ public Metadata(String title, String artist, String album, Integer year, String 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); } diff --git a/src/main/java/ch/jbert/models/Track.java b/src/main/java/ch/jbert/models/Track.java index bc4b8b0..fbffb62 100644 --- a/src/main/java/ch/jbert/models/Track.java +++ b/src/main/java/ch/jbert/models/Track.java @@ -1,5 +1,7 @@ package ch.jbert.models; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.util.Comparator; import java.util.Objects; import java.util.Optional; @@ -23,14 +25,22 @@ public Track(Metadata metadata) { } public Optional getMetadata() { - return Optional.ofNullable(this.metadata); + return Optional.ofNullable(metadata); } /** * Base64 encoded data */ public Optional getData() { - return Optional.ofNullable(this.data); + return Optional.ofNullable(data); + } + + /** + * Calculates the MD5 hash of the Base64 encoded data + */ + public String calculateMD5() throws NoSuchAlgorithmException { + final MessageDigest md = MessageDigest.getInstance("MD5"); + return md.digest(data.getBytes()).toString(); } @Override @@ -85,16 +95,16 @@ public Track build() { return new Track(metadata, data); } - public Builder withMetadata(Metadata value) { - this.metadata = value; + public Builder withMetadata(Metadata metadata) { + this.metadata = metadata; return this; } /** * Base64 encoded data */ - public Builder withData(String value) { - this.data = value; + public Builder withData(String data) { + this.data = data; return this; } } diff --git a/src/main/java/ch/jbert/services/PlaylistService.java b/src/main/java/ch/jbert/services/PlaylistService.java index f3cd624..d56c48c 100644 --- a/src/main/java/ch/jbert/services/PlaylistService.java +++ b/src/main/java/ch/jbert/services/PlaylistService.java @@ -3,6 +3,7 @@ import ch.jbert.models.Metadata; 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; @@ -108,9 +109,18 @@ private List getTracks(String filename) { } } + public List findTracksByTitle(Playlist playlist, String title) { + return trackService.filterByName(playlist.getTracks(), title); + } + @Override public Playlist update(Playlist original, Playlist update) throws IOException { + 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))); @@ -122,12 +132,15 @@ public Playlist update(Playlist original, Playlist update) throws IOException { @Override public Playlist delete(Playlist playlist) throws IOException { final Optional filePath = getFilePath(playlist); - if (filePath.isPresent()) { - Files.deleteIfExists(filePath.get()); - } + filePath.ifPresent(ThrowingConsumer.of(Files::deleteIfExists)); return playlist; } + public Playlist addTrack(Playlist playlist, Track track) throws IOException { + playlist.getTracks().add(track); + return addTracks(playlist); + } + public Playlist deleteTrackByIndex(Playlist playlist, int index) throws IOException { final List copiedTracks = Collections.emptyList(); diff --git a/src/main/java/ch/jbert/services/TrackService.java b/src/main/java/ch/jbert/services/TrackService.java index 9e1453b..bd9ffa6 100644 --- a/src/main/java/ch/jbert/services/TrackService.java +++ b/src/main/java/ch/jbert/services/TrackService.java @@ -4,6 +4,8 @@ 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.ThrowingPredicate; import io.micronaut.context.annotation.Value; import org.jaudiotagger.audio.AudioFile; @@ -23,6 +25,7 @@ import java.nio.file.Paths; import java.util.Base64; import java.util.List; +import java.util.Objects; import java.util.Optional; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -43,7 +46,7 @@ public class TrackService extends DataService { private String basePath; @Override - public Track create(Track track) throws IOException { + public Track create(Track track) { if (track.getData().isPresent()) { if (exists(track)) { @@ -51,16 +54,16 @@ public Track create(Track track) throws IOException { } final ByteSource data = ByteSource.wrap(Base64.getDecoder().decode(track.getData().get())); final Optional file = getFilePath(track); - if (file.isPresent()) { - Files.createDirectories(file.get().getParent()); - try (final OutputStream out = Files.newOutputStream(file.get())) { - // FIXME: Write ID3 tags based on provided track metadata + file.ifPresent(ThrowingConsumer.of(f -> { + Files.createDirectories(f.getParent()); + try (final OutputStream out = Files.newOutputStream(f)) { out.write(data.read()); } - } + track.getMetadata().ifPresent(m -> writeId3Tags(f.toFile(), m)); + })); } else if (notExists(track)) { - throw new IllegalArgumentException(String.format("Create track failed due to missing track data: '%s'", - track)); + throw new IllegalArgumentException( + String.format("Create track failed due to missing track data: '%s'", track)); } return track; } @@ -72,53 +75,75 @@ public List getAll() throws IOException { try (Stream files = Files.walk(Paths.get(basePath), FileVisitOption.FOLLOW_LINKS) .filter(p -> p.toString().endsWith(FILE_SUFFIX))) { - return files - .map(file -> { - try { - // FIXME: Create Metadata by file structure alternatively - return readId3Tags(file.toFile()); - } catch (Exception e) { - LOG.warn("Could not read ID3 tags from track '{}', returning empty metadata", file); - return Metadata.newBuilder().build(); - } - }) - .map(Track::new) - .sorted() - .collect(Collectors.toList()); + return files.map(file -> { + try { + return readId3Tags(file.toFile()); + } catch (Exception e) { + // FIXME: Create Metadata by file structure alternatively + LOG.warn("Could not read ID3 tags from track '{}', returning empty metadata", file); + return Metadata.newBuilder().build(); + } + }).map(Track::new).sorted().collect(Collectors.toList()); } } @Override public List findAllByName(String name) throws IOException { + return (name == null || name.isEmpty()) + ? getAll() + : filterByName(getAll(), name); + } - if (name == null || name.isEmpty()) { - return getAll(); - } + /** + * 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 List filterByName(List tracks, String name) { + 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 { + if (!isValidMD5(hash)) { + throw new IllegalArgumentException(String.format("Provided hash is not a valid MD5 hash: '%s'", hash)); + } return getAll().stream() - .filter(track -> { - final String pattern = "(?i:.*" + name + ".*)"; - final Optional metadataOptional = track.getMetadata(); - final String artist = metadataOptional.flatMap(Metadata::getArtist).orElse(""); - final String album = metadataOptional.flatMap(Metadata::getAlbum).orElse(""); - final String title = metadataOptional.flatMap(Metadata::getTitle).orElse(""); - return Stream.of(artist, album, title).anyMatch(s -> s.matches(pattern)); - }) - .collect(Collectors.toList()); + .filter(ThrowingPredicate.of(t -> Objects.equals(t.calculateMD5(), hash))) + .findAny(); } - public Optional findOneByHash(String hash) { - return Optional.empty(); + private boolean isValidMD5(String s) { + return s.matches("^[a-fA-F0-9]{32}$"); } @Override public Track update(Track original, Track update) { - return null; + + final Metadata metadata; + if (original.getMetadata().isPresent() && original.getMetadata().isPresent()) { + metadata = Metadata.merge(original.getMetadata().get(), update.getMetadata().get()); + } else { + metadata = update.getMetadata() + .orElseGet(() -> original.getMetadata() + .orElseThrow(() -> new IllegalStateException("Original track does not have metadata"))); + } + 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) { - return null; + final Optional filePath = getFilePath(track); + filePath.ifPresent(ThrowingConsumer.of(Files::deleteIfExists)); + return track; } private boolean exists(Track track) { @@ -193,4 +218,20 @@ private Metadata readId3Tags(File file) throws Exception { return trackBuilder.build(); } + + private void writeId3Tags(File file, Metadata metadata) { + try { + final AudioFile audioFile = AudioFileIO.read(file); + 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/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/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; + } +} From 969f22a6442e4b4aa68a3fe347d899d1a968a5bd Mon Sep 17 00:00:00 2001 From: Christian Harke Date: Fri, 5 Jun 2020 01:15:15 +0200 Subject: [PATCH 07/14] Add global exceptionhandler --- .../api/GenericExceptionHandler.java | 23 + .../api/IllegalArgumentExceptionHandler.java | 23 + .../controllers/api/PlaylistController.java | 489 +++--------------- .../controllers/api/TrackController.java | 239 ++------- .../api/UncheckedExceptionHandler.java | 26 + 5 files changed, 188 insertions(+), 612 deletions(-) create mode 100644 src/main/java/ch/jbert/controllers/api/GenericExceptionHandler.java create mode 100644 src/main/java/ch/jbert/controllers/api/IllegalArgumentExceptionHandler.java create mode 100644 src/main/java/ch/jbert/controllers/api/UncheckedExceptionHandler.java 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..bfa7436 --- /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.BAD_REQUEST.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 index 3f33539..72c7158 100644 --- a/src/main/java/ch/jbert/controllers/api/PlaylistController.java +++ b/src/main/java/ch/jbert/controllers/api/PlaylistController.java @@ -6,9 +6,7 @@ import ch.jbert.services.PlaylistService; import ch.jbert.utils.ThrowingFunction; import ch.jbert.utils.ThrowingSupplier; -import ch.jbert.utils.UncheckedException; import io.micronaut.http.HttpResponse; -import io.micronaut.http.HttpStatus; import io.micronaut.http.annotation.Body; import io.micronaut.http.annotation.Controller; import io.micronaut.http.annotation.Delete; @@ -23,26 +21,20 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - +import java.io.IOException; import java.util.List; import java.util.Optional; import javax.inject.Inject; -import static io.micronaut.http.HttpResponse.badRequest; import static io.micronaut.http.HttpResponse.created; import static io.micronaut.http.HttpResponse.notFound; import static io.micronaut.http.HttpResponse.ok; -import static io.micronaut.http.HttpResponse.serverError; @Controller("/api/playlists") @Tag(name = "${api.tags.playlists.name}") public class PlaylistController { - private static final Logger LOG = LoggerFactory.getLogger(PlaylistController.class); - @Inject private PlaylistService playlistService; @@ -50,33 +42,14 @@ public class PlaylistController { * 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) - ) - ) + @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) { - try { - final List playlists = q - .map(ThrowingFunction.of(n -> playlistService.findAllByName(n))) - .orElseGet(ThrowingSupplier.of(() -> playlistService.getAll())); - return ok(playlists); - } catch (Exception e) { - // TODO: Move into ErrorHandler - LOG.info("List Tracks failed", e); - return serverError(new Error(HttpStatus.INTERNAL_SERVER_ERROR.getCode(), "List Playlists failed")); - } + final List playlists = q + .map(ThrowingFunction.of(n -> playlistService.findAllByName(n))) + .orElseGet(ThrowingSupplier.of(() -> playlistService.getAll())); + return ok(playlists); } /** @@ -84,81 +57,23 @@ public HttpResponse list( */ @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) { - try { - return created(playlistService.create(playlist)); - } catch (UncheckedException e) { - // TODO: Move into ErrorHandler - if (e.getCause() instanceof IllegalArgumentException) { - return badRequest(new Error(HttpStatus.BAD_REQUEST.getCode(), e.getMessage())); - } - LOG.info("Create Playlist failed: {}", playlist, e); - return serverError( - new Error(HttpStatus.INTERNAL_SERVER_ERROR.getCode(), String.format("Create Playlist failed: '%s'", - playlist))); - } catch (IllegalArgumentException e) { - // TODO: Move into ErrorHandler - return badRequest(new Error(HttpStatus.BAD_REQUEST.getCode(), e.getMessage())); - } catch (Exception e) { - // TODO: Move into ErrorHandler - LOG.info("Create Playlist failed: {}", playlist, e); - return serverError( - new Error(HttpStatus.INTERNAL_SERVER_ERROR.getCode(), String.format("Create Playlist failed: '%s'", - playlist))); - } + @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) - ) - ) + @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) { - try { - return playlistService.findOneByName(name) - .map(HttpResponse::ok) - .orElse(notFound()); - } catch (Exception e) { - // TODO: Move into ErrorHandler - LOG.info("Find Playlist failed: '{}'", name, e); - return serverError( - new Error(HttpStatus.INTERNAL_SERVER_ERROR.getCode(), String.format("Find Playlist failed: '%s'", - name))); - } + return playlistService.findOneByName(name) + .map(HttpResponse::ok) + .orElse(notFound()); } /** @@ -166,158 +81,47 @@ public HttpResponse find(@Parameter(description = "The name of the playlist") St */ @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) - ) - ) + @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) { - try { - return playlistService.findOneByName(name) - .map(ThrowingFunction.of(original -> playlistService.update(original, playlist))) - .map(HttpResponse::ok) - .orElse(notFound()); - } catch (UncheckedException e) { - // TODO: Move into ErrorHandler - if (e.getCause() instanceof IllegalArgumentException) { - return badRequest(new Error(HttpStatus.BAD_REQUEST.getCode(), e.getMessage())); - } - LOG.info("Update Playlist failed: {}", playlist, e); - return serverError( - new Error(HttpStatus.INTERNAL_SERVER_ERROR.getCode(), String.format("Update Playlist failed: '%s'", - playlist))); - } catch (IllegalArgumentException e) { - // TODO: Move into ErrorHandler - return badRequest(new Error(HttpStatus.BAD_REQUEST.getCode(), e.getMessage())); - } catch (Exception e) { - // TODO: Move into ErrorHandler - LOG.info("Update Playlist failed: {}", playlist, e); - return serverError( - new Error(HttpStatus.INTERNAL_SERVER_ERROR.getCode(), String.format("Update Playlist failed: '%s'", - 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) - ) - ) + @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) { - try { - return playlistService.findOneByName(name) - .map(ThrowingFunction.of(playlistService::delete)) - .map(HttpResponse::ok) - .orElse(notFound()); - } catch (Exception e) { - // TODO: Move into ErrorHandler - LOG.info("Delete Playlist failed: '{}'", name, e); - return serverError( - new Error(HttpStatus.INTERNAL_SERVER_ERROR.getCode(), String.format("Delete Playlist failed: '%s'", - 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) { - try { - return playlistService.findOneByName(name) - .map(playlist -> q - .map(query -> playlistService.findTracksByTitle(playlist, query)) - .orElse(playlist.getTracks())) - .map(HttpResponse::ok) - .orElse(notFound()); - } catch (UncheckedException e) { - // TODO: Move into ErrorHandler - if (e.getCause() instanceof IllegalArgumentException) { - return badRequest(new Error(HttpStatus.BAD_REQUEST.getCode(), e.getMessage())); - } - LOG.info("Find Playlist failed: '{}'", name, e); - return serverError( - new Error(HttpStatus.INTERNAL_SERVER_ERROR.getCode(), String.format("Find Playlist failed: '%s'", - name))); - } catch (IllegalArgumentException e) { - // TODO: Move into ErrorHandler - return badRequest(new Error(HttpStatus.BAD_REQUEST.getCode(), e.getMessage())); - } catch (Exception e) { - // TODO: Move into ErrorHandler - LOG.info("Find Playlist failed: '{}'", name, e); - return serverError( - new Error(HttpStatus.INTERNAL_SERVER_ERROR.getCode(), String.format("Find Playlist failed: '%s'", - name))); - } + @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 -> playlistService.findTracksByTitle(playlist, query)) + .orElse(playlist.getTracks())) + .map(HttpResponse::ok) + .orElse(notFound()); } /** @@ -325,188 +129,53 @@ public HttpResponse listTracks( */ @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) { - try { - return playlistService.findOneByName(name) - .map(ThrowingFunction.of(p -> playlistService.addTrack(p, track))) - .map(HttpResponse::created) - .orElse(notFound()); - } catch (UncheckedException e) { - // TODO: Move into ErrorHandler - if (e.getCause() instanceof IllegalArgumentException) { - return badRequest(new Error(HttpStatus.BAD_REQUEST.getCode(), e.getMessage())); - } - LOG.info("Create Track failed: {}", track, e); - return serverError( - new Error(HttpStatus.INTERNAL_SERVER_ERROR.getCode(), String.format("Create Track failed: '%s'", - track))); - } catch (IllegalArgumentException e) { - // TODO: Move into ErrorHandler - return badRequest(new Error(HttpStatus.BAD_REQUEST.getCode(), e.getMessage())); - } catch (Exception e) { - // TODO: Move into ErrorHandler - LOG.info("Create Track failed: {}", track, e); - return serverError( - new Error(HttpStatus.INTERNAL_SERVER_ERROR.getCode(), String.format("Create Track failed: '%s'", - track))); - } + @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) { - try { - final Optional playlist = playlistService.findOneByName(name); - final boolean hasIndex = playlist.map(Playlist::getTracks) - .map(tracks -> tracks.size() > index) - .orElse(false); - return playlist.isPresent() && hasIndex - ? ok(playlist.map(Playlist::getTracks).map(tracks -> tracks.get(index))) - : notFound(); - } catch (UncheckedException e) { - // TODO: Move into ErrorHandler - if (e.getCause() instanceof IllegalArgumentException) { - return badRequest(new Error(HttpStatus.BAD_REQUEST.getCode(), e.getMessage())); - } - LOG.info("Find Playlist failed: '{}'", name, e); - return serverError( - new Error(HttpStatus.INTERNAL_SERVER_ERROR.getCode(), String.format("Get Track #%d from Playlist failed: '%s'", - index, name))); - } catch (IllegalArgumentException e) { - // TODO: Move into ErrorHandler - return badRequest(new Error(HttpStatus.BAD_REQUEST.getCode(), e.getMessage())); - } catch (Exception e) { - // TODO: Move into ErrorHandler - LOG.info("Find Playlist failed: '{}'", name, e); - return serverError( - new Error(HttpStatus.INTERNAL_SERVER_ERROR.getCode(), String.format("Get Track #%d from Playlist failed: '%s'", - index, name))); - } + @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) { + final Optional playlist = playlistService.findOneByName(name); + final boolean hasIndex = playlist.map(Playlist::getTracks) + .map(tracks -> tracks.size() > index) + .orElse(false); + return playlist.isPresent() && hasIndex + ? ok(playlist.map(Playlist::getTracks).map(tracks -> tracks.get(index))) + : 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) { - try { - final Optional playlist = playlistService.findOneByName(name); - final boolean hasIndex = playlist.map(Playlist::getTracks) - .map(tracks -> tracks.size() > index) - .orElse(false); - return playlist.isPresent() && hasIndex - ? ok(playlistService.deleteTrackByIndex(playlist.get(), index)) - : notFound(); - } catch (UncheckedException e) { - // TODO: Move into ErrorHandler - if (e.getCause() instanceof IllegalArgumentException) { - return badRequest(new Error(HttpStatus.BAD_REQUEST.getCode(), e.getMessage())); - } - LOG.info("Find Playlist failed: '{}'", name, e); - return serverError( - new Error(HttpStatus.INTERNAL_SERVER_ERROR.getCode(), String.format("Delete Track #%d Playlist failed: '%s'", - index, name))); - } catch (IllegalArgumentException e) { - // TODO: Move into ErrorHandler - return badRequest(new Error(HttpStatus.BAD_REQUEST.getCode(), e.getMessage())); - } catch (Exception e) { - // TODO: Move into ErrorHandler - LOG.info("Find Playlist failed: '{}'", name, e); - return serverError( - new Error(HttpStatus.INTERNAL_SERVER_ERROR.getCode(), String.format("Delete Track #%d Playlist failed: '%s'", - index, name))); - } + @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 { + final Optional playlist = playlistService.findOneByName(name); + final boolean hasIndex = playlist.map(Playlist::getTracks) + .map(tracks -> tracks.size() > index) + .orElse(false); + return playlist.isPresent() && hasIndex + ? ok(playlistService.deleteTrackByIndex(playlist.get(), index)) + : notFound(); } } diff --git a/src/main/java/ch/jbert/controllers/api/TrackController.java b/src/main/java/ch/jbert/controllers/api/TrackController.java index ff80e3c..e3e6038 100644 --- a/src/main/java/ch/jbert/controllers/api/TrackController.java +++ b/src/main/java/ch/jbert/controllers/api/TrackController.java @@ -5,9 +5,7 @@ import ch.jbert.services.TrackService; import ch.jbert.utils.ThrowingFunction; import ch.jbert.utils.ThrowingSupplier; -import ch.jbert.utils.UncheckedException; import io.micronaut.http.HttpResponse; -import io.micronaut.http.HttpStatus; import io.micronaut.http.annotation.Body; import io.micronaut.http.annotation.Controller; import io.micronaut.http.annotation.Delete; @@ -22,25 +20,19 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - import javax.inject.Inject; +import java.io.IOException; import java.util.List; import java.util.Optional; -import static io.micronaut.http.HttpResponse.badRequest; import static io.micronaut.http.HttpResponse.created; import static io.micronaut.http.HttpResponse.notFound; -import static io.micronaut.http.HttpResponse.serverError; @Controller("/api/tracks") @Tag(name = "${api.tags.tracks.name}") public class TrackController { - private static final Logger LOG = LoggerFactory.getLogger(TrackController.class); - @Inject private TrackService trackService; @@ -48,33 +40,12 @@ public class TrackController { * 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) { - try { - final List playlists = q - .map(ThrowingFunction.of(n -> trackService.findAllByName(n))) - .orElseGet(ThrowingSupplier.of(() -> trackService.getAll())); - return HttpResponse.ok(playlists); - } catch (Exception e) { - // TODO: Move into ErrorHandler - LOG.info("List Tracks failed", e); - return serverError(new Error(HttpStatus.INTERNAL_SERVER_ERROR.getCode(), "List Tracks failed")); - } + @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); } /** @@ -82,190 +53,54 @@ public HttpResponse list( */ @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) - ) - ) + @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) { - try { - return created(trackService.create(track)); - } catch (UncheckedException e) { - // TODO: Move into ErrorHandler - if (e.getCause() instanceof IllegalArgumentException) { - return badRequest(new Error(HttpStatus.BAD_REQUEST.getCode(), e.getMessage())); - } - LOG.info("Create Track failed: {}", track, e); - return serverError( - new Error(HttpStatus.INTERNAL_SERVER_ERROR.getCode(), String.format("Create Track failed: '%s'", - track))); - } catch (IllegalArgumentException e) { - // TODO: Move into ErrorHandler - return badRequest(new Error(HttpStatus.BAD_REQUEST.getCode(), e.getMessage())); - } catch (Exception e) { - // TODO: Move into ErrorHandler - LOG.info("Create Track failed: {}", track, e); - return serverError( - new Error(HttpStatus.INTERNAL_SERVER_ERROR.getCode(), String.format("Create Track failed: '%s'", - track))); - } + 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) - ) - ) - public HttpResponse find(@Parameter(description = "The hash of the track") String hash) { - try { - return trackService.findOneByHash(hash) - .map(HttpResponse::ok) - .orElse(notFound()); - } catch (IllegalArgumentException e) { - // TODO: Move into ErrorHandler - return badRequest(new Error(HttpStatus.BAD_REQUEST.getCode(), e.getMessage())); - } catch (Exception e) { - // TODO: Move into ErrorHandler - LOG.info("Find Track failed: '{}'", hash, e); - return serverError( - new Error(HttpStatus.INTERNAL_SERVER_ERROR.getCode(), String.format("Find Track failed: '%s'", - 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))) + 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) { - try { - return trackService.findOneByHash(hash) - .map(original -> trackService.update(original, track)) - .map(HttpResponse::ok) - .orElse(notFound()); - } catch (IllegalArgumentException e) { - // TODO: Move into ErrorHandler - return badRequest(new Error(HttpStatus.BAD_REQUEST.getCode(), e.getMessage())); - } catch (Exception e) { - // TODO: Move into ErrorHandler - LOG.info("Find Track failed: '{}'", hash, e); - return serverError( - new Error(HttpStatus.INTERNAL_SERVER_ERROR.getCode(), String.format("Update Track failed: '%s'", - 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(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) { - try { - return trackService.findOneByHash(hash) - .map(trackService::delete) - .map(HttpResponse::ok) - .orElse(notFound()); - } catch (IllegalArgumentException e) { - // TODO: Move into ErrorHandler - return badRequest(new Error(HttpStatus.BAD_REQUEST.getCode(), e.getMessage())); - } catch (Exception e) { - // TODO: Move into ErrorHandler - LOG.info("Find Track failed: '{}'", hash, e); - return serverError( - new Error(HttpStatus.INTERNAL_SERVER_ERROR.getCode(), String.format("Delete Track failed: '%s'", - 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(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..2109e38 --- /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.BAD_REQUEST.getCode(), exception.getCause().getMessage())); + } + +} \ No newline at end of file From 1c203f807e1ab7e0dfb7cafb81b6d191b932c2aa Mon Sep 17 00:00:00 2001 From: Christian Harke Date: Mon, 8 Jun 2020 23:56:19 +0200 Subject: [PATCH 08/14] Prefer interface over abstract class inheritance --- .../java/ch/jbert/services/DataService.java | 17 +++++++---------- .../java/ch/jbert/services/PlaylistService.java | 4 +++- .../java/ch/jbert/services/TrackService.java | 5 ++++- 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/src/main/java/ch/jbert/services/DataService.java b/src/main/java/ch/jbert/services/DataService.java index 672a5cc..ee731d7 100644 --- a/src/main/java/ch/jbert/services/DataService.java +++ b/src/main/java/ch/jbert/services/DataService.java @@ -3,22 +3,19 @@ import java.io.IOException; import java.util.List; -import javax.inject.Singleton; - /** - * CRUD Service template + * CRUD Service */ -@Singleton -public abstract class DataService { +public interface DataService { - public abstract T create(T entity) throws IOException; + T create(T entity) throws IOException; - public abstract List getAll() throws IOException; + List getAll() throws IOException; - public abstract List findAllByName(String name) throws IOException; + List findAllByName(String name) throws IOException; - public abstract T update(T original, T update) throws IOException; + T update(T original, T update) throws IOException; - public abstract T delete(T entity) throws IOException; + T delete(T entity) throws IOException; } diff --git a/src/main/java/ch/jbert/services/PlaylistService.java b/src/main/java/ch/jbert/services/PlaylistService.java index d56c48c..7a0769d 100644 --- a/src/main/java/ch/jbert/services/PlaylistService.java +++ b/src/main/java/ch/jbert/services/PlaylistService.java @@ -23,8 +23,10 @@ import java.util.stream.Collectors; import javax.inject.Inject; +import javax.inject.Singleton; -public class PlaylistService extends DataService { +@Singleton +public class PlaylistService implements DataService { private static final Logger LOG = LoggerFactory.getLogger(PlaylistService.class); private static final String FILE_SUFFIX = ".m3u"; diff --git a/src/main/java/ch/jbert/services/TrackService.java b/src/main/java/ch/jbert/services/TrackService.java index bd9ffa6..cf9a987 100644 --- a/src/main/java/ch/jbert/services/TrackService.java +++ b/src/main/java/ch/jbert/services/TrackService.java @@ -30,10 +30,13 @@ import java.util.stream.Collectors; import java.util.stream.Stream; +import javax.inject.Singleton; + import static java.nio.file.StandardOpenOption.CREATE; import static ch.jbert.utils.Strings.sanitizeFilename; -public class TrackService extends DataService { +@Singleton +public class TrackService implements DataService { private static final Logger LOG = LoggerFactory.getLogger(TrackService.class); private static final String FILE_SUFFIX = ".mp3"; From 917fa0b8aa27458b7e29dea04de38b2ff2d3c138 Mon Sep 17 00:00:00 2001 From: Christian Harke Date: Mon, 8 Jun 2020 22:38:47 +0200 Subject: [PATCH 09/14] Configure local environment properly --- application.yml.template | 41 ------------------------ runlocal.sh | 2 +- src/main/resources/application-local.yml | 23 +++++++++++++ src/main/resources/application.yml | 4 +++ 4 files changed, 28 insertions(+), 42 deletions(-) delete mode 100644 application.yml.template create mode 100644 src/main/resources/application-local.yml diff --git a/application.yml.template b/application.yml.template deleted file mode 100644 index 158872d..0000000 --- a/application.yml.template +++ /dev/null @@ -1,41 +0,0 @@ -micronaut: - application: - name: jbert - 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/** - -endpoints: - all: - port: 8085 - sensitive: false - -restapi: - playlists: - path: "/path/to/your/playlists" - tracks: - path: "/path/to/your/music" - -application: - event-router-name: SimplePlaylistLoad - -core: - hal: - mock: - enabled: true - rfid: - scan-interval: 2s - mpd: - server: "localhost" - port: 6600 diff --git a/runlocal.sh b/runlocal.sh index 3a551e2..ae352d8 100755 --- a/runlocal.sh +++ b/runlocal.sh @@ -1,3 +1,3 @@ #!/usr/bin/env bash -MICRONAUT_CONFIG_FILES=application.yml gradle run +MICRONAUT_ENVIRONMENTS=local gradle run 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.yml b/src/main/resources/application.yml index 54920ad..a655c32 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,6 +1,10 @@ +--- micronaut: application: name: jbert + +--- +micronaut: router: static-resources: swagger: From 454864b7d87dd0afe87e7183457ec902b342e4eb Mon Sep 17 00:00:00 2001 From: Christian Harke Date: Tue, 9 Jun 2020 00:13:19 +0200 Subject: [PATCH 10/14] Update .gitignore Ignore all build related folders and files. IDE specific ignores were removed, please set up your global ignore file accordingly instead. --- .gitignore | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 From e1a25a3b248de4fce290bea55633e90557e8c9e4 Mon Sep 17 00:00:00 2001 From: Christian Harke Date: Mon, 8 Jun 2020 23:57:27 +0200 Subject: [PATCH 11/14] WIP: Add tests for rest api --- build.gradle | 1 + .../api/GenericExceptionHandler.java | 2 +- .../controllers/api/PlaylistController.java | 26 +- .../controllers/api/TrackController.java | 7 +- .../api/UncheckedExceptionHandler.java | 2 +- src/main/java/ch/jbert/models/Error.java | 9 +- src/main/java/ch/jbert/models/Metadata.java | 47 +- src/main/java/ch/jbert/models/Playlist.java | 27 +- src/main/java/ch/jbert/models/Track.java | 39 +- .../java/ch/jbert/services/DataService.java | 6 + .../ch/jbert/services/PlaylistService.java | 77 +-- .../java/ch/jbert/services/TrackService.java | 248 +++++---- src/main/java/ch/jbert/utils/FileUtils.java | 22 + src/main/java/ch/jbert/utils/TagFields.java | 27 + .../ch/jbert/utils/UncheckedException.java | 3 + src/main/resources/application-test.yml | 23 + .../api/ExceptionHandlingTest.java | 127 +++++ .../api/GenericExceptionHandlerTest.java | 28 + .../IllegalArgumentExceptionHandlerTest.java | 28 + .../api/PlaylistControllerTest.java | 494 ++++++++++++++++++ .../controllers/api/TrackControllerTest.java | 246 +++++++++ .../api/UncheckedExceptionHandlerTest.java | 36 ++ .../jbert/services/PlaylistServiceTest.java | 266 ++++++++++ .../ch/jbert/services/TrackServiceTest.java | 314 +++++++++++ src/test/java/ch/jbert/utils/TestData.java | 70 +++ .../java/ch/jbert/utils/TestEnvironment.java | 32 ++ .../Silicon_Transmitter_-_08_-_Virus_1s.mp3 | Bin 0 -> 20163 bytes 27 files changed, 2018 insertions(+), 189 deletions(-) create mode 100644 src/main/java/ch/jbert/utils/FileUtils.java create mode 100644 src/main/java/ch/jbert/utils/TagFields.java create mode 100644 src/main/resources/application-test.yml create mode 100644 src/test/java/ch/jbert/controllers/api/ExceptionHandlingTest.java create mode 100644 src/test/java/ch/jbert/controllers/api/GenericExceptionHandlerTest.java create mode 100644 src/test/java/ch/jbert/controllers/api/IllegalArgumentExceptionHandlerTest.java create mode 100644 src/test/java/ch/jbert/controllers/api/PlaylistControllerTest.java create mode 100644 src/test/java/ch/jbert/controllers/api/TrackControllerTest.java create mode 100644 src/test/java/ch/jbert/controllers/api/UncheckedExceptionHandlerTest.java create mode 100644 src/test/java/ch/jbert/services/PlaylistServiceTest.java create mode 100644 src/test/java/ch/jbert/services/TrackServiceTest.java create mode 100644 src/test/java/ch/jbert/utils/TestData.java create mode 100644 src/test/java/ch/jbert/utils/TestEnvironment.java create mode 100644 src/test/resources/data/Silicon_Transmitter_-_08_-_Virus_1s.mp3 diff --git a/build.gradle b/build.gradle index c9dce83..92656fa 100644 --- a/build.gradle +++ b/build.gradle @@ -56,6 +56,7 @@ dependencies { 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 diff --git a/src/main/java/ch/jbert/controllers/api/GenericExceptionHandler.java b/src/main/java/ch/jbert/controllers/api/GenericExceptionHandler.java index bfa7436..ed53cd0 100644 --- a/src/main/java/ch/jbert/controllers/api/GenericExceptionHandler.java +++ b/src/main/java/ch/jbert/controllers/api/GenericExceptionHandler.java @@ -17,7 +17,7 @@ public class GenericExceptionHandler implements ExceptionHandler q) { return playlistService.findOneByName(name) .map(playlist -> q - .map(query -> playlistService.findTracksByTitle(playlist, query)) + .map(query -> TrackService.filterByName(playlist.getTracks(), query)) .orElse(playlist.getTracks())) .map(HttpResponse::ok) .orElse(notFound()); @@ -151,13 +152,11 @@ public HttpResponse createTrack(@Parameter(description = "The name of the playli @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) { - final Optional playlist = playlistService.findOneByName(name); - final boolean hasIndex = playlist.map(Playlist::getTracks) - .map(tracks -> tracks.size() > index) - .orElse(false); - return playlist.isPresent() && hasIndex - ? ok(playlist.map(Playlist::getTracks).map(tracks -> tracks.get(index))) - : notFound(); + return playlistService.findOneByName(name) + .map(Playlist::getTracks) + .map(tracks -> tracks.get(index)) + .map(HttpResponse::ok) + .orElse(notFound()); } /** @@ -170,12 +169,9 @@ public HttpResponse getTrackByIndex(@Parameter(description = "The name of the pl @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 { - final Optional playlist = playlistService.findOneByName(name); - final boolean hasIndex = playlist.map(Playlist::getTracks) - .map(tracks -> tracks.size() > index) - .orElse(false); - return playlist.isPresent() && hasIndex - ? ok(playlistService.deleteTrackByIndex(playlist.get(), index)) - : notFound(); + 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 index e3e6038..cab0b9f 100644 --- a/src/main/java/ch/jbert/controllers/api/TrackController.java +++ b/src/main/java/ch/jbert/controllers/api/TrackController.java @@ -56,7 +56,7 @@ public HttpResponse list(@Parameter(description = "Filter tracks by name", requi @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) { + public HttpResponse create(@Body Track track) throws IOException { return created(trackService.create(track)); } @@ -67,6 +67,7 @@ public HttpResponse create(@Body Track track) { @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) @@ -84,7 +85,7 @@ public HttpResponse find(@Parameter(description = "The hash of the track") Strin public HttpResponse update(@Parameter(description = "The hash of the track") String hash, @Body Track track) throws IOException { return trackService.findOneByHash(hash) - .map(original -> trackService.update(original, track)) + .map(ThrowingFunction.of(original -> trackService.update(original, track))) .map(HttpResponse::ok) .orElse(notFound()); } @@ -99,7 +100,7 @@ public HttpResponse update(@Parameter(description = "The hash of the track") Str @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(trackService::delete) + .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 index 2109e38..32a1aab 100644 --- a/src/main/java/ch/jbert/controllers/api/UncheckedExceptionHandler.java +++ b/src/main/java/ch/jbert/controllers/api/UncheckedExceptionHandler.java @@ -20,7 +20,7 @@ public class UncheckedExceptionHandler implements ExceptionHandler getYear() { } public Optional getGenre() { - return Optional.ofNullable(this.genre); + return Optional.ofNullable(genre); } public Optional getComment() { - return Optional.ofNullable(this.comment); + return Optional.ofNullable(comment); } public Optional getDuration() { @@ -103,6 +103,18 @@ 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; @@ -117,38 +129,41 @@ public Metadata build() { return new Metadata(title, artist, album, year, genre, comment, duration); } - public Builder withTitle(String value) { - this.title = value; + public Builder withTitle(String title) { + this.title = title; return this; } - public Builder withArtist(String value) { - this.artist = value; + public Builder withArtist(String artist) { + this.artist = artist; return this; } - public Builder withAlbum(String value) { - this.album = value; + public Builder withAlbum(String album) { + this.album = album; return this; } - public Builder withYear(Integer value) { - this.year = value; + public Builder withYear(Integer year) { + this.year = year; return this; } - public Builder withGenre(String value) { - this.genre = value; + public Builder withGenre(String genre) { + this.genre = genre; return this; } - public Builder withComment(String value) { - this.comment = value; + public Builder withComment(String comment) { + this.comment = comment; return this; } - public Builder withDuration(Integer value) { - this.duration = value; + /** + * 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 index 1035d68..eae1271 100644 --- a/src/main/java/ch/jbert/models/Playlist.java +++ b/src/main/java/ch/jbert/models/Playlist.java @@ -1,5 +1,7 @@ package ch.jbert.models; +import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Objects; import java.util.Optional; @@ -14,15 +16,15 @@ public final class Playlist implements Comparable { public Playlist(String name, List tracks) { this.name = name; - this.tracks = tracks; + this.tracks = Objects.requireNonNull(tracks); } public Optional getName() { - return Optional.ofNullable(this.name); + return Optional.ofNullable(name); } public List getTracks() { - return this.tracks; + return new ArrayList<>(tracks); } @Override @@ -48,21 +50,32 @@ 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 value) { - this.name = value; + public Builder withName(String name) { + this.name = name; return this; } - public Builder withTracks(List value) { - this.tracks = value; + public Builder withTracks(List tracks) { + this.tracks = Objects.requireNonNull(tracks); return this; } } diff --git a/src/main/java/ch/jbert/models/Track.java b/src/main/java/ch/jbert/models/Track.java index fbffb62..12ffa65 100644 --- a/src/main/java/ch/jbert/models/Track.java +++ b/src/main/java/ch/jbert/models/Track.java @@ -1,17 +1,25 @@ package ch.jbert.models; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; +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; @@ -36,11 +44,18 @@ public Optional getData() { } /** - * Calculates the MD5 hash of the Base64 encoded data + * Calculates the SHA256 hash of the Base64 encoded data */ - public String calculateMD5() throws NoSuchAlgorithmException { - final MessageDigest md = MessageDigest.getInstance("MD5"); - return md.digest(data.getBytes()).toString(); + 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 @@ -87,6 +102,13 @@ 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; @@ -107,5 +129,10 @@ 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 index ee731d7..07b453a 100644 --- a/src/main/java/ch/jbert/services/DataService.java +++ b/src/main/java/ch/jbert/services/DataService.java @@ -18,4 +18,10 @@ public interface DataService { 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 index 7a0769d..935c35e 100644 --- a/src/main/java/ch/jbert/services/PlaylistService.java +++ b/src/main/java/ch/jbert/services/PlaylistService.java @@ -1,6 +1,5 @@ package ch.jbert.services; -import ch.jbert.models.Metadata; import ch.jbert.models.Playlist; import ch.jbert.models.Track; import ch.jbert.utils.ThrowingConsumer; @@ -17,8 +16,8 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.util.Arrays; -import java.util.Collections; import java.util.List; +import java.util.Objects; import java.util.Optional; import java.util.stream.Collectors; @@ -40,6 +39,8 @@ public class PlaylistService implements DataService { @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))); @@ -91,19 +92,7 @@ private List getTracks(String filename) { try (final BufferedReader reader = Files.newBufferedReader(file)) { return reader.lines() - .map(line -> { - try { - return trackService.readId3Tags(line); - } catch (Exception e) { - LOG.info("Could not read ID3 tags from file {}: {}", line, e.getMessage()); - final List entry = Arrays.asList(line.split("/")); - return Metadata.newBuilder() - .withArtist(entry.get(0)) - .withAlbum(entry.get(1)) - .withTitle(entry.get(2)) - .build(); - } - }) + .map(trackService::readId3Tags) .map(metadata -> Track.newBuilder().withMetadata(metadata).build()) .collect(Collectors.toList()); } catch (IOException e) { @@ -111,13 +100,12 @@ private List getTracks(String filename) { } } - public List findTracksByTitle(Playlist playlist, String title) { - return trackService.filterByName(playlist.getTracks(), title); - } - @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")); @@ -126,6 +114,8 @@ public Playlist update(Playlist original, Playlist update) throws IOException { 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); @@ -133,38 +123,50 @@ public Playlist update(Playlist original, Playlist update) throws IOException { @Override public Playlist delete(Playlist playlist) throws IOException { - final Optional filePath = getFilePath(playlist); - filePath.ifPresent(ThrowingConsumer.of(Files::deleteIfExists)); + + 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 { - playlist.getTracks().add(track); - return addTracks(playlist); + + 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 { - final List copiedTracks = Collections.emptyList(); - Collections.copy(copiedTracks, playlist.getTracks()); + Objects.requireNonNull(playlist); - // Delete track from copied tracks list - copiedTracks.remove(index); + final String playlistName = playlist.getName().orElseThrow( + () -> new IllegalArgumentException(String.format("Could not get name from playlist '%s'", playlist))); - final Playlist update = playlist.getName().map(n -> new Playlist(n, copiedTracks)).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, update); + return update(playlist, new Playlist(playlistName, tracks)); } - private boolean exists(Playlist playlist) { + @Override + public boolean exists(Playlist playlist) { return getFilePath(playlist).map(Files::exists).orElse(false); } - private boolean notExists(Playlist playlist) { - return !exists(playlist); - } - private Playlist addTracks(Playlist playlist) throws IOException { final Path file = getFilePath(playlist).orElseThrow( @@ -172,7 +174,10 @@ private Playlist addTracks(Playlist playlist) throws IOException { try (final BufferedWriter writer = Files.newBufferedWriter(file)) { for (Track track : playlist.getTracks()) { - writer.write(trackService.getRelativeFilePath(trackService.create(track))); + if (trackService.notExists(track)) { + trackService.create(track); + } + writer.write(trackService.getRelativeFilePath(track)); writer.newLine(); } } diff --git a/src/main/java/ch/jbert/services/TrackService.java b/src/main/java/ch/jbert/services/TrackService.java index cf9a987..22f388b 100644 --- a/src/main/java/ch/jbert/services/TrackService.java +++ b/src/main/java/ch/jbert/services/TrackService.java @@ -1,10 +1,11 @@ package ch.jbert.services; -import com.google.common.hash.Hashing; +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; @@ -16,30 +17,33 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.io.File; 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 java.nio.file.StandardOpenOption.CREATE; 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"; @@ -49,52 +53,57 @@ public class TrackService implements DataService { private String basePath; @Override - public Track create(Track track) { + 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()) { - if (exists(track)) { - LOG.info("Track '{}' already exists, attempting to overwrite it", track); - } final ByteSource data = ByteSource.wrap(Base64.getDecoder().decode(track.getData().get())); - final Optional file = getFilePath(track); - file.ifPresent(ThrowingConsumer.of(f -> { - Files.createDirectories(f.getParent()); - try (final OutputStream out = Files.newOutputStream(f)) { - out.write(data.read()); - } - track.getMetadata().ifPresent(m -> writeId3Tags(f.toFile(), m)); - })); - } else if (notExists(track)) { + 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; + 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 playlists from folder {}", basePath); - - try (Stream files = Files.walk(Paths.get(basePath), FileVisitOption.FOLLOW_LINKS) - .filter(p -> p.toString().endsWith(FILE_SUFFIX))) { - return files.map(file -> { - try { - return readId3Tags(file.toFile()); - } catch (Exception e) { - // FIXME: Create Metadata by file structure alternatively - LOG.warn("Could not read ID3 tags from track '{}', returning empty metadata", file); - return Metadata.newBuilder().build(); - } - }).map(Track::new).sorted().collect(Collectors.toList()); + 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 (name == null || name.isEmpty()) - ? getAll() - : filterByName(getAll(), name); + return filterByName(getAll(), name); } /** @@ -104,36 +113,66 @@ public List findAllByName(String name) throws IOException { * @param name Name query * @return Sublist of the given tracks */ - public List filterByName(List tracks, String name) { + 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)) + .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 { - if (!isValidMD5(hash)) { + 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 getAll().stream() - .filter(ThrowingPredicate.of(t -> Objects.equals(t.calculateMD5(), hash))) + return getAllWithData().stream() + .filter(ThrowingPredicate.of(t -> Objects.equals(t.calculateSha256(), hash))) .findAny(); } - private boolean isValidMD5(String s) { - return s.matches("^[a-fA-F0-9]{32}$"); + 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) { + 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() && original.getMetadata().isPresent()) { + if (original.getMetadata().isPresent() && update.getMetadata().isPresent()) { metadata = Metadata.merge(original.getMetadata().get(), update.getMetadata().get()); } else { metadata = update.getMetadata() .orElseGet(() -> original.getMetadata() - .orElseThrow(() -> new IllegalStateException("Original track does not have metadata"))); + .orElse(null)); } final String data = update.getData() .orElseGet(() -> original.getData() @@ -143,33 +182,29 @@ public Track update(Track original, Track update) { } @Override - public Track delete(Track track) { - final Optional filePath = getFilePath(track); - filePath.ifPresent(ThrowingConsumer.of(Files::deleteIfExists)); - return track; - } + public Track delete(Track track) throws IOException { + + Objects.requireNonNull(track); + + if (notExists(track)) { + throw new IllegalArgumentException(String.format("Track '%s' does not exist", track)); + } - private boolean exists(Track track) { - return getFilePath(track).map(Files::exists).orElse(false); + Files.delete(getFilePath(track)); + return track; } - private boolean notExists(Track track) { - return !exists(track); + @Override + public boolean exists(Track track) { + return Files.exists(getFilePath(track)); } - private Optional getFilePath(Track track) { - try { - return Optional.of(Paths.get(basePath, getRelativeFilePath(track))); - } catch (IOException e) { - return Optional.empty(); - } + private Path getFilePath(Track track) { + return Paths.get(basePath, getRelativeFilePath(track)); } - String getRelativeFilePath(Track track) throws IOException { - final ByteSource byteSource = track.getData() - .map(data -> ByteSource.wrap(Base64.getDecoder().decode(data))) - .orElse(ByteSource.empty()); - final Metadata fromId3 = readId3Tags(byteSource); + 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) @@ -178,53 +213,66 @@ String getRelativeFilePath(Track track) throws IOException { .orElseGet(() -> fromId3.getTitle().orElse(UNKNOWN_TITLE)) + FILE_SUFFIX); } - private Metadata readId3Tags(ByteSource data) throws IOException { - - final Path tmpDir = Files.createDirectories(Paths.get(basePath, "_tmp")); - final Path tmpFile = Paths.get(tmpDir.toString(), data.hash(Hashing.sha256()).toString() + FILE_SUFFIX); - try (final OutputStream out = Files.newOutputStream(tmpFile, CREATE)) { - out.write(data.read()); - } - - try { - final Metadata fromId3 = readId3Tags(tmpFile.toFile()); - Files.deleteIfExists(tmpDir); - return fromId3; - } catch (Exception e) { - LOG.info("Could not read ID3 tags from file '{}': {}", tmpFile, e.getMessage()); - } - return Metadata.newBuilder().build(); + 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) throws Exception { - return readId3Tags(Paths.get(basePath, file).toFile()); + Metadata readId3Tags(String file) { + return readId3Tags(Paths.get(basePath, file)); } - private Metadata readId3Tags(File file) throws Exception { - final AudioFile audioFile = AudioFileIO.read(file); - final AudioHeader audioHeader = audioFile.getAudioHeader(); - final Tag tag = audioFile.getTag(); - final Metadata.Builder trackBuilder = 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)); + private Metadata readId3Tags(Path file) { + + LOG.debug("Attempting to read ID3 tags from file '{}'...", file); - final String year = tag.getFirst(FieldKey.YEAR); try { - trackBuilder.withYear(Integer.parseInt(year)); + 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 parse year '{}' as number: {}", year, e.getMessage()); + 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(); } - - return trackBuilder.build(); } - private void writeId3Tags(File file, Metadata metadata) { + private void writeId3Tags(Path file, Metadata metadata) { try { - final AudioFile audioFile = AudioFileIO.read(file); + 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))); 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/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/UncheckedException.java b/src/main/java/ch/jbert/utils/UncheckedException.java index 21f2659..9aeb731 100644 --- a/src/main/java/ch/jbert/utils/UncheckedException.java +++ b/src/main/java/ch/jbert/utils/UncheckedException.java @@ -4,6 +4,9 @@ public final class UncheckedException extends RuntimeException { + + private static final long serialVersionUID = 8303457839849480553L; + public UncheckedException(Throwable cause) { super(cause instanceof UncheckedException ? cause.getCause() : cause); } 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/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 0000000000000000000000000000000000000000..858f2caee0ee1707eb48fae97aab68e922cccbb6 GIT binary patch literal 20163 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 literal 0 HcmV?d00001 From 9bdba8ded07e9177628b35595795eec337360e01 Mon Sep 17 00:00:00 2001 From: Christian Harke Date: Wed, 10 Jun 2020 23:01:04 +0200 Subject: [PATCH 12/14] Update jaudiotagger dependency --- build.gradle | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 92656fa..e27a2af 100644 --- a/build.gradle +++ b/build.gradle @@ -25,6 +25,7 @@ group "ch.jbert" repositories { mavenCentral() maven { url "https://jcenter.bintray.com" } + maven { url 'https://jitpack.io' } } configurations { @@ -48,7 +49,7 @@ dependencies { implementation "io.micronaut:micronaut-http-client" implementation "io.micronaut:micronaut-management" implementation "io.swagger.core.v3:swagger-annotations" - implementation "org:jaudiotagger:2.0.3" + 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") From ad6bcf2213000460dfbbbcbc248c42ef1ff18c69 Mon Sep 17 00:00:00 2001 From: Christian Harke Date: Wed, 10 Jun 2020 23:01:36 +0200 Subject: [PATCH 13/14] Upgrade micronaut to latest version --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index e95c26f..c4a2b83 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1 +1 @@ -micronautVersion=1.3.5 \ No newline at end of file +micronautVersion=1.3.6 \ No newline at end of file From ff08ea92eec2af8f9495efe5023e4c3b2ce23824 Mon Sep 17 00:00:00 2001 From: Christian Harke Date: Wed, 10 Jun 2020 23:03:07 +0200 Subject: [PATCH 14/14] Upgrade gradle --- build.gradle | 8 +++---- core/build.gradle | 4 ++-- .../java/ch/jbert/mpd/MpdServiceImpl.java | 20 +++++++++++------- gradle/wrapper/gradle-wrapper.jar | Bin 54413 -> 55616 bytes gradle/wrapper/gradle-wrapper.properties | 2 +- gradlew | 18 +++++++++++++++- gradlew.bat | 18 +++++++++++++++- 7 files changed, 53 insertions(+), 17 deletions(-) diff --git a/build.gradle b/build.gradle index e27a2af..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' @@ -61,7 +61,7 @@ dependencies { testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine" // Sub-Project dependencies - compile project(':core') + implementation project(':core') } test.classpath += configurations.developmentOnly diff --git a/core/build.gradle b/core/build.gradle index c8b7634..077f627 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -12,9 +12,9 @@ repositories { 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 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/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 91ca28c8b802289c3a438766657a5e98f20eff03..5c2d1cf016b3885f6930543d57b744ea8c220a1a 100644 GIT binary patch literal 55616 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